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();
});
});