diff --git a/public/tabs/chat/chat_page.test.tsx b/public/tabs/chat/chat_page.test.tsx new file mode 100644 index 00000000..d768a183 --- /dev/null +++ b/public/tabs/chat/chat_page.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +import { coreMock } from '../../../../../src/core/public/mocks'; +import { SessionLoadService } from '../../services/session_load_service'; +import { ChatPage } from './chat_page'; +import * as chatContextExports from '../../contexts/chat_context'; +import * as coreContextExports from '../../contexts/core_context'; +import * as hookExports from '../../hooks/use_chat_state'; + +jest.mock('./controls/chat_input_controls', () => { + return { ChatInputControls: () =>
}; +}); + +jest.mock('./chat_page_content', () => { + return { + ChatPageContent: ({ onRefresh }: { onRefresh: () => void }) => ( + + ), + }; +}); + +describe('', () => { + const dispatchMock = jest.fn(); + const loadMock = jest.fn().mockResolvedValue({ + title: 'session title', + version: 1, + createdTimeMs: new Date().getTime(), + updatedTimeMs: new Date().getTime(), + messages: [], + interactions: [], + }); + const sessionLoadService = new SessionLoadService(coreMock.createStart().http); + + beforeEach(() => { + jest.spyOn(sessionLoadService, 'load').mockImplementation(loadMock); + + jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue({ + sessionId: 'mocked_session_id', + chatEnabled: true, + }); + + jest.spyOn(hookExports, 'useChatState').mockReturnValue({ + chatStateDispatch: dispatchMock, + chatState: { messages: [], llmResponding: false }, + }); + + jest.spyOn(coreContextExports, 'useCore').mockReturnValue({ + services: { + sessionLoad: sessionLoadService, + }, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should reload the current conversation when user click refresh', async () => { + render(); + fireEvent.click(screen.getByText('refresh')); + + expect(loadMock).toHaveBeenCalledWith('mocked_session_id'); + await waitFor(() => { + expect(dispatchMock).toHaveBeenCalledWith({ + type: 'receive', + payload: { messages: [], interactions: [] }, + }); + }); + }); + + it('should NOT call reload if current conversation is not set', async () => { + jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue({ + sessionId: undefined, + chatEnabled: true, + }); + render(); + fireEvent.click(screen.getByText('refresh')); + + expect(loadMock).not.toHaveBeenCalled(); + }); +}); diff --git a/public/tabs/chat/chat_page.tsx b/public/tabs/chat/chat_page.tsx index a35c7ad2..ff302eed 100644 --- a/public/tabs/chat/chat_page.tsx +++ b/public/tabs/chat/chat_page.tsx @@ -4,7 +4,7 @@ */ import { EuiFlyoutBody, EuiFlyoutFooter, EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback } from 'react'; import cs from 'classnames'; import { useObservable } from 'react-use'; import { useChatContext, useCore } from '../../contexts'; @@ -20,7 +20,6 @@ export const ChatPage: React.FC = (props) => { const core = useCore(); const chatContext = useChatContext(); const { chatState, chatStateDispatch } = useChatState(); - const [showGreetings, setShowGreetings] = useState(false); const sessionLoadStatus = useObservable(core.services.sessionLoad.status$); const messagesLoading = sessionLoadStatus === 'loading'; @@ -46,8 +45,6 @@ export const ChatPage: React.FC = (props) => { >; messagesLoading: boolean; messagesLoadingError?: Error; onRefresh: () => void; } -const findPreviousInput = (messages: IMessage[], index: number) => { - for (let i = index - 1; i >= 0; i--) { - if (messages[i].type === 'input') return messages[i]; - } -}; - export const ChatPageContent: React.FC = React.memo((props) => { const chatContext = useChatContext(); const { chatState } = useChatState(); @@ -114,7 +105,6 @@ export const ChatPageContent: React.FC = React.memo((props /> )} - {props.showGreetings && props.setShowGreetings(false)} />} {chatState.messages.map((message, i) => { // The latest llm output, just after the last user input const isLatestOutput = lastInputIndex >= 0 && i > lastInputIndex; @@ -142,7 +132,6 @@ export const ChatPageContent: React.FC = React.memo((props interaction={interaction} > - {/* */} {showSuggestions && } diff --git a/public/tabs/chat/chat_page_greetings.tsx b/public/tabs/chat/chat_page_greetings.tsx deleted file mode 100644 index 82fcc64b..00000000 --- a/public/tabs/chat/chat_page_greetings.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - EuiButtonIcon, - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiSpacer, - EuiText, -} from '@elastic/eui'; -import React from 'react'; -import chatIcon from '../../assets/chat.svg'; -import { GreetingCard } from '../../components/greeting_card'; - -interface ChatPageGreetingsProps { - dismiss: () => void; -} - -const messages = [ - { - title: 'example', - details: "Show me the most important SLO's in my system", - }, - { - title: 'limitations', - details: 'May occasionally generate incorrect information', - }, - { - title: 'capability', - details: 'Allows user to provide follow-up corrections', - }, -]; - -export const ChatPageGreetings: React.FC = (props) => { - return ( - <> - - - - - - - - OS ASSISTANT - - - - - - - - {messages.map((message) => ( -
- {message.details} - -
- ))} - - ); -}; diff --git a/public/tabs/chat/controls/chat_input_controls.test.tsx b/public/tabs/chat/controls/chat_input_controls.test.tsx new file mode 100644 index 00000000..c15528d7 --- /dev/null +++ b/public/tabs/chat/controls/chat_input_controls.test.tsx @@ -0,0 +1,117 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor, getByRole } from '@testing-library/react'; + +import { ChatInputControls } from './chat_input_controls'; +import * as contextExports from '../../../contexts/chat_context'; +import * as hookExports from '../../../hooks/use_chat_actions'; + +describe('', () => { + const sendMock = jest.fn(); + + beforeEach(() => { + jest.spyOn(contextExports, 'useChatContext').mockReturnValue({ + appId: 'mocked_app_id', + }); + jest.spyOn(hookExports, 'useChatActions').mockReturnValue({ + send: sendMock, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should display submit button and text box in different state accordingly', () => { + const { rerender } = render(); + expect(screen.getByRole('button')).toBeDisabled(); + expect(screen.getByRole('textbox')).toBeDisabled(); + expect(screen.getByRole('button')).toHaveTextContent('Generating...'); + + rerender(); + expect(screen.getByRole('button')).toBeDisabled(); + expect(screen.getByRole('textbox')).toBeDisabled(); + expect(screen.getByRole('button')).toHaveTextContent('Go'); + + rerender(); + expect(screen.getByRole('button')).toBeEnabled(); + expect(screen.getByRole('textbox')).toBeEnabled(); + expect(screen.getByRole('button')).toHaveTextContent('Generating...'); + + rerender(); + expect(screen.getByRole('button')).toBeEnabled(); + expect(screen.getByRole('textbox')).toBeEnabled(); + expect(screen.getByRole('button')).toHaveTextContent('Go'); + }); + + it('should send message when clicking submit button', () => { + render(); + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'what indices are in my cluster?' }, + }); + fireEvent.click(screen.getByRole('button')); + expect(sendMock).toHaveBeenCalledWith({ + type: 'input', + content: 'what indices are in my cluster?', + contentType: 'text', + context: { + appId: 'mocked_app_id', + }, + }); + }); + + it('should send message when pressing `Enter`', () => { + render(); + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'what indices are in my cluster?' }, + }); + fireEvent.keyPress(screen.getByRole('textbox'), { + key: 'Enter', + keyCode: 13, + shiftKey: false, + }); + expect(sendMock).toHaveBeenCalledWith({ + type: 'input', + content: 'what indices are in my cluster?', + contentType: 'text', + context: { + appId: 'mocked_app_id', + }, + }); + }); + + it('should NOT send message when pressing `shift+Enter`', () => { + render(); + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'what indices are in my cluster?' }, + }); + fireEvent.keyPress(screen.getByRole('textbox'), { + key: 'Enter', + keyCode: 13, + shiftKey: true, + }); + expect(sendMock).not.toHaveBeenCalled(); + }); + + it('should NOT send message if disabled', () => { + render(); + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'what indices are in my cluster?' }, + }); + fireEvent.click(screen.getByRole('button')); + expect(sendMock).not.toHaveBeenCalled(); + }); + + it('should NOT send message if input is trimmed empty', () => { + render(); + fireEvent.change(screen.getByRole('textbox'), { + target: { value: ' ' }, + }); + fireEvent.click(screen.getByRole('button')); + expect(sendMock).not.toHaveBeenCalled(); + }); +}); diff --git a/test/setup.jest.ts b/test/setup.jest.ts index 4ef34444..c7c9ed97 100644 --- a/test/setup.jest.ts +++ b/test/setup.jest.ts @@ -5,6 +5,7 @@ import { configure } from '@testing-library/react'; import { TextDecoder, TextEncoder } from 'util'; +import '@testing-library/jest-dom'; configure({ testIdAttribute: 'data-test-subj' });