DisplayArtifactPanel.test.tsx10.7 KBView on GitHub
/**
 * DisplayArtifactPanel tests (design: chat-display-artifact phase 2) — the active
 * window resolves to the calendar agenda by default (no selection) and to the
 * matching item's viewer when the active thread has a selectedArtifact. AgendaMeetings
 * is mocked to a sentinel so this stays a headless branch-selection proof (the real
 * component pulls the calendar/tRPC stack).
 */
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';

// The base case (nothing open) renders the Top Deals card list (lazy-loaded); mock it to a
// sentinel so this stays a headless branch-selection proof (the real one pulls the CRM stack).
jest.mock('@/modules/canvas/components/CardListCanvasView', () => ({
  CardListCanvasView: () => <div data-testid="card-list">TOP DEALS</div>,
}));

// A conversation in context renders the full ConversationView (lazy-loaded); mock it to
// a sentinel so this stays a headless branch-selection proof (the real one pulls the CRM stack).
jest.mock('@/modules/crm/components/ConversationView', () => ({
  ConversationView: () => <div data-testid="conversation-view">CONVERSATION VIEW</div>,
}));

// A `table` file renders the same grid the file editor mounts (lazy-loaded); mock it to a
// sentinel so this stays a headless branch-selection proof (the real one pulls Y.js + TipTap).
jest.mock('@/modules/documents/table', () => ({
  TableDocumentView: ({ documentId }: { documentId: string }) => (
    <div data-testid="table-grid">TABLE {documentId}</div>
  ),
}));

// A non-table file renders the real document editor (lazy-loaded); mock it to a sentinel for
// the same reason as the others — the real one pulls Y.js + TipTap, and this file is a
// branch-selection proof, not an editor test.
jest.mock('@/modules/documents/document', () => ({
  Document: ({ documentId }: { documentId: string }) => (
    <div data-testid="document-view">DOCUMENT {documentId}</div>
  ),
}));

// A channel chat renders the real ChannelThreadView (lazy-loaded); mock it to a sentinel for
// the same reason as the others — the real one pulls the Slack composer and the reaction stack.
jest.mock('@/modules/inbox/components/ChannelThreadView', () => ({
  ChannelThreadView: ({ item }: { item: { id: string } }) => (
    <div data-testid="channel-thread">CHAT {item.id}</div>
  ),
}));

// tRPC provider + react-query: the panel resolves the Top Deals canvas via
// canvas.ensureTopDeals, `file` items via documents.getDoc, and a channel chat's feed row via
// inbox.channelItem. Stub them so the module imports cleanly, and have useQuery resolve a
// canvas so the base case renders.
jest.mock('@/providers/query-provider', () => ({
  useTRPC: () => ({
    canvas: {
      ensureTopDeals: { queryOptions: () => ({ queryKey: ['canvas.ensureTopDeals'] }) },
    },
    documents: {
      getDoc: { queryOptions: (input: unknown) => ({ queryKey: ['documents.getDoc', input] }) },
    },
    // The file panel renames in place (the title is an editable field), so it asks for the
    // mutation on every render — the click that would fire it is not exercised here.
    files: { renameNode: { mutationOptions: () => ({}) } },
    inbox: {
      channelItem: {
        queryOptions: (input: unknown) => ({ queryKey: ['inbox.channelItem', input] }),
      },
    },
  }),
}));

/**
 * `useQuery` dispatches on the queryKey rather than answering everything the same way — the
 * panel asks two different questions (the Top Deals canvas, and the opened document), and a
 * single blanket answer would make the `file` branch untestable.
 */
const mockDocResponses: { current: Record<string, unknown> } = { current: {} };
const mockChannelItems: { current: Record<string, unknown> } = { current: {} };
jest.mock('@tanstack/react-query', () => ({
  useMutation: () => ({ mutate: jest.fn(), mutateAsync: jest.fn(), isPending: false }),
  // The file panel patches the doc's cached row when its title is renamed in place.
  useQueryClient: () => ({
    getQueryData: jest.fn(),
    setQueryData: jest.fn(),
    invalidateQueries: jest.fn(),
  }),
  useQuery: (options: { queryKey?: unknown[] }) => {
    const [route, input] = options.queryKey ?? [];
    if (route === 'documents.getDoc') {
      const documentId = (input as { documentId?: string } | undefined)?.documentId ?? '';
      const data = mockDocResponses.current[documentId] ?? null;
      return { data, isLoading: false, isError: !data };
    }
    if (route === 'inbox.channelItem') {
      const key=[redacted] as { containerKey?: string } | undefined)?.containerKey ?? '';
      const data = mockChannelItems.current[key] ?? null;
      return { data, isLoading: false, isPending: false, isError: !data };
    }
    return { data: { id: 'top-deals' }, isLoading: false, isError: false };
  },
}));

import {
  DEFAULT_THREAD_ID,
  type ContextItem,
} from '@/modules/cedar-os/src/store/messages/MessageTypes';
import { useCedarStore } from '@/modules/cedar-os/src/store/CedarStore';
import { DisplayArtifactPanel } from '@/modules/home/components/DisplayArtifactPanel';

const item = (over: Partial<ContextItem> & Pick<ContextItem, 'kind' | 'id'>): ContextItem => ({
  addedBy: 'agent',
  addedAt: '2026-07-05T00:00:00.000Z',
  ...over,
});

function seedThread(over: Partial<{ items: ContextItem[]; selectedArtifact: unknown }> = {}) {
  useCedarStore.setState((state) => ({
    ...state,
    threadMap: {
      [DEFAULT_THREAD_ID]: {
        id: DEFAULT_THREAD_ID,
        lastLoaded: new Date().toISOString(),
        messages: [],
        chatContext: { items: over.items ?? [] },
        selectedArtifact: (over.selectedArtifact ?? null) as never,
      },
    },
    mainThreadId: DEFAULT_THREAD_ID,
    activeThreadId: DEFAULT_THREAD_ID,
    messages: [],
  }));
}

function renderPanel() {
  // `NuqsAdapter` because the file panel reaches `useOpenCard` (a `?card=` query state) on
  // every render, not only when a card is opened — same as the shell, which mounts the whole
  // tree under one in client-providers.
  return render(
    <MemoryRouter>
      <NuqsAdapter>
        <DisplayArtifactPanel />
      </NuqsAdapter>
    </MemoryRouter>,
  );
}

const DOC_ID = '11111111-2222-3333-4444-555555555555';

describe('DisplayArtifactPanel', () => {
  beforeEach(() => {
    seedThread();
    mockDocResponses.current = {};
    mockChannelItems.current = {};
  });

  it('renders the Top Deals card list by default when nothing is open', async () => {
    renderPanel();
    expect(await screen.findByTestId('card-list')).toBeInTheDocument();
  });

  it('renders the full ConversationView when a conversation is the selectedArtifact', async () => {
    seedThread({
      items: [item({ kind: 'conversation', id: 'conv_abc', label: 'Numeral' })],
      selectedArtifact: { kind: 'conversation', id: 'conv_abc' },
    });

    renderPanel();
    expect(screen.queryByTestId('card-list')).not.toBeInTheDocument();
    expect(await screen.findByTestId('conversation-view')).toBeInTheDocument();
  });

  it('renders ConversationView for a primaryConversation selection too', async () => {
    useCedarStore.setState((state) => ({
      ...state,
      threadMap: {
        [DEFAULT_THREAD_ID]: {
          id: DEFAULT_THREAD_ID,
          messages: [],
          chatContext: { primaryConversation: { id: 'conv_primary', name: 'Adobe' }, items: [] },
          selectedArtifact: { kind: 'conversation', id: 'conv_primary' } as never,
        },
      },
      mainThreadId: DEFAULT_THREAD_ID,
      activeThreadId: DEFAULT_THREAD_ID,
    }));

    renderPanel();
    expect(screen.queryByTestId('agenda-meetings')).not.toBeInTheDocument();
    expect(await screen.findByTestId('conversation-view')).toBeInTheDocument();
  });
});

describe('a file artifact', () => {
  beforeEach(() => seedThread());

  it('renders the grid — not the <pre> markdown fallback — when the doc is a table', async () => {
    mockDocResponses.current = {
      [DOC_ID]: {
        id: DOC_ID,
        documentType: 'table',
        path: 'user/tables/q3-pipeline.md',
        title: 'Q3 pipeline',
        content: '| _id | Company |\n| --- | --- |\n',
      },
    };
    seedThread({
      items: [item({ kind: 'file', id: DOC_ID, label: 'Q3 pipeline' })],
      selectedArtifact: { kind: 'file', id: DOC_ID },
    });

    const { container } = renderPanel();
    // Lazy-loaded, so the grid arrives a microtask after the branch is chosen.
    expect(await screen.findByTestId('table-grid')).toBeInTheDocument();
    expect(container.querySelector('pre')).toBeNull();
  });

  it('still renders the markdown mirror for a non-table document', async () => {
    mockDocResponses.current = {
      [DOC_ID]: {
        id: DOC_ID,
        documentType: 'document',
        path: 'user/notes/notes.md',
        title: 'Notes',
        content: '# Notes',
      },
    };
    seedThread({
      items: [item({ kind: 'file', id: DOC_ID, label: 'Notes' })],
      selectedArtifact: { kind: 'file', id: DOC_ID },
    });

    renderPanel();
    // Both panel bodies are `lazy` behind one <Suspense>, so neither is there on the first
    // paint — the table branch above already awaits for the same reason.
    expect(await screen.findByTestId('document-view')).toBeInTheDocument();
    expect(screen.queryByTestId('table-grid')).not.toBeInTheDocument();
  });
});

/**
 * A LinkedIn chat used to render a card with the participant's name over the raw chat id and
 * no way to reach a single message — a screen that should not have been possible to reach.
 * It now opens the same ChannelThreadView the unibox opens, for every channel.
 */
describe('a channel chat artifact', () => {
  beforeEach(() => {
    seedThread();
    mockChannelItems.current = {};
  });

  it('renders the chat itself, not a card naming it', async () => {
    mockChannelItems.current = {
      chat_1: { id: 'li:chat_1', channel: 'linkedin', ref: { kind: 'linkedin', chatId: 'chat_1' } },
    };
    seedThread({
      items: [item({ kind: 'linkedin_chat', id: 'chat_1', label: 'Rohan Dalal' })],
      selectedArtifact: { kind: 'linkedin_chat', id: 'chat_1' },
    });

    renderPanel();
    expect(await screen.findByTestId('channel-thread')).toHaveTextContent('CHAT li:chat_1');
    // The dead end: the chat id was the BODY of the old card.
    expect(screen.queryByText('chat_1')).not.toBeInTheDocument();
  });

  it('offers a way back when the chat resolves to no container at all', () => {
    seedThread({
      items: [item({ kind: 'linkedin_chat', id: 'chat_gone', label: 'Rohan Dalal' })],
      selectedArtifact: { kind: 'linkedin_chat', id: 'chat_gone' },
    });

    renderPanel();
    expect(screen.getByText('Could not open this chat.')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Go back' })).toBeInTheDocument();
  });
});