cflx 0.6.11

Conflux – a spec-driven parallel coding orchestrator that runs AI agents on git worktrees
// @vitest-environment jsdom

import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';

import type { ProposalChatMessage } from '../../api/types';
import { ChatMessageList } from '../ChatMessageList';

const scrollIntoViewMock = vi.fn();

function setScrollMetrics(container: HTMLElement, metrics: { scrollTop: number; clientHeight: number; scrollHeight: number }) {
  Object.defineProperty(container, 'scrollTop', {
    value: metrics.scrollTop,
    writable: true,
    configurable: true,
  });
  Object.defineProperty(container, 'clientHeight', {
    value: metrics.clientHeight,
    configurable: true,
  });
  Object.defineProperty(container, 'scrollHeight', {
    value: metrics.scrollHeight,
    configurable: true,
  });
}

const baseMessages: ProposalChatMessage[] = [
  {
    id: 'm1',
    role: 'user',
    content: 'hello',
    timestamp: '2026-03-30T00:00:00.000Z',
    hydrated: true,
  },
];

describe('ChatMessageList', () => {
  const writeText = vi.fn();

  it('shows typing indicator only until assistant message is present', () => {
    const { rerender } = render(
      <ChatMessageList
        messages={[
          {
            id: 'u1',
            role: 'user',
            content: 'hello',
            timestamp: '2026-03-30T00:00:00.000Z',
          },
        ]}
        isAgentResponding
      />,
    );

    expect(screen.getByTestId('typing-indicator')).toBeTruthy();

    rerender(
      <ChatMessageList
        messages={[
          {
            id: 'u1',
            role: 'user',
            content: 'hello',
            timestamp: '2026-03-30T00:00:00.000Z',
          },
          {
            id: 'a1',
            role: 'assistant',
            content: 'response',
            timestamp: '2026-03-30T00:00:01.000Z',
          },
        ]}
        isAgentResponding
      />,
    );

    expect(screen.queryByTestId('typing-indicator')).toBeNull();
  });

  beforeEach(() => {
    scrollIntoViewMock.mockReset();
    Element.prototype.scrollIntoView = scrollIntoViewMock;

    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-03-30T00:10:00Z'));
    writeText.mockReset();
    Object.assign(navigator, {
      clipboard: {
        writeText,
      },
    });
  });

  afterEach(() => {
    cleanup();
    vi.useRealTimers();
  });

  it('does not auto-scroll and shows New messages when user scrolled up', () => {
    const { rerender } = render(<ChatMessageList messages={baseMessages} />);

    const scroller = screen.getByTestId('chat-scroll-container');
    setScrollMetrics(scroller, {
      scrollTop: 100,
      clientHeight: 400,
      scrollHeight: 1000,
    });

    scroller.dispatchEvent(new Event('scroll'));
    scrollIntoViewMock.mockClear();

    rerender(<ChatMessageList messages={baseMessages} />);

    expect(scrollIntoViewMock).not.toHaveBeenCalled();
    expect(screen.getByRole('button', { name: '↓ New messages' })).toBeTruthy();
  });

  it('renders headings, lists, links, and horizontal rules', () => {
    const messages: ProposalChatMessage[] = [
      {
        id: 'm2',
        role: 'assistant',
        content: [
          '# Heading One',
          '## Heading Two',
          '### Heading Three',
          '- bullet one',
          '* bullet two',
          '1. first',
          '2. second',
          '[Conflux](https://example.com)',
          '---',
        ].join('\n'),
        timestamp: '2026-03-30T00:08:00Z',
      },
    ];

    const { container } = render(<ChatMessageList messages={messages} />);

    expect(screen.getByRole('heading', { level: 1, name: 'Heading One' })).toBeTruthy();
    expect(screen.getByRole('heading', { level: 2, name: 'Heading Two' })).toBeTruthy();
    expect(screen.getByRole('heading', { level: 3, name: 'Heading Three' })).toBeTruthy();

    expect(container.querySelectorAll('ul li')).toHaveLength(2);
    expect(container.querySelectorAll('ol li')).toHaveLength(2);

    const link = screen.getByRole('link', { name: 'Conflux' });
    expect(link.getAttribute('href')).toBe('https://example.com');
    expect(link.getAttribute('target')).toBe('_blank');

    expect(container.querySelectorAll('hr')).toHaveLength(1);
  });

  it('renders code language label and copies code content', () => {
    const code = "console.log('ok')";
    const messages: ProposalChatMessage[] = [
      {
        id: 'm3',
        role: 'assistant',
        content: ['```typescript', code, '```'].join('\n'),
        timestamp: '2026-03-30T00:08:00Z',
      },
    ];

    render(<ChatMessageList messages={messages} />);

    expect(screen.getByText('typescript')).toBeTruthy();
    const copyButtons = screen.getAllByRole('button', { name: 'Copy code' });
    fireEvent.click(copyButtons[0]);

    expect(writeText).toHaveBeenCalledWith(code);
  });

  it('shows pending and failed indicators with retry action', () => {
    const onRetryMessage = vi.fn();
    const messages: ProposalChatMessage[] = [
      {
        id: 'pending-1',
        role: 'user',
        content: 'queued message',
        timestamp: '2026-03-30T00:08:00Z',
        sendStatus: 'pending',
      },
      {
        id: 'failed-1',
        role: 'user',
        content: 'failed message',
        timestamp: '2026-03-30T00:09:00Z',
        sendStatus: 'failed',
      },
    ];

    render(<ChatMessageList messages={messages} onRetryMessage={onRetryMessage} />);

    expect(screen.getByTestId('message-pending-indicator')).toBeTruthy();
    expect(screen.getByTestId('message-failed-indicator')).toBeTruthy();

    fireEvent.click(screen.getByRole('button', { name: /Retry/i }));
    expect(onRetryMessage).toHaveBeenCalledWith('failed-1');
  });

  it('copies assistant message and shows relative timestamp text', () => {
    const content = 'assistant message';
    const messages: ProposalChatMessage[] = [
      {
        id: 'm4',
        role: 'assistant',
        content,
        timestamp: '2026-03-30T00:08:00Z',
      },
      {
        id: 'm5',
        role: 'user',
        content: 'user message',
        timestamp: '2026-03-30T00:09:30Z',
      },
    ];

    render(<ChatMessageList messages={messages} />);

    const messageCopy = screen.getByRole('button', { name: 'Copy message' });
    fireEvent.click(messageCopy);
    expect(writeText).toHaveBeenCalledWith(content);

    expect(screen.getByText('2 minutes ago')).toBeTruthy();
    expect(screen.getByText('30 seconds ago')).toBeTruthy();
  });
});