[Security solution] More Elastic Assistant tests (#168146)

This commit is contained in:
Steph Milovic 2023-10-09 10:18:16 -06:00 committed by GitHub
parent e672224597
commit 413715f134
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 3508 additions and 196 deletions

View file

@ -8,7 +8,14 @@
import { HttpSetup } from '@kbn/core-http-browser';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/public/common';
import { fetchConnectorExecuteAction, FetchConnectorExecuteAction } from './api';
import {
deleteKnowledgeBase,
fetchConnectorExecuteAction,
FetchConnectorExecuteAction,
getKnowledgeBaseStatus,
postEvaluation,
postKnowledgeBase,
} from './api';
import type { Conversation, Message } from '../assistant_context/types';
import { API_ERROR } from './translations';
@ -28,136 +35,277 @@ const messages: Message[] = [
{ content: 'This is a test', role: 'user', timestamp: new Date().toLocaleString() },
];
describe('fetchConnectorExecuteAction', () => {
describe('API tests', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('calls the internal assistant API when assistantLangChain is true', async () => {
const testProps: FetchConnectorExecuteAction = {
assistantLangChain: true,
http: mockHttp,
messages,
apiConfig,
};
describe('fetchConnectorExecuteAction', () => {
it('calls the internal assistant API when assistantLangChain is true', async () => {
const testProps: FetchConnectorExecuteAction = {
assistantLangChain: true,
http: mockHttp,
messages,
apiConfig,
};
await fetchConnectorExecuteAction(testProps);
await fetchConnectorExecuteAction(testProps);
expect(mockHttp.fetch).toHaveBeenCalledWith(
'/internal/elastic_assistant/actions/connector/foo/_execute',
{
expect(mockHttp.fetch).toHaveBeenCalledWith(
'/internal/elastic_assistant/actions/connector/foo/_execute',
{
body: '{"params":{"subActionParams":{"model":"gpt-4","messages":[{"role":"user","content":"This is a test"}],"n":1,"stop":null,"temperature":0.2},"subAction":"invokeAI"}}',
headers: { 'Content-Type': 'application/json' },
method: 'POST',
signal: undefined,
}
);
});
it('calls the actions connector api when assistantLangChain is false', async () => {
const testProps: FetchConnectorExecuteAction = {
assistantLangChain: false,
http: mockHttp,
messages,
apiConfig,
};
await fetchConnectorExecuteAction(testProps);
expect(mockHttp.fetch).toHaveBeenCalledWith('/api/actions/connector/foo/_execute', {
body: '{"params":{"subActionParams":{"model":"gpt-4","messages":[{"role":"user","content":"This is a test"}],"n":1,"stop":null,"temperature":0.2},"subAction":"invokeAI"}}',
headers: { 'Content-Type': 'application/json' },
method: 'POST',
signal: undefined,
}
);
});
});
});
it('calls the actions connector api when assistantLangChain is false', async () => {
const testProps: FetchConnectorExecuteAction = {
assistantLangChain: false,
http: mockHttp,
messages,
apiConfig,
};
it('returns API_ERROR when the response status is not ok', async () => {
(mockHttp.fetch as jest.Mock).mockResolvedValue({ status: 'error' });
await fetchConnectorExecuteAction(testProps);
const testProps: FetchConnectorExecuteAction = {
assistantLangChain: false,
http: mockHttp,
messages,
apiConfig,
};
expect(mockHttp.fetch).toHaveBeenCalledWith('/api/actions/connector/foo/_execute', {
body: '{"params":{"subActionParams":{"model":"gpt-4","messages":[{"role":"user","content":"This is a test"}],"n":1,"stop":null,"temperature":0.2},"subAction":"invokeAI"}}',
headers: { 'Content-Type': 'application/json' },
method: 'POST',
signal: undefined,
const result = await fetchConnectorExecuteAction(testProps);
expect(result).toEqual({ response: API_ERROR, isError: true });
});
it('returns API_ERROR when there are no choices', async () => {
(mockHttp.fetch as jest.Mock).mockResolvedValue({ status: 'ok', data: '' });
const testProps: FetchConnectorExecuteAction = {
assistantLangChain: false,
http: mockHttp,
messages,
apiConfig,
};
const result = await fetchConnectorExecuteAction(testProps);
expect(result).toEqual({ response: API_ERROR, isError: true });
});
it('returns the value of the action_input property when assistantLangChain is true, and `content` has properly prefixed and suffixed JSON with the action_input property', async () => {
const response = '```json\n{"action_input": "value from action_input"}\n```';
(mockHttp.fetch as jest.Mock).mockResolvedValue({
status: 'ok',
data: response,
});
const testProps: FetchConnectorExecuteAction = {
assistantLangChain: true, // <-- requires response parsing
http: mockHttp,
messages,
apiConfig,
};
const result = await fetchConnectorExecuteAction(testProps);
expect(result).toEqual({ response: 'value from action_input', isError: false });
});
it('returns the original content when assistantLangChain is true, and `content` has properly formatted JSON WITHOUT the action_input property', async () => {
const response = '```json\n{"some_key": "some value"}\n```';
(mockHttp.fetch as jest.Mock).mockResolvedValue({
status: 'ok',
data: response,
});
const testProps: FetchConnectorExecuteAction = {
assistantLangChain: true, // <-- requires response parsing
http: mockHttp,
messages,
apiConfig,
};
const result = await fetchConnectorExecuteAction(testProps);
expect(result).toEqual({ response, isError: false });
});
it('returns the original when assistantLangChain is true, and `content` is not JSON', async () => {
const response = 'plain text content';
(mockHttp.fetch as jest.Mock).mockResolvedValue({
status: 'ok',
data: response,
});
const testProps: FetchConnectorExecuteAction = {
assistantLangChain: true, // <-- requires response parsing
http: mockHttp,
messages,
apiConfig,
};
const result = await fetchConnectorExecuteAction(testProps);
expect(result).toEqual({ response, isError: false });
});
});
it('returns API_ERROR when the response status is not ok', async () => {
(mockHttp.fetch as jest.Mock).mockResolvedValue({ status: 'error' });
describe('getKnowledgeBaseStatus', () => {
it('calls the knowledge base API when correct resource path', async () => {
const testProps = {
resource: 'a-resource',
http: mockHttp,
};
const testProps: FetchConnectorExecuteAction = {
assistantLangChain: false,
http: mockHttp,
messages,
apiConfig,
};
await getKnowledgeBaseStatus(testProps);
const result = await fetchConnectorExecuteAction(testProps);
expect(result).toEqual({ response: API_ERROR, isError: true });
});
it('returns API_ERROR when there are no choices', async () => {
(mockHttp.fetch as jest.Mock).mockResolvedValue({ status: 'ok', data: '' });
const testProps: FetchConnectorExecuteAction = {
assistantLangChain: false,
http: mockHttp,
messages,
apiConfig,
};
const result = await fetchConnectorExecuteAction(testProps);
expect(result).toEqual({ response: API_ERROR, isError: true });
});
it('returns the value of the action_input property when assistantLangChain is true, and `content` has properly prefixed and suffixed JSON with the action_input property', async () => {
const response = '```json\n{"action_input": "value from action_input"}\n```';
(mockHttp.fetch as jest.Mock).mockResolvedValue({
status: 'ok',
data: response,
expect(mockHttp.fetch).toHaveBeenCalledWith(
'/internal/elastic_assistant/knowledge_base/a-resource',
{
method: 'GET',
signal: undefined,
}
);
});
it('returns error when error is an error', async () => {
const testProps = {
resource: 'a-resource',
http: mockHttp,
};
const error = 'simulated error';
(mockHttp.fetch as jest.Mock).mockImplementation(() => {
throw new Error(error);
});
const testProps: FetchConnectorExecuteAction = {
assistantLangChain: true, // <-- requires response parsing
http: mockHttp,
messages,
apiConfig,
};
const result = await fetchConnectorExecuteAction(testProps);
expect(result).toEqual({ response: 'value from action_input', isError: false });
await expect(getKnowledgeBaseStatus(testProps)).resolves.toThrowError('simulated error');
});
});
it('returns the original content when assistantLangChain is true, and `content` has properly formatted JSON WITHOUT the action_input property', async () => {
const response = '```json\n{"some_key": "some value"}\n```';
describe('postKnowledgeBase', () => {
it('calls the knowledge base API when correct resource path', async () => {
const testProps = {
resource: 'a-resource',
http: mockHttp,
};
(mockHttp.fetch as jest.Mock).mockResolvedValue({
status: 'ok',
data: response,
await postKnowledgeBase(testProps);
expect(mockHttp.fetch).toHaveBeenCalledWith(
'/internal/elastic_assistant/knowledge_base/a-resource',
{
method: 'POST',
signal: undefined,
}
);
});
it('returns error when error is an error', async () => {
const testProps = {
resource: 'a-resource',
http: mockHttp,
};
const error = 'simulated error';
(mockHttp.fetch as jest.Mock).mockImplementation(() => {
throw new Error(error);
});
const testProps: FetchConnectorExecuteAction = {
assistantLangChain: true, // <-- requires response parsing
http: mockHttp,
messages,
apiConfig,
};
const result = await fetchConnectorExecuteAction(testProps);
expect(result).toEqual({ response, isError: false });
await expect(postKnowledgeBase(testProps)).resolves.toThrowError('simulated error');
});
});
it('returns the original when assistantLangChain is true, and `content` is not JSON', async () => {
const response = 'plain text content';
describe('deleteKnowledgeBase', () => {
it('calls the knowledge base API when correct resource path', async () => {
const testProps = {
resource: 'a-resource',
http: mockHttp,
};
(mockHttp.fetch as jest.Mock).mockResolvedValue({
status: 'ok',
data: response,
await deleteKnowledgeBase(testProps);
expect(mockHttp.fetch).toHaveBeenCalledWith(
'/internal/elastic_assistant/knowledge_base/a-resource',
{
method: 'DELETE',
signal: undefined,
}
);
});
it('returns error when error is an error', async () => {
const testProps = {
resource: 'a-resource',
http: mockHttp,
};
const error = 'simulated error';
(mockHttp.fetch as jest.Mock).mockImplementation(() => {
throw new Error(error);
});
const testProps: FetchConnectorExecuteAction = {
assistantLangChain: true, // <-- requires response parsing
http: mockHttp,
messages,
apiConfig,
};
await expect(deleteKnowledgeBase(testProps)).resolves.toThrowError('simulated error');
});
});
const result = await fetchConnectorExecuteAction(testProps);
describe('postEvaluation', () => {
it('calls the knowledge base API when correct resource path', async () => {
(mockHttp.fetch as jest.Mock).mockResolvedValue({ success: true });
const testProps = {
http: mockHttp,
evalParams: {
agents: ['not', 'alphabetical'],
dataset: '{}',
evalModel: ['not', 'alphabetical'],
evalPrompt: 'evalPrompt',
evaluationType: ['not', 'alphabetical'],
models: ['not', 'alphabetical'],
outputIndex: 'outputIndex',
},
};
expect(result).toEqual({ response, isError: false });
await postEvaluation(testProps);
expect(mockHttp.fetch).toHaveBeenCalledWith('/internal/elastic_assistant/evaluate', {
method: 'POST',
body: '{"dataset":{},"evalPrompt":"evalPrompt"}',
headers: { 'Content-Type': 'application/json' },
query: {
models: 'alphabetical,not',
agents: 'alphabetical,not',
evaluationType: 'alphabetical,not',
evalModel: 'alphabetical,not',
outputIndex: 'outputIndex',
},
signal: undefined,
});
});
it('returns error when error is an error', async () => {
const testProps = {
resource: 'a-resource',
http: mockHttp,
};
const error = 'simulated error';
(mockHttp.fetch as jest.Mock).mockImplementation(() => {
throw new Error(error);
});
await expect(postEvaluation(testProps)).resolves.toThrowError('simulated error');
});
});
});

View file

@ -0,0 +1,297 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { ConversationSelector } from '.';
import { render, fireEvent, within } from '@testing-library/react';
import { TestProviders } from '../../../mock/test_providers/test_providers';
import { alertConvo, customConvo, welcomeConvo } from '../../../mock/conversation';
import { CONVERSATION_SELECTOR_PLACE_HOLDER } from './translations';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants';
const setConversation = jest.fn();
const deleteConversation = jest.fn();
const mockConversation = {
appendMessage: jest.fn(),
appendReplacements: jest.fn(),
clearConversation: jest.fn(),
createConversation: jest.fn(),
deleteConversation,
setApiConfig: jest.fn(),
setConversation,
};
jest.mock('../../use_conversation', () => ({
useConversation: () => mockConversation,
}));
const onConversationSelected = jest.fn();
const defaultProps = {
isDisabled: false,
onConversationSelected,
selectedConversationId: 'Welcome',
defaultConnectorId: '123',
defaultProvider: OpenAiProviderType.OpenAi,
};
describe('Conversation selector', () => {
beforeAll(() => {
jest.useFakeTimers();
});
beforeEach(() => {
jest.clearAllMocks();
});
it('renders with correct selected conversation', () => {
const { getByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
}}
>
<ConversationSelector {...defaultProps} />
</TestProviders>
);
expect(getByTestId('conversation-selector')).toBeInTheDocument();
expect(getByTestId('euiComboBoxPill')).toHaveTextContent(welcomeConvo.id);
});
it('On change, selects new item', () => {
const { getByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
}}
>
<ConversationSelector {...defaultProps} />
</TestProviders>
);
fireEvent.click(getByTestId('comboBoxSearchInput'));
fireEvent.click(getByTestId(`convo-option-${alertConvo.id}`));
expect(onConversationSelected).toHaveBeenCalledWith(alertConvo.id);
});
it('On clear input, clears selected options', () => {
const { getByText, queryByText, getByTestId, queryByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
}}
>
<ConversationSelector {...defaultProps} />
</TestProviders>
);
expect(getByTestId('euiComboBoxPill')).toBeInTheDocument();
expect(queryByText(CONVERSATION_SELECTOR_PLACE_HOLDER)).not.toBeInTheDocument();
fireEvent.click(getByTestId('comboBoxClearButton'));
expect(getByText(CONVERSATION_SELECTOR_PLACE_HOLDER)).toBeInTheDocument();
expect(queryByTestId('euiComboBoxPill')).not.toBeInTheDocument();
});
it('We can add a custom option', () => {
const { getByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
}}
>
<ConversationSelector {...defaultProps} />
</TestProviders>
);
const customOption = 'Custom option';
fireEvent.change(getByTestId('comboBoxSearchInput'), { target: { value: customOption } });
fireEvent.keyDown(getByTestId('comboBoxSearchInput'), {
key: 'Enter',
code: 'Enter',
charCode: 13,
});
expect(setConversation).toHaveBeenCalledWith({
conversation: {
id: customOption,
messages: [],
apiConfig: {
connectorId: '123',
defaultSystemPromptId: undefined,
provider: 'OpenAI',
},
},
});
});
it('Only custom options can be deleted', () => {
const { getByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[customConvo.id]: customConvo,
}),
}}
>
<ConversationSelector {...defaultProps} />
</TestProviders>
);
fireEvent.click(getByTestId('comboBoxSearchInput'));
expect(
within(getByTestId(`convo-option-${customConvo.id}`)).getByTestId('delete-option')
).toBeInTheDocument();
expect(
within(getByTestId(`convo-option-${alertConvo.id}`)).queryByTestId('delete-option')
).not.toBeInTheDocument();
});
it('Custom options can be deleted', () => {
const { getByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[customConvo.id]: customConvo,
}),
}}
>
<ConversationSelector {...defaultProps} />
</TestProviders>
);
fireEvent.click(getByTestId('comboBoxSearchInput'));
fireEvent.click(
within(getByTestId(`convo-option-${customConvo.id}`)).getByTestId('delete-option')
);
jest.runAllTimers();
expect(onConversationSelected).not.toHaveBeenCalled();
expect(deleteConversation).toHaveBeenCalledWith(customConvo.id);
});
it('Previous conversation is set to active when selected conversation is deleted', () => {
const { getByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[customConvo.id]: customConvo,
}),
}}
>
<ConversationSelector {...defaultProps} selectedConversationId={customConvo.id} />
</TestProviders>
);
fireEvent.click(getByTestId('comboBoxSearchInput'));
fireEvent.click(
within(getByTestId(`convo-option-${customConvo.id}`)).getByTestId('delete-option')
);
expect(onConversationSelected).toHaveBeenCalledWith(welcomeConvo.id);
});
it('Left arrow selects first conversation', () => {
const { getByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[customConvo.id]: customConvo,
}),
}}
>
<ConversationSelector {...defaultProps} />
</TestProviders>
);
fireEvent.keyDown(getByTestId('comboBoxSearchInput'), {
key: 'ArrowLeft',
ctrlKey: true,
code: 'ArrowLeft',
charCode: 27,
});
expect(onConversationSelected).toHaveBeenCalledWith(alertConvo.id);
});
it('Right arrow selects last conversation', () => {
const { getByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[customConvo.id]: customConvo,
}),
}}
>
<ConversationSelector {...defaultProps} />
</TestProviders>
);
fireEvent.keyDown(getByTestId('comboBoxSearchInput'), {
key: 'ArrowRight',
ctrlKey: true,
code: 'ArrowRight',
charCode: 26,
});
expect(onConversationSelected).toHaveBeenCalledWith(customConvo.id);
});
it('Right arrow does nothing when ctrlKey is false', () => {
const { getByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[customConvo.id]: customConvo,
}),
}}
>
<ConversationSelector {...defaultProps} />
</TestProviders>
);
fireEvent.keyDown(getByTestId('comboBoxSearchInput'), {
key: 'ArrowRight',
ctrlKey: false,
code: 'ArrowRight',
charCode: 26,
});
expect(onConversationSelected).not.toHaveBeenCalled();
});
it('Right arrow does nothing when conversation lenth is 1', () => {
const { getByTestId } = render(
<TestProviders
providerContext={{
getInitialConversations: () => ({
[welcomeConvo.id]: welcomeConvo,
}),
}}
>
<ConversationSelector {...defaultProps} />
</TestProviders>
);
fireEvent.keyDown(getByTestId('comboBoxSearchInput'), {
key: 'ArrowRight',
ctrlKey: true,
code: 'ArrowRight',
charCode: 26,
});
expect(onConversationSelected).not.toHaveBeenCalled();
});
});

View file

@ -63,11 +63,10 @@ export const ConversationSelector: React.FC<Props> = React.memo(
shouldDisableKeyboardShortcut = () => false,
isDisabled = false,
}) => {
const { allSystemPrompts } = useAssistantContext();
const { allSystemPrompts, conversations } = useAssistantContext();
const { deleteConversation, setConversation } = useConversation();
const { conversations } = useAssistantContext();
const conversationIds = useMemo(() => Object.keys(conversations), [conversations]);
const conversationOptions = useMemo<ConversationSelectorOption[]>(() => {
return Object.values(conversations).map((conversation) => ({
@ -203,6 +202,7 @@ export const ConversationSelector: React.FC<Props> = React.memo(
className={'parentFlexGroup'}
component={'span'}
justifyContent="spaceBetween"
data-test-subj={`convo-option-${label}`}
>
<EuiFlexItem
component={'span'}
@ -232,6 +232,7 @@ export const ConversationSelector: React.FC<Props> = React.memo(
e.stopPropagation();
onDelete(label);
}}
data-test-subj="delete-option"
css={css`
visibility: hidden;
.parentFlexGroup:hover & {
@ -255,6 +256,7 @@ export const ConversationSelector: React.FC<Props> = React.memo(
`}
>
<EuiComboBox
data-test-subj="conversation-selector"
aria-label={i18n.CONVERSATION_SELECTOR_ARIA_LABEL}
customOptionText={`${i18n.CONVERSATION_SELECTOR_CUSTOM_OPTION_TEXT} {searchValue}`}
placeholder={i18n.CONVERSATION_SELECTOR_PLACE_HOLDER}

View file

@ -0,0 +1,80 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { ConversationSelectorSettings } from '.';
import { alertConvo, customConvo, welcomeConvo } from '../../../mock/conversation';
const onConversationSelectionChange = jest.fn();
const onConversationDeleted = jest.fn();
const mockConversations = {
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[customConvo.id]: customConvo,
};
const testProps = {
conversations: mockConversations,
selectedConversationId: welcomeConvo.id,
onConversationDeleted,
onConversationSelectionChange,
};
describe('ConversationSelectorSettings', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Selects an existing conversation', () => {
const { getByTestId } = render(<ConversationSelectorSettings {...testProps} />);
expect(getByTestId('comboBoxInput')).toHaveTextContent(welcomeConvo.id);
fireEvent.click(getByTestId('comboBoxToggleListButton'));
fireEvent.click(getByTestId(alertConvo.id));
expect(onConversationSelectionChange).toHaveBeenCalledWith(alertConvo);
});
it('Only custom option can be deleted', () => {
const { getByTestId } = render(<ConversationSelectorSettings {...testProps} />);
fireEvent.click(getByTestId('comboBoxToggleListButton'));
// there is only one delete conversation because there is only one custom convo
fireEvent.click(getByTestId('delete-conversation'));
expect(onConversationDeleted).toHaveBeenCalledWith(customConvo.id);
});
it('Selects existing conversation from the search input', () => {
const { getByTestId } = render(<ConversationSelectorSettings {...testProps} />);
fireEvent.change(getByTestId('comboBoxSearchInput'), { target: { value: alertConvo.id } });
fireEvent.keyDown(getByTestId('comboBoxSearchInput'), {
key: 'Enter',
code: 'Enter',
charCode: 13,
});
expect(onConversationSelectionChange).toHaveBeenCalledWith(alertConvo);
});
it('Creates a new conversation', () => {
const { getByTestId } = render(<ConversationSelectorSettings {...testProps} />);
const customOption = 'Cool new conversation';
fireEvent.change(getByTestId('comboBoxSearchInput'), { target: { value: customOption } });
fireEvent.keyDown(getByTestId('comboBoxSearchInput'), {
key: 'Enter',
code: 'Enter',
charCode: 13,
});
expect(onConversationSelectionChange).toHaveBeenCalledWith(customOption);
});
it('Left arrow selects previous conversation', () => {
const { getByTestId } = render(<ConversationSelectorSettings {...testProps} />);
fireEvent.click(getByTestId('arrowLeft'));
expect(onConversationSelectionChange).toHaveBeenCalledWith(alertConvo);
});
it('Right arrow selects next conversation', () => {
const { getByTestId } = render(<ConversationSelectorSettings {...testProps} />);
fireEvent.click(getByTestId('arrowRight'));
expect(onConversationSelectionChange).toHaveBeenCalledWith(customConvo);
});
});

View file

@ -18,20 +18,16 @@ import {
import React, { useCallback, useMemo, useState } from 'react';
import { css } from '@emotion/react';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants';
import { Conversation, Prompt } from '../../../..';
import { Conversation } from '../../../..';
import { UseAssistantContext } from '../../../assistant_context';
import * as i18n from './translations';
import { SystemPromptSelectorOption } from '../../prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector';
interface Props {
allSystemPrompts: Prompt[];
conversations: UseAssistantContext['conversations'];
onConversationDeleted: (conversationId: string) => void;
onConversationSelectionChange: (conversation?: Conversation | string) => void;
selectedConversationId?: string;
defaultConnectorId?: string;
defaultProvider?: OpenAiProviderType;
}
const getPreviousConversationId = (conversationIds: string[], selectedConversationId = '') => {
@ -58,13 +54,10 @@ export type ConversationSelectorSettingsOption = EuiComboBoxOptionOption<{
*/
export const ConversationSelectorSettings: React.FC<Props> = React.memo(
({
allSystemPrompts,
conversations,
onConversationDeleted,
onConversationSelectionChange,
selectedConversationId,
defaultConnectorId,
defaultProvider,
}) => {
const conversationIds = useMemo(() => Object.keys(conversations), [conversations]);
@ -74,6 +67,7 @@ export const ConversationSelectorSettings: React.FC<Props> = React.memo(
return Object.values(conversations).map((conversation) => ({
value: { isDefault: conversation.isDefault ?? false },
label: conversation.id,
'data-test-subj': conversation.id,
}));
});
@ -195,6 +189,7 @@ export const ConversationSelectorSettings: React.FC<Props> = React.memo(
iconType="cross"
aria-label={i18n.DELETE_CONVERSATION}
color="danger"
data-test-subj="delete-conversation"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onDelete(label);
@ -235,6 +230,7 @@ export const ConversationSelectorSettings: React.FC<Props> = React.memo(
prepend={
<EuiButtonIcon
iconType="arrowLeft"
data-test-subj="arrowLeft"
aria-label={i18n.PREVIOUS_CONVERSATION_TITLE}
onClick={onLeftArrowClick}
disabled={conversationIds.length <= 1}
@ -243,6 +239,7 @@ export const ConversationSelectorSettings: React.FC<Props> = React.memo(
append={
<EuiButtonIcon
iconType="arrowRight"
data-test-subj="arrowRight"
aria-label={i18n.NEXT_CONVERSATION_TITLE}
onClick={onRightArrowClick}
disabled={conversationIds.length <= 1}

View file

@ -0,0 +1,208 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { ConversationSettings, ConversationSettingsProps } from './conversation_settings';
import { TestProviders } from '../../../mock/test_providers/test_providers';
import { alertConvo, customConvo, welcomeConvo } from '../../../mock/conversation';
import { mockSystemPrompts } from '../../../mock/system_prompt';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants';
import { mockConnectors } from '../../../mock/connectors';
const mockConvos = {
[welcomeConvo.id]: welcomeConvo,
[alertConvo.id]: alertConvo,
[customConvo.id]: customConvo,
};
const onSelectedConversationChange = jest.fn();
const setUpdatedConversationSettings = jest.fn().mockImplementation((fn) => {
return fn(mockConvos);
});
const testProps = {
allSystemPrompts: mockSystemPrompts,
conversationSettings: mockConvos,
defaultConnectorId: '123',
defaultProvider: OpenAiProviderType.OpenAi,
http: { basePath: { get: jest.fn() } },
onSelectedConversationChange,
selectedConversation: welcomeConvo,
setUpdatedConversationSettings,
} as unknown as ConversationSettingsProps;
jest.mock('../../../connectorland/use_load_connectors', () => ({
useLoadConnectors: () => ({
data: mockConnectors,
error: null,
isSuccess: true,
}),
}));
const mockConvo = alertConvo;
jest.mock('../conversation_selector_settings', () => ({
// @ts-ignore
ConversationSelectorSettings: ({ onConversationDeleted, onConversationSelectionChange }) => (
<>
<button
type="button"
data-test-subj="delete-convo"
onClick={() => onConversationDeleted('Custom option')}
/>
<button
type="button"
data-test-subj="change-convo"
onClick={() => onConversationSelectionChange(mockConvo)}
/>
<button
type="button"
data-test-subj="change-convo-custom"
onClick={() => onConversationSelectionChange('Cool new conversation')}
/>
</>
),
}));
jest.mock('../../prompt_editor/system_prompt/select_system_prompt', () => ({
// @ts-ignore
SelectSystemPrompt: ({ onSystemPromptSelectionChange }) => (
<button
type="button"
data-test-subj="change-sp"
onClick={() => onSystemPromptSelectionChange(mockSystemPrompts[1].id)}
/>
),
}));
jest.mock('../../../connectorland/models/model_selector/model_selector', () => ({
// @ts-ignore
ModelSelector: ({ onModelSelectionChange }) => (
<button
type="button"
data-test-subj="change-model"
onClick={() => onModelSelectionChange('MODEL_GPT_4')}
/>
),
}));
const mockConnector = {
id: 'cool-id-bro',
actionTypeId: '.gen-ai',
name: 'cool name',
connectorTypeTitle: 'OpenAI',
};
jest.mock('../../../connectorland/connector_selector', () => ({
// @ts-ignore
ConnectorSelector: ({ onConnectorSelectionChange }) => (
<button
type="button"
data-test-subj="change-connector"
onClick={() => onConnectorSelectionChange(mockConnector)}
/>
),
}));
describe('ConversationSettings', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Selecting a system prompt updates the defaultSystemPromptId for the selected conversation', () => {
const { getByTestId } = render(
<TestProviders>
<ConversationSettings {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId('change-sp'));
expect(setUpdatedConversationSettings).toHaveReturnedWith({
...mockConvos,
[welcomeConvo.id]: {
...welcomeConvo,
apiConfig: {
...welcomeConvo.apiConfig,
defaultSystemPromptId: 'mock-superhero-system-prompt-1',
},
},
});
});
it('Selecting an existing conversation updates the selected convo and does not update convo settings', () => {
const { getByTestId } = render(
<TestProviders>
<ConversationSettings {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId('change-convo'));
expect(setUpdatedConversationSettings).toHaveReturnedWith(mockConvos);
expect(onSelectedConversationChange).toHaveBeenCalledWith(alertConvo);
});
it('Selecting an existing conversation updates the selected convo and is added to the convo settings', () => {
const { getByTestId } = render(
<TestProviders>
<ConversationSettings {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId('change-convo-custom'));
const newConvo = {
apiConfig: {
connectorId: '123',
defaultSystemPromptId: 'default-system-prompt',
provider: 'OpenAI',
},
id: 'Cool new conversation',
messages: [],
};
expect(setUpdatedConversationSettings).toHaveReturnedWith({
...mockConvos,
[newConvo.id]: newConvo,
});
expect(onSelectedConversationChange).toHaveBeenCalledWith(newConvo);
});
it('Deleting a conversation removes it from the convo settings', () => {
const { getByTestId } = render(
<TestProviders>
<ConversationSettings {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId('delete-convo'));
const { [customConvo.id]: _, ...rest } = mockConvos;
expect(setUpdatedConversationSettings).toHaveReturnedWith(rest);
});
it('Selecting a new connector updates the conversation', () => {
const { getByTestId } = render(
<TestProviders>
<ConversationSettings {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId('change-connector'));
expect(setUpdatedConversationSettings).toHaveReturnedWith({
...mockConvos,
[welcomeConvo.id]: {
...welcomeConvo,
apiConfig: {
connectorId: mockConnector.id,
},
},
});
});
it('Selecting a new connector model updates the conversation', () => {
const { getByTestId } = render(
<TestProviders>
<ConversationSettings {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId('change-model'));
expect(setUpdatedConversationSettings).toHaveReturnedWith({
...mockConvos,
[welcomeConvo.id]: {
...welcomeConvo,
apiConfig: {
...welcomeConvo.apiConfig,
model: 'MODEL_GPT_4',
},
},
});
});
});

View file

@ -202,7 +202,6 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
<ConversationSelectorSettings
selectedConversationId={selectedConversation?.id}
allSystemPrompts={allSystemPrompts}
conversations={conversationSettings}
onConversationDeleted={onConversationDeleted}
onConversationSelectionChange={onConversationSelectionChange}
@ -220,7 +219,7 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
compressed
conversation={selectedConversation}
isEditing={true}
isDisabled={selectedConversation == null}
isDisabled={isDisabled}
onSystemPromptSelectionChange={handleOnSystemPromptSelectionChange}
selectedPrompt={selectedSystemPrompt}
showTitles={true}
@ -247,7 +246,7 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
}
>
<ConnectorSelector
isDisabled={selectedConversation == null}
isDisabled={isDisabled}
onConnectorSelectionChange={handleOnConnectorSelectionChange}
selectedConnectorId={selectedConnector?.id}
/>

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { ConversationMultiSelector } from './conversation_multi_selector';
import { alertConvo, welcomeConvo, customConvo } from '../../../../../mock/conversation';
const onConversationSelectionChange = jest.fn();
const testProps = {
conversations: [alertConvo, welcomeConvo, customConvo],
onConversationSelectionChange,
selectedConversations: [welcomeConvo],
};
describe('ConversationMultiSelector', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Selects an existing quick prompt', () => {
const { getByTestId } = render(<ConversationMultiSelector {...testProps} />);
expect(getByTestId('euiComboBoxPill')).toHaveTextContent(welcomeConvo.id);
fireEvent.click(getByTestId('comboBoxToggleListButton'));
fireEvent.click(getByTestId(`conversationMultiSelectorOption-${alertConvo.id}`));
expect(onConversationSelectionChange).toHaveBeenCalledWith([alertConvo, welcomeConvo]);
});
it('Selects existing conversation from the search input', () => {
const { getByTestId } = render(<ConversationMultiSelector {...testProps} />);
fireEvent.change(getByTestId('comboBoxSearchInput'), {
target: { value: alertConvo.id },
});
fireEvent.keyDown(getByTestId('comboBoxSearchInput'), {
key: 'Enter',
code: 'Enter',
charCode: 13,
});
expect(onConversationSelectionChange).toHaveBeenCalledWith([alertConvo, welcomeConvo]);
});
it('Does not support custom options', () => {
const { getByTestId } = render(<ConversationMultiSelector {...testProps} />);
const customOption = 'Cool new prompt';
fireEvent.change(getByTestId('comboBoxSearchInput'), { target: { value: customOption } });
fireEvent.keyDown(getByTestId('comboBoxSearchInput'), {
key: 'Enter',
code: 'Enter',
charCode: 13,
});
expect(onConversationSelectionChange).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { SystemPromptSelector } from './system_prompt_selector';
import { mockSystemPrompts } from '../../../../../mock/system_prompt';
const onSystemPromptSelectionChange = jest.fn();
const onSystemPromptDeleted = jest.fn();
const testProps = {
systemPrompts: mockSystemPrompts,
onSystemPromptSelectionChange,
onSystemPromptDeleted,
selectedSystemPrompt: mockSystemPrompts[0],
};
describe('SystemPromptSelector', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Selects an existing quick prompt', () => {
const { getByTestId } = render(<SystemPromptSelector {...testProps} />);
expect(getByTestId('euiComboBoxPill')).toHaveTextContent(mockSystemPrompts[0].name);
fireEvent.click(getByTestId('comboBoxToggleListButton'));
fireEvent.click(getByTestId(`systemPromptSelector-${mockSystemPrompts[1].id}`));
expect(onSystemPromptSelectionChange).toHaveBeenCalledWith(mockSystemPrompts[1]);
});
it('Deletes a system prompt that is not selected', () => {
const { getByTestId, getAllByTestId } = render(<SystemPromptSelector {...testProps} />);
fireEvent.click(getByTestId('comboBoxToggleListButton'));
// there is only one delete quick prompt because there is only one custom option
fireEvent.click(getAllByTestId('delete-prompt')[1]);
expect(onSystemPromptDeleted).toHaveBeenCalledWith(mockSystemPrompts[1].name);
expect(onSystemPromptSelectionChange).not.toHaveBeenCalled();
});
it('Deletes a system prompt that is selected', () => {
const { getByTestId, getAllByTestId } = render(<SystemPromptSelector {...testProps} />);
fireEvent.click(getByTestId('comboBoxToggleListButton'));
// there is only one delete quick prompt because there is only one custom option
fireEvent.click(getAllByTestId('delete-prompt')[0]);
expect(onSystemPromptDeleted).toHaveBeenCalledWith(mockSystemPrompts[0].name);
expect(onSystemPromptSelectionChange).toHaveBeenCalledWith(undefined);
});
it('Selects existing quick prompt from the search input', () => {
const { getByTestId } = render(<SystemPromptSelector {...testProps} />);
fireEvent.change(getByTestId('comboBoxSearchInput'), {
target: { value: mockSystemPrompts[1].name },
});
fireEvent.keyDown(getByTestId('comboBoxSearchInput'), {
key: 'Enter',
code: 'Enter',
charCode: 13,
});
expect(onSystemPromptSelectionChange).toHaveBeenCalledWith(mockSystemPrompts[1]);
});
it('Creates a new system prompt', () => {
const { getByTestId } = render(<SystemPromptSelector {...testProps} />);
const customOption = 'Cool new prompt';
fireEvent.change(getByTestId('comboBoxSearchInput'), { target: { value: customOption } });
fireEvent.keyDown(getByTestId('comboBoxSearchInput'), {
key: 'Enter',
code: 'Enter',
charCode: 13,
});
expect(onSystemPromptSelectionChange).toHaveBeenCalledWith(customOption);
});
});

View file

@ -193,6 +193,7 @@ export const SystemPromptSelector: React.FC<Props> = React.memo(
iconType="cross"
aria-label={i18n.DELETE_SYSTEM_PROMPT}
color="danger"
data-test-subj="delete-prompt"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onDelete(label);

View file

@ -0,0 +1,161 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { SystemPromptSettings } from './system_prompt_settings';
import { TestProviders } from '../../../../mock/test_providers/test_providers';
import { alertConvo, welcomeConvo } from '../../../../mock/conversation';
import { mockSystemPrompts } from '../../../../mock/system_prompt';
import { TEST_IDS } from '../../../constants';
const onSelectedSystemPromptChange = jest.fn();
const setUpdatedSystemPromptSettings = jest.fn().mockImplementation((fn) => {
return fn(mockSystemPrompts);
});
const setUpdatedConversationSettings = jest.fn().mockImplementation((fn) => {
return fn({
[welcomeConvo.id]: welcomeConvo,
[alertConvo.id]: alertConvo,
});
});
const testProps = {
conversationSettings: {
[welcomeConvo.id]: welcomeConvo,
},
onSelectedSystemPromptChange,
selectedSystemPrompt: mockSystemPrompts[0],
setUpdatedSystemPromptSettings,
setUpdatedConversationSettings,
systemPromptSettings: mockSystemPrompts,
};
jest.mock('./system_prompt_selector/system_prompt_selector', () => ({
// @ts-ignore
SystemPromptSelector: ({ onSystemPromptDeleted, onSystemPromptSelectionChange }) => (
<>
<button
type="button"
data-test-subj="delete-sp"
onClick={() => onSystemPromptDeleted(mockSystemPrompts[1].name)}
/>
<button
type="button"
data-test-subj="change-sp"
onClick={() => onSystemPromptSelectionChange(mockSystemPrompts[1])}
/>
<button
type="button"
data-test-subj="change-sp-custom"
onClick={() => onSystemPromptSelectionChange('sooper custom prompt')}
/>
</>
),
}));
const mockConvos = [alertConvo, welcomeConvo];
jest.mock('./conversation_multi_selector/conversation_multi_selector', () => ({
// @ts-ignore
ConversationMultiSelector: ({ onConversationSelectionChange }) => (
<>
<button
type="button"
data-test-subj="change-multi"
onClick={() => onConversationSelectionChange(mockConvos)}
/>
</>
),
}));
describe('SystemPromptSettings', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Selecting a system prompt updates the selected system prompts', () => {
const { getByTestId } = render(
<TestProviders>
<SystemPromptSettings {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId('change-sp'));
expect(setUpdatedSystemPromptSettings).toHaveReturnedWith(mockSystemPrompts);
expect(onSelectedSystemPromptChange).toHaveBeenCalledWith(mockSystemPrompts[1]);
});
it('Entering a custom system prompt creates a new system prompt', () => {
const { getByTestId } = render(
<TestProviders>
<SystemPromptSettings {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId('change-sp-custom'));
const customOption = {
content: '',
id: 'sooper custom prompt',
name: 'sooper custom prompt',
promptType: 'system',
};
expect(setUpdatedSystemPromptSettings).toHaveReturnedWith([...mockSystemPrompts, customOption]);
expect(onSelectedSystemPromptChange).toHaveBeenCalledWith(customOption);
});
it('Updating the current prompt input updates the prompt', () => {
const { getByTestId } = render(
<TestProviders>
<SystemPromptSettings {...testProps} />
</TestProviders>
);
fireEvent.change(getByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.PROMPT_TEXT), {
target: { value: 'what does this do' },
});
const mutatableQuickPrompts = [...mockSystemPrompts];
const previousFirstElementOfTheArray = mutatableQuickPrompts.shift();
expect(setUpdatedSystemPromptSettings).toHaveReturnedWith([
{ ...previousFirstElementOfTheArray, content: 'what does this do' },
...mutatableQuickPrompts,
]);
});
it('Updating prompt contexts updates the categories of the prompt', () => {
const { getByTestId } = render(
<TestProviders>
<SystemPromptSettings {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId('change-multi'));
expect(setUpdatedConversationSettings).toHaveReturnedWith({
[welcomeConvo.id]: {
...welcomeConvo,
apiConfig: {
...welcomeConvo.apiConfig,
defaultSystemPromptId: 'mock-system-prompt-1',
},
},
[alertConvo.id]: {
...alertConvo,
apiConfig: {
...alertConvo.apiConfig,
defaultSystemPromptId: 'mock-system-prompt-1',
},
},
});
});
it('Toggle default conversation checkbox works', () => {
const { getByTestId } = render(
<TestProviders>
<SystemPromptSettings {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId(TEST_IDS.SYSTEM_PROMPT_MODAL.TOGGLE_ALL_DEFAULT_CONVERSATIONS));
const mutatableQuickPrompts = [...mockSystemPrompts];
const previousFirstElementOfTheArray = mutatableQuickPrompts.shift();
expect(setUpdatedSystemPromptSettings).toHaveReturnedWith([
{ ...previousFirstElementOfTheArray, isNewConversationDefault: true },
...mutatableQuickPrompts.map((p) => ({ ...p, isNewConversationDefault: false })),
]);
});
});

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { PromptContextSelector } from './prompt_context_selector';
import { mockPromptContexts } from '../../../mock/prompt_context';
const onPromptContextSelectionChange = jest.fn();
const testProps = {
promptContexts: mockPromptContexts,
selectedPromptContexts: [mockPromptContexts[0]],
onPromptContextSelectionChange,
};
describe('PromptContextSelector', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Selects an existing prompt context and adds it to the previous selection', () => {
const { getByTestId } = render(<PromptContextSelector {...testProps} />);
expect(getByTestId('euiComboBoxPill')).toHaveTextContent(mockPromptContexts[0].description);
fireEvent.click(getByTestId('comboBoxToggleListButton'));
fireEvent.click(getByTestId(mockPromptContexts[1].description));
expect(onPromptContextSelectionChange).toHaveBeenCalledWith(mockPromptContexts);
});
it('Selects existing prompt context from the search input', () => {
const { getByTestId } = render(<PromptContextSelector {...testProps} />);
fireEvent.change(getByTestId('comboBoxSearchInput'), {
target: { value: mockPromptContexts[1].description },
});
fireEvent.keyDown(getByTestId('comboBoxSearchInput'), {
key: 'Enter',
code: 'Enter',
charCode: 13,
});
expect(onPromptContextSelectionChange).toHaveBeenCalledWith(mockPromptContexts);
});
it('Does not support custom options', () => {
const { getByTestId } = render(<PromptContextSelector {...testProps} />);
const customOption = 'Cool new prompt';
fireEvent.change(getByTestId('comboBoxSearchInput'), { target: { value: customOption } });
fireEvent.keyDown(getByTestId('comboBoxSearchInput'), {
key: 'Enter',
code: 'Enter',
charCode: 13,
});
expect(onPromptContextSelectionChange).not.toHaveBeenCalled();
});
});

View file

@ -33,6 +33,7 @@ export const PromptContextSelector: React.FC<Props> = React.memo(
category: pc.category,
},
label: pc.description,
'data-test-subj': pc.description,
})),
[promptContexts]
);

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { QuickPromptSelector } from './quick_prompt_selector';
import { MOCK_QUICK_PROMPTS } from '../../../mock/quick_prompt';
const onQuickPromptSelectionChange = jest.fn();
const onQuickPromptDeleted = jest.fn();
const testProps = {
quickPrompts: MOCK_QUICK_PROMPTS,
selectedQuickPrompt: MOCK_QUICK_PROMPTS[0],
onQuickPromptDeleted,
onQuickPromptSelectionChange,
};
describe('QuickPromptSelector', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Selects an existing quick prompt', () => {
const { getByTestId } = render(<QuickPromptSelector {...testProps} />);
expect(getByTestId('euiComboBoxPill')).toHaveTextContent(MOCK_QUICK_PROMPTS[0].title);
fireEvent.click(getByTestId('comboBoxToggleListButton'));
fireEvent.click(getByTestId(MOCK_QUICK_PROMPTS[1].title));
expect(onQuickPromptSelectionChange).toHaveBeenCalledWith(MOCK_QUICK_PROMPTS[1]);
});
it('Only custom option can be deleted', () => {
const { getByTestId } = render(<QuickPromptSelector {...testProps} />);
fireEvent.click(getByTestId('comboBoxToggleListButton'));
// there is only one delete quick prompt because there is only one custom option
fireEvent.click(getByTestId('delete-quick-prompt'));
expect(onQuickPromptDeleted).toHaveBeenCalledWith('A_CUSTOM_OPTION');
});
it('Selects existing quick prompt from the search input', () => {
const { getByTestId } = render(<QuickPromptSelector {...testProps} />);
const customOption = 'A_CUSTOM_OPTION';
fireEvent.change(getByTestId('comboBoxSearchInput'), { target: { value: customOption } });
fireEvent.keyDown(getByTestId('comboBoxSearchInput'), {
key: 'Enter',
code: 'Enter',
charCode: 13,
});
expect(onQuickPromptSelectionChange).toHaveBeenCalledWith({
categories: [],
color: '#D36086',
prompt: 'quickly prompt please',
title: 'A_CUSTOM_OPTION',
});
});
it('Creates a new quick prompt', () => {
const { getByTestId } = render(<QuickPromptSelector {...testProps} />);
const customOption = 'Cool new prompt';
fireEvent.change(getByTestId('comboBoxSearchInput'), { target: { value: customOption } });
fireEvent.keyDown(getByTestId('comboBoxSearchInput'), {
key: 'Enter',
code: 'Enter',
charCode: 13,
});
expect(onQuickPromptSelectionChange).toHaveBeenCalledWith(customOption);
});
});

View file

@ -49,6 +49,7 @@ export const QuickPromptSelector: React.FC<Props> = React.memo(
isDefault: qp.isDefault ?? false,
},
label: qp.title,
'data-test-subj': qp.title,
color: qp.color,
}))
);
@ -164,6 +165,7 @@ export const QuickPromptSelector: React.FC<Props> = React.memo(
<EuiButtonIcon
iconType="cross"
aria-label={i18n.DELETE_QUICK_PROMPT_}
data-test-subj="delete-quick-prompt"
color="danger"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
@ -185,6 +187,7 @@ export const QuickPromptSelector: React.FC<Props> = React.memo(
return (
<EuiComboBox
data-test-subj="quickPromptSelector"
aria-label={i18n.QUICK_PROMPT_SELECTOR}
compressed
isDisabled={isDisabled}

View file

@ -0,0 +1,152 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { QuickPromptSettings } from './quick_prompt_settings';
import { TestProviders } from '../../../mock/test_providers/test_providers';
import { MOCK_QUICK_PROMPTS } from '../../../mock/quick_prompt';
import { mockPromptContexts } from '../../../mock/prompt_context';
const onSelectedQuickPromptChange = jest.fn();
const setUpdatedQuickPromptSettings = jest.fn().mockImplementation((fn) => {
return fn(MOCK_QUICK_PROMPTS);
});
const testProps = {
onSelectedQuickPromptChange,
quickPromptSettings: MOCK_QUICK_PROMPTS,
selectedQuickPrompt: MOCK_QUICK_PROMPTS[0],
setUpdatedQuickPromptSettings,
};
const mockContext = {
basePromptContexts: MOCK_QUICK_PROMPTS,
};
jest.mock('../../../assistant_context', () => ({
...jest.requireActual('../../../assistant_context'),
useAssistantContext: () => mockContext,
}));
jest.mock('../quick_prompt_selector/quick_prompt_selector', () => ({
// @ts-ignore
QuickPromptSelector: ({ onQuickPromptDeleted, onQuickPromptSelectionChange }) => (
<>
<button
type="button"
data-test-subj="delete-qp"
onClick={() => onQuickPromptDeleted('A_CUSTOM_OPTION')}
/>
<button
type="button"
data-test-subj="change-qp"
onClick={() => onQuickPromptSelectionChange(MOCK_QUICK_PROMPTS[3])}
/>
<button
type="button"
data-test-subj="change-qp-custom"
onClick={() => onQuickPromptSelectionChange('sooper custom prompt')}
/>
</>
),
}));
jest.mock('../prompt_context_selector/prompt_context_selector', () => ({
// @ts-ignore
PromptContextSelector: ({ onPromptContextSelectionChange }) => (
<>
<button
type="button"
data-test-subj="change-pc"
onClick={() => onPromptContextSelectionChange(mockPromptContexts)}
/>
</>
),
}));
describe('QuickPromptSettings', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Selecting a quick prompt updates the selected quick prompts', () => {
const { getByTestId } = render(
<TestProviders>
<QuickPromptSettings {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId('change-qp'));
expect(setUpdatedQuickPromptSettings).toHaveReturnedWith(MOCK_QUICK_PROMPTS);
expect(onSelectedQuickPromptChange).toHaveBeenCalledWith(MOCK_QUICK_PROMPTS[3]);
});
it('Entering a custom quick prompt creates a new quick prompt', () => {
const { getByTestId } = render(
<TestProviders>
<QuickPromptSettings {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId('change-qp-custom'));
const customOption = {
categories: [],
color: '#D36086',
prompt: '',
title: 'sooper custom prompt',
};
expect(setUpdatedQuickPromptSettings).toHaveReturnedWith([...MOCK_QUICK_PROMPTS, customOption]);
expect(onSelectedQuickPromptChange).toHaveBeenCalledWith(customOption);
});
it('Quick prompt badge color can be updated', () => {
const { getByTestId } = render(
<TestProviders>
<QuickPromptSettings {...testProps} />
</TestProviders>
);
fireEvent.change(getByTestId('euiColorPickerAnchor'), { target: { value: '#000' } });
fireEvent.keyDown(getByTestId('euiColorPickerAnchor'), {
key: 'Enter',
code: 'Enter',
charCode: 13,
});
const mutatableQuickPrompts = [...MOCK_QUICK_PROMPTS];
const previousFirstElementOfTheArray = mutatableQuickPrompts.shift();
expect(setUpdatedQuickPromptSettings).toHaveReturnedWith([
{ ...previousFirstElementOfTheArray, color: '#000' },
...mutatableQuickPrompts,
]);
});
it('Updating the current prompt input updates the prompt', () => {
const { getByTestId } = render(
<TestProviders>
<QuickPromptSettings {...testProps} />
</TestProviders>
);
fireEvent.change(getByTestId('quick-prompt-prompt'), {
target: { value: 'what does this do' },
});
const mutatableQuickPrompts = [...MOCK_QUICK_PROMPTS];
const previousFirstElementOfTheArray = mutatableQuickPrompts.shift();
expect(setUpdatedQuickPromptSettings).toHaveReturnedWith([
{ ...previousFirstElementOfTheArray, prompt: 'what does this do' },
...mutatableQuickPrompts,
]);
});
it('Updating prompt contexts updates the categories of the prompt', () => {
const { getByTestId } = render(
<TestProviders>
<QuickPromptSettings {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId('change-pc'));
const mutatableQuickPrompts = [...MOCK_QUICK_PROMPTS];
const previousFirstElementOfTheArray = mutatableQuickPrompts.shift();
expect(setUpdatedQuickPromptSettings).toHaveReturnedWith([
{ ...previousFirstElementOfTheArray, categories: ['alert', 'event'] },
...mutatableQuickPrompts,
]);
});
});

View file

@ -197,6 +197,7 @@ export const QuickPromptSettings: React.FC<Props> = React.memo<Props>(
compressed
disabled={selectedQuickPrompt == null}
fullWidth
data-test-subj="quick-prompt-prompt"
onChange={handlePromptChange}
placeholder={i18n.QUICK_PROMPT_PROMPT_PLACEHOLDER}
value={prompt}

View file

@ -0,0 +1,131 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { alertConvo, customConvo, welcomeConvo } from '../../mock/conversation';
import { useAssistantContext } from '../../assistant_context';
import { fireEvent, render } from '@testing-library/react';
import {
AssistantSettings,
ANONYMIZATION_TAB,
CONVERSATIONS_TAB,
EVALUATION_TAB,
KNOWLEDGE_BASE_TAB,
QUICK_PROMPTS_TAB,
SYSTEM_PROMPTS_TAB,
} from './assistant_settings';
import React from 'react';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants';
import { MOCK_QUICK_PROMPTS } from '../../mock/quick_prompt';
const mockConversations = {
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
};
const saveSettings = jest.fn();
const mockValues = {
conversationSettings: mockConversations,
saveSettings,
};
const setSelectedSettingsTab = jest.fn();
const mockContext = {
basePromptContexts: MOCK_QUICK_PROMPTS,
setSelectedSettingsTab,
http: {},
modelEvaluatorEnabled: true,
selectedSettingsTab: 'CONVERSATIONS_TAB',
};
const onClose = jest.fn();
const onSave = jest.fn();
const setSelectedConversationId = jest.fn();
const testProps = {
defaultConnectorId: '123',
defaultProvider: OpenAiProviderType.OpenAi,
selectedConversation: welcomeConvo,
onClose,
onSave,
setSelectedConversationId,
};
jest.mock('../../assistant_context');
jest.mock('.', () => {
return {
AnonymizationSettings: () => <span data-test-subj="ANONYMIZATION_TAB-tab" />,
ConversationSettings: () => <span data-test-subj={`CONVERSATION_TAB-tab`} />,
EvaluationSettings: () => <span data-test-subj="EVALUATION_TAB-tab" />,
KnowledgeBaseSettings: () => <span data-test-subj="KNOWLEDGE_BASE_TAB-tab" />,
QuickPromptSettings: () => <span data-test-subj="QUICK_PROMPTS_TAB-tab" />,
SystemPromptSettings: () => <span data-test-subj="SYSTEM_PROMPTS_TAB-tab" />,
};
});
jest.mock('./use_settings_updater/use_settings_updater', () => {
const original = jest.requireActual('./use_settings_updater/use_settings_updater');
return {
...original,
useSettingsUpdater: jest.fn().mockImplementation(() => mockValues),
};
});
describe('AssistantSettings', () => {
beforeEach(() => {
jest.clearAllMocks();
(useAssistantContext as jest.Mock).mockImplementation(() => mockContext);
});
it('saves changes', () => {
const { getByTestId } = render(<AssistantSettings {...testProps} />);
fireEvent.click(getByTestId('save-button'));
expect(onSave).toHaveBeenCalled();
expect(saveSettings).toHaveBeenCalled();
});
it('saves changes and updates selected conversation when selected conversation has been deleted', () => {
const { getByTestId } = render(
<AssistantSettings {...testProps} selectedConversation={customConvo} />
);
fireEvent.click(getByTestId('save-button'));
expect(onSave).toHaveBeenCalled();
expect(setSelectedConversationId).toHaveBeenCalled();
expect(saveSettings).toHaveBeenCalled();
});
it('on close is called when settings modal closes', () => {
const { getByTestId } = render(<AssistantSettings {...testProps} />);
fireEvent.click(getByTestId('cancel-button'));
expect(onClose).toHaveBeenCalled();
});
describe.each([
ANONYMIZATION_TAB,
CONVERSATIONS_TAB,
EVALUATION_TAB,
KNOWLEDGE_BASE_TAB,
QUICK_PROMPTS_TAB,
SYSTEM_PROMPTS_TAB,
])('%s', (tab) => {
it('Opens the tab on button click', () => {
(useAssistantContext as jest.Mock).mockImplementation(() => ({
...mockContext,
selectedSettingsTab: tab === CONVERSATIONS_TAB ? ANONYMIZATION_TAB : CONVERSATIONS_TAB,
}));
const { getByTestId } = render(<AssistantSettings {...testProps} />);
fireEvent.click(getByTestId(`${tab}-button`));
expect(setSelectedSettingsTab).toHaveBeenCalledWith(tab);
});
it('renders with the correct tab open', () => {
(useAssistantContext as jest.Mock).mockImplementation(() => ({
...mockContext,
selectedSettingsTab: tab,
}));
const { getByTestId } = render(<AssistantSettings {...testProps} />);
expect(getByTestId(`${tab}-tab`)).toBeInTheDocument();
});
});
});

View file

@ -27,14 +27,16 @@ import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/c
import { Conversation, Prompt, QuickPrompt } from '../../..';
import * as i18n from './translations';
import { useAssistantContext } from '../../assistant_context';
import { AnonymizationSettings } from '../../data_anonymization/settings/anonymization_settings';
import { QuickPromptSettings } from '../quick_prompts/quick_prompt_settings/quick_prompt_settings';
import { SystemPromptSettings } from '../prompt_editor/system_prompt/system_prompt_modal/system_prompt_settings';
import { KnowledgeBaseSettings } from '../../knowledge_base/knowledge_base_settings/knowledge_base_settings';
import { ConversationSettings } from '../conversations/conversation_settings/conversation_settings';
import { TEST_IDS } from '../constants';
import { useSettingsUpdater } from './use_settings_updater/use_settings_updater';
import { EvaluationSettings } from './evaluation_settings/evaluation_settings';
import {
AnonymizationSettings,
ConversationSettings,
EvaluationSettings,
KnowledgeBaseSettings,
QuickPromptSettings,
SystemPromptSettings,
} from '.';
const StyledEuiModal = styled(EuiModal)`
width: 800px;
@ -81,6 +83,7 @@ export const AssistantSettings: React.FC<Props> = React.memo(
}) => {
const { modelEvaluatorEnabled, http, selectedSettingsTab, setSelectedSettingsTab } =
useAssistantContext();
const {
conversationSettings,
defaultAllow,
@ -171,6 +174,7 @@ export const AssistantSettings: React.FC<Props> = React.memo(
label={i18n.CONVERSATIONS_MENU_ITEM}
isSelected={selectedSettingsTab === CONVERSATIONS_TAB}
onClick={() => setSelectedSettingsTab(CONVERSATIONS_TAB)}
data-test-subj={`${CONVERSATIONS_TAB}-button`}
>
<>
<EuiIcon
@ -197,6 +201,7 @@ export const AssistantSettings: React.FC<Props> = React.memo(
label={i18n.QUICK_PROMPTS_MENU_ITEM}
isSelected={selectedSettingsTab === QUICK_PROMPTS_TAB}
onClick={() => setSelectedSettingsTab(QUICK_PROMPTS_TAB)}
data-test-subj={`${QUICK_PROMPTS_TAB}-button`}
>
<>
<EuiIcon type="editorComment" size="xxl" />
@ -217,6 +222,7 @@ export const AssistantSettings: React.FC<Props> = React.memo(
label={i18n.SYSTEM_PROMPTS_MENU_ITEM}
isSelected={selectedSettingsTab === SYSTEM_PROMPTS_TAB}
onClick={() => setSelectedSettingsTab(SYSTEM_PROMPTS_TAB)}
data-test-subj={`${SYSTEM_PROMPTS_TAB}-button`}
>
<EuiIcon type="editorComment" size="xxl" />
<EuiIcon
@ -235,6 +241,7 @@ export const AssistantSettings: React.FC<Props> = React.memo(
label={i18n.ANONYMIZATION_MENU_ITEM}
isSelected={selectedSettingsTab === ANONYMIZATION_TAB}
onClick={() => setSelectedSettingsTab(ANONYMIZATION_TAB)}
data-test-subj={`${ANONYMIZATION_TAB}-button`}
>
<EuiIcon type="eyeClosed" size="l" />
</EuiKeyPadMenuItem>
@ -243,6 +250,7 @@ export const AssistantSettings: React.FC<Props> = React.memo(
label={i18n.KNOWLEDGE_BASE_MENU_ITEM}
isSelected={selectedSettingsTab === KNOWLEDGE_BASE_TAB}
onClick={() => setSelectedSettingsTab(KNOWLEDGE_BASE_TAB)}
data-test-subj={`${KNOWLEDGE_BASE_TAB}-button`}
>
<EuiIcon type="notebookApp" size="l" />
</EuiKeyPadMenuItem>
@ -252,6 +260,7 @@ export const AssistantSettings: React.FC<Props> = React.memo(
label={i18n.EVALUATION_MENU_ITEM}
isSelected={selectedSettingsTab === EVALUATION_TAB}
onClick={() => setSelectedSettingsTab(EVALUATION_TAB)}
data-test-subj={`${EVALUATION_TAB}-button`}
>
<EuiIcon type="crossClusterReplicationApp" size="l" />
</EuiKeyPadMenuItem>
@ -276,6 +285,7 @@ export const AssistantSettings: React.FC<Props> = React.memo(
setUpdatedConversationSettings={setUpdatedConversationSettings}
allSystemPrompts={systemPromptSettings}
selectedConversation={selectedConversation}
isDisabled={selectedConversation == null}
onSelectedConversationChange={onHandleSelectedConversationChange}
http={http}
/>
@ -327,11 +337,17 @@ export const AssistantSettings: React.FC<Props> = React.memo(
padding: 4px;
`}
>
<EuiButtonEmpty size="s" onClick={onClose}>
<EuiButtonEmpty size="s" data-test-subj="cancel-button" onClick={onClose}>
{i18n.CANCEL}
</EuiButtonEmpty>
<EuiButton size="s" type="submit" onClick={handleSave} fill>
<EuiButton
size="s"
type="submit"
data-test-subj="save-button"
onClick={handleSave}
fill
>
{i18n.SAVE}
</EuiButton>
</EuiModalFooter>

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants';
import { AssistantSettingsButton } from './assistant_settings_button';
import { welcomeConvo } from '../../mock/conversation';
import { CONVERSATIONS_TAB } from './assistant_settings';
const setIsSettingsModalVisible = jest.fn();
const setSelectedConversationId = jest.fn();
const testProps = {
defaultConnectorId: '123',
defaultProvider: OpenAiProviderType.OpenAi,
isSettingsModalVisible: false,
selectedConversation: welcomeConvo,
setIsSettingsModalVisible,
setSelectedConversationId,
};
const setSelectedSettingsTab = jest.fn();
const mockUseAssistantContext = {
setSelectedSettingsTab,
};
jest.mock('../../assistant_context', () => {
const original = jest.requireActual('../../assistant_context');
return {
...original,
useAssistantContext: () => mockUseAssistantContext,
};
});
jest.mock('./assistant_settings', () => ({
...jest.requireActual('./assistant_settings'),
// @ts-ignore
AssistantSettings: ({ onClose, onSave }) => (
<>
<button type="button" data-test-subj="on-close" onClick={onClose} />
<button type="button" data-test-subj="on-save" onClick={onSave} />
</>
),
}));
describe('AssistantSettingsButton', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Clicking the settings gear opens the conversations tab', () => {
const { getByTestId } = render(<AssistantSettingsButton {...testProps} />);
fireEvent.click(getByTestId('settings'));
expect(setSelectedSettingsTab).toHaveBeenCalledWith(CONVERSATIONS_TAB);
expect(setIsSettingsModalVisible).toHaveBeenCalledWith(true);
});
it('Settings modal is visble and calls correct actions per click', () => {
const { getByTestId } = render(
<AssistantSettingsButton {...testProps} isSettingsModalVisible />
);
fireEvent.click(getByTestId('on-close'));
expect(setIsSettingsModalVisible).toHaveBeenCalledWith(false);
fireEvent.click(getByTestId('on-save'));
expect(setIsSettingsModalVisible).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,131 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { act, renderHook } from '@testing-library/react-hooks';
import { usePerformEvaluation, UsePerformEvaluationParams } from './use_perform_evaluation';
import { postEvaluation as _postEvaluation } from '../../api';
import { useMutation as _useMutation } from '@tanstack/react-query';
const useMutationMock = _useMutation as jest.Mock;
const postEvaluationMock = _postEvaluation as jest.Mock;
jest.mock('../../api', () => {
const actual = jest.requireActual('../../api');
return {
...actual,
postEvaluation: jest.fn((...args) => actual.postEvaluation(...args)),
};
});
jest.mock('@tanstack/react-query', () => ({
useMutation: jest.fn().mockImplementation(async (queryKey, fn, opts) => {
try {
const res = await fn();
return Promise.resolve(res);
} catch (e) {
opts.onError(e);
}
}),
}));
const statusResponse = {
success: true,
};
const http = {
fetch: jest.fn().mockResolvedValue(statusResponse),
};
const toasts = {
addError: jest.fn(),
};
const defaultProps = { http, toasts } as unknown as UsePerformEvaluationParams;
describe('usePerformEvaluation', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should call api with undefined evalParams', async () => {
await act(async () => {
const { waitForNextUpdate } = renderHook(() => usePerformEvaluation(defaultProps));
await waitForNextUpdate();
expect(defaultProps.http.fetch).toHaveBeenCalledWith('/internal/elastic_assistant/evaluate', {
method: 'POST',
body: '{"dataset":[],"evalPrompt":""}',
headers: {
'Content-Type': 'application/json',
},
query: {
agents: undefined,
evalModel: undefined,
evaluationType: undefined,
models: undefined,
outputIndex: undefined,
},
signal: undefined,
});
expect(toasts.addError).not.toHaveBeenCalled();
});
});
it('Correctly passes and formats evalParams', async () => {
useMutationMock.mockImplementation(async (queryKey, fn, opts) => {
try {
const res = await fn({
agents: ['d', 'c'],
dataset: '["kewl"]',
evalModel: ['b', 'a'],
evalPrompt: 'evalPrompt',
evaluationType: ['f', 'e'],
models: ['h', 'g'],
outputIndex: 'outputIndex',
});
return Promise.resolve(res);
} catch (e) {
opts.onError(e);
}
});
await act(async () => {
const { waitForNextUpdate } = renderHook(() => usePerformEvaluation(defaultProps));
await waitForNextUpdate();
expect(defaultProps.http.fetch).toHaveBeenCalledWith('/internal/elastic_assistant/evaluate', {
method: 'POST',
body: '{"dataset":["kewl"],"evalPrompt":"evalPrompt"}',
headers: {
'Content-Type': 'application/json',
},
query: {
agents: 'c,d',
evalModel: 'a,b',
evaluationType: 'e,f',
models: 'g,h',
outputIndex: 'outputIndex',
},
signal: undefined,
});
});
});
it('should return evaluation response', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => usePerformEvaluation(defaultProps));
await waitForNextUpdate();
await expect(result.current).resolves.toStrictEqual(statusResponse);
});
});
it('should display error toast when api throws error', async () => {
postEvaluationMock.mockRejectedValue(new Error('this is an error'));
await act(async () => {
const { waitForNextUpdate } = renderHook(() => usePerformEvaluation(defaultProps));
await waitForNextUpdate();
expect(toasts.addError).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { AnonymizationSettings } from '../../data_anonymization/settings/anonymization_settings';
export { ConversationSettings } from '../conversations/conversation_settings/conversation_settings';
export { EvaluationSettings } from './evaluation_settings/evaluation_settings';
export { KnowledgeBaseSettings } from '../../knowledge_base/knowledge_base_settings';
export { QuickPromptSettings } from '../quick_prompts/quick_prompt_settings/quick_prompt_settings';
export { SystemPromptSettings } from '../prompt_editor/system_prompt/system_prompt_modal/system_prompt_settings';

View file

@ -0,0 +1,143 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { act, renderHook } from '@testing-library/react-hooks';
import { alertConvo, customConvo, welcomeConvo } from '../../../mock/conversation';
import { useSettingsUpdater } from './use_settings_updater';
import { Prompt } from '../../../..';
import {
defaultSystemPrompt,
mockSuperheroSystemPrompt,
mockSystemPrompt,
} from '../../../mock/system_prompt';
const mockConversations = {
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
};
const mockSystemPrompts: Prompt[] = [mockSystemPrompt];
const mockQuickPrompts: Prompt[] = [defaultSystemPrompt];
const initialDefaultAllow = ['allow1'];
const initialDefaultAllowReplacement = ['replacement1'];
const setAllQuickPromptsMock = jest.fn();
const setAllSystemPromptsMock = jest.fn();
const setConversationsMock = jest.fn();
const setDefaultAllowMock = jest.fn();
const setDefaultAllowReplacementMock = jest.fn();
const setKnowledgeBaseMock = jest.fn();
const mockValues = {
conversations: mockConversations,
allSystemPrompts: mockSystemPrompts,
allQuickPrompts: mockQuickPrompts,
defaultAllow: initialDefaultAllow,
defaultAllowReplacement: initialDefaultAllowReplacement,
knowledgeBase: {
assistantLangChain: true,
},
setAllQuickPrompts: setAllQuickPromptsMock,
setConversations: setConversationsMock,
setAllSystemPrompts: setAllSystemPromptsMock,
setDefaultAllow: setDefaultAllowMock,
setDefaultAllowReplacement: setDefaultAllowReplacementMock,
setKnowledgeBase: setKnowledgeBaseMock,
};
const updatedValues = {
conversations: { [customConvo.id]: customConvo },
allSystemPrompts: [mockSuperheroSystemPrompt],
allQuickPrompts: [{ title: 'Prompt 2', prompt: 'Prompt 2', color: 'red' }],
defaultAllow: ['allow2'],
defaultAllowReplacement: ['replacement2'],
knowledgeBase: {
assistantLangChain: false,
},
};
jest.mock('../../../assistant_context', () => {
const original = jest.requireActual('../../../assistant_context');
return {
...original,
useAssistantContext: jest.fn().mockImplementation(() => mockValues),
};
});
describe('useSettingsUpdater', () => {
it('should set all state variables to their initial values when resetSettings is called', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useSettingsUpdater());
await waitForNextUpdate();
const {
setUpdatedConversationSettings,
setUpdatedQuickPromptSettings,
setUpdatedSystemPromptSettings,
setUpdatedDefaultAllow,
setUpdatedDefaultAllowReplacement,
setUpdatedKnowledgeBaseSettings,
resetSettings,
} = result.current;
setUpdatedConversationSettings(updatedValues.conversations);
setUpdatedQuickPromptSettings(updatedValues.allQuickPrompts);
setUpdatedSystemPromptSettings(updatedValues.allSystemPrompts);
setUpdatedDefaultAllow(updatedValues.defaultAllow);
setUpdatedDefaultAllowReplacement(updatedValues.defaultAllowReplacement);
setUpdatedKnowledgeBaseSettings(updatedValues.knowledgeBase);
expect(result.current.conversationSettings).toEqual(updatedValues.conversations);
expect(result.current.quickPromptSettings).toEqual(updatedValues.allQuickPrompts);
expect(result.current.systemPromptSettings).toEqual(updatedValues.allSystemPrompts);
expect(result.current.defaultAllow).toEqual(updatedValues.defaultAllow);
expect(result.current.defaultAllowReplacement).toEqual(updatedValues.defaultAllowReplacement);
expect(result.current.knowledgeBase).toEqual(updatedValues.knowledgeBase);
resetSettings();
expect(result.current.conversationSettings).toEqual(mockValues.conversations);
expect(result.current.quickPromptSettings).toEqual(mockValues.allQuickPrompts);
expect(result.current.systemPromptSettings).toEqual(mockValues.allSystemPrompts);
expect(result.current.defaultAllow).toEqual(mockValues.defaultAllow);
expect(result.current.defaultAllowReplacement).toEqual(mockValues.defaultAllowReplacement);
expect(result.current.knowledgeBase).toEqual(mockValues.knowledgeBase);
});
});
it('should update all state variables to their updated values when saveSettings is called', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useSettingsUpdater());
await waitForNextUpdate();
const {
setUpdatedConversationSettings,
setUpdatedQuickPromptSettings,
setUpdatedSystemPromptSettings,
setUpdatedDefaultAllow,
setUpdatedDefaultAllowReplacement,
setUpdatedKnowledgeBaseSettings,
} = result.current;
setUpdatedConversationSettings(updatedValues.conversations);
setUpdatedQuickPromptSettings(updatedValues.allQuickPrompts);
setUpdatedSystemPromptSettings(updatedValues.allSystemPrompts);
setUpdatedDefaultAllow(updatedValues.defaultAllow);
setUpdatedDefaultAllowReplacement(updatedValues.defaultAllowReplacement);
setUpdatedKnowledgeBaseSettings(updatedValues.knowledgeBase);
result.current.saveSettings();
expect(setAllQuickPromptsMock).toHaveBeenCalledWith(updatedValues.allQuickPrompts);
expect(setAllSystemPromptsMock).toHaveBeenCalledWith(updatedValues.allSystemPrompts);
expect(setConversationsMock).toHaveBeenCalledWith(updatedValues.conversations);
expect(setDefaultAllowMock).toHaveBeenCalledWith(updatedValues.defaultAllow);
expect(setDefaultAllowReplacementMock).toHaveBeenCalledWith(
updatedValues.defaultAllowReplacement
);
expect(setKnowledgeBaseMock).toHaveBeenCalledWith(updatedValues.knowledgeBase);
});
});
});

View file

@ -0,0 +1,256 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useConversation } from '.';
import { act, renderHook } from '@testing-library/react-hooks';
import { TestProviders } from '../../mock/test_providers/test_providers';
import { alertConvo, welcomeConvo } from '../../mock/conversation';
import React from 'react';
import { ConversationRole } from '../../assistant_context/types';
const message = {
content: 'You are a robot',
role: 'user' as ConversationRole,
timestamp: '10/04/2023, 1:00:36 PM',
};
const mockConvo = {
id: 'new-convo',
messages: [message],
apiConfig: { defaultSystemPromptId: 'default-system-prompt' },
theme: {
title: 'Elastic AI Assistant',
titleIcon: 'logoSecurity',
assistant: { name: 'Assistant', icon: 'logoSecurity' },
system: { icon: 'logoElastic' },
user: {},
},
};
describe('useConversation', () => {
it('should append a message to an existing conversation when called with valid conversationId and message', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useConversation(), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
}}
>
{children}
</TestProviders>
),
});
await waitForNextUpdate();
const appendResult = result.current.appendMessage({
conversationId: welcomeConvo.id,
message,
});
expect(appendResult).toHaveLength(3);
expect(appendResult[2]).toEqual(message);
});
});
it('should create a new conversation when called with valid conversationId and message', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useConversation(), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
}}
>
{children}
</TestProviders>
),
});
await waitForNextUpdate();
const createResult = result.current.createConversation({
conversationId: mockConvo.id,
messages: mockConvo.messages,
});
expect(createResult).toEqual(mockConvo);
});
});
it('should delete an existing conversation when called with valid conversationId', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useConversation(), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
[mockConvo.id]: mockConvo,
}),
}}
>
{children}
</TestProviders>
),
});
await waitForNextUpdate();
const deleteResult = result.current.deleteConversation('new-convo');
expect(deleteResult).toEqual(mockConvo);
});
});
it('should update the apiConfig for an existing conversation when called with a valid conversationId and apiConfig', async () => {
await act(async () => {
const setConversations = jest.fn();
const { result, waitForNextUpdate } = renderHook(() => useConversation(), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[welcomeConvo.id]: welcomeConvo,
}),
setConversations,
}}
>
{children}
</TestProviders>
),
});
await waitForNextUpdate();
result.current.setApiConfig({
conversationId: welcomeConvo.id,
apiConfig: mockConvo.apiConfig,
});
expect(setConversations).toHaveBeenCalledWith({
[welcomeConvo.id]: { ...welcomeConvo, apiConfig: mockConvo.apiConfig },
});
});
});
it('overwrites a conversation', async () => {
await act(async () => {
const setConversations = jest.fn();
const { result, waitForNextUpdate } = renderHook(() => useConversation(), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
setConversations,
}}
>
{children}
</TestProviders>
),
});
await waitForNextUpdate();
result.current.setConversation({
conversation: {
...mockConvo,
id: welcomeConvo.id,
},
});
expect(setConversations).toHaveBeenCalledWith({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: { ...mockConvo, id: welcomeConvo.id },
});
});
});
it('clears a conversation', async () => {
await act(async () => {
const setConversations = jest.fn();
const { result, waitForNextUpdate } = renderHook(() => useConversation(), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
setConversations,
}}
>
{children}
</TestProviders>
),
});
await waitForNextUpdate();
result.current.clearConversation(welcomeConvo.id);
expect(setConversations).toHaveBeenCalledWith({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: {
...welcomeConvo,
apiConfig: {
...welcomeConvo.apiConfig,
defaultSystemPromptId: 'default-system-prompt',
},
messages: [],
replacements: undefined,
},
});
});
});
it('appends replacements', async () => {
await act(async () => {
const setConversations = jest.fn();
const { result, waitForNextUpdate } = renderHook(() => useConversation(), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
setConversations,
}}
>
{children}
</TestProviders>
),
});
await waitForNextUpdate();
result.current.appendReplacements({
conversationId: welcomeConvo.id,
replacements: {
'1.0.0.721': '127.0.0.1',
'1.0.0.01': '10.0.0.1',
'tsoh-tset': 'test-host',
},
});
expect(setConversations).toHaveBeenCalledWith({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: {
...welcomeConvo,
replacements: {
'1.0.0.721': '127.0.0.1',
'1.0.0.01': '10.0.0.1',
'tsoh-tset': 'test-host',
},
},
});
});
});
});

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { ActionType } from '@kbn/actions-plugin/common';
import { ConnectorAddModal } from '@kbn/triggers-actions-ui-plugin/public/common/constants';
import {
ActionConnector,
ActionTypeRegistryContract,
} from '@kbn/triggers-actions-ui-plugin/public';
import { ActionTypeSelectorModal } from '../connector_selector_inline/action_type_selector_modal';
interface Props {
actionTypeRegistry: ActionTypeRegistryContract;
actionTypes?: ActionType[];
onClose: () => void;
onSaveConnector: (connector: ActionConnector) => void;
onSelectActionType: (actionType: ActionType) => void;
selectedActionType: ActionType | null;
}
export const AddConnectorModal: React.FC<Props> = React.memo(
({
actionTypeRegistry,
actionTypes,
onClose,
onSaveConnector,
onSelectActionType,
selectedActionType,
}) =>
!selectedActionType ? (
<ActionTypeSelectorModal
actionTypes={actionTypes}
actionTypeRegistry={actionTypeRegistry}
onClose={onClose}
onSelect={onSelectActionType}
/>
) : (
<ConnectorAddModal
actionType={selectedActionType}
actionTypeRegistry={actionTypeRegistry}
onClose={onClose}
postSaveEventHandler={onSaveConnector}
/>
)
);
AddConnectorModal.displayName = 'AddConnectorModal';

View file

@ -0,0 +1,116 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { ConnectorSelector } from '.';
import { fireEvent, render } from '@testing-library/react';
import { TestProviders } from '../../mock/test_providers/test_providers';
import { mockActionTypes, mockConnectors } from '../../mock/connectors';
const onConnectorSelectionChange = jest.fn();
const setIsOpen = jest.fn();
const defaultProps = {
isDisabled: false,
onConnectorSelectionChange,
selectedConnectorId: 'connectorId',
setIsOpen,
};
const connectorTwo = mockConnectors[1];
const mockRefetchConnectors = jest.fn();
jest.mock('../use_load_connectors', () => ({
useLoadConnectors: jest.fn(() => {
return {
data: mockConnectors,
error: null,
isSuccess: true,
isLoading: false,
isFetching: false,
refetch: mockRefetchConnectors,
};
}),
}));
jest.mock('../use_load_action_types', () => ({
useLoadActionTypes: jest.fn(() => {
return {
data: mockActionTypes,
};
}),
}));
const newConnector = { actionTypeId: '.gen-ai', name: 'cool name' };
jest.mock('../add_connector_modal', () => ({
// @ts-ignore
AddConnectorModal: ({ onSaveConnector }) => (
<>
<button
type="button"
data-test-subj="modal-mock"
onClick={() => onSaveConnector(newConnector)}
/>
</>
),
}));
describe('Connector selector', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders empty selection if no selected connector is provided', () => {
const { getByTestId } = render(
<TestProviders>
<ConnectorSelector {...defaultProps} selectedConnectorId={undefined} />
</TestProviders>
);
expect(getByTestId('connector-selector')).toBeInTheDocument();
expect(getByTestId('connector-selector')).toHaveTextContent('');
});
it('renders with provided selected connector', () => {
const { getByTestId } = render(
<TestProviders>
<ConnectorSelector {...defaultProps} />
</TestProviders>
);
expect(getByTestId('connector-selector')).toBeInTheDocument();
expect(getByTestId('connector-selector')).toHaveTextContent('Captain Connector');
});
it('Calls onConnectorSelectionChange with new selection', () => {
const { getByTestId } = render(
<TestProviders>
<ConnectorSelector {...defaultProps} />
</TestProviders>
);
expect(getByTestId('connector-selector')).toBeInTheDocument();
fireEvent.click(getByTestId('connector-selector'));
fireEvent.click(getByTestId(connectorTwo.id));
expect(onConnectorSelectionChange).toHaveBeenCalledWith({
...connectorTwo,
connectorTypeTitle: 'OpenAI',
});
});
it('Calls onConnectorSelectionChange once new connector is saved', () => {
const { getByTestId } = render(
<TestProviders>
<ConnectorSelector {...defaultProps} />
</TestProviders>
);
fireEvent.click(getByTestId('connector-selector'));
fireEvent.click(getByTestId('addNewConnectorButton'));
fireEvent.click(getByTestId('modal-mock'));
expect(onConnectorSelectionChange).toHaveBeenCalledWith({
...newConnector,
connectorTypeTitle: 'OpenAI',
});
expect(mockRefetchConnectors).toHaveBeenCalled();
expect(setIsOpen).toHaveBeenCalledWith(false);
});
});

View file

@ -10,13 +10,12 @@ import React, { useCallback, useMemo, useState } from 'react';
import { ActionConnector, ActionType } from '@kbn/triggers-actions-ui-plugin/public';
import { ConnectorAddModal } from '@kbn/triggers-actions-ui-plugin/public/common/constants';
import { ActionTypeSelectorModal } from '../connector_selector_inline/action_type_selector_modal';
import { useLoadConnectors } from '../use_load_connectors';
import * as i18n from '../translations';
import { useLoadActionTypes } from '../use_load_action_types';
import { useAssistantContext } from '../../assistant_context';
import { getActionTypeTitle, getGenAiConfig } from '../helpers';
import { AddConnectorModal } from '../add_connector_modal';
export const ADD_NEW_CONNECTOR = 'ADD_NEW_CONNECTOR';
@ -105,6 +104,7 @@ export const ConnectorSelector: React.FC<Props> = React.memo(
: connectorTypeTitle;
return {
value: connector.id,
'data-test-subj': connector.id,
inputDisplay: displayFancy ? displayFancy(connector.name) : connector.name,
dropdownDisplay: (
<React.Fragment key={connector.id}>
@ -171,6 +171,7 @@ export const ConnectorSelector: React.FC<Props> = React.memo(
<EuiSuperSelect
aria-label={i18n.CONNECTOR_SELECTOR_TITLE}
compressed={true}
data-test-subj="connector-selector"
disabled={localIsDisabled}
hasDividers={true}
isLoading={isLoading}
@ -179,20 +180,14 @@ export const ConnectorSelector: React.FC<Props> = React.memo(
options={allConnectorOptions}
valueOfSelected={selectedConnectorId ?? ''}
/>
{isConnectorModalVisible && !selectedActionType && (
<ActionTypeSelectorModal
{isConnectorModalVisible && (
<AddConnectorModal
actionTypeRegistry={actionTypeRegistry}
actionTypes={actionTypes}
actionTypeRegistry={actionTypeRegistry}
onClose={() => setIsConnectorModalVisible(false)}
onSelect={(actionType: ActionType) => setSelectedActionType(actionType)}
/>
)}
{isConnectorModalVisible && selectedActionType && (
<ConnectorAddModal
actionType={selectedActionType}
onClose={cleanupAndCloseModal}
postSaveEventHandler={onSaveConnector}
actionTypeRegistry={actionTypeRegistry}
onSaveConnector={onSaveConnector}
onSelectActionType={(actionType: ActionType) => setSelectedActionType(actionType)}
selectedActionType={selectedActionType}
/>
)}
</>

View file

@ -0,0 +1,80 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { ActionTypeSelectorModal } from './action_type_selector_modal';
import { ActionType } from '@kbn/actions-plugin/common';
import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock';
const actionTypes = [
{
id: '123',
name: 'Gen AI',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
isSystemActionType: true,
supportedFeatureIds: ['generativeAI'],
} as ActionType,
{
id: '456',
name: 'Another one',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
isSystemActionType: true,
supportedFeatureIds: ['generativeAI'],
} as ActionType,
];
const actionTypeRegistry = {
...actionTypeRegistryMock.create(),
get: jest.fn().mockReturnValue({ iconClass: 'icon-class' }),
};
const onClose = jest.fn();
const onSelect = jest.fn();
const defaultProps = {
actionTypes,
actionTypeRegistry,
onClose,
onSelect,
};
describe('ActionTypeSelectorModal', () => {
it('should render modal with header and body when actionTypes is not empty', () => {
const { getByTestId, getAllByTestId } = render(<ActionTypeSelectorModal {...defaultProps} />);
expect(getByTestId('action-type-selector-modal')).toBeInTheDocument();
expect(getAllByTestId('action-option')).toHaveLength(actionTypes.length);
});
it('should render null when actionTypes is undefined', () => {
const { actionTypes: _, ...rest } = defaultProps;
const { queryByTestId } = render(<ActionTypeSelectorModal {...rest} />);
expect(queryByTestId('action-type-selector-modal')).not.toBeInTheDocument();
});
it('should render null when actionTypes is an empty array', () => {
const { queryByTestId } = render(
<ActionTypeSelectorModal {...defaultProps} actionTypes={[]} />
);
expect(queryByTestId('action-type-selector-modal')).not.toBeInTheDocument();
});
it('should call onSelect with actionType when clicked', () => {
const { getByTestId } = render(<ActionTypeSelectorModal {...defaultProps} />);
fireEvent.click(getByTestId(`action-option-${actionTypes[1].name}`));
expect(onSelect).toHaveBeenCalledWith(actionTypes[1]);
});
});

View file

@ -34,7 +34,7 @@ export const ActionTypeSelectorModal = ({
onSelect,
}: Props) =>
actionTypes && actionTypes.length > 0 ? (
<EuiModal onClose={onClose}>
<EuiModal onClose={onClose} data-test-subj="action-type-selector-modal">
<EuiModalHeader>
<EuiModalHeaderTitle>{i18n.INLINE_CONNECTOR_PLACEHOLDER}</EuiModalHeaderTitle>
</EuiModalHeader>
@ -44,11 +44,12 @@ export const ActionTypeSelectorModal = ({
{actionTypes.map((actionType: ActionType) => {
const fullAction = actionTypeRegistry.get(actionType.id);
return (
<EuiFlexItem key={actionType.id} grow={false}>
<EuiFlexItem data-test-subj="action-option" key={actionType.id} grow={false}>
<EuiKeyPadMenuItem
key={actionType.id}
isDisabled={!actionType.enabled}
label={actionType.name}
data-test-subj={`action-option-${actionType.name}`}
onClick={() => onSelect(actionType)}
>
<EuiIcon size="xl" type={fullAction.iconClass} />

View file

@ -6,14 +6,23 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { fireEvent, render } from '@testing-library/react';
import { TestProviders } from '../../mock/test_providers/test_providers';
import { mockConnectors } from '../../mock/connectors';
import { ConnectorSelectorInline } from './connector_selector_inline';
import * as i18n from '../translations';
import { Conversation } from '../../..';
import { useLoadConnectors } from '../use_load_connectors';
const setApiConfig = jest.fn();
const mockConversation = {
setApiConfig,
};
jest.mock('../../assistant/use_conversation', () => ({
useConversation: () => mockConversation,
}));
jest.mock('@kbn/triggers-actions-ui-plugin/public/common/constants', () => ({
loadActionTypes: jest.fn(() => {
return Promise.resolve([
@ -39,18 +48,6 @@ jest.mock('../use_load_connectors', () => ({
}),
}));
const mockConnectors = [
{
id: 'connectorId',
name: 'Captain Connector',
isMissingSecrets: false,
actionTypeId: '.gen-ai',
config: {
apiProvider: 'OpenAI',
},
},
];
(useLoadConnectors as jest.Mock).mockReturnValue({
data: mockConnectors,
error: null,
@ -58,6 +55,9 @@ const mockConnectors = [
});
describe('ConnectorSelectorInline', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders empty view if no selected conversation is provided', () => {
const { getByText } = render(
<TestProviders>
@ -88,22 +88,74 @@ describe('ConnectorSelectorInline', () => {
);
expect(getByText(i18n.INLINE_CONNECTOR_PLACEHOLDER)).toBeInTheDocument();
});
it('renders selected connector if selected selectedConnectorId is in list of connectors', () => {
it('Clicking add connector button opens the connector selector', () => {
const conversation: Conversation = {
id: 'conversation_id',
messages: [],
apiConfig: {},
};
const { getByText } = render(
const { getByTestId, queryByTestId } = render(
<TestProviders>
<ConnectorSelectorInline
isDisabled={false}
selectedConnectorId={mockConnectors[0].id}
selectedConnectorId={'missing-connector-id'}
selectedConversation={conversation}
/>
</TestProviders>
);
expect(getByText(mockConnectors[0].name)).toBeInTheDocument();
expect(queryByTestId('connector-selector')).not.toBeInTheDocument();
fireEvent.click(getByTestId('connectorSelectorPlaceholderButton'));
expect(getByTestId('connector-selector')).toBeInTheDocument();
});
it('On connector change, update conversation API config', () => {
const connectorTwo = mockConnectors[1];
const conversation: Conversation = {
id: 'conversation_id',
messages: [],
apiConfig: {},
};
const { getByTestId, queryByTestId } = render(
<TestProviders>
<ConnectorSelectorInline
isDisabled={false}
selectedConnectorId={'missing-connector-id'}
selectedConversation={conversation}
/>
</TestProviders>
);
fireEvent.click(getByTestId('connectorSelectorPlaceholderButton'));
fireEvent.click(getByTestId('connector-selector'));
fireEvent.click(getByTestId(connectorTwo.id));
expect(queryByTestId('connector-selector')).not.toBeInTheDocument();
expect(setApiConfig).toHaveBeenCalledWith({
apiConfig: {
connectorId: connectorTwo.id,
connectorTypeTitle: 'OpenAI',
model: undefined,
provider: 'OpenAI',
},
conversationId: 'conversation_id',
});
});
it('On connector change to add new connector, onchange event does nothing', () => {
const conversation: Conversation = {
id: 'conversation_id',
messages: [],
apiConfig: {},
};
const { getByTestId } = render(
<TestProviders>
<ConnectorSelectorInline
isDisabled={false}
selectedConnectorId={'missing-connector-id'}
selectedConversation={conversation}
/>
</TestProviders>
);
fireEvent.click(getByTestId('connectorSelectorPlaceholderButton'));
fireEvent.click(getByTestId('connector-selector'));
fireEvent.click(getByTestId('addNewConnectorButton'));
expect(getByTestId('connector-selector')).toBeInTheDocument();
expect(setApiConfig).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,176 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useConnectorSetup } from '.';
import { act, renderHook } from '@testing-library/react-hooks';
import { fireEvent, render } from '@testing-library/react';
import { alertConvo, welcomeConvo } from '../../mock/conversation';
import { TestProviders } from '../../mock/test_providers/test_providers';
import { EuiCommentList } from '@elastic/eui';
const onSetupComplete = jest.fn();
const defaultProps = {
conversation: welcomeConvo,
onSetupComplete,
};
const newConnector = { actionTypeId: '.gen-ai', name: 'cool name' };
jest.mock('../add_connector_modal', () => ({
// @ts-ignore
AddConnectorModal: ({ onSaveConnector }) => (
<>
<button
type="button"
data-test-subj="modal-mock"
onClick={() => onSaveConnector(newConnector)}
/>
</>
),
}));
const setConversation = jest.fn();
const setApiConfig = jest.fn();
const mockConversation = {
appendMessage: jest.fn(),
appendReplacements: jest.fn(),
clearConversation: jest.fn(),
createConversation: jest.fn(),
deleteConversation: jest.fn(),
setApiConfig,
setConversation,
};
jest.mock('../../assistant/use_conversation', () => ({
useConversation: () => mockConversation,
}));
jest.spyOn(global, 'clearTimeout');
describe('useConnectorSetup', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render comments and prompts', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useConnectorSetup(defaultProps), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
}}
>
{children}
</TestProviders>
),
});
await waitForNextUpdate();
expect(
result.current.comments.map((c) => ({ username: c.username, timestamp: c.timestamp }))
).toEqual([
{
username: 'You',
timestamp: 'at: 7/17/2023, 1:00:36 PM',
},
{
username: 'Elastic AI Assistant',
timestamp: 'at: 7/17/2023, 1:00:40 PM',
},
]);
expect(result.current.prompt.props['data-test-subj']).toEqual('prompt');
});
});
it('should set api config for each conversation when new connector is saved', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useConnectorSetup(defaultProps), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
}}
>
{children}
</TestProviders>
),
});
await waitForNextUpdate();
const { getByTestId, queryByTestId, rerender } = render(result.current.prompt, {
wrapper: TestProviders,
});
expect(getByTestId('connectorButton')).toBeInTheDocument();
expect(queryByTestId('skip-setup-button')).not.toBeInTheDocument();
fireEvent.click(getByTestId('connectorButton'));
rerender(result.current.prompt);
fireEvent.click(getByTestId('modal-mock'));
expect(setApiConfig).toHaveBeenCalledTimes(2);
});
});
it('should show skip button if message has presentation data', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(
() =>
useConnectorSetup({
...defaultProps,
conversation: {
...defaultProps.conversation,
messages: [
{
...defaultProps.conversation.messages[0],
presentation: {
delay: 0,
stream: false,
},
},
],
},
}),
{
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
}
);
await waitForNextUpdate();
const { getByTestId, queryByTestId } = render(result.current.prompt, {
wrapper: TestProviders,
});
expect(getByTestId('skip-setup-button')).toBeInTheDocument();
expect(queryByTestId('connectorButton')).not.toBeInTheDocument();
});
});
it('should call onSetupComplete and setConversation when onHandleMessageStreamingComplete', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useConnectorSetup(defaultProps), {
wrapper: ({ children }) => (
<TestProviders
providerContext={{
getInitialConversations: () => ({
[alertConvo.id]: alertConvo,
[welcomeConvo.id]: welcomeConvo,
}),
}}
>
{children}
</TestProviders>
),
});
await waitForNextUpdate();
render(<EuiCommentList comments={result.current.comments} />, {
wrapper: TestProviders,
});
expect(clearTimeout).toHaveBeenCalled();
expect(onSetupComplete).toHaveBeenCalled();
expect(setConversation).toHaveBeenCalled();
});
});
});

View file

@ -10,13 +10,10 @@ import type { EuiCommentProps } from '@elastic/eui';
import { EuiAvatar, EuiBadge, EuiMarkdownFormat, EuiText, EuiTextAlign } from '@elastic/eui';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import {
ActionConnector,
ConnectorAddModal,
} from '@kbn/triggers-actions-ui-plugin/public/common/constants';
import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public/common/constants';
import { ActionType } from '@kbn/triggers-actions-ui-plugin/public';
import { ActionTypeSelectorModal } from '../connector_selector_inline/action_type_selector_modal';
import { AddConnectorModal } from '../add_connector_modal';
import { WELCOME_CONVERSATION } from '../../assistant/use_conversation/sample_conversations';
import { Conversation, Message } from '../../..';
import { useLoadActionTypes } from '../use_load_action_types';
@ -204,7 +201,7 @@ export const useConnectorSetup = ({
conversationId: conversation.id,
message: {
role: 'assistant',
content: 'Connector setup complete!',
content: i18n.CONNECTOR_SETUP_COMPLETE,
timestamp: new Date().toLocaleString(),
},
});
@ -233,6 +230,7 @@ export const useConnectorSetup = ({
<EuiTextAlign textAlign="center">
<EuiBadge
color="hollow"
data-test-subj="skip-setup-button"
onClick={handleSkipSetup}
onClickAriaLabel={i18n.CONNECTOR_SETUP_SKIP}
>
@ -241,20 +239,14 @@ export const useConnectorSetup = ({
</EuiTextAlign>
</SkipEuiText>
)}
{isConnectorModalVisible && !selectedActionType && (
<ActionTypeSelectorModal
{isConnectorModalVisible && (
<AddConnectorModal
actionTypeRegistry={actionTypeRegistry}
actionTypes={actionTypes}
actionTypeRegistry={actionTypeRegistry}
onClose={() => setIsConnectorModalVisible(false)}
onSelect={(actionType: ActionType) => setSelectedActionType(actionType)}
/>
)}
{isConnectorModalVisible && selectedActionType && (
<ConnectorAddModal
actionType={selectedActionType}
onClose={() => setIsConnectorModalVisible(false)}
postSaveEventHandler={onSaveConnector}
actionTypeRegistry={actionTypeRegistry}
onSaveConnector={onSaveConnector}
onSelectActionType={(actionType: ActionType) => setSelectedActionType(actionType)}
selectedActionType={selectedActionType}
/>
)}
</div>

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { MODEL_GPT_3_5_TURBO, MODEL_GPT_4, ModelSelector } from './model_selector';
import { fireEvent, render } from '@testing-library/react';
describe('ModelSelector', () => {
it('should render with correct default selection', () => {
const onModelSelectionChange = jest.fn();
const { getByTestId } = render(
<ModelSelector onModelSelectionChange={onModelSelectionChange} />
);
expect(getByTestId('euiComboBoxPill')).toHaveTextContent(MODEL_GPT_3_5_TURBO);
});
it('should call onModelSelectionChange when custom option', () => {
const onModelSelectionChange = jest.fn();
const { getByTestId } = render(
<ModelSelector onModelSelectionChange={onModelSelectionChange} />
);
const comboBox = getByTestId('comboBoxSearchInput');
const customOption = 'Custom option';
fireEvent.change(comboBox, { target: { value: customOption } });
fireEvent.keyDown(comboBox, {
key: 'Enter',
code: 'Enter',
charCode: 13,
});
expect(onModelSelectionChange).toHaveBeenCalledWith(customOption);
});
it('should call onModelSelectionChange when existing option is selected', () => {
const onModelSelectionChange = jest.fn();
const { getByTestId } = render(
<ModelSelector onModelSelectionChange={onModelSelectionChange} />
);
const comboBox = getByTestId('comboBoxSearchInput');
fireEvent.click(comboBox);
fireEvent.click(getByTestId(MODEL_GPT_4));
expect(onModelSelectionChange).toHaveBeenCalledWith(MODEL_GPT_4);
});
});

View file

@ -29,9 +29,7 @@ export const ModelSelector: React.FC<Props> = React.memo(
({ models = DEFAULT_MODELS, onModelSelectionChange, selectedModel = DEFAULT_MODELS[0] }) => {
// Form options
const [options, setOptions] = useState<EuiComboBoxOptionOption[]>(
models.map((model) => ({
label: model,
}))
models.map((model) => ({ 'data-test-subj': model, label: model }))
);
const selectedOptions = useMemo<EuiComboBoxOptionOption[]>(() => {
return selectedModel ? [{ label: selectedModel }] : [];
@ -92,6 +90,7 @@ export const ModelSelector: React.FC<Props> = React.memo(
<EuiComboBox
aria-label={i18n.HELP_LABEL}
compressed
data-test-subj="model-selector"
isClearable={false}
placeholder={i18n.PLACEHOLDER_TEXT}
customOptionText={`${i18n.CUSTOM_OPTION_TEXT} {searchValue}`}

View file

@ -122,6 +122,13 @@ export const CONNECTOR_SETUP_SKIP = i18n.translate(
}
);
export const CONNECTOR_SETUP_COMPLETE = i18n.translate(
'xpack.elasticAssistant.assistant.connectors.setup.complete',
{
defaultMessage: 'Connector setup complete!',
}
);
export const MISSING_CONNECTOR_CALLOUT_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.connectors.connectorMissingCallout.calloutTitle',
{

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { act, renderHook } from '@testing-library/react-hooks';
import { useLoadActionTypes, Props } from '.';
import { mockActionTypes } from '../../mock/connectors';
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn().mockImplementation(async (queryKey, fn, opts) => {
try {
const res = await fn();
return Promise.resolve(res);
} catch (e) {
opts.onError(e);
}
}),
}));
const http = {
get: jest.fn().mockResolvedValue(mockActionTypes),
};
const toasts = {
addError: jest.fn(),
};
const defaultProps = { http, toasts } as unknown as Props;
describe('useLoadActionTypes', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should call api to load action types', async () => {
await act(async () => {
const { waitForNextUpdate } = renderHook(() => useLoadActionTypes(defaultProps));
await waitForNextUpdate();
expect(defaultProps.http.get).toHaveBeenCalledWith('/api/actions/connector_types', {
query: { feature_id: 'generativeAI' },
});
expect(toasts.addError).not.toHaveBeenCalled();
});
});
it('should return sorted action types', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useLoadActionTypes(defaultProps));
await waitForNextUpdate();
await expect(result.current).resolves.toStrictEqual(
mockActionTypes.sort((a, b) => a.name.localeCompare(b.name))
);
});
});
it('should display error toast when api throws error', async () => {
await act(async () => {
const mockHttp = {
get: jest.fn().mockRejectedValue(new Error('this is an error')),
} as unknown as Props['http'];
const { waitForNextUpdate } = renderHook(() =>
useLoadActionTypes({ ...defaultProps, http: mockHttp })
);
await waitForNextUpdate();
expect(toasts.addError).toHaveBeenCalled();
});
});
});

View file

@ -22,7 +22,7 @@ import * as i18n from '../translations';
* Cache expiration in ms -- 1 minute, useful if connector is deleted/access removed
*/
const STALE_TIME = 1000 * 60;
const QUERY_KEY = ['elastic-assistant, load-action-types'];
export const QUERY_KEY = ['elastic-assistant, load-action-types'];
export interface Props {
http: HttpSetup;

View file

@ -0,0 +1,101 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { act, renderHook } from '@testing-library/react-hooks';
import { useLoadConnectors, Props } from '.';
import { mockConnectors } from '../../mock/connectors';
const mockConnectorsAndExtras = [
...mockConnectors,
{
...mockConnectors[0],
id: 'connector-missing-secrets',
name: 'Connector Missing Secrets',
isMissingSecrets: true,
},
{
...mockConnectors[0],
id: 'connector-wrong-action-type',
name: 'Connector Wrong Action Type',
isMissingSecrets: true,
actionTypeId: '.d3',
},
];
const connectorsApiResponse = mockConnectorsAndExtras.map((c) => ({
...c,
connector_type_id: c.actionTypeId,
is_preconfigured: false,
is_deprecated: false,
referenced_by_count: 0,
is_missing_secrets: c.isMissingSecrets,
is_system_action: false,
}));
const loadConnectorsResult = mockConnectors.map((c) => ({
...c,
isPreconfigured: false,
isDeprecated: false,
referencedByCount: 0,
isSystemAction: false,
}));
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn().mockImplementation(async (queryKey, fn, opts) => {
try {
const res = await fn();
return Promise.resolve(res);
} catch (e) {
opts.onError(e);
}
}),
}));
const http = {
get: jest.fn().mockResolvedValue(connectorsApiResponse),
};
const toasts = {
addError: jest.fn(),
};
const defaultProps = { http, toasts } as unknown as Props;
describe('useLoadConnectors', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should call api to load action types', async () => {
await act(async () => {
const { waitForNextUpdate } = renderHook(() => useLoadConnectors(defaultProps));
await waitForNextUpdate();
expect(defaultProps.http.get).toHaveBeenCalledWith('/api/actions/connectors');
expect(toasts.addError).not.toHaveBeenCalled();
});
});
it('should return sorted action types, removing isMissingSecrets and wrong action type ids', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useLoadConnectors(defaultProps));
await waitForNextUpdate();
await expect(result.current).resolves.toStrictEqual(loadConnectorsResult);
});
});
it('should display error toast when api throws error', async () => {
await act(async () => {
const mockHttp = {
get: jest.fn().mockRejectedValue(new Error('this is an error')),
} as unknown as Props['http'];
const { waitForNextUpdate } = renderHook(() =>
useLoadConnectors({ ...defaultProps, http: mockHttp })
);
await waitForNextUpdate();
expect(toasts.addError).toHaveBeenCalled();
});
});
});

View file

@ -42,7 +42,6 @@ export const useLoadConnectors = ({
{
retry: false,
keepPreviousData: true,
// staleTime: STALE_TIME,
onError: (error: ServerError) => {
if (error.name !== 'AbortError') {
toasts?.addError(

View file

@ -0,0 +1,179 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { KnowledgeBaseSettings } from './knowledge_base_settings';
import { TestProviders } from '../mock/test_providers/test_providers';
import { useKnowledgeBaseStatus } from './use_knowledge_base_status';
const setUpdatedKnowledgeBaseSettings = jest.fn();
const defaultProps = {
knowledgeBase: {
assistantLangChain: true,
},
setUpdatedKnowledgeBaseSettings,
};
const mockDelete = jest.fn();
jest.mock('./use_delete_knowledge_base', () => ({
useDeleteKnowledgeBase: jest.fn(() => {
return {
mutate: mockDelete,
isLoading: false,
};
}),
}));
const mockSetup = jest.fn();
jest.mock('./use_setup_knowledge_base', () => ({
useSetupKnowledgeBase: jest.fn(() => {
return {
mutate: mockSetup,
isLoading: false,
};
}),
}));
jest.mock('./use_knowledge_base_status', () => ({
useKnowledgeBaseStatus: jest.fn(() => {
return {
data: {
elser_exists: true,
esql_exists: true,
index_exists: true,
pipeline_exists: true,
},
isLoading: false,
isFetching: false,
};
}),
}));
describe('Knowledge base settings', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Shows correct description when esql is installed', () => {
const { getByTestId, queryByTestId } = render(
<TestProviders>
<KnowledgeBaseSettings {...defaultProps} />
</TestProviders>
);
expect(getByTestId('esql-installed')).toBeInTheDocument();
expect(queryByTestId('install-esql')).not.toBeInTheDocument();
expect(getByTestId('esqlEnableButton')).toBeInTheDocument();
});
it('On click enable esql button, esql is enabled', () => {
(useKnowledgeBaseStatus as jest.Mock).mockImplementation(() => {
return {
data: {
elser_exists: true,
esql_exists: false,
index_exists: true,
pipeline_exists: true,
},
isLoading: false,
isFetching: false,
};
});
const { getByTestId, queryByTestId } = render(
<TestProviders>
<KnowledgeBaseSettings {...defaultProps} />
</TestProviders>
);
expect(queryByTestId('esql-installed')).not.toBeInTheDocument();
expect(getByTestId('install-esql')).toBeInTheDocument();
fireEvent.click(getByTestId('esqlEnableButton'));
expect(mockSetup).toHaveBeenCalledWith('esql');
});
it('On disable lang chain, set assistantLangChain to false', () => {
const { getByTestId } = render(
<TestProviders>
<KnowledgeBaseSettings {...defaultProps} />
</TestProviders>
);
fireEvent.click(getByTestId('assistantLangChainSwitch'));
expect(setUpdatedKnowledgeBaseSettings).toHaveBeenCalledWith({
assistantLangChain: false,
});
expect(mockSetup).not.toHaveBeenCalled();
});
it('On enable lang chain, set up with esql by default if ELSER exists', () => {
const { getByTestId } = render(
<TestProviders>
<KnowledgeBaseSettings
{...defaultProps}
knowledgeBase={{
assistantLangChain: false,
}}
/>
</TestProviders>
);
fireEvent.click(getByTestId('assistantLangChainSwitch'));
expect(setUpdatedKnowledgeBaseSettings).toHaveBeenCalledWith({
assistantLangChain: true,
});
expect(mockSetup).toHaveBeenCalledWith('esql');
});
it('On disable knowledge base, call delete knowledge base setup', () => {
const { getByTestId, queryByTestId } = render(
<TestProviders>
<KnowledgeBaseSettings {...defaultProps} />
</TestProviders>
);
expect(queryByTestId('install-kb')).not.toBeInTheDocument();
expect(getByTestId('kb-installed')).toBeInTheDocument();
fireEvent.click(getByTestId('knowledgeBaseActionButton'));
expect(mockDelete).toHaveBeenCalledWith();
});
it('On enable knowledge base, call setup knowledge base setup', () => {
(useKnowledgeBaseStatus as jest.Mock).mockImplementation(() => {
return {
data: {
elser_exists: true,
esql_exists: false,
index_exists: false,
pipeline_exists: false,
},
isLoading: false,
isFetching: false,
};
});
const { getByTestId, queryByTestId } = render(
<TestProviders>
<KnowledgeBaseSettings {...defaultProps} />
</TestProviders>
);
expect(queryByTestId('kb-installed')).not.toBeInTheDocument();
expect(getByTestId('install-kb')).toBeInTheDocument();
fireEvent.click(getByTestId('knowledgeBaseActionButton'));
expect(mockSetup).toHaveBeenCalledWith();
});
it('If elser does not exist, do not offer knowledge base', () => {
(useKnowledgeBaseStatus as jest.Mock).mockImplementation(() => {
return {
data: {
elser_exists: false,
esql_exists: false,
index_exists: false,
pipeline_exists: false,
},
isLoading: false,
isFetching: false,
};
});
const { queryByTestId } = render(
<TestProviders>
<KnowledgeBaseSettings {...defaultProps} />
</TestProviders>
);
expect(queryByTestId('knowledgeBaseActionButton')).not.toBeInTheDocument();
});
});

View file

@ -26,12 +26,12 @@ import {
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/react';
import * as i18n from './translations';
import { useAssistantContext } from '../../assistant_context';
import { useDeleteKnowledgeBase } from '../use_delete_knowledge_base/use_delete_knowledge_base';
import { useKnowledgeBaseStatus } from '../use_knowledge_base_status/use_knowledge_base_status';
import { useSetupKnowledgeBase } from '../use_setup_knowledge_base/use_setup_knowledge_base';
import { useAssistantContext } from '../assistant_context';
import { useDeleteKnowledgeBase } from './use_delete_knowledge_base';
import { useKnowledgeBaseStatus } from './use_knowledge_base_status';
import { useSetupKnowledgeBase } from './use_setup_knowledge_base';
import type { KnowledgeBaseConfig } from '../../assistant/types';
import type { KnowledgeBaseConfig } from '../assistant/types';
const ESQL_RESOURCE = 'esql';
const KNOWLEDGE_BASE_INDEX_PATTERN = '.kibana-elastic-ai-assistant-kb';
@ -95,6 +95,7 @@ export const KnowledgeBaseSettings: React.FC<Props> = React.memo(
) : (
<EuiSwitch
showLabel={false}
data-test-subj="assistantLangChainSwitch"
checked={knowledgeBase.assistantLangChain}
onChange={onEnableAssistantLangChainChange}
label={i18n.KNOWLEDGE_BASE_LABEL}
@ -123,6 +124,7 @@ export const KnowledgeBaseSettings: React.FC<Props> = React.memo(
<EuiButtonEmpty
color={isKnowledgeBaseEnabled ? 'danger' : 'primary'}
flush="left"
data-test-subj={'knowledgeBaseActionButton'}
onClick={() => onEnableKB(!isKnowledgeBaseEnabled)}
size="xs"
>
@ -135,14 +137,14 @@ export const KnowledgeBaseSettings: React.FC<Props> = React.memo(
const knowledgeBaseDescription = useMemo(() => {
return isKnowledgeBaseEnabled ? (
<>
<span data-test-subj="kb-installed">
{i18n.KNOWLEDGE_BASE_DESCRIPTION_INSTALLED(KNOWLEDGE_BASE_INDEX_PATTERN)}{' '}
{knowledgeBaseActionButton}
</>
</span>
) : (
<>
<span data-test-subj="install-kb">
{i18n.KNOWLEDGE_BASE_DESCRIPTION} {knowledgeBaseActionButton}
</>
</span>
);
}, [isKnowledgeBaseEnabled, knowledgeBaseActionButton]);
@ -166,6 +168,7 @@ export const KnowledgeBaseSettings: React.FC<Props> = React.memo(
<EuiButtonEmpty
color={isESQLEnabled ? 'danger' : 'primary'}
flush="left"
data-test-subj="esqlEnableButton"
onClick={() => onEnableESQL(!isESQLEnabled)}
size="xs"
>
@ -176,13 +179,13 @@ export const KnowledgeBaseSettings: React.FC<Props> = React.memo(
const esqlDescription = useMemo(() => {
return isESQLEnabled ? (
<>
<span data-test-subj="esql-installed">
{i18n.ESQL_DESCRIPTION_INSTALLED} {esqlActionButton}
</>
</span>
) : (
<>
<span data-test-subj="install-esql">
{i18n.ESQL_DESCRIPTION} {esqlActionButton}
</>
</span>
);
}, [esqlActionButton, isESQLEnabled]);

View file

@ -0,0 +1,106 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { act, renderHook } from '@testing-library/react-hooks';
import { useDeleteKnowledgeBase, UseDeleteKnowledgeBaseParams } from './use_delete_knowledge_base';
import { deleteKnowledgeBase as _deleteKnowledgeBase } from '../assistant/api';
import { useMutation as _useMutation } from '@tanstack/react-query';
const useMutationMock = _useMutation as jest.Mock;
const deleteKnowledgeBaseMock = _deleteKnowledgeBase as jest.Mock;
jest.mock('../assistant/api', () => {
const actual = jest.requireActual('../assistant/api');
return {
...actual,
deleteKnowledgeBase: jest.fn((...args) => actual.deleteKnowledgeBase(...args)),
};
});
jest.mock('./use_knowledge_base_status');
jest.mock('@tanstack/react-query', () => ({
useMutation: jest.fn().mockImplementation(async (queryKey, fn, opts) => {
try {
const res = await fn();
return Promise.resolve(res);
} catch (e) {
opts.onError(e);
}
}),
}));
const statusResponse = {
success: true,
};
const http = {
fetch: jest.fn().mockResolvedValue(statusResponse),
};
const toasts = {
addError: jest.fn(),
};
const defaultProps = { http, toasts } as unknown as UseDeleteKnowledgeBaseParams;
describe('useDeleteKnowledgeBase', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should call api to delete knowledge base', async () => {
await act(async () => {
const { waitForNextUpdate } = renderHook(() => useDeleteKnowledgeBase(defaultProps));
await waitForNextUpdate();
expect(defaultProps.http.fetch).toHaveBeenCalledWith(
'/internal/elastic_assistant/knowledge_base/',
{
method: 'DELETE',
}
);
expect(toasts.addError).not.toHaveBeenCalled();
});
});
it('should call api to delete knowledge base with resource arg', async () => {
useMutationMock.mockImplementation(async (queryKey, fn, opts) => {
try {
const res = await fn('something');
return Promise.resolve(res);
} catch (e) {
opts.onError(e);
}
});
await act(async () => {
const { waitForNextUpdate } = renderHook(() => useDeleteKnowledgeBase(defaultProps));
await waitForNextUpdate();
expect(defaultProps.http.fetch).toHaveBeenCalledWith(
'/internal/elastic_assistant/knowledge_base/something',
{
method: 'DELETE',
}
);
});
});
it('should return delete response', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useDeleteKnowledgeBase(defaultProps));
await waitForNextUpdate();
await expect(result.current).resolves.toStrictEqual(statusResponse);
});
});
it('should display error toast when api throws error', async () => {
deleteKnowledgeBaseMock.mockRejectedValue(new Error('this is an error'));
await act(async () => {
const { waitForNextUpdate } = renderHook(() => useDeleteKnowledgeBase(defaultProps));
await waitForNextUpdate();
expect(toasts.addError).toHaveBeenCalled();
});
});
});

View file

@ -9,8 +9,8 @@ import { useMutation } from '@tanstack/react-query';
import type { IToasts } from '@kbn/core-notifications-browser';
import type { HttpSetup, IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser';
import { i18n } from '@kbn/i18n';
import { deleteKnowledgeBase } from '../../assistant/api';
import { useInvalidateKnowledgeBaseStatus } from '../use_knowledge_base_status/use_knowledge_base_status';
import { deleteKnowledgeBase } from '../assistant/api';
import { useInvalidateKnowledgeBaseStatus } from './use_knowledge_base_status';
const DELETE_KNOWLEDGE_BASE_MUTATION_KEY = ['elastic-assistant', 'delete-knowledge-base'];

View file

@ -0,0 +1,99 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { act, renderHook } from '@testing-library/react-hooks';
import { useKnowledgeBaseStatus, UseKnowledgeBaseStatusParams } from './use_knowledge_base_status';
import { getKnowledgeBaseStatus as _getKnowledgeBaseStatus } from '../assistant/api';
const getKnowledgeBaseStatusMock = _getKnowledgeBaseStatus as jest.Mock;
jest.mock('../assistant/api', () => {
const actual = jest.requireActual('../assistant/api');
return {
...actual,
getKnowledgeBaseStatus: jest.fn((...args) => actual.getKnowledgeBaseStatus(...args)),
};
});
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn().mockImplementation(async (queryKey, fn, opts) => {
try {
const res = await fn({});
return Promise.resolve(res);
} catch (e) {
opts.onError(e);
}
}),
}));
const statusResponse = {
elser_exists: true,
esql_exists: true,
index_exists: true,
pipeline_exists: true,
};
const http = {
fetch: jest.fn().mockResolvedValue(statusResponse),
};
const toasts = {
addError: jest.fn(),
};
const defaultProps = { http, toasts } as unknown as UseKnowledgeBaseStatusParams;
describe('useKnowledgeBaseStatus', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should call api to get knowledge base status without resource arg', async () => {
await act(async () => {
const { waitForNextUpdate } = renderHook(() => useKnowledgeBaseStatus(defaultProps));
await waitForNextUpdate();
expect(defaultProps.http.fetch).toHaveBeenCalledWith(
'/internal/elastic_assistant/knowledge_base/',
{
method: 'GET',
}
);
expect(toasts.addError).not.toHaveBeenCalled();
});
});
it('should call api to get knowledge base status with resource arg', async () => {
await act(async () => {
const { waitForNextUpdate } = renderHook(() =>
useKnowledgeBaseStatus({ ...defaultProps, resource: 'something' })
);
await waitForNextUpdate();
expect(defaultProps.http.fetch).toHaveBeenCalledWith(
'/internal/elastic_assistant/knowledge_base/something',
{
method: 'GET',
}
);
});
});
it('should return status response', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useKnowledgeBaseStatus(defaultProps));
await waitForNextUpdate();
await expect(result.current).resolves.toStrictEqual(statusResponse);
});
});
it('should display error toast when api throws error', async () => {
getKnowledgeBaseStatusMock.mockRejectedValue(new Error('this is an error'));
await act(async () => {
const { waitForNextUpdate } = renderHook(() => useKnowledgeBaseStatus(defaultProps));
await waitForNextUpdate();
expect(toasts.addError).toHaveBeenCalled();
});
});
});

View file

@ -11,7 +11,7 @@ import type { HttpSetup, IHttpFetchError, ResponseErrorBody } from '@kbn/core-ht
import type { IToasts } from '@kbn/core-notifications-browser';
import { i18n } from '@kbn/i18n';
import { useCallback } from 'react';
import { getKnowledgeBaseStatus } from '../../assistant/api';
import { getKnowledgeBaseStatus } from '../assistant/api';
const KNOWLEDGE_BASE_STATUS_QUERY_KEY = ['elastic-assistant', 'knowledge-base-status'];

View file

@ -0,0 +1,105 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { act, renderHook } from '@testing-library/react-hooks';
import { useSetupKnowledgeBase, UseSetupKnowledgeBaseParams } from './use_setup_knowledge_base';
import { postKnowledgeBase as _postKnowledgeBase } from '../assistant/api';
import { useMutation as _useMutation } from '@tanstack/react-query';
const postKnowledgeBaseMock = _postKnowledgeBase as jest.Mock;
const useMutationMock = _useMutation as jest.Mock;
jest.mock('../assistant/api', () => {
const actual = jest.requireActual('../assistant/api');
return {
...actual,
postKnowledgeBase: jest.fn((...args) => actual.postKnowledgeBase(...args)),
};
});
jest.mock('./use_knowledge_base_status');
jest.mock('@tanstack/react-query', () => ({
useMutation: jest.fn().mockImplementation(async (queryKey, fn, opts) => {
try {
const res = await fn();
return Promise.resolve(res);
} catch (e) {
opts.onError(e);
}
}),
}));
const statusResponse = {
success: true,
};
const http = {
fetch: jest.fn().mockResolvedValue(statusResponse),
};
const toasts = {
addError: jest.fn(),
};
const defaultProps = { http, toasts } as unknown as UseSetupKnowledgeBaseParams;
describe('useSetupKnowledgeBase', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should call api to post knowledge base setup', async () => {
await act(async () => {
const { waitForNextUpdate } = renderHook(() => useSetupKnowledgeBase(defaultProps));
await waitForNextUpdate();
expect(defaultProps.http.fetch).toHaveBeenCalledWith(
'/internal/elastic_assistant/knowledge_base/',
{
method: 'POST',
}
);
expect(toasts.addError).not.toHaveBeenCalled();
});
});
it('should call api to post knowledge base setup with resource arg', async () => {
useMutationMock.mockImplementation(async (queryKey, fn, opts) => {
try {
const res = await fn('something');
return Promise.resolve(res);
} catch (e) {
opts.onError(e);
}
});
await act(async () => {
const { waitForNextUpdate } = renderHook(() => useSetupKnowledgeBase(defaultProps));
await waitForNextUpdate();
expect(defaultProps.http.fetch).toHaveBeenCalledWith(
'/internal/elastic_assistant/knowledge_base/something',
{
method: 'POST',
}
);
});
});
it('should return setup response', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useSetupKnowledgeBase(defaultProps));
await waitForNextUpdate();
await expect(result.current).resolves.toStrictEqual(statusResponse);
});
});
it('should display error toast when api throws error', async () => {
postKnowledgeBaseMock.mockRejectedValue(new Error('this is an error'));
await act(async () => {
const { waitForNextUpdate } = renderHook(() => useSetupKnowledgeBase(defaultProps));
await waitForNextUpdate();
expect(toasts.addError).toHaveBeenCalled();
});
});
});

View file

@ -9,8 +9,8 @@ import { useMutation } from '@tanstack/react-query';
import type { HttpSetup, IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser';
import type { IToasts } from '@kbn/core-notifications-browser';
import { i18n } from '@kbn/i18n';
import { postKnowledgeBase } from '../../assistant/api';
import { useInvalidateKnowledgeBaseStatus } from '../use_knowledge_base_status/use_knowledge_base_status';
import { postKnowledgeBase } from '../assistant/api';
import { useInvalidateKnowledgeBaseStatus } from './use_knowledge_base_status';
const SETUP_KNOWLEDGE_BASE_MUTATION_KEY = ['elastic-assistant', 'post-knowledge-base'];

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ActionType } from '@kbn/actions-plugin/common';
import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public';
export const mockActionTypes = [
{
id: '.gen-ai',
name: 'OpenAI',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
isSystemActionType: true,
supportedFeatureIds: ['generativeAI'],
} as ActionType,
{
id: '.bedrock',
name: 'Bedrock',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
isSystemActionType: true,
supportedFeatureIds: ['generativeAI'],
} as ActionType,
];
export const mockConnectors: ActionConnector[] = [
{
id: 'connectorId',
name: 'Captain Connector',
isMissingSecrets: false,
actionTypeId: '.gen-ai',
secrets: {},
isPreconfigured: false,
isDeprecated: false,
isSystemAction: false,
config: {
apiProvider: 'OpenAI',
},
},
{
id: 'c29c28a0-20fe-11ee-9306-a1f4d42ec542',
name: 'Professor Connector',
isMissingSecrets: false,
actionTypeId: '.gen-ai',
secrets: {},
isPreconfigured: false,
isDeprecated: false,
isSystemAction: false,
config: {
apiProvider: 'OpenAI',
},
},
];

View file

@ -69,3 +69,13 @@ export const welcomeConvo: Conversation = {
},
],
};
export const customConvo: Conversation = {
id: 'Custom option',
isDefault: false,
messages: [],
apiConfig: {
connectorId: 'c29c28a0-20fe-11ee-9306-a1f4d42ec542',
provider: OpenAiProviderType.OpenAi,
},
};

View file

@ -22,3 +22,5 @@ export const mockEventPromptContext: PromptContext = {
id: 'mock-event-prompt-context-1',
tooltip: 'Add this event as context',
};
export const mockPromptContexts: PromptContext[] = [mockAlertPromptContext, mockEventPromptContext];

View file

@ -31,3 +31,9 @@ export const defaultSystemPrompt: Prompt = {
isDefault: true,
isNewConversationDefault: true,
};
export const mockSystemPrompts: Prompt[] = [
mockSystemPrompt,
mockSuperheroSystemPrompt,
defaultSystemPrompt,
];