Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add tests for ChatPageContent component #54

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ interface Props {
username: string;
}

export const TermsAndConditions = (props: Props) => {
export const WelcomeMessage = (props: Props) => {
return (
<EuiEmptyPrompt
aria-label="chat welcome message"
iconType="cheer"
iconColor="primary"
titleSize="s"
Expand Down
243 changes: 243 additions & 0 deletions public/tabs/chat/chat_page_content.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { ChatPageContent } from './chat_page_content';
import * as chatContextExports from '../../contexts/chat_context';
import * as chatStateHookExports from '../../hooks/use_chat_state';
import * as chatActionHookExports from '../../hooks/use_chat_actions';
import { IMessage } from '../../../common/types/chat_saved_object_attributes';

jest.mock('./messages/message_bubble', () => {
return {
MessageBubble: ({ children }: { children?: React.ReactNode }) => (
<div aria-label="chat message bubble">{children}</div>
),
};
});

jest.mock('./messages/message_content', () => {
return { MessageContent: () => <div /> };
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just curious why are child components mocked?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good question! There has been lots of discussions around this. For unit test, I tend to mock child components in RTL which are smart components, for example, the components that:

  1. Access data via API call
  2. Have side effects such as read/write local storage
  3. Access context
  4. Access global state

By mocking such components, I think it makes the tests concise and less fragile. And ofc, the child component should be covered by its own unit tests.


describe('<ChatPageContent />', () => {
const abortActionMock = jest.fn();
const executeActionMock = jest.fn();

beforeEach(() => {
jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue({
sessionId: 'test_session_id',
actionExecutors: {
view_ppl_visualization: jest.fn(),
},
currentAccount: {
username: 'test_user',
tenant: 'private',
},
});

jest.spyOn(chatStateHookExports, 'useChatState').mockReturnValue({
chatState: { messages: [], llmResponding: false, interactions: [] },
chatStateDispatch: jest.fn(),
});

jest.spyOn(chatActionHookExports, 'useChatActions').mockReturnValue({
regenerate: jest.fn(),
send: jest.fn(),
loadChat: jest.fn(),
openChatUI: jest.fn(),
executeAction: executeActionMock,
abortAction: abortActionMock,
});
});

afterEach(() => {
jest.resetAllMocks();
});

afterAll(() => {
jest.restoreAllMocks();
});

it('should display welcome message by default', () => {
render(<ChatPageContent messagesLoading={false} onRefresh={jest.fn()} />);
expect(screen.queryAllByLabelText('chat message bubble')).toHaveLength(1);
expect(screen.queryByLabelText('chat welcome message')).toBeInTheDocument();
});

it('should display a default suggested action', () => {
render(<ChatPageContent messagesLoading={false} onRefresh={jest.fn()} />);
expect(screen.queryAllByLabelText('chat suggestions')).toHaveLength(1);
expect(screen.queryByText('What are the indices in my cluster?')).toBeInTheDocument();
});

it('should display messages', () => {
const messages: IMessage[] = [
{
type: 'input',
content: 'what indices are in my cluster?',
contentType: 'text',
},
{
type: 'output',
content: 'here are the indices in your cluster: .alert',
contentType: 'markdown',
suggestedActions: [{ actionType: 'send_as_input', message: 'suggested action mock' }],
},
];
jest.spyOn(chatStateHookExports, 'useChatState').mockReturnValue({
chatState: { messages, llmResponding: false, interactions: [] },
chatStateDispatch: jest.fn(),
});
render(<ChatPageContent messagesLoading={false} onRefresh={jest.fn()} />);
expect(screen.queryAllByLabelText('chat message bubble')).toHaveLength(3);
});

it('should only display the suggested actions of last output', () => {
const messages: IMessage[] = [
{
type: 'input',
content: 'what indices are in my cluster?',
contentType: 'text',
},
{
type: 'output',
content: 'here are the indices in your cluster: .kibana',
contentType: 'markdown',
suggestedActions: [{ actionType: 'send_as_input', message: 'suggested action mock' }],
},
wanglam marked this conversation as resolved.
Show resolved Hide resolved
{
type: 'input',
content: 'Are there any alerts in my system?',
contentType: 'text',
},
{
type: 'output',
content: 'there is no alert in the system',
contentType: 'markdown',
suggestedActions: [{ actionType: 'send_as_input', message: 'suggested action mock' }],
},
];
jest.spyOn(chatStateHookExports, 'useChatState').mockReturnValue({
chatState: { messages, llmResponding: false, interactions: [] },
chatStateDispatch: jest.fn(),
});
render(<ChatPageContent messagesLoading={false} onRefresh={jest.fn()} />);
expect(screen.queryAllByLabelText('chat suggestions')).toHaveLength(1);
expect(screen.queryByText('suggested action mock')).toBeInTheDocument();
});

it('should NOT display the suggested actions if no suggested actions', () => {
const messages: IMessage[] = [
{
type: 'input',
content: 'what indices are in my cluster?',
contentType: 'text',
},
{
type: 'output',
content: 'here are the indices in your cluster: .kibana',
contentType: 'markdown',
suggestedActions: [],
},
];
jest.spyOn(chatStateHookExports, 'useChatState').mockReturnValue({
chatState: { messages, llmResponding: false, interactions: [] },
chatStateDispatch: jest.fn(),
});
render(<ChatPageContent messagesLoading={false} onRefresh={jest.fn()} />);
expect(screen.queryAllByLabelText('chat suggestions')).toHaveLength(0);
});

it('should not display suggested actions on user input message bubble', () => {
const messages: IMessage[] = [
{
type: 'input',
content: 'what indices are in my cluster?',
contentType: 'text',
},
{
type: 'output',
content: 'here are the indices in your cluster: .kibana',
contentType: 'markdown',
suggestedActions: [{ actionType: 'send_as_input', message: 'suggested action mock' }],
},
{
type: 'input',
content: 'show me visualizations about sales',
contentType: 'text',
},
];
jest.spyOn(chatStateHookExports, 'useChatState').mockReturnValue({
chatState: { messages, llmResponding: false, interactions: [] },
chatStateDispatch: jest.fn(),
});
render(<ChatPageContent messagesLoading={false} onRefresh={jest.fn()} />);
expect(screen.queryAllByLabelText('chat suggestions')).toHaveLength(0);
});

it('should display loading screen when loading the messages', () => {
render(<ChatPageContent messagesLoading={true} onRefresh={jest.fn()} />);
expect(screen.queryByText('Loading conversation')).toBeInTheDocument();
expect(screen.queryAllByLabelText('chat message bubble')).toHaveLength(0);
});

it('should show error message with refresh button', () => {
const onRefreshMock = jest.fn();
render(
<ChatPageContent
messagesLoading={false}
messagesLoadingError={new Error('failed to get response')}
onRefresh={onRefreshMock}
/>
);
expect(screen.queryByText('failed to get response')).toBeInTheDocument();
expect(screen.queryAllByLabelText('chat message bubble')).toHaveLength(0);

fireEvent.click(screen.getByText('Refresh'));
expect(onRefreshMock).toHaveBeenCalled();
});

it('should display `Stop generating response` when llm is responding', () => {
jest.spyOn(chatStateHookExports, 'useChatState').mockReturnValue({
chatState: { messages: [], llmResponding: true, interactions: [] },
chatStateDispatch: jest.fn(),
});
render(<ChatPageContent messagesLoading={false} onRefresh={jest.fn()} />);
expect(screen.queryByText('Stop generating response')).toBeInTheDocument();
fireEvent.click(screen.getByText('Stop generating response'));
expect(abortActionMock).toHaveBeenCalledWith('test_session_id');
});

it('should display `How was this generated?`', () => {
const messages: IMessage[] = [
{
type: 'input',
content: 'what indices are in my cluster?',
contentType: 'text',
},
{
type: 'output',
content: 'here are the indices in your cluster: .kibana',
contentType: 'markdown',
suggestedActions: [],
traceId: 'trace_id_mock',
},
];
jest.spyOn(chatStateHookExports, 'useChatState').mockReturnValue({
chatState: { messages, llmResponding: false, interactions: [] },
chatStateDispatch: jest.fn(),
});
render(<ChatPageContent messagesLoading={false} onRefresh={jest.fn()} />);
expect(screen.queryByText('How was this generated?')).toBeInTheDocument();
});

it('should call executeAction', () => {
render(<ChatPageContent messagesLoading={false} onRefresh={jest.fn()} />);
fireEvent.click(screen.getByText('What are the indices in my cluster?'));
expect(executeActionMock).toHaveBeenCalled();
});
});
6 changes: 3 additions & 3 deletions public/tabs/chat/chat_page_content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
ISuggestedAction,
Interaction,
} from '../../../common/types/chat_saved_object_attributes';
import { TermsAndConditions } from '../../components/terms_and_conditions';
import { WelcomeMessage } from '../../components/chat_welcome_message';
import { useChatContext } from '../../contexts';
import { useChatState, useChatActions } from '../../hooks';
import { MessageBubble } from './messages/message_bubble';
Expand Down Expand Up @@ -89,7 +89,7 @@ export const ChatPageContent: React.FC<ChatPageContentProps> = React.memo((props
message={{ type: 'output', contentType: 'markdown', content: '' }}
showActionBar={false}
>
<TermsAndConditions username={chatContext.currentAccount.username} />
<WelcomeMessage username={chatContext.currentAccount.username} />
</MessageBubble>
{firstInputIndex < 0 && (
<Suggestions
Expand Down Expand Up @@ -220,7 +220,7 @@ const Suggestions: React.FC<SuggestionsProps> = (props) => {
}

return (
<div style={{ marginLeft: '55px', marginTop: '5px' }}>
<div aria-label="chat suggestions" style={{ marginLeft: '55px', marginTop: '5px' }}>
<EuiText color="subdued" size="xs" style={{ paddingLeft: 10 }}>
<small>Available suggestions</small>
</EuiText>
Expand Down
Loading
Loading