Коммит 4a9997ac создал по автору Denys Mishunov's avatar Denys Mishunov
Просмотр файлов

Merge branch '403727-ai-genie-component-tests' into '403727-ai-genie-component'

Add more unit tests, update translations

See merge request https://gitlab.com/gitlab-org/gitlab/-/merge_requests/116979



Merged-by: default avatarDenys Mishunov <dmishunov@gitlab.com>
Approved-by: default avatarStanislav Lashmanov <slashmanov@gitlab.com>
Approved-by: default avatarDenys Mishunov <dmishunov@gitlab.com>
Reviewed-by: default avatarStanislav Lashmanov <slashmanov@gitlab.com>
Co-authored-by: default avatarNataliia Radina <nradina@gitlab.com>
владельцы 7276515e 63dc1965
/* eslint-disable jest/no-disabled-tests */ /* eslint-disable jest/no-disabled-tests */
<script> <script>
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { marked } from 'marked';
import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import SafeHtml from '~/vue_shared/directives/safe_html'; import SafeHtml from '~/vue_shared/directives/safe_html';
...@@ -122,7 +121,7 @@ export default { ...@@ -122,7 +121,7 @@ export default {
const { const {
message: { content: explanation }, message: { content: explanation },
} = aiResponse; } = aiResponse;
this.codeExplanation = renderMarkdown(marked(explanation)); this.codeExplanation = renderMarkdown(explanation);
} catch (err) { } catch (err) {
this.codeExplanationError = err.message; this.codeExplanationError = err.message;
} finally { } finally {
......
...@@ -77,6 +77,7 @@ export default { ...@@ -77,6 +77,7 @@ export default {
class="markdown-code-block gl-fixed gl-top-half gl-right-0 gl-bg-white gl-w-40p gl-rounded-top-left-base gl-rounded-bottom-left-base gl-border gl-border-r-none gl-font-sm" class="markdown-code-block gl-fixed gl-top-half gl-right-0 gl-bg-white gl-w-40p gl-rounded-top-left-base gl-rounded-bottom-left-base gl-border gl-border-r-none gl-font-sm"
style="transform: translate(0px, -50%)" style="transform: translate(0px, -50%)"
role="complementary" role="complementary"
data-testid="chat-component"
> >
<header class="gl-p-5 gl-display-flex gl-justify-content-space-between gl-align-items-center"> <header class="gl-p-5 gl-display-flex gl-justify-content-space-between gl-align-items-center">
<h3 class="gl-font-base gl-m-0">{{ i18n.GENIE_TITLE }}</h3> <h3 class="gl-font-base gl-m-0">{{ i18n.GENIE_TITLE }}</h3>
...@@ -101,7 +102,7 @@ export default { ...@@ -101,7 +102,7 @@ export default {
<gl-alert v-if="error" :dismissible="false" variant="danger" class="gl-mb-0" role="alert" <gl-alert v-if="error" :dismissible="false" variant="danger" class="gl-mb-0" role="alert"
><span v-safe-html="error"></span ><span v-safe-html="error"></span
></gl-alert> ></gl-alert>
<div v-else v-safe-html="content" class="md"></div> <div v-else v-safe-html="content" class="md" data-testid="chat-content"></div>
</div> </div>
</section> </section>
</aside> </aside>
......
/* eslint-disable jest/no-disabled-tests */ import { GlButton, GlSkeletonLoader, GlAlert } from '@gitlab/ui';
import { nextTick } from 'vue';
import AiGenieChat from 'ee/ai/components/ai_genie_chat.vue'; import AiGenieChat from 'ee/ai/components/ai_genie_chat.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import CodeBlockHighlighted from '~/vue_shared/components/code_block_highlighted.vue';
describe('AiGenie', () => { describe('AiGenie', () => {
// eslint-disable-next-line no-unused-vars
let wrapper; let wrapper;
const createComponent = (propsData = {}) => { const createComponent = (props = {}) => {
wrapper = shallowMountExtended(AiGenieChat, { wrapper = shallowMountExtended(AiGenieChat, {
propsData, propsData: {
...props,
},
}); });
}; };
const findChatComponent = () => wrapper.findByTestId('chat-component');
const findCloseButton = () => wrapper.findComponent(GlButton);
const findSceletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findSelectedText = () => wrapper.findComponent(CodeBlockHighlighted);
const findChatContent = () => wrapper.findByTestId('chat-content');
const findAlert = () => wrapper.findComponent(GlAlert);
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
}); });
it.skip('renders correctly', () => {});
it.skip('is hidden after the header button is clicked', () => {}); describe('component with default props', () => {
it.skip('renders skeleton when isLoading', () => {}); it('renders chat component', async () => {
it.skip('renders alert if error', () => {}); expect(findChatComponent().exists()).toBe(true);
it.skip('renders content if content', () => {}); });
it('does not not render skeleton', () => {
expect(findSceletonLoader().exists()).toBe(false);
});
it('does not not render alert', () => {
expect(findAlert().exists()).toBe(false);
});
it('renders "text" as a default language', () => {
const defaultLanguage = 'text';
expect(findSelectedText().props('language')).toBe(defaultLanguage);
});
});
it('is hidden after the header button is clicked', async () => {
findCloseButton().vm.$emit('click');
await nextTick();
expect(findChatComponent().exists()).toBe(false);
});
it('renders skeleton when isLoading', () => {
createComponent({ isLoading: true });
expect(findSceletonLoader().exists()).toBe(true);
});
it('renders alert if error', () => {
const errorMessage = 'Something went Wrong';
createComponent({ error: errorMessage });
expect(findAlert().text()).toBe(errorMessage);
});
it('renders content once content is passed', () => {
const content = 'This is some nice content';
createComponent({ content });
expect(findChatContent().text()).toBe(content);
});
it('renders selectedText', () => {
const selectedText = 'Text to explain';
createComponent({ selectedText });
expect(findSelectedText().props('code')).toBe(selectedText);
});
it('updates language once new value is passed', () => {
const snippetLanguage = 'vue';
createComponent({ snippetLanguage });
expect(findSelectedText().props('language')).toBe(snippetLanguage);
});
}); });
...@@ -3,24 +3,31 @@ import { nextTick } from 'vue'; ...@@ -3,24 +3,31 @@ import { nextTick } from 'vue';
import { GlButton } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import AiGenie, { i18n } from 'ee/ai/components/ai_genie.vue'; import AiGenie, { i18n } from 'ee/ai/components/ai_genie.vue';
import AiGenieChat from 'ee/ai/components/ai_genie_chat.vue'; import AiGenieChat from 'ee/ai/components/ai_genie_chat.vue';
import { explainCode } from 'ee/ai/utils';
import { renderMarkdown } from '~/notes/utils';
import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
jest.mock('ee/ai/utils');
jest.mock('~/notes/utils');
describe('AiGenie', () => { describe('AiGenie', () => {
let wrapper; let wrapper;
const containerId = 'container'; const containerId = 'container';
const language = 'vue';
const createComponent = (propsData = {}) => { const createComponent = (propsData = {}) => {
wrapper = shallowMountExtended(AiGenie, { wrapper = shallowMountExtended(AiGenie, {
propsData, propsData,
stubs: {
AiGenieChat,
},
}); });
}; };
const findButton = () => wrapper.findComponent(GlButton); const findButton = () => wrapper.findComponent(GlButton);
const findGenieChat = () => wrapper.findComponent(AiGenieChat); const findGenieChat = () => wrapper.findComponent(AiGenieChat);
const simulateSelection = () => {
const selectionChangeEvent = new Event('selectionchange');
document.dispatchEvent(selectionChangeEvent);
};
const getSelectionMock = () => { const getSelectionMock = () => {
return { return {
anchorNode: document.getElementById('first-paragraph'), anchorNode: document.getElementById('first-paragraph'),
...@@ -38,16 +45,36 @@ describe('AiGenie', () => { ...@@ -38,16 +45,36 @@ describe('AiGenie', () => {
}; };
}), }),
rangeCount: 1, rangeCount: 1,
toString: function toString() {
return 'Foo';
},
}; };
}; };
const simulateSelectionEvent = () => {
const selectionChangeEvent = new Event('selectionchange');
document.dispatchEvent(selectionChangeEvent);
};
const waitForDebounce = async () => { const waitForDebounce = async () => {
await nextTick(); await nextTick();
jest.runOnlyPendingTimers(); jest.runOnlyPendingTimers();
}; };
const simulateSelectText = async () => {
jest.spyOn(window, 'getSelection').mockImplementation(() => getSelectionMock());
jest.spyOn(document.getElementById(containerId), 'contains').mockImplementation(() => true);
simulateSelectionEvent();
await waitForDebounce();
};
const requestExplanation = async () => {
findButton().vm.$emit('click');
await waitForPromises();
};
beforeEach(() => { beforeEach(() => {
setHTMLFixture( setHTMLFixture(
`<div id="${containerId}" style="height:1000px; width: 800px"><p id="first-paragraph">Foo</p></div>`, `<div id="${containerId}" style="height:1000px; width: 800px"><p lang=${language} id="first-paragraph">Foo</p></div>`,
); );
createComponent({ createComponent({
containerId, containerId,
...@@ -74,13 +101,12 @@ describe('AiGenie', () => { ...@@ -74,13 +101,12 @@ describe('AiGenie', () => {
expect(btnWrapper.isVisible()).toBe(false); expect(btnWrapper.isVisible()).toBe(false);
expect(btnWrapper.attributes('title')).toBe(i18n.GENIE_TOOLTIP); expect(btnWrapper.attributes('title')).toBe(i18n.GENIE_TOOLTIP);
}); });
it('is rendered when a text is selected', async () => { it('is rendered when a text is selected', async () => {
jest.spyOn(window, 'getSelection').mockImplementation(() => getSelectionMock()); await simulateSelectText();
jest.spyOn(document.getElementById(containerId), 'contains').mockImplementation(() => true);
simulateSelection();
await waitForDebounce();
expect(findButton().isVisible()).toBe(true); expect(findButton().isVisible()).toBe(true);
}); });
it.skip('is positioned correctly at the top of the selection', () => {}); it.skip('is positioned correctly at the top of the selection', () => {});
}); });
...@@ -119,19 +145,39 @@ describe('AiGenie', () => { ...@@ -119,19 +145,39 @@ describe('AiGenie', () => {
}); });
describe('interaction', () => { describe('interaction', () => {
beforeEach(() => { it('toggles genie when the button is clicked', async () => {
createComponent({ findButton().vm.$emit('click');
containerId, await nextTick();
}); expect(findGenieChat().exists()).toBe(true);
});
it('once the response arrives, :content is set with the response message', async () => {
const content = 'Returned Foo';
explainCode.mockResolvedValue({ message: { content } });
renderMarkdown.mockReturnValue(content);
await requestExplanation();
expect(explainCode).toHaveBeenCalledTimes(1);
expect(renderMarkdown).toHaveBeenCalledTimes(1);
expect(findGenieChat().props().content).toBe(content);
}); });
it.skip('toggles genie when the button is clicked', () => { it('if the response fails, genie gets :error set with the error message', async () => {
// Genie is visible and has `is-loading: true` const message = 'Network error!';
explainCode.mockRejectedValue({ message });
await requestExplanation();
expect(findGenieChat().props().error).toBe(message);
}); });
it.skip('once the response arrives, :content is set with the response message', () => {}); it('when a snippet is selected, :selected-text gets the same content', async () => {
it.skip('if the response fails, genie gets :error set with the error message', () => {}); await simulateSelectText();
it.skip('when a snippet is selected, :selected-code gets the same content', () => {}); await requestExplanation();
it.skip('sets the snippet language', () => {}); expect(findGenieChat().props().selectedText).toBe('Foo');
});
it('sets the snippet language', async () => {
await simulateSelectText();
await requestExplanation();
expect(findGenieChat().props().snippetLanguage).toBe(language);
});
}); });
}); });
...@@ -9,7 +9,7 @@ jest.mock('ee/api', () => { ...@@ -9,7 +9,7 @@ jest.mock('ee/api', () => {
describe('AI Utils', () => { describe('AI Utils', () => {
describe('explainCode', () => { describe('explainCode', () => {
const filPath = 'fooPath'; const filePath = 'fooPath';
const fileText = 'barText'; const fileText = 'barText';
const error = 'Returned Foo Error'; const error = 'Returned Foo Error';
const returnedMessage = { message: { content: 'Returned Foo' } }; const returnedMessage = { message: { content: 'Returned Foo' } };
...@@ -22,7 +22,7 @@ describe('AI Utils', () => { ...@@ -22,7 +22,7 @@ describe('AI Utils', () => {
}); });
it('generates a request to AIChat endpoint with the passed props', () => { it('generates a request to AIChat endpoint with the passed props', () => {
utils.explainCode(filPath, fileText); utils.explainCode(filePath, fileText);
expect(Api.requestAIChat).toHaveBeenCalledWith({ expect(Api.requestAIChat).toHaveBeenCalledWith({
model: 'gpt-3.5-turbo', model: 'gpt-3.5-turbo',
...@@ -32,21 +32,21 @@ describe('AI Utils', () => { ...@@ -32,21 +32,21 @@ describe('AI Utils', () => {
expect.any(Object), expect.any(Object),
{ {
role: 'user', role: 'user',
content: expect.stringContaining(filPath) && expect.stringContaining(fileText), content: expect.stringContaining(filePath) && expect.stringContaining(fileText),
}, },
], ],
}); });
}); });
it('returns correct prop from the response', async () => { it('returns correct prop from the response', async () => {
const result = await utils.explainCode(filPath, fileText); const result = await utils.explainCode(filePath, fileText);
expect(result).toEqual(returnedMessage); expect(result).toEqual(returnedMessage);
}); });
it('throws an error if the request fails', async () => { it('throws an error if the request fails', async () => {
Api.requestAIChat.mockRejectedValue({ response: { data: { error: returnedError } } }); Api.requestAIChat.mockRejectedValue({ response: { data: { error: returnedError } } });
await expect(utils.explainCode(filPath, fileText)).rejects.toThrow(error); await expect(utils.explainCode(filePath, fileText)).rejects.toThrow(error);
}); });
}); });
}); });
...@@ -1839,6 +1839,18 @@ msgstr "" ...@@ -1839,6 +1839,18 @@ msgstr ""
msgid "ACTION REQUIRED: Something went wrong while obtaining the Let's Encrypt certificate for GitLab Pages domain '%{domain}'" msgid "ACTION REQUIRED: Something went wrong while obtaining the Let's Encrypt certificate for GitLab Pages domain '%{domain}'"
msgstr "" msgstr ""
   
msgid "AI|Close the Code Explanation"
msgstr ""
msgid "AI|Code Explanation"
msgstr ""
msgid "AI|Explain the code from %{filePath} in human understandable language presented in Markdown format. In the response add neither original code snippet nor any title. `%{text}`"
msgstr ""
msgid "AI|What does the selected code mean?"
msgstr ""
msgid "API" msgid "API"
msgstr "" msgstr ""
   
...@@ -52898,6 +52910,9 @@ msgstr "" ...@@ -52898,6 +52910,9 @@ msgstr ""
msgid "projects" msgid "projects"
msgstr "" msgstr ""
   
msgid "random"
msgstr ""
msgid "reCAPTCHA" msgid "reCAPTCHA"
msgstr "" msgstr ""
   
Поддерживает Markdown
0% или .
You are about to add 0 people to the discussion. Proceed with caution.
Сначала завершите редактирование этого сообщения!
Пожалуйста, зарегистрируйтесь или чтобы прокомментировать