diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-07-19 17:16:28 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-07-19 17:16:28 +0300 |
commit | e4384360a16dd9a19d4d2d25d0ef1f2b862ed2a6 (patch) | |
tree | 2fcdfa7dcdb9db8f5208b2562f4b4e803d671243 /spec/frontend | |
parent | ffda4e7bcac36987f936b4ba515995a6698698f0 (diff) |
Add latest changes from gitlab-org/gitlab@16-2-stable-eev16.2.0-rc42
Diffstat (limited to 'spec/frontend')
478 files changed, 12380 insertions, 19566 deletions
diff --git a/spec/frontend/__helpers__/set_vue_error_handler.js b/spec/frontend/__helpers__/set_vue_error_handler.js new file mode 100644 index 00000000000..d254630d1e4 --- /dev/null +++ b/spec/frontend/__helpers__/set_vue_error_handler.js @@ -0,0 +1,30 @@ +import Vue from 'vue'; + +const modifiedInstances = []; + +export function setVueErrorHandler({ instance, handler }) { + if (Vue.version.startsWith('2')) { + // only global handlers are supported + const { config } = Vue; + config.errorHandler = handler; + return; + } + + // eslint-disable-next-line no-param-reassign + instance.$.appContext.config.errorHandler = handler; + modifiedInstances.push(instance); +} + +export function resetVueErrorHandler() { + if (Vue.version.startsWith('2')) { + const { config } = Vue; + config.errorHandler = null; + return; + } + + modifiedInstances.forEach((instance) => { + // eslint-disable-next-line no-param-reassign + instance.$.appContext.config.errorHandler = null; + }); + modifiedInstances.length = 0; +} diff --git a/spec/frontend/access_tokens/components/tokens_app_spec.js b/spec/frontend/access_tokens/components/tokens_app_spec.js index 6e7dee6a2cc..59cc8e25414 100644 --- a/spec/frontend/access_tokens/components/tokens_app_spec.js +++ b/spec/frontend/access_tokens/components/tokens_app_spec.js @@ -43,8 +43,8 @@ describe('TokensApp', () => { }) => { const container = extendedWrapper(wrapper.findByTestId(testId)); - expect(container.findByText(expectedLabel, { selector: 'h4' }).exists()).toBe(true); - expect(container.findByText(expectedDescription).exists()).toBe(true); + expect(container.findByText(expectedLabel).exists()).toBe(true); + expect(container.findByText(expectedDescription, { exact: false }).exists()).toBe(true); expect(container.findByText(expectedInputDescription, { exact: false }).exists()).toBe(true); expect(container.findByText('reset this token').attributes()).toMatchObject({ 'data-confirm': expectedResetConfirmMessage, diff --git a/spec/frontend/actioncable_connection_monitor_spec.js b/spec/frontend/actioncable_connection_monitor_spec.js deleted file mode 100644 index c68eb53acde..00000000000 --- a/spec/frontend/actioncable_connection_monitor_spec.js +++ /dev/null @@ -1,79 +0,0 @@ -import ConnectionMonitor from '~/actioncable_connection_monitor'; - -describe('ConnectionMonitor', () => { - let monitor; - - beforeEach(() => { - monitor = new ConnectionMonitor({}); - }); - - describe('#getPollInterval', () => { - beforeEach(() => { - Math.originalRandom = Math.random; - }); - afterEach(() => { - Math.random = Math.originalRandom; - }); - - const { staleThreshold, reconnectionBackoffRate } = ConnectionMonitor; - const backoffFactor = 1 + reconnectionBackoffRate; - const ms = 1000; - - it('uses exponential backoff', () => { - Math.random = () => 0; - - monitor.reconnectAttempts = 0; - expect(monitor.getPollInterval()).toEqual(staleThreshold * ms); - - monitor.reconnectAttempts = 1; - expect(monitor.getPollInterval()).toEqual(staleThreshold * backoffFactor * ms); - - monitor.reconnectAttempts = 2; - expect(monitor.getPollInterval()).toEqual( - staleThreshold * backoffFactor * backoffFactor * ms, - ); - }); - - it('caps exponential backoff after some number of reconnection attempts', () => { - Math.random = () => 0; - monitor.reconnectAttempts = 42; - const cappedPollInterval = monitor.getPollInterval(); - - monitor.reconnectAttempts = 9001; - expect(monitor.getPollInterval()).toEqual(cappedPollInterval); - }); - - it('uses 100% jitter when 0 reconnection attempts', () => { - Math.random = () => 0; - expect(monitor.getPollInterval()).toEqual(staleThreshold * ms); - - Math.random = () => 0.5; - expect(monitor.getPollInterval()).toEqual(staleThreshold * 1.5 * ms); - }); - - it('uses reconnectionBackoffRate for jitter when >0 reconnection attempts', () => { - monitor.reconnectAttempts = 1; - - Math.random = () => 0.25; - expect(monitor.getPollInterval()).toEqual( - staleThreshold * backoffFactor * (1 + reconnectionBackoffRate * 0.25) * ms, - ); - - Math.random = () => 0.5; - expect(monitor.getPollInterval()).toEqual( - staleThreshold * backoffFactor * (1 + reconnectionBackoffRate * 0.5) * ms, - ); - }); - - it('applies jitter after capped exponential backoff', () => { - monitor.reconnectAttempts = 9001; - - Math.random = () => 0; - const withoutJitter = monitor.getPollInterval(); - Math.random = () => 0.5; - const withJitter = monitor.getPollInterval(); - - expect(withJitter).toBeGreaterThan(withoutJitter); - }); - }); -}); diff --git a/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js b/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js index 27fe010c354..fa051f7a43a 100644 --- a/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js +++ b/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js @@ -45,14 +45,13 @@ describe('AddContextCommitsModal', () => { ...props, }, }); - return wrapper; }; const findModal = () => wrapper.findComponent(GlModal); const findSearch = () => wrapper.findComponent(GlFilteredSearch); beforeEach(() => { - wrapper = createWrapper(); + createWrapper(); }); it('renders modal with 2 tabs', () => { @@ -98,7 +97,7 @@ describe('AddContextCommitsModal', () => { }); it('enabled ok button when atleast one row is selected', async () => { - wrapper.vm.$store.state.selectedCommits = [{ ...commit, isSelected: true }]; + store.state.selectedCommits = [{ ...commit, isSelected: true }]; await nextTick(); expect(findModal().attributes('ok-disabled')).toBe(undefined); }); @@ -106,14 +105,14 @@ describe('AddContextCommitsModal', () => { describe('when in second tab, renders a modal with', () => { beforeEach(() => { - wrapper.vm.$store.state.tabIndex = 1; + store.state.tabIndex = 1; }); it('a disabled ok button when no row is selected', () => { expect(findModal().attributes('ok-disabled')).toBe('true'); }); it('an enabled ok button when atleast one row is selected', async () => { - wrapper.vm.$store.state.selectedCommits = [{ ...commit, isSelected: true }]; + store.state.selectedCommits = [{ ...commit, isSelected: true }]; await nextTick(); expect(findModal().attributes('ok-disabled')).toBe(undefined); }); @@ -126,7 +125,7 @@ describe('AddContextCommitsModal', () => { describe('has an ok button when clicked calls action', () => { it('"createContextCommits" when only new commits to be added', async () => { - wrapper.vm.$store.state.selectedCommits = [{ ...commit, isSelected: true }]; + store.state.selectedCommits = [{ ...commit, isSelected: true }]; findModal().vm.$emit('ok'); await nextTick(); expect(createContextCommits).toHaveBeenCalledWith(expect.anything(), { @@ -135,14 +134,14 @@ describe('AddContextCommitsModal', () => { }); }); it('"removeContextCommits" when only added commits are to be removed', async () => { - wrapper.vm.$store.state.toRemoveCommits = [commit.short_id]; + store.state.toRemoveCommits = [commit.short_id]; findModal().vm.$emit('ok'); await nextTick(); expect(removeContextCommits).toHaveBeenCalledWith(expect.anything(), true); }); it('"createContextCommits" and "removeContextCommits" when new commits are to be added and old commits are to be removed', async () => { - wrapper.vm.$store.state.selectedCommits = [{ ...commit, isSelected: true }]; - wrapper.vm.$store.state.toRemoveCommits = [commit.short_id]; + store.state.selectedCommits = [{ ...commit, isSelected: true }]; + store.state.toRemoveCommits = [commit.short_id]; findModal().vm.$emit('ok'); await nextTick(); expect(createContextCommits).toHaveBeenCalledWith(expect.anything(), { diff --git a/spec/frontend/admin/abuse_reports/components/abuse_category_spec.js b/spec/frontend/admin/abuse_reports/components/abuse_category_spec.js new file mode 100644 index 00000000000..456df3b1857 --- /dev/null +++ b/spec/frontend/admin/abuse_reports/components/abuse_category_spec.js @@ -0,0 +1,43 @@ +import { GlLabel } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import AbuseCategory from '~/admin/abuse_reports/components/abuse_category.vue'; +import { ABUSE_CATEGORIES } from '~/admin/abuse_reports/constants'; +import { mockAbuseReports } from '../mock_data'; + +describe('AbuseCategory', () => { + let wrapper; + + const mockAbuseReport = mockAbuseReports[0]; + const category = ABUSE_CATEGORIES[mockAbuseReport.category]; + + const findLabel = () => wrapper.findComponent(GlLabel); + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(AbuseCategory, { + propsData: { + category: mockAbuseReport.category, + ...props, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders a label', () => { + expect(findLabel().exists()).toBe(true); + }); + + it('renders the label with the right background color for the category', () => { + expect(findLabel().props()).toMatchObject({ + backgroundColor: category.backgroundColor, + title: category.title, + target: null, + }); + }); + + it('renders the label with the right text color for the category', () => { + expect(findLabel().attributes('class')).toBe(`gl-text-${category.color}`); + }); +}); diff --git a/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js b/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js index f3cced81478..03bf510f3ad 100644 --- a/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js +++ b/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js @@ -1,6 +1,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import AbuseReportRow from '~/admin/abuse_reports/components/abuse_report_row.vue'; +import AbuseCategory from '~/admin/abuse_reports/components/abuse_category.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import { getTimeago } from '~/lib/utils/datetime_utility'; import { SORT_UPDATED_AT } from '~/admin/abuse_reports/constants'; @@ -11,7 +12,8 @@ describe('AbuseReportRow', () => { const mockAbuseReport = mockAbuseReports[0]; const findListItem = () => wrapper.findComponent(ListItem); - const findTitle = () => wrapper.findByTestId('title'); + const findAbuseCategory = () => wrapper.findComponent(AbuseCategory); + const findAbuseReportTitle = () => wrapper.findByTestId('abuse-report-title'); const findDisplayedDate = () => wrapper.findByTestId('abuse-report-date'); const createComponent = (props = {}) => { @@ -35,13 +37,13 @@ describe('AbuseReportRow', () => { const { reporter, reportedUser, category, reportPath } = mockAbuseReport; it('displays correctly formatted title', () => { - expect(findTitle().text()).toMatchInterpolatedText( + expect(findAbuseReportTitle().text()).toMatchInterpolatedText( `${reportedUser.name} reported for ${category} by ${reporter.name}`, ); }); it('links to the details page', () => { - expect(findTitle().attributes('href')).toEqual(reportPath); + expect(findAbuseReportTitle().attributes('href')).toEqual(reportPath); }); describe('when the reportedUser is missing', () => { @@ -50,7 +52,7 @@ describe('AbuseReportRow', () => { }); it('displays correctly formatted title', () => { - expect(findTitle().text()).toMatchInterpolatedText( + expect(findAbuseReportTitle().text()).toMatchInterpolatedText( `Deleted user reported for ${category} by ${reporter.name}`, ); }); @@ -62,7 +64,7 @@ describe('AbuseReportRow', () => { }); it('displays correctly formatted title', () => { - expect(findTitle().text()).toMatchInterpolatedText( + expect(findAbuseReportTitle().text()).toMatchInterpolatedText( `${reportedUser.name} reported for ${category} by Deleted user`, ); }); @@ -88,4 +90,8 @@ describe('AbuseReportRow', () => { }); }); }); + + it('renders abuse category', () => { + expect(findAbuseCategory().exists()).toBe(true); + }); }); diff --git a/spec/frontend/admin/applications/components/delete_application_spec.js b/spec/frontend/admin/applications/components/delete_application_spec.js index 315c38a2bbc..e0282b8c149 100644 --- a/spec/frontend/admin/applications/components/delete_application_spec.js +++ b/spec/frontend/admin/applications/components/delete_application_spec.js @@ -1,6 +1,7 @@ import { GlModal, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { stubComponent } from 'helpers/stub_component'; import DeleteApplication from '~/admin/applications/components/delete_application.vue'; const path = 'application/path/1'; @@ -14,6 +15,11 @@ describe('DeleteApplication', () => { const createComponent = () => { wrapper = shallowMount(DeleteApplication, { stubs: { + GlModal: stubComponent(GlModal, { + methods: { + show: jest.fn(), + }, + }), GlSprintf, }, }); @@ -36,7 +42,6 @@ describe('DeleteApplication', () => { describe('the modal component', () => { beforeEach(() => { - wrapper.vm.$refs.deleteModal.show = jest.fn(); document.querySelector('.js-application-delete-button').click(); }); diff --git a/spec/frontend/admin/broadcast_messages/components/message_form_spec.js b/spec/frontend/admin/broadcast_messages/components/message_form_spec.js index dca77e67cac..b937a58a742 100644 --- a/spec/frontend/admin/broadcast_messages/components/message_form_spec.js +++ b/spec/frontend/admin/broadcast_messages/components/message_form_spec.js @@ -5,7 +5,12 @@ import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_BAD_REQUEST } from '~/lib/utils/http_status'; import MessageForm from '~/admin/broadcast_messages/components/message_form.vue'; -import { TYPE_BANNER, TYPE_NOTIFICATION, THEMES } from '~/admin/broadcast_messages/constants'; +import { + TYPE_BANNER, + TYPE_NOTIFICATION, + THEMES, + TARGET_OPTIONS, +} from '~/admin/broadcast_messages/constants'; import waitForPromises from 'helpers/wait_for_promises'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { MOCK_TARGET_ACCESS_LEVELS } from '../mock_data'; @@ -37,6 +42,8 @@ describe('MessageForm', () => { const findCancelButton = () => wrapper.findComponent('[data-testid=cancel-button]'); const findForm = () => wrapper.findComponent(GlForm); const findShowInCli = () => wrapper.findComponent('[data-testid=show-in-cli-checkbox]'); + const findTargetSelect = () => wrapper.findComponent('[data-testid=target-select]'); + const findTargetPath = () => wrapper.findComponent('[data-testid=target-path-input]'); function createComponent({ broadcastMessage = {} } = {}) { wrapper = mount(MessageForm, { @@ -112,10 +119,38 @@ describe('MessageForm', () => { }); }); - describe('target roles checkboxes', () => { - it('renders target roles', () => { + describe('target select', () => { + it('renders the first option and hide target path and target roles when creating message', () => { createComponent(); - expect(findTargetRoles().exists()).toBe(true); + expect(findTargetSelect().element.value).toBe(TARGET_OPTIONS[0].value); + expect(findTargetRoles().isVisible()).toBe(false); + expect(findTargetPath().isVisible()).toBe(false); + }); + + it('triggers displaying target path and target roles when selecting different options', async () => { + createComponent(); + const options = findTargetSelect().findAll('option'); + await options.at(1).setSelected(); + expect(findTargetPath().isVisible()).toBe(true); + expect(findTargetRoles().isVisible()).toBe(false); + + await options.at(2).setSelected(); + expect(findTargetPath().isVisible()).toBe(true); + expect(findTargetRoles().isVisible()).toBe(true); + }); + + it('renders the second option and hide target roles when editing message with path specified', () => { + createComponent({ broadcastMessage: { targetPath: '/welcome' } }); + expect(findTargetSelect().element.value).toBe(TARGET_OPTIONS[1].value); + expect(findTargetRoles().isVisible()).toBe(false); + expect(findTargetPath().isVisible()).toBe(true); + }); + + it('renders the third option when editing message with path and roles specified', () => { + createComponent({ broadcastMessage: { targetPath: '/welcome', targetAccessLevels: [20] } }); + expect(findTargetSelect().element.value).toBe(TARGET_OPTIONS[2].value); + expect(findTargetRoles().isVisible()).toBe(true); + expect(findTargetPath().isVisible()).toBe(true); }); }); diff --git a/spec/frontend/admin/topics/components/remove_avatar_spec.js b/spec/frontend/admin/topics/components/remove_avatar_spec.js index c069203d046..705066c3ef0 100644 --- a/spec/frontend/admin/topics/components/remove_avatar_spec.js +++ b/spec/frontend/admin/topics/components/remove_avatar_spec.js @@ -73,7 +73,7 @@ describe('RemoveAvatar', () => { let formSubmitSpy; beforeEach(() => { - formSubmitSpy = jest.spyOn(wrapper.vm.$refs.deleteForm, 'submit'); + formSubmitSpy = jest.spyOn(findForm().element, 'submit'); findModal().vm.$emit('primary'); }); diff --git a/spec/frontend/admin/topics/components/topic_select_spec.js b/spec/frontend/admin/topics/components/topic_select_spec.js index 113a0e3d404..5b7e6365606 100644 --- a/spec/frontend/admin/topics/components/topic_select_spec.js +++ b/spec/frontend/admin/topics/components/topic_select_spec.js @@ -58,10 +58,6 @@ describe('TopicSelect', () => { }); } - afterEach(() => { - jest.clearAllMocks(); - }); - it('mounts', () => { createComponent(); diff --git a/spec/frontend/alert_management/components/alert_management_table_spec.js b/spec/frontend/alert_management/components/alert_management_table_spec.js index afd88e1a6ac..9980843defb 100644 --- a/spec/frontend/alert_management/components/alert_management_table_spec.js +++ b/spec/frontend/alert_management/components/alert_management_table_spec.js @@ -186,7 +186,7 @@ describe('AlertManagementTable', () => { expect(findSeverityFields().at(0).text()).toBe('Critical'); }); - it('renders Unassigned when no assignee(s) present', () => { + it('renders Unassigned when no assignees present', () => { mountComponent({ data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, loading: false, diff --git a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js index 4a0c7f65493..e6b38a1e824 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js @@ -68,8 +68,11 @@ describe('AlertsSettingsForm', () => { await options.at(index).setSelected(); }; - const enableIntegration = (index, value) => { - findFormFields().at(index).setValue(value); + const enableIntegration = (index, value = '') => { + if (value !== '') { + findFormFields().at(index).setValue(value); + } + findFormToggle().vm.$emit('change', true); }; @@ -100,7 +103,8 @@ describe('AlertsSettingsForm', () => { it('hides the name input when the selected value is prometheus', async () => { createComponent(); await selectOptionAtIndex(2); - expect(findFormFields().at(0).attributes('id')).not.toBe('name-integration'); + + expect(findFormFields()).toHaveLength(0); }); it('verify pricing link url', () => { @@ -203,8 +207,8 @@ describe('AlertsSettingsForm', () => { it('create', async () => { createComponent(); await selectOptionAtIndex(2); - const apiUrl = 'https://test.com'; - enableIntegration(0, apiUrl); + enableIntegration(0); + const submitBtn = findSubmitButton(); expect(submitBtn.exists()).toBe(true); expect(submitBtn.text()).toBe('Save integration'); @@ -213,14 +217,14 @@ describe('AlertsSettingsForm', () => { expect(wrapper.emitted('create-new-integration')[0][0]).toMatchObject({ type: typeSet.prometheus, - variables: { apiUrl, active: true }, + variables: { active: true }, }); }); it('update', () => { createComponent({ data: { - integrationForm: { id: '1', apiUrl: 'https://test-pre.com', type: typeSet.prometheus }, + integrationForm: { id: '1', type: typeSet.prometheus }, currentIntegration: { id: '1' }, }, props: { @@ -228,8 +232,7 @@ describe('AlertsSettingsForm', () => { }, }); - const apiUrl = 'https://test-post.com'; - enableIntegration(0, apiUrl); + enableIntegration(0); const submitBtn = findSubmitButton(); expect(submitBtn.exists()).toBe(true); @@ -239,7 +242,7 @@ describe('AlertsSettingsForm', () => { expect(wrapper.emitted('update-integration')[0][0]).toMatchObject({ type: typeSet.prometheus, - variables: { apiUrl, active: true }, + variables: { active: true }, }); }); }); @@ -442,16 +445,8 @@ describe('AlertsSettingsForm', () => { expect(findSubmitButton().attributes('disabled')).toBe(undefined); }); - it('should not be able to submit when Prometheus integration form is invalid', async () => { - await selectOptionAtIndex(2); - await findFormFields().at(0).vm.$emit('input', ''); - - expect(findSubmitButton().attributes('disabled')).toBeDefined(); - }); - it('should be able to submit when Prometheus integration form is valid', async () => { await selectOptionAtIndex(2); - await findFormFields().at(0).vm.$emit('input', 'http://valid.url'); expect(findSubmitButton().attributes('disabled')).toBe(undefined); }); diff --git a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js index 4e0b546b3d2..802da47d6cd 100644 --- a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js +++ b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js @@ -57,7 +57,6 @@ describe('ProjectsDropdownFilter component', () => { }); }; - const findClearAllButton = () => wrapper.findByTestId('listbox-reset-button'); const findSelectedProjectsLabel = () => wrapper.findComponent(GlTruncate); const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); @@ -143,10 +142,6 @@ describe('ProjectsDropdownFilter component', () => { expect(findSelectedProjectsLabel().text()).toBe('Select projects'); }); - - it('does not render the clear all button', () => { - expect(findClearAllButton().exists()).toBe(false); - }); }); describe('with a selected project', () => { @@ -169,12 +164,6 @@ describe('ProjectsDropdownFilter component', () => { expect(findSelectedProjectsLabel().text()).toBe(projects[0].name); }); - it('renders the clear all button', async () => { - await selectDropdownItemAtIndex([0], false); - - expect(findClearAllButton().exists()).toBe(true); - }); - it('clears all selected items when the clear all button is clicked', async () => { createComponent({ mountFn: mountExtended, @@ -186,7 +175,7 @@ describe('ProjectsDropdownFilter component', () => { expect(findSelectedProjectsLabel().text()).toBe('2 projects selected'); - await findClearAllButton().vm.$emit('click'); + await findDropdown().vm.$emit('reset'); expect(findSelectedProjectsLabel().text()).toBe('Select projects'); }); diff --git a/spec/frontend/analytics/usage_trends/components/users_chart_spec.js b/spec/frontend/analytics/usage_trends/components/users_chart_spec.js index 20836d7cc70..8638d82ae3c 100644 --- a/spec/frontend/analytics/usage_trends/components/users_chart_spec.js +++ b/spec/frontend/analytics/usage_trends/components/users_chart_spec.js @@ -22,23 +22,19 @@ describe('UsersChart', () => { let queryHandler; const createComponent = ({ - loadingError = false, - loading = false, users = [], additionalData = [], + handler = mockQueryResponse({ key: 'users', data: users, additionalData }), } = {}) => { - queryHandler = mockQueryResponse({ key: 'users', data: users, loading, additionalData }); + queryHandler = handler; - return shallowMount(UsersChart, { + wrapper = shallowMount(UsersChart, { + apolloProvider: createMockApollo([[usersQuery, queryHandler]]), props: { startDate: new Date(2020, 9, 26), endDate: new Date(2020, 10, 1), totalDataPoints: mockCountsData2.length, }, - apolloProvider: createMockApollo([[usersQuery, queryHandler]]), - data() { - return { loadingError }; - }, }); }; @@ -48,7 +44,7 @@ describe('UsersChart', () => { describe('while loading', () => { beforeEach(() => { - wrapper = createComponent({ loading: true }); + createComponent({ loading: true }); }); it('displays the skeleton loader', () => { @@ -62,7 +58,7 @@ describe('UsersChart', () => { describe('without data', () => { beforeEach(async () => { - wrapper = createComponent({ users: [] }); + createComponent({ users: [] }); await nextTick(); }); @@ -81,7 +77,7 @@ describe('UsersChart', () => { describe('with data', () => { beforeEach(async () => { - wrapper = createComponent({ users: mockCountsData2 }); + createComponent({ users: mockCountsData2 }); await waitForPromises(); }); @@ -102,11 +98,17 @@ describe('UsersChart', () => { describe('with errors', () => { beforeEach(async () => { - wrapper = createComponent({ loadingError: true }); + createComponent(); await nextTick(); }); - it('renders an error message', () => { + it('renders an error message', async () => { + createComponent({ + handler: jest.fn().mockRejectedValue({}), + }); + + await waitForPromises(); + expect(findAlert().text()).toBe( 'Could not load the user chart. Please refresh the page to try again.', ); @@ -124,42 +126,37 @@ describe('UsersChart', () => { describe('when fetching more data', () => { describe('when the fetchMore query returns data', () => { beforeEach(async () => { - wrapper = createComponent({ + createComponent({ users: mockCountsData2, additionalData: mockCountsData1, }); - jest.spyOn(wrapper.vm.$apollo.queries.users, 'fetchMore'); await nextTick(); }); it('requests data twice', () => { expect(queryHandler).toHaveBeenCalledTimes(2); }); - - it('calls fetchMore', () => { - expect(wrapper.vm.$apollo.queries.users.fetchMore).toHaveBeenCalledTimes(1); - }); }); describe('when the fetchMore query throws an error', () => { beforeEach(async () => { - wrapper = createComponent({ + createComponent({ users: mockCountsData2, additionalData: mockCountsData1, }); - jest - .spyOn(wrapper.vm.$apollo.queries.users, 'fetchMore') - .mockImplementation(jest.fn().mockRejectedValue()); await waitForPromises(); }); it('calls fetchMore', () => { - expect(wrapper.vm.$apollo.queries.users.fetchMore).toHaveBeenCalledTimes(1); + expect(queryHandler).toHaveBeenCalledTimes(2); }); - it('renders an error message', () => { + it('renders an error message', async () => { + createComponent({ handler: jest.fn().mockRejectedValue({}) }); + await waitForPromises(); + expect(findAlert().text()).toBe( 'Could not load the user chart. Please refresh the page to try again.', ); diff --git a/spec/frontend/api/user_api_spec.js b/spec/frontend/api/user_api_spec.js index b2ecfeb8394..a6e08e1cf4b 100644 --- a/spec/frontend/api/user_api_spec.js +++ b/spec/frontend/api/user_api_spec.js @@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import projects from 'test_fixtures/api/users/projects/get.json'; import followers from 'test_fixtures/api/users/followers/get.json'; +import following from 'test_fixtures/api/users/following/get.json'; import { followUser, unfollowUser, @@ -9,6 +10,7 @@ import { updateUserStatus, getUserProjects, getUserFollowers, + getUserFollowing, } from '~/api/user_api'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; @@ -131,4 +133,23 @@ describe('~/api/user_api', () => { expect(axiosMock.history.get[0].params).toEqual({ ...params, per_page: DEFAULT_PER_PAGE }); }); }); + + describe('getUserFollowing', () => { + it('calls correct URL and returns expected response', async () => { + const MOCK_USER_ID = 1; + const MOCK_PAGE = 2; + + const expectedUrl = `/api/v4/users/${MOCK_USER_ID}/following`; + const expectedResponse = { data: following }; + const params = { page: MOCK_PAGE }; + + axiosMock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, expectedResponse); + + await expect(getUserFollowing(MOCK_USER_ID, params)).resolves.toEqual( + expect.objectContaining({ data: expectedResponse }), + ); + expect(axiosMock.history.get[0].url).toBe(expectedUrl); + expect(axiosMock.history.get[0].params).toEqual({ ...params, per_page: DEFAULT_PER_PAGE }); + }); + }); }); diff --git a/spec/frontend/batch_comments/components/draft_note_spec.js b/spec/frontend/batch_comments/components/draft_note_spec.js index 159e36c1364..b6042b4aa81 100644 --- a/spec/frontend/batch_comments/components/draft_note_spec.js +++ b/spec/frontend/batch_comments/components/draft_note_spec.js @@ -41,9 +41,11 @@ describe('Batch comments draft note component', () => { }, }); - jest.spyOn(wrapper.vm.$store, 'dispatch').mockImplementation(); + jest.spyOn(store, 'dispatch').mockImplementation(); }; + const findNoteableNote = () => wrapper.findComponent(NoteableNote); + beforeEach(() => { store = createStore(); draft = createDraft(); @@ -53,32 +55,28 @@ describe('Batch comments draft note component', () => { createComponent(); expect(wrapper.findComponent(GlBadge).exists()).toBe(true); - const note = wrapper.findComponent(NoteableNote); - - expect(note.exists()).toBe(true); - expect(note.props().note).toEqual(draft); + expect(findNoteableNote().exists()).toBe(true); + expect(findNoteableNote().props('note')).toEqual(draft); }); describe('update', () => { it('dispatches updateDraft', async () => { createComponent(); - const note = wrapper.findComponent(NoteableNote); - - note.vm.$emit('handleEdit'); + findNoteableNote().vm.$emit('handleEdit'); await nextTick(); const formData = { note: draft, noteText: 'a', resolveDiscussion: false, + callback: jest.fn(), + parentElement: wrapper.vm.$el, + errorCallback: jest.fn(), }; - note.vm.$emit('handleUpdateNote', formData); + findNoteableNote().vm.$emit('handleUpdateNote', formData); - expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith( - 'batchComments/updateDraft', - formData, - ); + expect(store.dispatch).toHaveBeenCalledWith('batchComments/updateDraft', formData); }); }); @@ -87,18 +85,15 @@ describe('Batch comments draft note component', () => { createComponent(); jest.spyOn(window, 'confirm').mockImplementation(() => true); - const note = wrapper.findComponent(NoteableNote); - - note.vm.$emit('handleDeleteNote', draft); + findNoteableNote().vm.$emit('handleDeleteNote', draft); - expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('batchComments/deleteDraft', draft); + expect(store.dispatch).toHaveBeenCalledWith('batchComments/deleteDraft', draft); }); }); describe('quick actions', () => { it('renders referenced commands', async () => { - createComponent(); - wrapper.setProps({ + createComponent({ draft: { ...draft, references: { @@ -116,22 +111,27 @@ describe('Batch comments draft note component', () => { }); describe('multiline comments', () => { - describe.each` - desc | props | event | expectedCalls - ${'with `draft.position`'} | ${draftWithLineRange} | ${'mouseenter'} | ${[['setSelectedCommentPositionHover', LINE_RANGE]]} - ${'with `draft.position`'} | ${draftWithLineRange} | ${'mouseleave'} | ${[['setSelectedCommentPositionHover']]} - ${'without `draft.position`'} | ${{}} | ${'mouseenter'} | ${[]} - ${'without `draft.position`'} | ${{}} | ${'mouseleave'} | ${[]} - `('$desc', ({ props, event, expectedCalls }) => { - beforeEach(() => { - createComponent({ draft: { ...draft, ...props } }); - jest.spyOn(store, 'dispatch'); - }); + it(`calls store with draft.position with mouseenter`, () => { + createComponent({ draft: { ...draft, ...draftWithLineRange } }); + findNoteableNote().trigger('mouseenter'); - it(`calls store ${expectedCalls.length} times on ${event}`, () => { - wrapper.element.dispatchEvent(new MouseEvent(event, { bubbles: true })); - expect(store.dispatch.mock.calls).toEqual(expectedCalls); - }); + expect(store.dispatch).toHaveBeenCalledWith('setSelectedCommentPositionHover', LINE_RANGE); + }); + + it(`calls store with draft.position and mouseleave`, () => { + createComponent({ draft: { ...draft, ...draftWithLineRange } }); + findNoteableNote().trigger('mouseleave'); + + expect(store.dispatch).toHaveBeenCalledWith('setSelectedCommentPositionHover'); + }); + + it(`does not call store without draft position`, () => { + createComponent({ draft }); + + findNoteableNote().trigger('mouseenter'); + findNoteableNote().trigger('mouseleave'); + + expect(store.dispatch).not.toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/batch_comments/components/submit_dropdown_spec.js b/spec/frontend/batch_comments/components/submit_dropdown_spec.js index 5c33df882bf..7e2ff7f786f 100644 --- a/spec/frontend/batch_comments/components/submit_dropdown_spec.js +++ b/spec/frontend/batch_comments/components/submit_dropdown_spec.js @@ -3,6 +3,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import SubmitDropdown from '~/batch_comments/components/submit_dropdown.vue'; +import { mockTracking } from 'helpers/tracking_helper'; jest.mock('~/autosave'); @@ -10,9 +11,11 @@ Vue.use(Vuex); let wrapper; let publishReview; +let trackingSpy; function factory({ canApprove = true, shouldAnimateReviewButton = false } = {}) { publishReview = jest.fn(); + trackingSpy = mockTracking(undefined, null, jest.spyOn); const store = new Vuex.Store({ getters: { @@ -69,6 +72,20 @@ describe('Batch comments submit dropdown', () => { }); }); + it('tracks submit action', () => { + factory(); + + findCommentTextarea().setValue('Hello world'); + + findForm().vm.$emit('submit', { preventDefault: jest.fn() }); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'editor_type_used', { + context: 'MergeRequest_review', + editorType: 'editor_type_plain_text_editor', + label: 'editor_tracking', + }); + }); + it('switches to the overview tab after submit', async () => { window.mrTabs = { tabShown: jest.fn() }; diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js index 521bbf06b02..824b2a296c6 100644 --- a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js +++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js @@ -1,10 +1,15 @@ import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; +import { sprintf } from '~/locale'; +import { createAlert } from '~/alert'; import service from '~/batch_comments/services/drafts_service'; import * as actions from '~/batch_comments/stores/modules/batch_comments/actions'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { UPDATE_COMMENT_FORM } from '~/notes/i18n'; + +jest.mock('~/alert'); describe('Batch comments store actions', () => { let res = {}; @@ -44,15 +49,15 @@ describe('Batch comments store actions', () => { }); it('does not commit ADD_NEW_DRAFT if errors returned', () => { + const commit = jest.fn(); + mock.onAny().reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - return testAction( - actions.addDraftToDiscussion, - { endpoint: TEST_HOST, data: 'test' }, - null, - [], - [], - ); + return actions + .addDraftToDiscussion({ commit }, { endpoint: TEST_HOST, data: 'test' }) + .catch(() => { + expect(commit).not.toHaveBeenCalledWith('ADD_NEW_DRAFT', expect.anything()); + }); }); }); @@ -84,15 +89,13 @@ describe('Batch comments store actions', () => { }); it('does not commit ADD_NEW_DRAFT if errors returned', () => { + const commit = jest.fn(); + mock.onAny().reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - return testAction( - actions.createNewDraft, - { endpoint: TEST_HOST, data: 'test' }, - null, - [], - [], - ); + return actions.createNewDraft({ commit }, { endpoint: TEST_HOST, data: 'test' }).catch(() => { + expect(commit).not.toHaveBeenCalledWith('ADD_NEW_DRAFT', expect.anything()); + }); }); }); @@ -239,8 +242,6 @@ describe('Batch comments store actions', () => { params = { note: { id: 1 }, noteText: 'test' }; }); - afterEach(() => jest.clearAllMocks()); - it('commits RECEIVE_DRAFT_UPDATE_SUCCESS with returned data', () => { return actions.updateDraft(context, { ...params, callback() {} }).then(() => { expect(commit).toHaveBeenCalledWith('RECEIVE_DRAFT_UPDATE_SUCCESS', { id: 1 }); @@ -267,6 +268,28 @@ describe('Batch comments store actions', () => { expect(service.update.mock.calls[0][1].position).toBe(expectation); }); }); + + describe('when updating a draft returns an error', () => { + const errorCallback = jest.fn(); + const flashContainer = null; + const error = 'server error'; + + beforeEach(async () => { + service.update.mockRejectedValue({ response: { data: { errors: error } } }); + await actions.updateDraft(context, { ...params, flashContainer, errorCallback }); + }); + + it('renders an error message', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: sprintf(UPDATE_COMMENT_FORM.error, { reason: error }), + parent: flashContainer, + }); + }); + + it('calls errorCallback', () => { + expect(errorCallback).toHaveBeenCalledTimes(1); + }); + }); }); describe('expandAllDiscussions', () => { diff --git a/spec/frontend/behaviors/gl_emoji_spec.js b/spec/frontend/behaviors/gl_emoji_spec.js index 995e4219ae3..c7f4fce0e4c 100644 --- a/spec/frontend/behaviors/gl_emoji_spec.js +++ b/spec/frontend/behaviors/gl_emoji_spec.js @@ -1,11 +1,18 @@ import { initEmojiMock, clearEmojiMock } from 'helpers/emoji'; import waitForPromises from 'helpers/wait_for_promises'; +import { createMockClient } from 'helpers/mock_apollo_helper'; import installGlEmojiElement from '~/behaviors/gl_emoji'; import { EMOJI_VERSION } from '~/emoji'; +import customEmojiQuery from '~/emoji/queries/custom_emoji.query.graphql'; import * as EmojiUnicodeSupport from '~/emoji/support'; +let mockClient; + jest.mock('~/emoji/support'); +jest.mock('~/lib/graphql', () => { + return () => mockClient; +}); describe('gl_emoji', () => { const emojiData = { @@ -36,101 +43,144 @@ describe('gl_emoji', () => { return div.firstElementChild; } - beforeEach(async () => { - await initEmojiMock(emojiData); - }); - afterEach(() => { clearEmojiMock(); document.body.innerHTML = ''; }); - describe.each([ - [ - 'bomb emoji just with name attribute', - '<gl-emoji data-name="bomb"></gl-emoji>', - '<gl-emoji data-name="bomb" data-unicode-version="6.0" title="bomb">💣</gl-emoji>', - `<gl-emoji data-name="bomb" data-unicode-version="6.0" title="bomb"><img class="emoji" title=":bomb:" alt=":bomb:" src="/-/emojis/${EMOJI_VERSION}/bomb.png" width="16" height="16" align="absmiddle"></gl-emoji>`, - ], - [ - 'bomb emoji with name attribute and unicode version', - '<gl-emoji data-name="bomb" data-unicode-version="6.0">💣</gl-emoji>', - '<gl-emoji data-name="bomb" data-unicode-version="6.0">💣</gl-emoji>', - `<gl-emoji data-name="bomb" data-unicode-version="6.0"><img class="emoji" title=":bomb:" alt=":bomb:" src="/-/emojis/${EMOJI_VERSION}/bomb.png" width="16" height="16" align="absmiddle"></gl-emoji>`, - ], - [ - 'bomb emoji with sprite fallback', - '<gl-emoji data-fallback-sprite-class="emoji-bomb" data-name="bomb"></gl-emoji>', - '<gl-emoji data-fallback-sprite-class="emoji-bomb" data-name="bomb" data-unicode-version="6.0" title="bomb">💣</gl-emoji>', - '<gl-emoji data-fallback-sprite-class="emoji-bomb" data-name="bomb" data-unicode-version="6.0" title="bomb" class="emoji-icon emoji-bomb">💣</gl-emoji>', - ], - [ - 'bomb emoji with image fallback', - '<gl-emoji data-fallback-src="/bomb.png" data-name="bomb"></gl-emoji>', - '<gl-emoji data-fallback-src="/bomb.png" data-name="bomb" data-unicode-version="6.0" title="bomb">💣</gl-emoji>', - '<gl-emoji data-fallback-src="/bomb.png" data-name="bomb" data-unicode-version="6.0" title="bomb"><img class="emoji" title=":bomb:" alt=":bomb:" src="/bomb.png" width="16" height="16" align="absmiddle"></gl-emoji>', - ], - [ - 'invalid emoji', - '<gl-emoji data-name="invalid_emoji"></gl-emoji>', - '<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament">❔</gl-emoji>', - `<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament"><img class="emoji" title=":grey_question:" alt=":grey_question:" src="/-/emojis/${EMOJI_VERSION}/grey_question.png" width="16" height="16" align="absmiddle"></gl-emoji>`, - ], - [ - 'custom emoji with image fallback', - '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"></gl-emoji>', - '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" width="16" height="16" align="absmiddle"></gl-emoji>', - '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" width="16" height="16" align="absmiddle"></gl-emoji>', - ], - ])('%s', (name, markup, withEmojiSupport, withoutEmojiSupport) => { - it(`renders correctly with emoji support`, async () => { - jest.spyOn(EmojiUnicodeSupport, 'default').mockReturnValue(true); - const glEmojiElement = markupToDomElement(markup); + describe('standard emoji', () => { + beforeEach(async () => { + await initEmojiMock(emojiData); + }); + + describe.each([ + [ + 'bomb emoji just with name attribute', + '<gl-emoji data-name="bomb"></gl-emoji>', + '<gl-emoji data-name="bomb" data-unicode-version="6.0" title="bomb">💣</gl-emoji>', + `<gl-emoji data-name="bomb" data-unicode-version="6.0" title="bomb"><img class="emoji" title=":bomb:" alt=":bomb:" src="/-/emojis/${EMOJI_VERSION}/bomb.png" align="absmiddle"></gl-emoji>`, + ], + [ + 'bomb emoji with name attribute and unicode version', + '<gl-emoji data-name="bomb" data-unicode-version="6.0">💣</gl-emoji>', + '<gl-emoji data-name="bomb" data-unicode-version="6.0">💣</gl-emoji>', + `<gl-emoji data-name="bomb" data-unicode-version="6.0"><img class="emoji" title=":bomb:" alt=":bomb:" src="/-/emojis/${EMOJI_VERSION}/bomb.png" align="absmiddle"></gl-emoji>`, + ], + [ + 'bomb emoji with sprite fallback', + '<gl-emoji data-fallback-sprite-class="emoji-bomb" data-name="bomb"></gl-emoji>', + '<gl-emoji data-fallback-sprite-class="emoji-bomb" data-name="bomb" data-unicode-version="6.0" title="bomb">💣</gl-emoji>', + '<gl-emoji data-fallback-sprite-class="emoji-bomb" data-name="bomb" data-unicode-version="6.0" title="bomb" class="emoji-icon emoji-bomb">💣</gl-emoji>', + ], + [ + 'bomb emoji with image fallback', + '<gl-emoji data-fallback-src="/bomb.png" data-name="bomb"></gl-emoji>', + '<gl-emoji data-fallback-src="/bomb.png" data-name="bomb" data-unicode-version="6.0" title="bomb">💣</gl-emoji>', + '<gl-emoji data-fallback-src="/bomb.png" data-name="bomb" data-unicode-version="6.0" title="bomb"><img class="emoji" title=":bomb:" alt=":bomb:" src="/bomb.png" align="absmiddle"></gl-emoji>', + ], + [ + 'invalid emoji', + '<gl-emoji data-name="invalid_emoji"></gl-emoji>', + '<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament">❔</gl-emoji>', + `<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament"><img class="emoji" title=":grey_question:" alt=":grey_question:" src="/-/emojis/${EMOJI_VERSION}/grey_question.png" align="absmiddle"></gl-emoji>`, + ], + [ + 'custom emoji with image fallback', + '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"></gl-emoji>', + '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" align="absmiddle"></gl-emoji>', + '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" align="absmiddle"></gl-emoji>', + ], + ])('%s', (name, markup, withEmojiSupport, withoutEmojiSupport) => { + it(`renders correctly with emoji support`, async () => { + jest.spyOn(EmojiUnicodeSupport, 'default').mockReturnValue(true); + const glEmojiElement = markupToDomElement(markup); + + await waitForPromises(); + + expect(glEmojiElement.outerHTML).toBe(withEmojiSupport); + }); + + it(`renders correctly without emoji support`, async () => { + jest.spyOn(EmojiUnicodeSupport, 'default').mockReturnValue(false); + const glEmojiElement = markupToDomElement(markup); + + await waitForPromises(); + + expect(glEmojiElement.outerHTML).toBe(withoutEmojiSupport); + }); + }); + + it('escapes gl-emoji name', async () => { + const glEmojiElement = markupToDomElement( + "<gl-emoji data-name='"x="y" onload="alert(document.location.href)"' data-unicode-version='x'>abc</gl-emoji>", + ); await waitForPromises(); - expect(glEmojiElement.outerHTML).toBe(withEmojiSupport); + expect(glEmojiElement.outerHTML).toBe( + '<gl-emoji data-name=""x="y" onload="alert(document.location.href)"" data-unicode-version="x"><img class="emoji" title=":"x="y" onload="alert(document.location.href)":" alt=":"x="y" onload="alert(document.location.href)":" src="/-/emojis/2/grey_question.png" align="absmiddle"></gl-emoji>', + ); }); - it(`renders correctly without emoji support`, async () => { + it('Adds sprite CSS if emojis are not supported', async () => { + const testPath = '/test-path.css'; jest.spyOn(EmojiUnicodeSupport, 'default').mockReturnValue(false); - const glEmojiElement = markupToDomElement(markup); + window.gon.emoji_sprites_css_path = testPath; + expect(document.head.querySelector(`link[href="${testPath}"]`)).toBe(null); + expect(window.gon.emoji_sprites_css_added).toBe(undefined); + + markupToDomElement( + '<gl-emoji data-fallback-sprite-class="emoji-bomb" data-name="bomb"></gl-emoji>', + ); await waitForPromises(); - expect(glEmojiElement.outerHTML).toBe(withoutEmojiSupport); + expect(document.head.querySelector(`link[href="${testPath}"]`).outerHTML).toBe( + '<link rel="stylesheet" href="/test-path.css">', + ); + expect(window.gon.emoji_sprites_css_added).toBe(true); }); }); - it('escapes gl-emoji name', async () => { - const glEmojiElement = markupToDomElement( - "<gl-emoji data-name='"x="y" onload="alert(document.location.href)"' data-unicode-version='x'>abc</gl-emoji>", - ); - - await waitForPromises(); + describe('custom emoji', () => { + beforeEach(async () => { + mockClient = createMockClient([ + [ + customEmojiQuery, + jest.fn().mockResolvedValue({ + data: { + group: { + id: 1, + customEmoji: { + nodes: [{ id: 1, name: 'parrot', url: 'parrot.gif' }], + }, + }, + }, + }), + ], + ]); + + window.gon = { features: { customEmoji: true } }; + document.body.dataset.groupFullPath = 'test-group'; + + await initEmojiMock(emojiData); + }); - expect(glEmojiElement.outerHTML).toBe( - '<gl-emoji data-name=""x="y" onload="alert(document.location.href)"" data-unicode-version="x"><img class="emoji" title=":"x="y" onload="alert(document.location.href)":" alt=":"x="y" onload="alert(document.location.href)":" src="/-/emojis/2/grey_question.png" width="16" height="16" align="absmiddle"></gl-emoji>', - ); - }); + afterEach(() => { + window.gon = {}; + delete document.body.dataset.groupFullPath; + }); - it('Adds sprite CSS if emojis are not supported', async () => { - const testPath = '/test-path.css'; - jest.spyOn(EmojiUnicodeSupport, 'default').mockReturnValue(false); - window.gon.emoji_sprites_css_path = testPath; + it('renders custom emoji', async () => { + const glEmojiElement = markupToDomElement('<gl-emoji data-name="parrot"></gl-emoji>'); - expect(document.head.querySelector(`link[href="${testPath}"]`)).toBe(null); - expect(window.gon.emoji_sprites_css_added).toBe(undefined); + await waitForPromises(); - markupToDomElement( - '<gl-emoji data-fallback-sprite-class="emoji-bomb" data-name="bomb"></gl-emoji>', - ); - await waitForPromises(); + const img = glEmojiElement.querySelector('img'); - expect(document.head.querySelector(`link[href="${testPath}"]`).outerHTML).toBe( - '<link rel="stylesheet" href="/test-path.css">', - ); - expect(window.gon.emoji_sprites_css_added).toBe(true); + expect(glEmojiElement.dataset.unicodeVersion).toBe('custom'); + expect(img.getAttribute('src')).toBe('parrot.gif'); + }); }); }); diff --git a/spec/frontend/behaviors/markdown/render_gfm_spec.js b/spec/frontend/behaviors/markdown/render_gfm_spec.js index 220ad874b47..0bbb92282e5 100644 --- a/spec/frontend/behaviors/markdown/render_gfm_spec.js +++ b/spec/frontend/behaviors/markdown/render_gfm_spec.js @@ -1,7 +1,4 @@ import { renderGFM } from '~/behaviors/markdown/render_gfm'; -import renderMetrics from '~/behaviors/markdown/render_metrics'; - -jest.mock('~/behaviors/markdown/render_metrics'); describe('renderGFM', () => { it('handles a missing element', () => { @@ -9,27 +6,4 @@ describe('renderGFM', () => { renderGFM(); }).not.toThrow(); }); - - describe('remove_monitor_metrics flag', () => { - let metricsElement; - - beforeEach(() => { - window.gon = { features: { removeMonitorMetrics: true } }; - metricsElement = document.createElement('div'); - metricsElement.setAttribute('class', '.js-render-metrics'); - }); - - it('renders metrics when the flag is disabled', () => { - window.gon.features = { features: { removeMonitorMetrics: false } }; - renderGFM(metricsElement); - - expect(renderMetrics).toHaveBeenCalled(); - }); - - it('does not render metrics when the flag is enabled', () => { - renderGFM(metricsElement); - - expect(renderMetrics).not.toHaveBeenCalled(); - }); - }); }); diff --git a/spec/frontend/behaviors/markdown/render_metrics_spec.js b/spec/frontend/behaviors/markdown/render_metrics_spec.js deleted file mode 100644 index ab81ed6b8f0..00000000000 --- a/spec/frontend/behaviors/markdown/render_metrics_spec.js +++ /dev/null @@ -1,49 +0,0 @@ -import { TEST_HOST } from 'helpers/test_constants'; -import renderMetrics from '~/behaviors/markdown/render_metrics'; - -const mockEmbedGroup = jest.fn(); - -jest.mock('vue', () => ({ extend: () => mockEmbedGroup })); -jest.mock('~/monitoring/components/embeds/embed_group.vue', () => jest.fn()); -jest.mock('~/monitoring/stores/embed_group/', () => ({ createStore: jest.fn() })); - -const getElements = () => Array.from(document.getElementsByClassName('js-render-metrics')); - -describe('Render metrics for Gitlab Flavoured Markdown', () => { - it('does nothing when no elements are found', () => { - return renderMetrics([]).then(() => { - expect(mockEmbedGroup).not.toHaveBeenCalled(); - }); - }); - - it('renders a vue component when elements are found', () => { - document.body.innerHTML = `<div class="js-render-metrics" data-dashboard-url="${TEST_HOST}"></div>`; - - return renderMetrics(getElements()).then(() => { - expect(mockEmbedGroup).toHaveBeenCalledTimes(1); - expect(mockEmbedGroup).toHaveBeenCalledWith( - expect.objectContaining({ propsData: { urls: [`${TEST_HOST}`] } }), - ); - }); - }); - - it('takes sibling metrics and groups them under a shared parent', () => { - document.body.innerHTML = ` - <p><span>Hello</span></p> - <div class="js-render-metrics" data-dashboard-url="${TEST_HOST}/1"></div> - <div class="js-render-metrics" data-dashboard-url="${TEST_HOST}/2"></div> - <p><span>Hello</span></p> - <div class="js-render-metrics" data-dashboard-url="${TEST_HOST}/3"></div> - `; - - return renderMetrics(getElements()).then(() => { - expect(mockEmbedGroup).toHaveBeenCalledTimes(2); - expect(mockEmbedGroup).toHaveBeenCalledWith( - expect.objectContaining({ propsData: { urls: [`${TEST_HOST}/1`, `${TEST_HOST}/2`] } }), - ); - expect(mockEmbedGroup).toHaveBeenCalledWith( - expect.objectContaining({ propsData: { urls: [`${TEST_HOST}/3`] } }), - ); - }); - }); -}); diff --git a/spec/frontend/blob/line_highlighter_spec.js b/spec/frontend/blob/line_highlighter_spec.js index b2e1a29b84f..de39a8f688a 100644 --- a/spec/frontend/blob/line_highlighter_spec.js +++ b/spec/frontend/blob/line_highlighter_spec.js @@ -1,5 +1,4 @@ /* eslint-disable no-return-assign, no-new, no-underscore-dangle */ -import $ from 'jquery'; import htmlStaticLineHighlighter from 'test_fixtures_static/line_highlighter.html'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import LineHighlighter from '~/blob/line_highlighter'; @@ -9,11 +8,15 @@ describe('LineHighlighter', () => { const testContext = {}; const clickLine = (number, eventData = {}) => { - if ($.isEmptyObject(eventData)) { - return $(`#L${number}`).click(); + if (Object.keys(eventData).length === 0) { + return document.querySelector(`#L${number}`).click(); } - const e = $.Event('click', eventData); - return $(`#L${number}`).trigger(e); + const e = new MouseEvent('click', { + bubbles: true, + cancelable: true, + ...eventData, + }); + return document.querySelector(`#L${number}`).dispatchEvent(e); }; beforeEach(() => { @@ -35,32 +38,30 @@ describe('LineHighlighter', () => { it('highlights one line given in the URL hash', () => { new LineHighlighter({ hash: '#L13' }); - expect($('#LC13')).toHaveClass(testContext.css); + expect(document.querySelector('#LC13').classList).toContain(testContext.css); }); it('highlights one line given in the URL hash with given CSS class name', () => { const hiliter = new LineHighlighter({ hash: '#L13', highlightLineClass: 'hilite' }); expect(hiliter.highlightLineClass).toBe('hilite'); - expect($('#LC13')).toHaveClass('hilite'); - expect($('#LC13')).not.toHaveClass('hll'); + expect(document.querySelector('#LC13').classList).toContain('hilite'); + expect(document.querySelector('#LC13').classList).not.toContain('hll'); }); it('highlights a range of lines given in the URL hash', () => { new LineHighlighter({ hash: '#L5-25' }); - expect($(`.${testContext.css}`).length).toBe(21); for (let line = 5; line <= 25; line += 1) { - expect($(`#LC${line}`)).toHaveClass(testContext.css); + expect(document.querySelector(`#LC${line}`).classList).toContain(testContext.css); } }); it('highlights a range of lines given in the URL hash using GitHub format', () => { new LineHighlighter({ hash: '#L5-L25' }); - expect($(`.${testContext.css}`).length).toBe(21); for (let line = 5; line <= 25; line += 1) { - expect($(`#LC${line}`)).toHaveClass(testContext.css); + expect(document.querySelector(`#LC${line}`).classList).toContain(testContext.css); } }); @@ -74,11 +75,13 @@ describe('LineHighlighter', () => { it('discards click events', () => { const clickSpy = jest.fn(); - $('a[data-line-number]').click(clickSpy); + document.querySelectorAll('a[data-line-number]').forEach((el) => { + el.addEventListener('click', clickSpy); + }); clickLine(13); - expect(clickSpy.mock.calls[0][0].isDefaultPrevented()).toEqual(true); + expect(clickSpy.mock.calls[0][0].defaultPrevented).toEqual(true); }); it('handles garbage input from the hash', () => { @@ -101,27 +104,19 @@ describe('LineHighlighter', () => { }); describe('clickHandler', () => { - it('handles clicking on a child icon element', () => { - const spy = jest.spyOn(testContext.class, 'setHash'); - $('#L13 [data-testid="link-icon"]').mousedown().click(); - - expect(spy).toHaveBeenCalledWith(13); - expect($('#LC13')).toHaveClass(testContext.css); - }); - describe('without shiftKey', () => { it('highlights one line when clicked', () => { clickLine(13); - expect($('#LC13')).toHaveClass(testContext.css); + expect(document.querySelector('#LC13').classList).toContain(testContext.css); }); it('unhighlights previously highlighted lines', () => { clickLine(13); clickLine(20); - expect($('#LC13')).not.toHaveClass(testContext.css); - expect($('#LC20')).toHaveClass(testContext.css); + expect(document.querySelector('#LC13').classList).not.toContain(testContext.css); + expect(document.querySelector('#LC20').classList).toContain(testContext.css); }); it('sets the hash', () => { @@ -138,6 +133,8 @@ describe('LineHighlighter', () => { clickLine(13); clickLine(20, { shiftKey: true, + bubbles: true, + cancelable: true, }); expect(spy).toHaveBeenCalledWith(13); @@ -150,8 +147,8 @@ describe('LineHighlighter', () => { shiftKey: true, }); - expect($('#LC13')).toHaveClass(testContext.css); - expect($(`.${testContext.css}`).length).toBe(1); + expect(document.querySelector('#LC13').classList).toContain(testContext.css); + expect(document.querySelectorAll(`.${testContext.css}`).length).toBe(1); }); it('sets the hash', () => { @@ -171,9 +168,9 @@ describe('LineHighlighter', () => { shiftKey: true, }); - expect($(`.${testContext.css}`).length).toBe(6); + expect(document.querySelectorAll(`.${testContext.css}`).length).toBe(6); for (let line = 15; line <= 20; line += 1) { - expect($(`#LC${line}`)).toHaveClass(testContext.css); + expect(document.querySelector(`#LC${line}`).classList).toContain(testContext.css); } }); @@ -183,9 +180,9 @@ describe('LineHighlighter', () => { shiftKey: true, }); - expect($(`.${testContext.css}`).length).toBe(6); + expect(document.querySelectorAll(`.${testContext.css}`).length).toBe(6); for (let line = 5; line <= 10; line += 1) { - expect($(`#LC${line}`)).toHaveClass(testContext.css); + expect(document.querySelector(`#LC${line}`).classList).toContain(testContext.css); } }); }); @@ -205,9 +202,9 @@ describe('LineHighlighter', () => { shiftKey: true, }); - expect($(`.${testContext.css}`).length).toBe(6); + expect(document.querySelectorAll(`.${testContext.css}`).length).toBe(6); for (let line = 5; line <= 10; line += 1) { - expect($(`#LC${line}`)).toHaveClass(testContext.css); + expect(document.querySelector(`#LC${line}`).classList).toContain(testContext.css); } }); @@ -216,9 +213,9 @@ describe('LineHighlighter', () => { shiftKey: true, }); - expect($(`.${testContext.css}`).length).toBe(6); + expect(document.querySelectorAll(`.${testContext.css}`).length).toBe(6); for (let line = 10; line <= 15; line += 1) { - expect($(`#LC${line}`)).toHaveClass(testContext.css); + expect(document.querySelector(`#LC${line}`).classList).toContain(testContext.css); } }); }); @@ -251,13 +248,13 @@ describe('LineHighlighter', () => { it('highlights the specified line', () => { testContext.subject(13); - expect($('#LC13')).toHaveClass(testContext.css); + expect(document.querySelector('#LC13').classList).toContain(testContext.css); }); it('accepts a String-based number', () => { testContext.subject('13'); - expect($('#LC13')).toHaveClass(testContext.css); + expect(document.querySelector('#LC13').classList).toContain(testContext.css); }); }); diff --git a/spec/frontend/blob_edit/edit_blob_spec.js b/spec/frontend/blob_edit/edit_blob_spec.js index 9ab20fc2cd7..1bdc54723ce 100644 --- a/spec/frontend/blob_edit/edit_blob_spec.js +++ b/spec/frontend/blob_edit/edit_blob_spec.js @@ -61,7 +61,6 @@ describe('Blob Editing', () => { }); afterEach(() => { mock.restore(); - jest.clearAllMocks(); unuseMock.mockClear(); useMock.mockClear(); resetHTMLFixture(); diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js index a925f752f5e..36556ba00af 100644 --- a/spec/frontend/boards/board_card_inner_spec.js +++ b/spec/frontend/boards/board_card_inner_spec.js @@ -92,6 +92,7 @@ describe('Board card component', () => { isEpicBoard, issuableType: TYPE_ISSUE, isGroupBoard, + isApolloBoard: false, }, }); }; @@ -111,7 +112,6 @@ describe('Board card component', () => { afterEach(() => { store = null; - jest.clearAllMocks(); }); it('renders issue title', () => { diff --git a/spec/frontend/boards/components/board_app_spec.js b/spec/frontend/boards/components/board_app_spec.js index 3d6e4c18f51..e7624437ac5 100644 --- a/spec/frontend/boards/components/board_app_spec.js +++ b/spec/frontend/boards/components/board_app_spec.js @@ -4,7 +4,9 @@ import VueApollo from 'vue-apollo'; import Vuex from 'vuex'; import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import BoardApp from '~/boards/components/board_app.vue'; +import eventHub from '~/boards/eventhub'; import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql'; import { rawIssue, boardListsQueryResponse } from '../mock_data'; @@ -93,5 +95,14 @@ describe('BoardApp', () => { expect(wrapper.classes()).not.toContain('is-compact'); }); + + it('refetches lists when updateBoard event is received', async () => { + jest.spyOn(eventHub, '$on').mockImplementation(() => {}); + + createComponent({ isApolloBoard: true }); + await waitForPromises(); + + expect(eventHub.$on).toHaveBeenCalledWith('updateBoard', wrapper.vm.refetchLists); + }); }); }); diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js index 9260718a94b..0a2a78479fb 100644 --- a/spec/frontend/boards/components/board_content_spec.js +++ b/spec/frontend/boards/components/board_content_spec.js @@ -4,7 +4,7 @@ import VueApollo from 'vue-apollo'; import Vue from 'vue'; import Draggable from 'vuedraggable'; import Vuex from 'vuex'; -import eventHub from '~/boards/eventhub'; + import createMockApollo from 'helpers/mock_apollo_helper'; import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; @@ -182,15 +182,6 @@ describe('BoardContent', () => { expect(wrapper.findComponent(BoardContentSidebar).exists()).toBe(true); }); - it('refetches lists when updateBoard event is received', async () => { - jest.spyOn(eventHub, '$on').mockImplementation(() => {}); - - createComponent({ isApolloBoard: true }); - await waitForPromises(); - - expect(eventHub.$on).toHaveBeenCalledWith('updateBoard', wrapper.vm.refetchLists); - }); - it('reorders lists', async () => { const movableListsOrder = [mockLists[0].id, mockLists[1].id]; diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js index ad2674f9d3b..0c9e1b4646e 100644 --- a/spec/frontend/boards/components/board_list_header_spec.js +++ b/spec/frontend/boards/components/board_list_header_spec.js @@ -1,4 +1,4 @@ -import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui'; +import { GlButtonGroup } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import Vuex from 'vuex'; @@ -93,18 +93,17 @@ describe('Board List Header Component', () => { ...injectedProps, }, stubs: { - GlDisclosureDropdown, - GlDisclosureDropdownItem, + GlButtonGroup, }, }); }; - const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findButtonGroup = () => wrapper.findComponent(GlButtonGroup); const isCollapsed = () => wrapper.vm.list.collapsed; const findTitle = () => wrapper.find('.board-title'); const findCaret = () => wrapper.findByTestId('board-title-caret'); - const findNewIssueButton = () => wrapper.findByTestId('newIssueBtn'); - const findSettingsButton = () => wrapper.findByTestId('settingsBtn'); + const findNewIssueButton = () => wrapper.findByTestId('new-issue-btn'); + const findSettingsButton = () => wrapper.findByTestId('settings-btn'); const findBoardListHeader = () => wrapper.findByTestId('board-list-header'); it('renders border when label color is present', () => { @@ -131,13 +130,13 @@ describe('Board List Header Component', () => { it.each(hasNoAddButton)('does not render dropdown when List Type is `%s`', (listType) => { createComponent({ listType }); - expect(findDropdown().exists()).toBe(false); + expect(findButtonGroup().exists()).toBe(false); }); it.each(hasAddButton)('does render when List Type is `%s`', (listType) => { createComponent({ listType }); - expect(findDropdown().exists()).toBe(true); + expect(findButtonGroup().exists()).toBe(true); expect(findNewIssueButton().exists()).toBe(true); }); @@ -146,7 +145,7 @@ describe('Board List Header Component', () => { currentUserId: null, }); - expect(findDropdown().exists()).toBe(false); + expect(findButtonGroup().exists()).toBe(false); }); }); @@ -156,20 +155,20 @@ describe('Board List Header Component', () => { it.each(hasSettings)('does render for List Type `%s`', (listType) => { createComponent({ listType }); - expect(findDropdown().exists()).toBe(true); + expect(findButtonGroup().exists()).toBe(true); expect(findSettingsButton().exists()).toBe(true); }); it('does not render dropdown when ListType `closed`', () => { createComponent({ listType: ListType.closed }); - expect(findDropdown().exists()).toBe(false); + expect(findButtonGroup().exists()).toBe(false); }); it('renders dropdown but not the Settings button when ListType `backlog`', () => { createComponent({ listType: ListType.backlog }); - expect(findDropdown().exists()).toBe(true); + expect(findButtonGroup().exists()).toBe(true); expect(findSettingsButton().exists()).toBe(false); }); }); diff --git a/spec/frontend/boards/components/board_new_issue_spec.js b/spec/frontend/boards/components/board_new_issue_spec.js index 651d1daee52..a1088f1e8f7 100644 --- a/spec/frontend/boards/components/board_new_issue_spec.js +++ b/spec/frontend/boards/components/board_new_issue_spec.js @@ -1,25 +1,49 @@ import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; import BoardNewIssue from '~/boards/components/board_new_issue.vue'; import BoardNewItem from '~/boards/components/board_new_item.vue'; import ProjectSelect from '~/boards/components/project_select.vue'; import eventHub from '~/boards/eventhub'; - -import { mockList, mockGroupProjects, mockIssue, mockIssue2 } from '../mock_data'; +import groupBoardQuery from '~/boards/graphql/group_board.query.graphql'; +import projectBoardQuery from '~/boards/graphql/project_board.query.graphql'; +import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; + +import { + mockList, + mockGroupProjects, + mockIssue, + mockIssue2, + mockProjectBoardResponse, + mockGroupBoardResponse, +} from '../mock_data'; Vue.use(Vuex); +Vue.use(VueApollo); const addListNewIssuesSpy = jest.fn().mockResolvedValue(); const mockActions = { addListNewIssue: addListNewIssuesSpy }; +const projectBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockProjectBoardResponse); +const groupBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockGroupBoardResponse); + +const mockApollo = createMockApollo([ + [projectBoardQuery, projectBoardQueryHandlerSuccess], + [groupBoardQuery, groupBoardQueryHandlerSuccess], +]); + const createComponent = ({ - state = { selectedProject: mockGroupProjects[0] }, + state = {}, actions = mockActions, getters = { getBoardItemsByList: () => () => [] }, isGroupBoard = true, + data = { selectedProject: mockGroupProjects[0] }, + provide = {}, } = {}) => shallowMount(BoardNewIssue, { + apolloProvider: mockApollo, store: new Vuex.Store({ state, actions, @@ -27,13 +51,19 @@ const createComponent = ({ }), propsData: { list: mockList, + boardId: 'gid://gitlab/Board/1', }, + data: () => data, provide: { groupId: 1, fullPath: mockGroupProjects[0].fullPath, weightFeatureAvailable: false, boardWeight: null, isGroupBoard, + boardType: 'group', + isEpicBoard: false, + isApolloBoard: false, + ...provide, }, stubs: { BoardNewItem, @@ -137,4 +167,33 @@ describe('Issue boards new issue form', () => { expect(projectSelect.exists()).toBe(false); }); }); + + describe('Apollo boards', () => { + it.each` + boardType | queryHandler | notCalledHandler + ${WORKSPACE_GROUP} | ${groupBoardQueryHandlerSuccess} | ${projectBoardQueryHandlerSuccess} + ${WORKSPACE_PROJECT} | ${projectBoardQueryHandlerSuccess} | ${groupBoardQueryHandlerSuccess} + `( + 'fetches $boardType board and emits addNewIssue event', + async ({ boardType, queryHandler, notCalledHandler }) => { + wrapper = createComponent({ + provide: { + boardType, + isProjectBoard: boardType === WORKSPACE_PROJECT, + isGroupBoard: boardType === WORKSPACE_GROUP, + isApolloBoard: true, + }, + }); + + await nextTick(); + findBoardNewItem().vm.$emit('form-submit', { title: 'Foo' }); + + await nextTick(); + + expect(queryHandler).toHaveBeenCalled(); + expect(notCalledHandler).not.toHaveBeenCalled(); + expect(wrapper.emitted('addNewIssue')[0][0]).toMatchObject({ title: 'Foo' }); + }, + ); + }); }); diff --git a/spec/frontend/boards/components/board_settings_sidebar_spec.js b/spec/frontend/boards/components/board_settings_sidebar_spec.js index b1e14be8ceb..affe1260c66 100644 --- a/spec/frontend/boards/components/board_settings_sidebar_spec.js +++ b/spec/frontend/boards/components/board_settings_sidebar_spec.js @@ -90,10 +90,6 @@ describe('BoardSettingsSidebar', () => { const findModal = () => wrapper.findComponent(GlModal); const findRemoveButton = () => wrapper.findComponent(GlButton); - afterEach(() => { - jest.restoreAllMocks(); - }); - it('finds a MountingPortal component', () => { createComponent(); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 447aacd9cea..8235c3e4194 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -836,6 +836,7 @@ export const mockTokens = (fetchLabels, fetchUsers, fetchMilestones, isSignedIn) type: TOKEN_TYPE_ASSIGNEE, operators: OPERATORS_IS_NOT, token: UserToken, + dataType: 'user', unique: true, fetchUsers, preloadedUsers: [], @@ -847,6 +848,7 @@ export const mockTokens = (fetchLabels, fetchUsers, fetchMilestones, isSignedIn) operators: OPERATORS_IS_NOT, symbol: '@', token: UserToken, + dataType: 'user', unique: true, fetchUsers, preloadedUsers: [], @@ -1040,4 +1042,43 @@ export const destroyBoardListMutationResponse = { }, }; +export const mockProjects = [ + { + id: 'gid://gitlab/Project/1', + name: 'Gitlab Shell', + nameWithNamespace: 'Gitlab Org / Gitlab Shell', + fullPath: 'gitlab-org/gitlab-shell', + archived: false, + __typename: 'Project', + }, + { + id: 'gid://gitlab/Project/2', + name: 'Gitlab Test', + nameWithNamespace: 'Gitlab Org / Gitlab Test', + fullPath: 'gitlab-org/gitlab-test', + archived: true, + __typename: 'Project', + }, +]; + +export const mockGroupProjectsResponse = (projects = mockProjects) => ({ + data: { + group: { + id: 'gid://gitlab/Group/1', + projects: { + nodes: projects, + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'abc', + endCursor: 'bcd', + __typename: 'PageInfo', + }, + __typename: 'ProjectConnection', + }, + __typename: 'Group', + }, + }, +}); + export const DEFAULT_COLOR = '#1068bf'; diff --git a/spec/frontend/boards/project_select_spec.js b/spec/frontend/boards/project_select_spec.js index b4308b38947..f1daccfadda 100644 --- a/spec/frontend/boards/project_select_spec.js +++ b/spec/frontend/boards/project_select_spec.js @@ -1,17 +1,19 @@ import { GlCollapsibleListbox, GlListboxItem, GlLoadingIcon } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; -import Vuex from 'vuex'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import groupProjectsQuery from '~/boards/graphql/group_projects.query.graphql'; import ProjectSelect from '~/boards/components/project_select.vue'; -import defaultState from '~/boards/stores/state'; -import { mockActiveGroupProjects, mockList } from './mock_data'; +import { mockList, mockGroupProjectsResponse, mockProjects } from './mock_data'; -const mockProjectsList1 = mockActiveGroupProjects.slice(0, 1); +Vue.use(VueApollo); describe('ProjectSelect component', () => { let wrapper; - let store; + let mockApollo; const findLabel = () => wrapper.find("[data-testid='header-label']"); const findGlCollapsibleListBox = () => wrapper.findComponent(GlCollapsibleListbox); @@ -26,77 +28,54 @@ describe('ProjectSelect component', () => { const findInMenuLoadingIcon = () => wrapper.find("[data-testid='listbox-search-loader']"); const findEmptySearchMessage = () => wrapper.find("[data-testid='listbox-no-results-text']"); - const createStore = ({ state, activeGroupProjects }) => { - Vue.use(Vuex); - - store = new Vuex.Store({ - state: { - defaultState, - groupProjectsFlags: { - isLoading: false, - pageInfo: { - hasNextPage: false, - }, - }, - ...state, - }, - actions: { - fetchGroupProjects: jest.fn(), - setSelectedProject: jest.fn(), - }, - getters: { - activeGroupProjects: () => activeGroupProjects, - }, - }); - }; - - const createWrapper = ({ state = {}, activeGroupProjects = [] } = {}) => { - createStore({ - state, - activeGroupProjects, - }); + const projectsQueryHandler = jest.fn().mockResolvedValue(mockGroupProjectsResponse()); + const emptyProjectsQueryHandler = jest.fn().mockResolvedValue(mockGroupProjectsResponse([])); - wrapper = mount(ProjectSelect, { + const createWrapper = ({ queryHandler = projectsQueryHandler, selectedProject = {} } = {}) => { + mockApollo = createMockApollo([[groupProjectsQuery, queryHandler]]); + wrapper = mountExtended(ProjectSelect, { + apolloProvider: mockApollo, propsData: { list: mockList, + selectedProject, }, - store, provide: { groupId: 1, + fullPath: 'gitlab-org', }, attachTo: document.body, }); }; - it('displays a header title', () => { - createWrapper(); - - expect(findLabel().text()).toBe('Projects'); - }); - - it('renders a default dropdown text', () => { - createWrapper(); - - expect(findGlCollapsibleListBox().exists()).toBe(true); - expect(findGlCollapsibleListBox().text()).toContain('Select a project'); - }); - describe('when mounted', () => { - it('displays a loading icon while projects are being fetched', async () => { + beforeEach(() => { createWrapper(); + }); + it('displays a loading icon while projects are being fetched', async () => { expect(findGlDropdownLoadingIcon().exists()).toBe(true); - await nextTick(); + await waitForPromises(); expect(findGlDropdownLoadingIcon().exists()).toBe(false); + expect(projectsQueryHandler).toHaveBeenCalled(); + }); + + it('displays a header title', () => { + expect(findLabel().text()).toBe('Projects'); + }); + + it('renders a default dropdown text', () => { + expect(findGlCollapsibleListBox().exists()).toBe(true); + expect(findGlCollapsibleListBox().text()).toContain('Select a project'); }); }); describe('when dropdown menu is open', () => { describe('by default', () => { - beforeEach(() => { - createWrapper({ activeGroupProjects: mockActiveGroupProjects }); + beforeEach(async () => { + createWrapper(); + await waitForPromises(); }); it('shows GlListboxSearchInput with placeholder text', () => { @@ -106,7 +85,7 @@ describe('ProjectSelect component', () => { it("displays the fetched project's name", () => { expect(findFirstGlDropdownItem().exists()).toBe(true); - expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList1[0].name); + expect(findFirstGlDropdownItem().text()).toContain(mockProjects[0].name); }); it("doesn't render loading icon in the menu", () => { @@ -119,33 +98,31 @@ describe('ProjectSelect component', () => { }); describe('when no projects are being returned', () => { - it('renders empty search result message', () => { - createWrapper(); + it('renders empty search result message', async () => { + createWrapper({ queryHandler: emptyProjectsQueryHandler }); + await waitForPromises(); expect(findEmptySearchMessage().exists()).toBe(true); }); }); describe('when a project is selected', () => { - beforeEach(() => { - createWrapper({ activeGroupProjects: mockProjectsList1 }); - - findFirstGlDropdownItem().find('li').trigger('click'); + beforeEach(async () => { + createWrapper({ selectedProject: mockProjects[0] }); + await waitForPromises(); }); it('renders the name of the selected project', () => { expect(findGlCollapsibleListBox().find('.gl-new-dropdown-button-text').text()).toBe( - mockProjectsList1[0].name, + mockProjects[0].name, ); }); }); describe('when projects are loading', () => { - beforeEach(() => { - createWrapper({ state: { groupProjectsFlags: { isLoading: true } } }); - }); - - it('displays and hides gl-loading-icon while and after fetching data', () => { + it('displays and hides gl-loading-icon while and after fetching data', async () => { + createWrapper(); + await nextTick(); expect(findInMenuLoadingIcon().isVisible()).toBe(true); }); }); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index f3800ce8324..a2961fb1302 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -1541,8 +1541,8 @@ describe('addListNewIssue', () => { it('should add board scope to the issue being created', async () => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { - createIssue: { - issue: mockIssue, + createIssuable: { + issuable: mockIssue, errors: [], }, }, @@ -1600,8 +1600,8 @@ describe('addListNewIssue', () => { it('dispatches a correct set of mutations', () => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { - createIssue: { - issue: mockIssue, + createIssuable: { + issuable: mockIssue, errors: [], }, }, diff --git a/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap b/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap index 9db6a523dec..4da56a865d5 100644 --- a/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap +++ b/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap @@ -5,7 +5,6 @@ exports[`Delete merged branches component Delete merged branches confirmation mo <gl-base-dropdown-stub category="tertiary" class="gl-disclosure-dropdown gl-display-none gl-md-display-block!" - data-qa-selector="delete_merged_branches_dropdown_button" icon="ellipsis_v" nocaret="true" offset="[object Object]" @@ -34,7 +33,7 @@ exports[`Delete merged branches component Delete merged branches confirmation mo <b-button-stub class="gl-display-block gl-md-display-none! gl-button btn-danger-secondary" - data-qa-selector="delete_merged_branches_button" + data-testid="delete-merged-branches-button" size="md" tag="button" type="button" @@ -100,7 +99,6 @@ exports[`Delete merged branches component Delete merged branches confirmation mo aria-labelledby="input-label" autocomplete="off" class="gl-form-input gl-mt-2 gl-form-input-sm" - data-qa-selector="delete_merged_branches_input" debounce="0" formatter="[Function]" type="text" @@ -146,7 +144,6 @@ exports[`Delete merged branches component Delete merged branches confirmation mo <b-button-stub class="gl-button" - data-qa-selector="delete_merged_branches_confirmation_button" data-testid="delete-merged-branches-confirmation-button" disabled="true" size="md" diff --git a/spec/frontend/branches/components/delete_merged_branches_spec.js b/spec/frontend/branches/components/delete_merged_branches_spec.js index 3e47e76622d..3319ed13004 100644 --- a/spec/frontend/branches/components/delete_merged_branches_spec.js +++ b/spec/frontend/branches/components/delete_merged_branches_spec.js @@ -37,7 +37,7 @@ const createComponent = (mountFn = shallowMountExtended, stubs = {}) => { }; const findDeleteButton = () => - wrapper.findComponent('[data-qa-selector="delete_merged_branches_button"]'); + wrapper.findComponent('[data-testid="delete-merged-branches-button"]'); const findModal = () => wrapper.findComponent(GlModal); const findConfirmationButton = () => wrapper.findByTestId('delete-merged-branches-confirmation-button'); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js index 1937e3b34b7..64227872af3 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js @@ -1,4 +1,10 @@ -import { GlListboxItem, GlCollapsibleListbox, GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { + GlListboxItem, + GlCollapsibleListbox, + GlDropdownDivider, + GlDropdownItem, + GlIcon, +} from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { allEnvironments, ENVIRONMENT_QUERY_LIMIT } from '~/ci/ci_variable_list/constants'; import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue'; @@ -10,6 +16,7 @@ describe('Ci environments dropdown', () => { const defaultProps = { areEnvironmentsLoading: false, environments: envs, + hasEnvScopeQuery: false, selectedEnvironmentScope: '', }; @@ -19,19 +26,15 @@ describe('Ci environments dropdown', () => { const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); const findListboxText = () => findListbox().props('toggleText'); const findCreateWildcardButton = () => wrapper.findComponent(GlDropdownItem); + const findDropdownDivider = () => wrapper.findComponent(GlDropdownDivider); const findMaxEnvNote = () => wrapper.findByTestId('max-envs-notice'); - const createComponent = ({ props = {}, searchTerm = '', enableFeatureFlag = false } = {}) => { + const createComponent = ({ props = {}, searchTerm = '' } = {}) => { wrapper = mountExtended(CiEnvironmentsDropdown, { propsData: { ...defaultProps, ...props, }, - provide: { - glFeatures: { - ciLimitEnvironmentScope: enableFeatureFlag, - }, - }, }); findListbox().vm.$emit('search', searchTerm); @@ -42,6 +45,10 @@ describe('Ci environments dropdown', () => { createComponent({ searchTerm: 'stable' }); }); + it('renders dropdown divider', () => { + expect(findDropdownDivider().exists()).toBe(true); + }); + it('renders create button with search term if environments do not contain search term', () => { const button = findCreateWildcardButton(); expect(button.exists()).toBe(true); @@ -51,14 +58,14 @@ describe('Ci environments dropdown', () => { describe('Search term is empty', () => { describe.each` - featureFlag | flagStatus | defaultEnvStatus | firstItemValue | envIndices - ${true} | ${'enabled'} | ${'prepends'} | ${'*'} | ${[1, 2, 3]} - ${false} | ${'disabled'} | ${'does not prepend'} | ${envs[0]} | ${[0, 1, 2]} + hasEnvScopeQuery | status | defaultEnvStatus | firstItemValue | envIndices + ${true} | ${'exists'} | ${'prepends'} | ${'*'} | ${[1, 2, 3]} + ${false} | ${'does not exist'} | ${'does not prepend'} | ${envs[0]} | ${[0, 1, 2]} `( - 'when ciLimitEnvironmentScope feature flag is $flagStatus', - ({ featureFlag, defaultEnvStatus, firstItemValue, envIndices }) => { + 'when query for fetching environment scope $status', + ({ defaultEnvStatus, firstItemValue, hasEnvScopeQuery, envIndices }) => { beforeEach(() => { - createComponent({ props: { environments: envs }, enableFeatureFlag: featureFlag }); + createComponent({ props: { environments: envs, hasEnvScopeQuery } }); }); it(`${defaultEnvStatus} * in listbox`, () => { @@ -91,7 +98,7 @@ describe('Ci environments dropdown', () => { }); }); - describe('When ciLimitEnvironmentScope feature flag is disabled', () => { + describe('when environments are not fetched via graphql', () => { const currentEnv = envs[2]; beforeEach(() => { @@ -118,11 +125,15 @@ describe('Ci environments dropdown', () => { }); }); - describe('When ciLimitEnvironmentScope feature flag is enabled', () => { + describe('when fetching environments via graphql', () => { const currentEnv = envs[2]; beforeEach(() => { - createComponent({ enableFeatureFlag: true }); + createComponent({ props: { hasEnvScopeQuery: true } }); + }); + + it('renders dropdown divider', () => { + expect(findDropdownDivider().exists()).toBe(true); }); it('renders environments passed down to it', async () => { @@ -131,6 +142,22 @@ describe('Ci environments dropdown', () => { expect(findAllListboxItems()).toHaveLength(envs.length); }); + it('renders dropdown loading icon while fetch query is loading', () => { + createComponent({ props: { areEnvironmentsLoading: true, hasEnvScopeQuery: true } }); + + expect(findListbox().props('loading')).toBe(true); + expect(findListbox().props('searching')).toBe(false); + expect(findDropdownDivider().exists()).toBe(false); + }); + + it('renders search loading icon while search query is loading and dropdown is open', async () => { + createComponent({ props: { areEnvironmentsLoading: true, hasEnvScopeQuery: true } }); + await findListbox().vm.$emit('shown'); + + expect(findListbox().props('loading')).toBe(false); + expect(findListbox().props('searching')).toBe(true); + }); + it('emits event when searching', async () => { expect(wrapper.emitted('search-environment-scope')).toHaveLength(1); @@ -140,12 +167,6 @@ describe('Ci environments dropdown', () => { expect(wrapper.emitted('search-environment-scope')[1]).toEqual([currentEnv]); }); - it('renders loading icon while search query is loading', () => { - createComponent({ enableFeatureFlag: true, props: { areEnvironmentsLoading: true } }); - - expect(findListbox().props('searching')).toBe(true); - }); - it('displays note about max environments shown', () => { expect(findMaxEnvNote().exists()).toBe(true); expect(findMaxEnvNote().text()).toContain(String(ENVIRONMENT_QUERY_LIMIT)); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js index 7436210fe70..b364f098a3a 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js @@ -9,15 +9,13 @@ import { DELETE_MUTATION_ACTION, UPDATE_MUTATION_ACTION, } from '~/ci/ci_variable_list/constants'; +import getGroupEnvironments from '~/ci/ci_variable_list/graphql/queries/group_environments.query.graphql'; import getGroupVariables from '~/ci/ci_variable_list/graphql/queries/group_variables.query.graphql'; import addGroupVariable from '~/ci/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql'; import deleteGroupVariable from '~/ci/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql'; import updateGroupVariable from '~/ci/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql'; const mockProvide = { - glFeatures: { - groupScopedCiVariables: false, - }, groupPath: '/group', groupId: 12, }; @@ -27,9 +25,16 @@ describe('Ci Group Variable wrapper', () => { const findCiShared = () => wrapper.findComponent(ciVariableShared); - const createComponent = ({ provide = {} } = {}) => { + const createComponent = ({ featureFlags } = {}) => { wrapper = shallowMount(ciGroupVariables, { - provide: { ...mockProvide, ...provide }, + provide: { + ...mockProvide, + glFeatures: { + ciGroupEnvScopeGraphql: false, + groupScopedCiVariables: false, + ...featureFlags, + }, + }, }); }; @@ -62,10 +67,10 @@ describe('Ci Group Variable wrapper', () => { }); }); - describe('feature flag', () => { + describe('groupScopedCiVariables feature flag', () => { describe('When enabled', () => { beforeEach(() => { - createComponent({ provide: { glFeatures: { groupScopedCiVariables: true } } }); + createComponent({ featureFlags: { groupScopedCiVariables: true } }); }); it('Passes down `true` to variable shared component', () => { @@ -75,7 +80,7 @@ describe('Ci Group Variable wrapper', () => { describe('When disabled', () => { beforeEach(() => { - createComponent({ provide: { glFeatures: { groupScopedCiVariables: false } } }); + createComponent(); }); it('Passes down `false` to variable shared component', () => { @@ -83,4 +88,26 @@ describe('Ci Group Variable wrapper', () => { }); }); }); + + describe('ciGroupEnvScopeGraphql feature flag', () => { + describe('When enabled', () => { + beforeEach(() => { + createComponent({ featureFlags: { ciGroupEnvScopeGraphql: true } }); + }); + + it('Passes down environments query to variable shared component', () => { + expect(findCiShared().props('queryData').environments.query).toBe(getGroupEnvironments); + }); + }); + + describe('When disabled', () => { + beforeEach(() => { + createComponent(); + }); + + it('Does not pass down environments query to variable shared component', () => { + expect(findCiShared().props('queryData').environments).toBe(undefined); + }); + }); + }); }); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js index e9484cfce57..d843646df16 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js @@ -48,6 +48,7 @@ describe('Ci variable modal', () => { areScopedVariablesAvailable: true, environments: [], hideEnvironmentScope: false, + hasEnvScopeQuery: false, mode: ADD_VARIABLE_ACTION, selectedVariable: {}, variables: [], @@ -349,14 +350,14 @@ describe('Ci variable modal', () => { expect(link.attributes('href')).toBe(defaultProvide.environmentScopeLink); }); - describe('when feature flag is enabled', () => { + describe('when query for envioronment scope exists', () => { beforeEach(() => { createComponent({ props: { environments: mockEnvs, + hasEnvScopeQuery: true, variables: mockVariablesWithUniqueScopes(projectString), }, - provide: { glFeatures: { ciLimitEnvironmentScope: true } }, }); }); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js index 12ca9a78369..d72cfc5fc14 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js @@ -21,6 +21,7 @@ describe('Ci variable table', () => { environments: mapEnvironmentNames(mockEnvs), hideEnvironmentScope: false, isLoading: false, + hasEnvScopeQuery: false, maxVariableLimit: 5, pageInfo: { after: '' }, variables: mockVariablesWithScopes(projectString), @@ -60,6 +61,7 @@ describe('Ci variable table', () => { areEnvironmentsLoading: defaultProps.areEnvironmentsLoading, areScopedVariablesAvailable: defaultProps.areScopedVariablesAvailable, environments: defaultProps.environments, + hasEnvScopeQuery: defaultProps.hasEnvScopeQuery, hideEnvironmentScope: defaultProps.hideEnvironmentScope, variables: defaultProps.variables, mode: ADD_VARIABLE_ACTION, diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js index f7b90c3da30..6fa1915f3c1 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js @@ -52,6 +52,7 @@ const mockProvide = { const defaultProps = { areScopedVariablesAvailable: true, + hasEnvScopeQuery: false, pageInfo: {}, hideEnvironmentScope: false, refetchAfterMutation: false, @@ -219,16 +220,12 @@ describe('Ci Variable Shared Component', () => { expect(mockEnvironments).toHaveBeenCalled(); }); - describe('when Limit Environment Scope FF is enabled', () => { + // applies only to project-level CI variables + describe('when environment scope is limited', () => { beforeEach(async () => { await createComponentWithApollo({ props: { ...createProjectProps() }, - provide: { - glFeatures: { - ciLimitEnvironmentScope: true, - ciVariablesPages: isVariablePagesEnabled, - }, - }, + provide: pagesFeatureFlagProvide, }); }); @@ -251,26 +248,11 @@ describe('Ci Variable Shared Component', () => { expect.objectContaining({ search: 'staging' }), ); }); - }); - - describe('when Limit Environment Scope FF is disabled', () => { - beforeEach(async () => { - await createComponentWithApollo({ - props: { ...createProjectProps() }, - provide: pagesFeatureFlagProvide, - }); - }); - it('initial query is called with the correct variables', () => { - expect(mockEnvironments).toHaveBeenCalledWith({ fullPath: '/namespace/project/' }); - }); + it('does not show loading icon in table while searching for environments', () => { + findCiSettings().vm.$emit('search-environment-scope', 'staging'); - it(`does not refetch environments when search term is present`, async () => { - expect(mockEnvironments).toHaveBeenCalledTimes(1); - - await findCiSettings().vm.$emit('search-environment-scope', 'staging'); - - expect(mockEnvironments).toHaveBeenCalledTimes(1); + expect(findLoadingIcon().exists()).toBe(false); }); }); }); @@ -532,6 +514,7 @@ describe('Ci Variable Shared Component', () => { areEnvironmentsLoading: false, areScopedVariablesAvailable: wrapper.props().areScopedVariablesAvailable, hideEnvironmentScope: defaultProps.hideEnvironmentScope, + hasEnvScopeQuery: props.hasEnvScopeQuery, pageInfo: defaultProps.pageInfo, isLoading: false, maxVariableLimit, diff --git a/spec/frontend/ci/ci_variable_list/mocks.js b/spec/frontend/ci/ci_variable_list/mocks.js index 9c9c99ad5ea..41dfc0ebfda 100644 --- a/spec/frontend/ci/ci_variable_list/mocks.js +++ b/spec/frontend/ci/ci_variable_list/mocks.js @@ -189,6 +189,7 @@ export const createProjectProps = () => { componentName: 'ProjectVariable', entity: 'project', fullPath: '/namespace/project/', + hasEnvScopeQuery: true, id: 'gid://gitlab/Project/20', mutationData: { [ADD_MUTATION_ACTION]: addProjectVariable, @@ -213,6 +214,7 @@ export const createGroupProps = () => { componentName: 'GroupVariable', entity: 'group', fullPath: '/my-group', + hasEnvScopeQuery: false, id: 'gid://gitlab/Group/20', mutationData: { [ADD_MUTATION_ACTION]: addGroupVariable, @@ -231,6 +233,7 @@ export const createGroupProps = () => { export const createInstanceProps = () => { return { componentName: 'InstanceVariable', + hasEnvScopeQuery: false, entity: '', mutationData: { [ADD_MUTATION_ACTION]: addAdminVariable, diff --git a/spec/frontend/ci/pipeline_editor/components/commit/commit_section_spec.js b/spec/frontend/ci/pipeline_editor/components/commit/commit_section_spec.js index 8834231aaef..7a9b4ffdce8 100644 --- a/spec/frontend/ci/pipeline_editor/components/commit/commit_section_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/commit/commit_section_spec.js @@ -17,7 +17,8 @@ import { import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers'; import commitCreate from '~/ci/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql'; import getCurrentBranch from '~/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql'; -import updatePipelineEtag from '~/ci/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql'; +import getPipelineEtag from '~/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql'; + import { mockCiConfigPath, mockCiYml, @@ -253,18 +254,20 @@ describe('Pipeline Editor | Commit section', () => { describe('when the commit returns a different etag path', () => { beforeEach(async () => { createComponentWithApollo(); - jest.spyOn(wrapper.vm.$apollo, 'mutate'); + jest.spyOn(mockApollo.clients.defaultClient.cache, 'writeQuery'); + mockMutateCommitData.mockResolvedValue(mockCommitCreateResponseNewEtag); await submitCommit(); }); - it('calls the client mutation to update the etag', () => { - // 1:Commit submission, 2:etag update, 3:currentBranch update, 4:lastCommit update - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(4); - expect(wrapper.vm.$apollo.mutate).toHaveBeenNthCalledWith(2, { - mutation: updatePipelineEtag, - variables: { - pipelineEtag: mockCommitCreateResponseNewEtag.data.commitCreate.commitPipelinePath, + it('calls the client mutation to update the etag in the cache', () => { + expect(mockApollo.clients.defaultClient.cache.writeQuery).toHaveBeenCalledWith({ + query: getPipelineEtag, + data: { + etags: { + __typename: 'EtagValues', + pipeline: mockCommitCreateResponseNewEtag.data.commitCreate.commitPipelinePath, + }, }, }); }); diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js index edaa96a197a..d40499fae87 100644 --- a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js @@ -49,32 +49,36 @@ describe('Rules item', () => { findRulesWhenSelect().vm.$emit('input', dummyRulesWhen); - expect(wrapper.emitted('update-job')).toHaveLength(1); + expect(wrapper.emitted('update-job')).toHaveLength(2); expect(wrapper.emitted('update-job')[0]).toEqual([ 'rules[0].when', JOB_RULES_WHEN.delayed.value, ]); + expect(wrapper.emitted('update-job')[1]).toEqual([ + 'rules[0].start_in', + `1 ${JOB_RULES_START_IN.second.value}`, + ]); findRulesStartInNumberInput().vm.$emit('input', dummyRulesStartInNumber); - expect(wrapper.emitted('update-job')).toHaveLength(2); - expect(wrapper.emitted('update-job')[1]).toEqual([ + expect(wrapper.emitted('update-job')).toHaveLength(3); + expect(wrapper.emitted('update-job')[2]).toEqual([ 'rules[0].start_in', `2 ${JOB_RULES_START_IN.second.value}s`, ]); findRulesStartInUnitSelect().vm.$emit('input', dummyRulesStartInUnit); - expect(wrapper.emitted('update-job')).toHaveLength(3); - expect(wrapper.emitted('update-job')[2]).toEqual([ + expect(wrapper.emitted('update-job')).toHaveLength(4); + expect(wrapper.emitted('update-job')[3]).toEqual([ 'rules[0].start_in', `2 ${dummyRulesStartInUnit}s`, ]); findRulesAllowFailureCheckBox().vm.$emit('input', dummyRulesAllowFailure); - expect(wrapper.emitted('update-job')).toHaveLength(4); - expect(wrapper.emitted('update-job')[3]).toEqual([ + expect(wrapper.emitted('update-job')).toHaveLength(5); + expect(wrapper.emitted('update-job')[4]).toEqual([ 'rules[0].allow_failure', dummyRulesAllowFailure, ]); diff --git a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js index 639c2dbef4c..bb48d4dc38d 100644 --- a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js @@ -1,14 +1,47 @@ import MockAdapter from 'axios-mock-adapter'; -import { GlForm } from '@gitlab/ui'; -import { nextTick } from 'vue'; +import { GlForm, GlLoadingIcon } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { createAlert } from '~/alert'; import PipelineSchedulesForm from '~/ci/pipeline_schedules/components/pipeline_schedules_form.vue'; import RefSelector from '~/ref/components/ref_selector.vue'; import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue'; import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue'; +import createPipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/mutations/create_pipeline_schedule.mutation.graphql'; +import updatePipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/mutations/update_pipeline_schedule.mutation.graphql'; +import getPipelineSchedulesQuery from '~/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql'; import { timezoneDataFixture } from '../../../vue_shared/components/timezone_dropdown/helpers'; +import { + createScheduleMutationResponse, + updateScheduleMutationResponse, + mockSinglePipelineScheduleNode, +} from '../mock_data'; + +Vue.use(VueApollo); + +jest.mock('~/alert'); +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn(), + joinPaths: jest.fn().mockReturnValue(''), + queryToObject: jest.fn().mockReturnValue({ id: '1' }), +})); + +const { + data: { + project: { + pipelineSchedules: { nodes }, + }, + }, +} = mockSinglePipelineScheduleNode; + +const schedule = nodes[0]; +const variables = schedule.variables.nodes; describe('Pipeline schedules form', () => { let wrapper; @@ -17,22 +50,36 @@ describe('Pipeline schedules form', () => { const cron = ''; const dailyLimit = ''; - const createComponent = (mountFn = shallowMountExtended, stubs = {}) => { + const querySuccessHandler = jest.fn().mockResolvedValue(mockSinglePipelineScheduleNode); + const queryFailedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + + const createMutationHandlerSuccess = jest.fn().mockResolvedValue(createScheduleMutationResponse); + const createMutationHandlerFailed = jest.fn().mockRejectedValue(new Error('GraphQL error')); + const updateMutationHandlerSuccess = jest.fn().mockResolvedValue(updateScheduleMutationResponse); + const updateMutationHandlerFailed = jest.fn().mockRejectedValue(new Error('GraphQL error')); + + const createMockApolloProvider = ( + requestHandlers = [[createPipelineScheduleMutation, createMutationHandlerSuccess]], + ) => { + return createMockApollo(requestHandlers); + }; + + const createComponent = (mountFn = shallowMountExtended, editing = false, requestHandlers) => { wrapper = mountFn(PipelineSchedulesForm, { propsData: { timezoneData: timezoneDataFixture, refParam: 'master', + editing, }, provide: { fullPath: 'gitlab-org/gitlab', projectId, defaultBranch, - cron, - cronTimezone: '', dailyLimit, settingsLink: '', + schedulesPath: '/root/ci-project/-/pipeline_schedules', }, - stubs, + apolloProvider: createMockApolloProvider(requestHandlers), }); }; @@ -43,17 +90,24 @@ describe('Pipeline schedules form', () => { const findRefSelector = () => wrapper.findComponent(RefSelector); const findSubmitButton = () => wrapper.findByTestId('schedule-submit-button'); const findCancelButton = () => wrapper.findByTestId('schedule-cancel-button'); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); // Variables const findVariableRows = () => wrapper.findAllByTestId('ci-variable-row'); const findKeyInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-key'); const findValueInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-value'); const findRemoveIcons = () => wrapper.findAllByTestId('remove-ci-variable-row'); - beforeEach(() => { - createComponent(); - }); + const addVariableToForm = () => { + const input = findKeyInputs().at(0); + input.element.value = 'test_var_2'; + input.trigger('change'); + }; describe('Form elements', () => { + beforeEach(() => { + createComponent(); + }); + it('displays form', () => { expect(findForm().exists()).toBe(true); }); @@ -102,19 +156,16 @@ describe('Pipeline schedules form', () => { it('displays the submit and cancel buttons', () => { expect(findSubmitButton().exists()).toBe(true); expect(findCancelButton().exists()).toBe(true); + expect(findCancelButton().attributes('href')).toBe('/root/ci-project/-/pipeline_schedules'); }); }); describe('CI variables', () => { let mock; - const addVariableToForm = () => { - const input = findKeyInputs().at(0); - input.element.value = 'test_var_2'; - input.trigger('change'); - }; - beforeEach(() => { + // mock is needed when we fully mount + // downstream components request needs to be mocked mock = new MockAdapter(axios); createComponent(mountExtended); }); @@ -157,4 +208,229 @@ describe('Pipeline schedules form', () => { expect(findVariableRows()).toHaveLength(1); }); }); + + describe('Button text', () => { + it.each` + editing | expectedText + ${true} | ${'Edit pipeline schedule'} + ${false} | ${'Create pipeline schedule'} + `( + 'button text is $expectedText when editing is $editing', + async ({ editing, expectedText }) => { + createComponent(shallowMountExtended, editing, [ + [getPipelineSchedulesQuery, querySuccessHandler], + ]); + + await waitForPromises(); + + expect(findSubmitButton().text()).toBe(expectedText); + }, + ); + }); + + describe('Schedule creation', () => { + it('when creating a schedule the query is not called', () => { + createComponent(); + + expect(querySuccessHandler).not.toHaveBeenCalled(); + }); + + it('does not show loading state when creating new schedule', () => { + createComponent(); + + expect(findLoadingIcon().exists()).toBe(false); + }); + + describe('schedule creation success', () => { + let mock; + + beforeEach(() => { + // mock is needed when we fully mount + // downstream components request needs to be mocked + mock = new MockAdapter(axios); + createComponent(mountExtended); + }); + + afterEach(() => { + mock.restore(); + }); + + it('creates pipeline schedule', async () => { + findDescription().element.value = 'My schedule'; + findDescription().trigger('change'); + + findTimezoneDropdown().vm.$emit('input', { + formattedTimezone: '[UTC-4] Eastern Time (US & Canada)', + identifier: 'America/New_York', + }); + + findIntervalComponent().vm.$emit('cronValue', '0 16 * * *'); + + addVariableToForm(); + + findSubmitButton().vm.$emit('click'); + + await waitForPromises(); + + expect(createMutationHandlerSuccess).toHaveBeenCalledWith({ + input: { + active: true, + cron: '0 16 * * *', + cronTimezone: 'America/New_York', + description: 'My schedule', + projectPath: 'gitlab-org/gitlab', + ref: 'main', + variables: [ + { + key: 'test_var_2', + value: '', + variableType: 'ENV_VAR', + }, + ], + }, + }); + expect(visitUrl).toHaveBeenCalledWith('/root/ci-project/-/pipeline_schedules'); + expect(createAlert).not.toHaveBeenCalled(); + }); + }); + + describe('schedule creation failure', () => { + beforeEach(() => { + createComponent(shallowMountExtended, false, [ + [createPipelineScheduleMutation, createMutationHandlerFailed], + ]); + }); + + it('shows error for failed pipeline schedule creation', async () => { + findSubmitButton().vm.$emit('click'); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred while creating the pipeline schedule.', + }); + }); + }); + }); + + describe('Schedule editing', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + it('shows loading state when editing', async () => { + createComponent(shallowMountExtended, true, [ + [getPipelineSchedulesQuery, querySuccessHandler], + ]); + + expect(findLoadingIcon().exists()).toBe(true); + + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + }); + + describe('schedule fetch success', () => { + it('fetches schedule and sets form data correctly', async () => { + createComponent(mountExtended, true, [[getPipelineSchedulesQuery, querySuccessHandler]]); + + expect(querySuccessHandler).toHaveBeenCalled(); + + await waitForPromises(); + + expect(findDescription().element.value).toBe(schedule.description); + expect(findIntervalComponent().props('initialCronInterval')).toBe(schedule.cron); + expect(findTimezoneDropdown().props('value')).toBe(schedule.cronTimezone); + expect(findRefSelector().props('value')).toBe(schedule.ref); + expect(findVariableRows()).toHaveLength(3); + expect(findKeyInputs().at(0).element.value).toBe(variables[0].key); + expect(findKeyInputs().at(1).element.value).toBe(variables[1].key); + expect(findValueInputs().at(0).element.value).toBe(variables[0].value); + expect(findValueInputs().at(1).element.value).toBe(variables[1].value); + }); + }); + + it('schedule fetch failure', async () => { + createComponent(shallowMountExtended, true, [ + [getPipelineSchedulesQuery, queryFailedHandler], + ]); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred while trying to fetch the pipeline schedule.', + }); + }); + + it('edit schedule success', async () => { + createComponent(mountExtended, true, [ + [getPipelineSchedulesQuery, querySuccessHandler], + [updatePipelineScheduleMutation, updateMutationHandlerSuccess], + ]); + + await waitForPromises(); + + findDescription().element.value = 'Updated schedule'; + findDescription().trigger('change'); + + findIntervalComponent().vm.$emit('cronValue', '0 22 16 * *'); + + // Ensures variable is sent with destroy property set true + findRemoveIcons().at(0).vm.$emit('click'); + + findSubmitButton().vm.$emit('click'); + + await waitForPromises(); + + expect(updateMutationHandlerSuccess).toHaveBeenCalledWith({ + input: { + active: schedule.active, + cron: '0 22 16 * *', + cronTimezone: schedule.cronTimezone, + id: schedule.id, + ref: schedule.ref, + description: 'Updated schedule', + variables: [ + { + destroy: true, + id: variables[0].id, + key: variables[0].key, + value: variables[0].value, + variableType: variables[0].variableType, + }, + { + destroy: false, + id: variables[1].id, + key: variables[1].key, + value: variables[1].value, + variableType: variables[1].variableType, + }, + ], + }, + }); + }); + + it('edit schedule failure', async () => { + createComponent(shallowMountExtended, true, [ + [getPipelineSchedulesQuery, querySuccessHandler], + [updatePipelineScheduleMutation, updateMutationHandlerFailed], + ]); + + await waitForPromises(); + + findSubmitButton().vm.$emit('click'); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred while updating the pipeline schedule.', + }); + }); + }); }); diff --git a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js index 50008cedd9c..01a19711264 100644 --- a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js @@ -57,6 +57,7 @@ describe('Pipeline schedules app', () => { wrapper = mountExtended(PipelineSchedules, { provide: { fullPath: 'gitlab-org/gitlab', + newSchedulePath: '/root/ci-project/-/pipeline_schedules/new', }, mocks: { $toast, @@ -101,6 +102,10 @@ describe('Pipeline schedules app', () => { expect(findLoadingIcon().exists()).toBe(false); }); + + it('new schedule button links to new schedule path', () => { + expect(findNewButton().attributes('href')).toBe('/root/ci-project/-/pipeline_schedules/new'); + }); }); describe('fetching pipeline schedules', () => { @@ -146,15 +151,13 @@ describe('Pipeline schedules app', () => { [deletePipelineScheduleMutation, deleteMutationHandlerSuccess], ]); - jest.spyOn(wrapper.vm.$apollo.queries.schedules, 'refetch'); - await waitForPromises(); const scheduleId = mockPipelineScheduleNodes[0].id; findTable().vm.$emit('showDeleteModal', scheduleId); - expect(wrapper.vm.$apollo.queries.schedules.refetch).not.toHaveBeenCalled(); + expect(successHandler).toHaveBeenCalledTimes(1); findDeleteModal().vm.$emit('deleteSchedule'); @@ -163,7 +166,7 @@ describe('Pipeline schedules app', () => { expect(deleteMutationHandlerSuccess).toHaveBeenCalledWith({ id: scheduleId, }); - expect(wrapper.vm.$apollo.queries.schedules.refetch).toHaveBeenCalled(); + expect(successHandler).toHaveBeenCalledTimes(2); expect($toast.show).toHaveBeenCalledWith('Pipeline schedule successfully deleted.'); }); @@ -252,15 +255,13 @@ describe('Pipeline schedules app', () => { [takeOwnershipMutation, takeOwnershipMutationHandlerSuccess], ]); - jest.spyOn(wrapper.vm.$apollo.queries.schedules, 'refetch'); - await waitForPromises(); const scheduleId = mockPipelineScheduleNodes[1].id; findTable().vm.$emit('showTakeOwnershipModal', scheduleId); - expect(wrapper.vm.$apollo.queries.schedules.refetch).not.toHaveBeenCalled(); + expect(successHandler).toHaveBeenCalledTimes(1); findTakeOwnershipModal().vm.$emit('takeOwnership'); @@ -269,7 +270,7 @@ describe('Pipeline schedules app', () => { expect(takeOwnershipMutationHandlerSuccess).toHaveBeenCalledWith({ id: scheduleId, }); - expect(wrapper.vm.$apollo.queries.schedules.refetch).toHaveBeenCalled(); + expect(successHandler).toHaveBeenCalledTimes(2); expect($toast.show).toHaveBeenCalledWith('Successfully taken ownership from Admin.'); }); @@ -297,7 +298,7 @@ describe('Pipeline schedules app', () => { describe('pipeline schedule tabs', () => { beforeEach(async () => { - createComponent(); + createComponent([[getPipelineSchedulesQuery, successHandler]]); await waitForPromises(); }); @@ -315,13 +316,23 @@ describe('Pipeline schedules app', () => { }); it('should refetch the schedules query on a tab click', async () => { - jest.spyOn(wrapper.vm.$apollo.queries.schedules, 'refetch').mockImplementation(jest.fn()); - - expect(wrapper.vm.$apollo.queries.schedules.refetch).toHaveBeenCalledTimes(0); + expect(successHandler).toHaveBeenCalledTimes(1); await findAllTab().trigger('click'); - expect(wrapper.vm.$apollo.queries.schedules.refetch).toHaveBeenCalledTimes(1); + expect(successHandler).toHaveBeenCalledTimes(3); + }); + + it('all tab click should not send scope value with query', async () => { + findAllTab().trigger('click'); + + await nextTick(); + + expect(successHandler).toHaveBeenCalledWith({ + ids: null, + projectPath: 'gitlab-org/gitlab', + status: null, + }); }); }); diff --git a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js index be0052fc7cf..5eca355fcf4 100644 --- a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js @@ -1,6 +1,7 @@ import { GlButton } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import PipelineScheduleActions from '~/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { mockPipelineScheduleNodes, mockPipelineScheduleCurrentUser, @@ -28,6 +29,7 @@ describe('Pipeline schedule actions', () => { const findDeleteBtn = () => wrapper.findByTestId('delete-pipeline-schedule-btn'); const findTakeOwnershipBtn = () => wrapper.findByTestId('take-ownership-pipeline-schedule-btn'); const findPlayScheduleBtn = () => wrapper.findByTestId('play-pipeline-schedule-btn'); + const findEditScheduleBtn = () => wrapper.findByTestId('edit-pipeline-schedule-btn'); it('displays buttons when user is the owner of schedule and has adminPipelineSchedule permissions', () => { createComponent(); @@ -76,4 +78,15 @@ describe('Pipeline schedule actions', () => { playPipelineSchedule: [[mockPipelineScheduleNodes[0].id]], }); }); + + it('edit button links to edit schedule path', () => { + createComponent(); + + const { schedule } = defaultProps; + const id = getIdFromGraphQLId(schedule.id); + + const expectedPath = `${schedule.editPath}?id=${id}`; + + expect(findEditScheduleBtn().attributes('href')).toBe(expectedPath); + }); }); diff --git a/spec/frontend/ci/pipeline_schedules/mock_data.js b/spec/frontend/ci/pipeline_schedules/mock_data.js index 1485f6beea4..0a4f233f199 100644 --- a/spec/frontend/ci/pipeline_schedules/mock_data.js +++ b/spec/frontend/ci/pipeline_schedules/mock_data.js @@ -2,6 +2,7 @@ import mockGetPipelineSchedulesGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.json'; import mockGetPipelineSchedulesAsGuestGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.as_guest.json'; import mockGetPipelineSchedulesTakeOwnershipGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.take_ownership.json'; +import mockGetSinglePipelineScheduleGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.single.json'; const { data: { @@ -30,15 +31,22 @@ const { export const mockPipelineScheduleNodes = nodes; export const mockPipelineScheduleCurrentUser = currentUser; - export const mockPipelineScheduleAsGuestNodes = guestNodes; - export const mockTakeOwnershipNodes = takeOwnershipNodes; +export const mockSinglePipelineScheduleNode = mockGetSinglePipelineScheduleGraphQLResponse; + export const emptyPipelineSchedulesResponse = { data: { + currentUser: { + id: 'gid://gitlab/User/1', + username: 'root', + }, project: { id: 'gid://gitlab/Project/1', - pipelineSchedules: { nodes: [], count: 0 }, + pipelineSchedules: { + count: 0, + nodes: [], + }, }, }, }; @@ -79,4 +87,24 @@ export const takeOwnershipMutationResponse = { }, }; +export const createScheduleMutationResponse = { + data: { + pipelineScheduleCreate: { + clientMutationId: null, + errors: [], + __typename: 'PipelineScheduleCreatePayload', + }, + }, +}; + +export const updateScheduleMutationResponse = { + data: { + pipelineScheduleUpdate: { + clientMutationId: null, + errors: [], + __typename: 'PipelineScheduleUpdatePayload', + }, + }, +}; + export { mockGetPipelineSchedulesGraphQLResponse }; diff --git a/spec/frontend/ci/reports/components/__snapshots__/grouped_issues_list_spec.js.snap b/spec/frontend/ci/reports/components/__snapshots__/grouped_issues_list_spec.js.snap deleted file mode 100644 index 311a67a3e31..00000000000 --- a/spec/frontend/ci/reports/components/__snapshots__/grouped_issues_list_spec.js.snap +++ /dev/null @@ -1,26 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Grouped Issues List renders a smart virtual list with the correct props 1`] = ` -Object { - "length": 4, - "remain": 20, - "rtag": "div", - "size": 32, - "wclass": "report-block-list", - "wtag": "ul", -} -`; - -exports[`Grouped Issues List with data renders a report item with the correct props 1`] = ` -Object { - "component": "CodequalityIssueBody", - "iconComponent": "IssueStatusIcon", - "isNew": false, - "issue": Object { - "name": "foo", - }, - "showReportSectionStatusIcon": false, - "status": "none", - "statusIconSize": 24, -} -`; diff --git a/spec/frontend/ci/reports/components/grouped_issues_list_spec.js b/spec/frontend/ci/reports/components/grouped_issues_list_spec.js deleted file mode 100644 index 8beec220802..00000000000 --- a/spec/frontend/ci/reports/components/grouped_issues_list_spec.js +++ /dev/null @@ -1,83 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import GroupedIssuesList from '~/ci/reports/components/grouped_issues_list.vue'; -import ReportItem from '~/ci/reports/components/report_item.vue'; -import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; - -describe('Grouped Issues List', () => { - let wrapper; - - const createComponent = ({ propsData = {}, stubs = {} } = {}) => { - wrapper = shallowMount(GroupedIssuesList, { - propsData, - stubs, - }); - }; - - const findHeading = (groupName) => wrapper.find(`[data-testid="${groupName}Heading"`); - - it('renders a smart virtual list with the correct props', () => { - createComponent({ - propsData: { - resolvedIssues: [{ name: 'foo' }], - unresolvedIssues: [{ name: 'bar' }], - }, - stubs: { - SmartVirtualList, - }, - }); - - expect(wrapper.findComponent(SmartVirtualList).props()).toMatchSnapshot(); - }); - - describe('without data', () => { - beforeEach(() => { - createComponent(); - }); - - it.each(['unresolved', 'resolved'])('does not a render a header for %s issues', (issueName) => { - expect(findHeading(issueName).exists()).toBe(false); - }); - - it.each(['resolved', 'unresolved'])('does not render report items for %s issues', () => { - expect(wrapper.findComponent(ReportItem).exists()).toBe(false); - }); - }); - - describe('with data', () => { - it.each` - givenIssues | givenHeading | groupName - ${[{ name: 'foo issue' }]} | ${'Foo Heading'} | ${'resolved'} - ${[{ name: 'bar issue' }]} | ${'Bar Heading'} | ${'unresolved'} - `('renders the heading for $groupName issues', ({ givenIssues, givenHeading, groupName }) => { - createComponent({ - propsData: { [`${groupName}Issues`]: givenIssues, [`${groupName}Heading`]: givenHeading }, - }); - - expect(findHeading(groupName).text()).toBe(givenHeading); - }); - - it.each(['resolved', 'unresolved'])('renders all %s issues', (issueName) => { - const issues = [{ name: 'foo' }, { name: 'bar' }]; - - createComponent({ - propsData: { [`${issueName}Issues`]: issues }, - }); - - expect(wrapper.findAllComponents(ReportItem)).toHaveLength(issues.length); - }); - - it('renders a report item with the correct props', () => { - createComponent({ - propsData: { - resolvedIssues: [{ name: 'foo' }], - component: 'CodequalityIssueBody', - }, - stubs: { - ReportItem, - }, - }); - - expect(wrapper.findComponent(ReportItem).props()).toMatchSnapshot(); - }); - }); -}); diff --git a/spec/frontend/ci/reports/components/summary_row_spec.js b/spec/frontend/ci/reports/components/summary_row_spec.js deleted file mode 100644 index b1ae9e26b5b..00000000000 --- a/spec/frontend/ci/reports/components/summary_row_spec.js +++ /dev/null @@ -1,63 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import HelpPopover from '~/vue_shared/components/help_popover.vue'; -import SummaryRow from '~/ci/reports/components/summary_row.vue'; - -describe('Summary row', () => { - let wrapper; - - const summary = 'SAST detected 1 new vulnerability and 1 fixed vulnerability'; - const popoverOptions = { - title: 'Static Application Security Testing (SAST)', - content: '<a>Learn more about SAST</a>', - }; - const statusIcon = 'warning'; - - const createComponent = ({ props = {}, slots = {} } = {}) => { - wrapper = extendedWrapper( - mount(SummaryRow, { - propsData: { - summary, - popoverOptions, - statusIcon, - ...props, - }, - slots, - }), - ); - }; - - const findSummary = () => wrapper.findByTestId('summary-row-description'); - const findStatusIcon = () => wrapper.findByTestId('summary-row-icon'); - const findHelpPopover = () => wrapper.findComponent(HelpPopover); - - it('renders provided summary', () => { - createComponent(); - expect(findSummary().text()).toContain(summary); - }); - - it('renders provided icon', () => { - createComponent(); - expect(findStatusIcon().classes()).toContain('js-ci-status-icon-warning'); - }); - - it('renders help popover if popoverOptions are provided', () => { - createComponent(); - expect(findHelpPopover().props('options')).toEqual(popoverOptions); - }); - - it('does not render help popover if popoverOptions are not provided', () => { - createComponent({ props: { popoverOptions: null } }); - expect(findHelpPopover().exists()).toBe(false); - }); - - describe('summary slot', () => { - it('replaces the summary prop', () => { - const summarySlotContent = 'Summary slot content'; - createComponent({ slots: { summary: summarySlotContent } }); - - expect(wrapper.text()).not.toContain(summary); - expect(findSummary().text()).toContain(summarySlotContent); - }); - }); -}); diff --git a/spec/frontend/ci/reports/mock_data/mock_data.js b/spec/frontend/ci/reports/mock_data/mock_data.js index 2599b0ac365..2983a9f1125 100644 --- a/spec/frontend/ci/reports/mock_data/mock_data.js +++ b/spec/frontend/ci/reports/mock_data/mock_data.js @@ -1,3 +1,6 @@ +import { SEVERITIES as SEVERITIES_CODE_QUALITY } from '~/ci/reports/codequality_report/constants'; +import { SEVERITIES as SEVERITIES_SAST } from '~/ci/reports/sast/constants'; + export const failedIssue = { result: 'failure', name: 'Test#sum when a is 1 and b is 2 returns summary', @@ -36,3 +39,54 @@ export const failedReport = { }, ], }; + +export const findingSastInfo = { + scale: 'sast', + severity: 'info', +}; + +export const findingSastInfoEnhanced = { + scale: 'sast', + severity: 'info', + class: SEVERITIES_SAST.info.class, + name: SEVERITIES_SAST.info.name, +}; + +export const findingsCodeQualityBlocker = { + scale: 'codeQuality', + severity: 'blocker', +}; + +export const findingCodeQualityBlockerEnhanced = { + scale: 'codeQuality', + severity: 'blocker', + class: SEVERITIES_CODE_QUALITY.blocker.class, + name: SEVERITIES_CODE_QUALITY.blocker.name, +}; + +export const findingCodeQualityInfo = { + scale: 'codeQuality', + severity: 'info', +}; + +export const findingCodeQualityInfoEnhanced = { + scale: 'codeQuality', + severity: 'info', + class: SEVERITIES_CODE_QUALITY.info.class, + name: SEVERITIES_CODE_QUALITY.info.name, +}; + +export const findingUnknownInfo = { + scale: 'codeQuality', + severity: 'info', +}; + +export const findingUnknownInfoEnhanced = { + scale: 'codeQuality', + severity: 'info', + class: SEVERITIES_CODE_QUALITY.info.class, + name: SEVERITIES_CODE_QUALITY.info.name, +}; + +export const findingsArray = [findingSastInfo, findingsCodeQualityBlocker]; +export const findingsArrayEnhanced = [findingSastInfoEnhanced, findingCodeQualityBlockerEnhanced]; diff --git a/spec/frontend/ci/reports/utils_spec.js b/spec/frontend/ci/reports/utils_spec.js new file mode 100644 index 00000000000..e01aa903a97 --- /dev/null +++ b/spec/frontend/ci/reports/utils_spec.js @@ -0,0 +1,30 @@ +import { getSeverity } from '~/ci/reports/utils'; + +import { + findingSastInfo, + findingSastInfoEnhanced, + findingCodeQualityInfo, + findingCodeQualityInfoEnhanced, + findingUnknownInfo, + findingUnknownInfoEnhanced, + findingsArray, + findingsArrayEnhanced, +} from './mock_data/mock_data'; + +describe('getSeverity utility function', () => { + it('should enhance finding with sast scale', () => { + expect(getSeverity(findingSastInfo)).toEqual(findingSastInfoEnhanced); + }); + + it('should enhance finding with codequality scale', () => { + expect(getSeverity(findingCodeQualityInfo)).toEqual(findingCodeQualityInfoEnhanced); + }); + + it('should use codeQuality scale when scale is unknown', () => { + expect(getSeverity(findingUnknownInfo)).toEqual(findingUnknownInfoEnhanced); + }); + + it('should correctly enhance an array of findings', () => { + expect(getSeverity(findingsArray)).toEqual(findingsArrayEnhanced); + }); +}); diff --git a/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js index c4ed6d1bdb5..c9349c64bfb 100644 --- a/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js +++ b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js @@ -9,10 +9,8 @@ import { visitUrl } from '~/lib/utils/url_utility'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerHeader from '~/ci/runner/components/runner_header.vue'; +import RunnerHeaderActions from '~/ci/runner/components/runner_header_actions.vue'; import RunnerDetails from '~/ci/runner/components/runner_details.vue'; -import RunnerPauseButton from '~/ci/runner/components/runner_pause_button.vue'; -import RunnerDeleteButton from '~/ci/runner/components/runner_delete_button.vue'; -import RunnerEditButton from '~/ci/runner/components/runner_edit_button.vue'; import RunnerDetailsTabs from '~/ci/runner/components/runner_details_tabs.vue'; import RunnersJobs from '~/ci/runner/components/runner_jobs.vue'; @@ -46,9 +44,7 @@ describe('AdminRunnerShowApp', () => { const findRunnerHeader = () => wrapper.findComponent(RunnerHeader); const findRunnerDetails = () => wrapper.findComponent(RunnerDetails); - const findRunnerDeleteButton = () => wrapper.findComponent(RunnerDeleteButton); - const findRunnerEditButton = () => wrapper.findComponent(RunnerEditButton); - const findRunnerPauseButton = () => wrapper.findComponent(RunnerPauseButton); + const findRunnerHeaderActions = () => wrapper.findComponent(RunnerHeaderActions); const findRunnerDetailsTabs = () => wrapper.findComponent(RunnerDetailsTabs); const findRunnersJobs = () => wrapper.findComponent(RunnersJobs); @@ -94,9 +90,10 @@ describe('AdminRunnerShowApp', () => { }); it('displays the runner edit and pause buttons', () => { - expect(findRunnerEditButton().attributes('href')).toBe(mockRunner.editAdminUrl); - expect(findRunnerPauseButton().exists()).toBe(true); - expect(findRunnerDeleteButton().exists()).toBe(true); + expect(findRunnerHeaderActions().props()).toEqual({ + runner: mockRunner, + editPath: mockRunner.editAdminUrl, + }); }); it('shows runner details', () => { @@ -122,54 +119,6 @@ describe('AdminRunnerShowApp', () => { expect(wrapper.text().replace(/\s+/g, ' ')).toContain(expected); }); - describe('when runner cannot be updated', () => { - beforeEach(async () => { - mockRunnerQueryResult({ - userPermissions: { - ...mockRunner.userPermissions, - updateRunner: false, - }, - }); - - await createComponent({ - mountFn: mountExtended, - }); - }); - - it('does not display the runner edit and pause buttons', () => { - expect(findRunnerEditButton().exists()).toBe(false); - expect(findRunnerPauseButton().exists()).toBe(false); - }); - - it('displays delete button', () => { - expect(findRunnerDeleteButton().exists()).toBe(true); - }); - }); - - describe('when runner cannot be deleted', () => { - beforeEach(async () => { - mockRunnerQueryResult({ - userPermissions: { - ...mockRunner.userPermissions, - deleteRunner: false, - }, - }); - - await createComponent({ - mountFn: mountExtended, - }); - }); - - it('does not display the delete button', () => { - expect(findRunnerDeleteButton().exists()).toBe(false); - }); - - it('displays edit and pause buttons', () => { - expect(findRunnerEditButton().exists()).toBe(true); - expect(findRunnerPauseButton().exists()).toBe(true); - }); - }); - describe('when runner is deleted', () => { beforeEach(async () => { await createComponent({ @@ -178,7 +127,7 @@ describe('AdminRunnerShowApp', () => { }); it('redirects to the runner list page', () => { - findRunnerDeleteButton().vm.$emit('deleted', { message: 'Runner deleted' }); + findRunnerHeaderActions().vm.$emit('deleted', { message: 'Runner deleted' }); expect(saveAlertToLocalStorage).toHaveBeenCalledWith({ message: 'Runner deleted', @@ -187,23 +136,6 @@ describe('AdminRunnerShowApp', () => { expect(visitUrl).toHaveBeenCalledWith(mockRunnersPath); }); }); - - describe('when runner does not have an edit url', () => { - beforeEach(async () => { - mockRunnerQueryResult({ - editAdminUrl: null, - }); - - await createComponent({ - mountFn: mountExtended, - }); - }); - - it('does not display the runner edit button', () => { - expect(findRunnerEditButton().exists()).toBe(false); - expect(findRunnerPauseButton().exists()).toBe(true); - }); - }); }); describe('When loading', () => { diff --git a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js index fc74e2947b6..1bbcb991619 100644 --- a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js @@ -156,7 +156,7 @@ describe('AdminRunnersApp', () => { await createComponent({ mountFn: mountExtended }); }); - // https://gitlab.com/gitlab-org/gitlab/-/issues/414975 + // quarantine: https://gitlab.com/gitlab-org/gitlab/-/issues/414975 // eslint-disable-next-line jest/no-disabled-tests it.skip('fetches counts', () => { expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); diff --git a/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js index cda3876f9b2..ad20d7682ed 100644 --- a/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js +++ b/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js @@ -1,5 +1,6 @@ +import { GlSprintf } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import RunnerSummaryCell from '~/ci/runner/components/cells/runner_summary_cell.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -31,8 +32,8 @@ describe('RunnerTypeCell', () => { wrapper.findAllComponents(RunnerSummaryField).filter((w) => w.props('icon') === icon) .wrappers[0]; - const createComponent = (runner, options) => { - wrapper = mountExtended(RunnerSummaryCell, { + const createComponent = ({ runner, mountFn = shallowMountExtended, ...options } = {}) => { + wrapper = mountFn(RunnerSummaryCell, { propsData: { runner: { ...mockRunner, @@ -40,7 +41,7 @@ describe('RunnerTypeCell', () => { }, }, stubs: { - RunnerSummaryField, + GlSprintf, }, ...options, }); @@ -51,6 +52,8 @@ describe('RunnerTypeCell', () => { }); it('Displays the runner name as id and short token', () => { + createComponent({ mountFn: mountExtended }); + expect(wrapper.text()).toContain( `#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`, ); @@ -58,13 +61,16 @@ describe('RunnerTypeCell', () => { it('Displays no runner manager count', () => { createComponent({ - managers: { count: 0 }, + runner: { managers: { nodes: { count: 0 } } }, + mountFn: mountExtended, }); expect(findRunnerManagersBadge().html()).toBe(''); }); it('Displays runner manager count', () => { + createComponent({ mountFn: mountExtended }); + expect(findRunnerManagersBadge().text()).toBe('2'); }); @@ -74,8 +80,8 @@ describe('RunnerTypeCell', () => { it('Displays the locked icon for locked runners', () => { createComponent({ - runnerType: PROJECT_TYPE, - locked: true, + runner: { runnerType: PROJECT_TYPE, locked: true }, + mountFn: mountExtended, }); expect(findLockIcon().exists()).toBe(true); @@ -83,8 +89,8 @@ describe('RunnerTypeCell', () => { it('Displays the runner type', () => { createComponent({ - runnerType: INSTANCE_TYPE, - locked: true, + runner: { runnerType: INSTANCE_TYPE, locked: true }, + mountFn: mountExtended, }); expect(wrapper.text()).toContain(I18N_INSTANCE_TYPE); @@ -101,7 +107,7 @@ describe('RunnerTypeCell', () => { it('Displays "No description" for missing runner description', () => { createComponent({ - description: null, + runner: { description: null }, }); expect(wrapper.findByText(I18N_NO_DESCRIPTION).classes()).toContain('gl-text-secondary'); @@ -109,7 +115,7 @@ describe('RunnerTypeCell', () => { it('Displays last contact', () => { createComponent({ - contactedAt: '2022-01-02', + runner: { contactedAt: '2022-01-02' }, }); expect(findRunnerSummaryField('clock').findComponent(TimeAgo).props('time')).toBe('2022-01-02'); @@ -124,20 +130,46 @@ describe('RunnerTypeCell', () => { expect(findRunnerSummaryField('clock').text()).toContain(__('Never')); }); - it('Displays ip address', () => { - createComponent({ - ipAddress: '127.0.0.1', + describe('IP address', () => { + it('with no managers', () => { + createComponent({ + runner: { + managers: { count: 0, nodes: [] }, + }, + }); + + expect(findRunnerSummaryField('disk')).toBeUndefined(); }); - expect(findRunnerSummaryField('disk').text()).toContain('127.0.0.1'); - }); + it('with no ip', () => { + createComponent({ + runner: { + managers: { count: 1, nodes: [{ ipAddress: null }] }, + }, + }); - it('Displays no ip address', () => { - createComponent({ - ipAddress: null, + expect(findRunnerSummaryField('disk')).toBeUndefined(); }); - expect(findRunnerSummaryField('disk')).toBeUndefined(); + it.each` + count | ipAddress | expected + ${1} | ${'127.0.0.1'} | ${'127.0.0.1'} + ${2} | ${'127.0.0.2'} | ${'127.0.0.2 (+1)'} + ${11} | ${'127.0.0.3'} | ${'127.0.0.3 (+10)'} + ${1001} | ${'127.0.0.4'} | ${'127.0.0.4 (+1,000)'} + `( + 'with $count managers, ip $ipAddress displays $expected', + ({ count, ipAddress, expected }) => { + createComponent({ + runner: { + // `first: 1` is requested, `count` varies when there are more managers + managers: { count, nodes: [{ ipAddress }] }, + }, + }); + + expect(findRunnerSummaryField('disk').text()).toMatchInterpolatedText(expected); + }, + ); }); it('Displays job count', () => { @@ -146,7 +178,7 @@ describe('RunnerTypeCell', () => { it('Formats large job counts', () => { createComponent({ - jobCount: 1000, + runner: { jobCount: 1000 }, }); expect(findRunnerSummaryField('pipeline').text()).toContain('1,000'); @@ -154,7 +186,7 @@ describe('RunnerTypeCell', () => { it('Formats large job counts with a plus symbol', () => { createComponent({ - jobCount: 1001, + runner: { jobCount: 1001 }, }); expect(findRunnerSummaryField('pipeline').text()).toContain('1,000+'); @@ -165,7 +197,7 @@ describe('RunnerTypeCell', () => { it('Displays created at ...', () => { createComponent({ - createdBy: null, + runner: { createdBy: null }, }); expect(findRunnerSummaryField('calendar').text()).toMatchInterpolatedText( @@ -177,12 +209,15 @@ describe('RunnerTypeCell', () => { }); it('Displays created at ... by ...', () => { + createComponent({ mountFn: mountExtended }); + expect(findRunnerSummaryField('calendar').text()).toMatchInterpolatedText( sprintf(I18N_CREATED_AT_BY_LABEL, { timeAgo: findCreatedTime().text(), avatar: mockRunner.createdBy.username, }), ); + expect(findCreatedTime().props('time')).toBe(mockRunner.createdAt); }); @@ -200,7 +235,7 @@ describe('RunnerTypeCell', () => { it('Displays tag list', () => { createComponent({ - tagList: ['shell', 'linux'], + runner: { tagList: ['shell', 'linux'] }, }); expect(findRunnerTags().props('tagList')).toEqual(['shell', 'linux']); @@ -209,14 +244,11 @@ describe('RunnerTypeCell', () => { it('Displays a custom runner-name slot', () => { const slotContent = 'My custom runner name'; - createComponent( - {}, - { - slots: { - 'runner-name': slotContent, - }, + createComponent({ + slots: { + 'runner-name': slotContent, }, - ); + }); expect(wrapper.text()).toContain(slotContent); }); diff --git a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js index e564cf49ca0..e4373d1c198 100644 --- a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js +++ b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js @@ -1,4 +1,10 @@ -import { GlModal, GlDropdown, GlDropdownItem, GlDropdownForm, GlIcon } from '@gitlab/ui'; +import { + GlModal, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDropdownForm, + GlIcon, +} from '@gitlab/ui'; import { createWrapper } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; @@ -35,13 +41,16 @@ Vue.use(VueApollo); describe('RegistrationDropdown', () => { let wrapper; - const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); const findDropdownBtn = () => findDropdown().find('button'); - const findRegistrationInstructionsDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findRegistrationInstructionsDropdownItem = () => + wrapper.findComponent(GlDisclosureDropdownItem); const findTokenDropdownItem = () => wrapper.findComponent(GlDropdownForm); const findRegistrationToken = () => wrapper.findComponent(RegistrationToken); const findRegistrationTokenInput = () => - wrapper.findByLabelText(RegistrationToken.i18n.registrationToken); + wrapper.findByLabelText( + `${RegistrationToken.i18n.registrationToken} ${RegistrationDropdown.i18n.supportForRegistrationTokensDeprecated}`, + ); const findTokenResetDropdownItem = () => wrapper.findComponent(RegistrationTokenResetDropdownItem); const findModal = () => wrapper.findComponent(GlModal); @@ -52,9 +61,8 @@ describe('RegistrationDropdown', () => { .replace(/[\n\t\s]+/g, ' '); const openModal = async () => { - await findRegistrationInstructionsDropdownItem().trigger('click'); + await findRegistrationInstructionsDropdownItem().vm.$emit('action'); findModal().vm.$emit('shown'); - await waitForPromises(); }; @@ -65,6 +73,9 @@ describe('RegistrationDropdown', () => { type: INSTANCE_TYPE, ...props, }, + stubs: { + GlDisclosureDropdownItem, + }, ...options, }); }; @@ -107,12 +118,12 @@ describe('RegistrationDropdown', () => { createComponent(); expect(findDropdown().props()).toMatchObject({ - category: 'primary', - variant: 'confirm', + category: 'tertiary', + variant: 'default', }); expect(findDropdown().attributes()).toMatchObject({ - toggleclass: '', + toggleclass: 'gl-px-3!', }); }); @@ -186,6 +197,26 @@ describe('RegistrationDropdown', () => { }); }); + describe('Dropdown is expanded', () => { + beforeEach(() => { + createComponent({}, mountExtended); + findDropdownBtn().vm.$emit('click'); + }); + + it('has aria-expanded set to true', () => { + expect(findDropdownBtn().attributes('aria-expanded')).toBe('true'); + }); + + describe('when token is copied', () => { + it('should close dropdown', async () => { + findRegistrationToken().vm.$emit('copy'); + await nextTick(); + + expect(findDropdownBtn().attributes('aria-expanded')).toBeUndefined(); + }); + }); + }); + describe('When token is reset', () => { const newToken = 'mock1'; @@ -217,19 +248,15 @@ describe('RegistrationDropdown', () => { }); }); - describe.each([ - { createRunnerWorkflowForAdmin: true }, - { createRunnerWorkflowForNamespace: true }, - ])('When showing a "deprecated" warning', (glFeatures) => { + describe('When showing a "deprecated" warning', () => { it('passes deprecated variant props and attributes to dropdown', () => { - createComponent({ - provide: { glFeatures }, - }); + createComponent(); expect(findDropdown().props()).toMatchObject({ category: 'tertiary', variant: 'default', - text: '', + toggleText: I18N_REGISTER_INSTANCE_TYPE, + textSrOnly: true, }); expect(findDropdown().attributes()).toMatchObject({ @@ -249,12 +276,7 @@ describe('RegistrationDropdown', () => { }); it('shows warning text', () => { - createComponent( - { - provide: { glFeatures }, - }, - mountExtended, - ); + createComponent({}, mountExtended); const text = wrapper.findByText(s__('Runners|Support for registration tokens is deprecated')); @@ -262,12 +284,7 @@ describe('RegistrationDropdown', () => { }); it('button shows ellipsis icon', () => { - createComponent( - { - provide: { glFeatures }, - }, - mountExtended, - ); + createComponent({}, mountExtended); expect(findDropdownBtn().findComponent(GlIcon).props('name')).toBe('ellipsis_v'); expect(findDropdownBtn().findAllComponents(GlIcon)).toHaveLength(1); diff --git a/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js b/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js index db54bf0c80e..d599bc1291c 100644 --- a/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js +++ b/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js @@ -1,4 +1,4 @@ -import { GlDropdownItem, GlLoadingIcon, GlToast, GlModal } from '@gitlab/ui'; +import { GlDisclosureDropdownItem, GlLoadingIcon, GlToast, GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; @@ -27,7 +27,7 @@ describe('RegistrationTokenResetDropdownItem', () => { let showToast; const mockEvent = { preventDefault: jest.fn() }; - const findDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findModal = () => wrapper.findComponent(GlModal); const clickSubmit = () => findModal().vm.$emit('primary', mockEvent); diff --git a/spec/frontend/ci/runner/components/registration/registration_token_spec.js b/spec/frontend/ci/runner/components/registration/registration_token_spec.js index 869c032c0b5..fd3896d5500 100644 --- a/spec/frontend/ci/runner/components/registration/registration_token_spec.js +++ b/spec/frontend/ci/runner/components/registration/registration_token_spec.js @@ -7,7 +7,7 @@ import { mockRegistrationToken } from '../../mock_data'; describe('RegistrationToken', () => { let wrapper; - let showToast; + const showToastMock = jest.fn(); Vue.use(GlToast); @@ -21,9 +21,12 @@ describe('RegistrationToken', () => { ...props, }, ...options, + mocks: { + $toast: { + show: showToastMock, + }, + }, }); - - showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null; }; it('Displays value and copy button', () => { @@ -58,8 +61,14 @@ describe('RegistrationToken', () => { it('shows a copied message', () => { findInputCopyToggleVisibility().vm.$emit('copy'); - expect(showToast).toHaveBeenCalledTimes(1); - expect(showToast).toHaveBeenCalledWith('Registration token copied!'); + expect(showToastMock).toHaveBeenCalledTimes(1); + expect(showToastMock).toHaveBeenCalledWith('Registration token copied!'); + }); + + it('emits a copy event', () => { + findInputCopyToggleVisibility().vm.$emit('copy'); + + expect(wrapper.emitted('copy')).toHaveLength(1); }); }); @@ -76,9 +85,7 @@ describe('RegistrationToken', () => { }); it('passes slots to the input component', () => { - const slot = findInputCopyToggleVisibility().vm.$scopedSlots[slotName]; - - expect(slot()[0].text).toBe(slotContent); + expect(findInputCopyToggleVisibility().text()).toBe(slotContent); }); }); }); diff --git a/spec/frontend/ci/runner/components/runner_delete_action_spec.js b/spec/frontend/ci/runner/components/runner_delete_action_spec.js new file mode 100644 index 00000000000..d6617e6e75c --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_delete_action_spec.js @@ -0,0 +1,223 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { stubComponent } from 'helpers/stub_component'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import runnerDeleteMutation from '~/ci/runner/graphql/shared/runner_delete.mutation.graphql'; +import waitForPromises from 'helpers/wait_for_promises'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { createAlert } from '~/alert'; + +import RunnerDeleteAction from '~/ci/runner/components/runner_delete_action.vue'; +import RunnerDeleteModal from '~/ci/runner/components/runner_delete_modal.vue'; +import { allRunnersData } from '../mock_data'; + +const mockRunner = allRunnersData.data.runners.nodes[0]; +const mockRunnerId = getIdFromGraphQLId(mockRunner.id); +const mockRunnerName = `#${mockRunnerId} (${mockRunner.shortSha})`; + +Vue.use(VueApollo); + +jest.mock('~/alert'); +jest.mock('~/ci/runner/sentry_utils'); + +describe('RunnerDeleteAction', () => { + let wrapper; + let apolloProvider; + let apolloCache; + let runnerDeleteHandler; + let mockModalShow; + + const findBtn = () => wrapper.find('button'); + const findModal = () => wrapper.findComponent(RunnerDeleteModal); + + const createComponent = ({ props = {} } = {}) => { + const { runner, ...propsData } = props; + + wrapper = shallowMountExtended(RunnerDeleteAction, { + propsData: { + runner: { + // We need typename so that cache.identify works + // eslint-disable-next-line no-underscore-dangle + __typename: mockRunner.__typename, + id: mockRunner.id, + shortSha: mockRunner.shortSha, + ...runner, + }, + ...propsData, + }, + apolloProvider, + stubs: { + RunnerDeleteModal: stubComponent(RunnerDeleteModal, { + methods: { + show: mockModalShow, + }, + }), + }, + scopedSlots: { + default: '<button :disabled="props.loading" @click="props.onClick"/>', + }, + }); + }; + + const clickOkAndWait = async () => { + findModal().vm.$emit('primary'); + await waitForPromises(); + }; + + beforeEach(() => { + mockModalShow = jest.fn(); + + runnerDeleteHandler = jest.fn().mockImplementation(() => { + return Promise.resolve({ + data: { + runnerDelete: { + errors: [], + }, + }, + }); + }); + apolloProvider = createMockApollo([[runnerDeleteMutation, runnerDeleteHandler]]); + apolloCache = apolloProvider.defaultClient.cache; + + jest.spyOn(apolloCache, 'evict'); + jest.spyOn(apolloCache, 'gc'); + + createComponent(); + }); + + it('Displays an action in the slot', () => { + expect(findBtn().exists()).toBe(true); + }); + + it('Displays a modal with the runner name', () => { + expect(findModal().props('runnerName')).toBe(mockRunnerName); + }); + + it('Displays a modal with the runner manager count', () => { + createComponent({ + props: { + runner: { managers: { count: 2 } }, + }, + }); + + expect(findModal().props('managersCount')).toBe(2); + }); + + it('Displays a modal when action is triggered', async () => { + await findBtn().trigger('click'); + + expect(mockModalShow).toHaveBeenCalled(); + }); + + describe('Before the delete button is clicked', () => { + it('The mutation has not been called', () => { + expect(runnerDeleteHandler).toHaveBeenCalledTimes(0); + }); + }); + + describe('Immediately after the delete button is clicked', () => { + beforeEach(() => { + findModal().vm.$emit('primary'); + }); + + it('The button has a loading state', () => { + expect(findBtn().attributes('disabled')).toBe('disabled'); + }); + }); + + describe('After clicking on the delete button', () => { + beforeEach(async () => { + await clickOkAndWait(); + }); + + it('The mutation to delete is called', () => { + expect(runnerDeleteHandler).toHaveBeenCalledTimes(1); + expect(runnerDeleteHandler).toHaveBeenCalledWith({ + input: { + id: mockRunner.id, + }, + }); + }); + + it('The user can be notified with an event', () => { + const done = wrapper.emitted('done'); + + expect(done).toHaveLength(1); + expect(done[0][0].message).toMatch(`#${mockRunnerId}`); + expect(done[0][0].message).toMatch(`${mockRunner.shortSha}`); + }); + + it('evicts runner from apollo cache', () => { + expect(apolloCache.evict).toHaveBeenCalledWith({ + id: apolloCache.identify(mockRunner), + }); + expect(apolloCache.gc).toHaveBeenCalled(); + }); + }); + + describe('When update fails', () => { + describe('On a network error', () => { + const mockErrorMsg = 'Update error!'; + + beforeEach(async () => { + runnerDeleteHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); + + await clickOkAndWait(); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error(mockErrorMsg), + component: 'RunnerDeleteAction', + }); + }); + + it('error is shown to the user', () => { + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ + title: expect.stringContaining(mockRunnerName), + message: mockErrorMsg, + }); + }); + }); + + describe('On a validation error', () => { + const mockErrorMsg = 'Runner not found!'; + const mockErrorMsg2 = 'User not allowed!'; + + beforeEach(async () => { + runnerDeleteHandler.mockResolvedValueOnce({ + data: { + runnerDelete: { + errors: [mockErrorMsg, mockErrorMsg2], + }, + }, + }); + + await clickOkAndWait(); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`), + component: 'RunnerDeleteAction', + }); + }); + + it('error is shown to the user', () => { + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ + title: expect.stringContaining(mockRunnerName), + message: `${mockErrorMsg} ${mockErrorMsg2}`, + }); + }); + + it('does not evict runner from apollo cache', () => { + expect(apolloCache.evict).not.toHaveBeenCalled(); + expect(apolloCache.gc).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_delete_button_spec.js b/spec/frontend/ci/runner/components/runner_delete_button_spec.js index 3b3f3b1770d..87e857510de 100644 --- a/spec/frontend/ci/runner/components/runner_delete_button_spec.js +++ b/spec/frontend/ci/runner/components/runner_delete_button_spec.js @@ -1,110 +1,73 @@ -import Vue from 'vue'; import { GlButton } from '@gitlab/ui'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; +import { stubComponent } from 'helpers/stub_component'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; -import runnerDeleteMutation from '~/ci/runner/graphql/shared/runner_delete.mutation.graphql'; -import waitForPromises from 'helpers/wait_for_promises'; -import { captureException } from '~/ci/runner/sentry_utils'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { createAlert } from '~/alert'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { I18N_DELETE_RUNNER } from '~/ci/runner/constants'; import RunnerDeleteButton from '~/ci/runner/components/runner_delete_button.vue'; -import RunnerDeleteModal from '~/ci/runner/components/runner_delete_modal.vue'; +import RunnerDeleteAction from '~/ci/runner/components/runner_delete_action.vue'; import { allRunnersData } from '../mock_data'; const mockRunner = allRunnersData.data.runners.nodes[0]; -const mockRunnerId = getIdFromGraphQLId(mockRunner.id); -const mockRunnerName = `#${mockRunnerId} (${mockRunner.shortSha})`; - -Vue.use(VueApollo); jest.mock('~/alert'); jest.mock('~/ci/runner/sentry_utils'); describe('RunnerDeleteButton', () => { let wrapper; - let apolloProvider; - let apolloCache; - let runnerDeleteHandler; const findBtn = () => wrapper.findComponent(GlButton); - const findModal = () => wrapper.findComponent(RunnerDeleteModal); - const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value; - const getModal = () => getBinding(findBtn().element, 'gl-modal').value; - const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => { - const { runner, ...propsData } = props; - - wrapper = mountFn(RunnerDeleteButton, { + const createComponent = ({ props = {}, loading, onClick = jest.fn() } = {}) => { + wrapper = shallowMountExtended(RunnerDeleteButton, { propsData: { - runner: { - // We need typename so that cache.identify works - // eslint-disable-next-line no-underscore-dangle - __typename: mockRunner.__typename, - id: mockRunner.id, - shortSha: mockRunner.shortSha, - ...runner, - }, - ...propsData, + runner: mockRunner, + ...props, }, - apolloProvider, directives: { GlTooltip: createMockDirective('gl-tooltip'), - GlModal: createMockDirective('gl-modal'), + }, + stubs: { + RunnerDeleteAction: stubComponent(RunnerDeleteAction, { + render() { + return this.$scopedSlots.default({ + loading, + onClick, + }); + }, + }), }, }); }; - const clickOkAndWait = async () => { - findModal().vm.$emit('primary'); - await waitForPromises(); - }; - beforeEach(() => { - runnerDeleteHandler = jest.fn().mockImplementation(() => { - return Promise.resolve({ - data: { - runnerDelete: { - errors: [], - }, - }, - }); - }); - apolloProvider = createMockApollo([[runnerDeleteMutation, runnerDeleteHandler]]); - apolloCache = apolloProvider.defaultClient.cache; - - jest.spyOn(apolloCache, 'evict'); - jest.spyOn(apolloCache, 'gc'); - createComponent(); }); - it('Displays a delete button without an icon', () => { + it('Displays a delete button without a icon or tooltip', () => { expect(findBtn().props()).toMatchObject({ loading: false, icon: '', }); expect(findBtn().classes('btn-icon')).toBe(false); expect(findBtn().text()).toBe(I18N_DELETE_RUNNER); - }); - it('Displays a modal with the runner name', () => { - expect(findModal().props('runnerName')).toBe(mockRunnerName); + expect(getTooltip()).toBe(''); }); it('Does not have tabindex when button is enabled', () => { expect(wrapper.attributes('tabindex')).toBeUndefined(); }); - it('Displays a modal when clicked', () => { - const modalId = `delete-runner-modal-${mockRunnerId}`; + it('Triggers delete when clicked', () => { + const mockOnClick = jest.fn(); + + createComponent({ onClick: mockOnClick }); + expect(mockOnClick).not.toHaveBeenCalled(); - expect(getModal()).toBe(modalId); - expect(findModal().attributes('modal-id')).toBe(modalId); + findBtn().vm.$emit('click'); + expect(mockOnClick).toHaveBeenCalledTimes(1); }); it('Does not display redundant text for screen readers', () => { @@ -117,135 +80,41 @@ describe('RunnerDeleteButton', () => { expect(findBtn().props('category')).toBe('secondary'); }); - describe(`Before the delete button is clicked`, () => { - it('The mutation has not been called', () => { - expect(runnerDeleteHandler).toHaveBeenCalledTimes(0); - }); - }); - - describe('Immediately after the delete button is clicked', () => { + describe('When loading result', () => { beforeEach(() => { - findModal().vm.$emit('primary'); + createComponent({ loading: true }); }); it('The button has a loading state', () => { expect(findBtn().props('loading')).toBe(true); }); - - it('The stale tooltip is removed', () => { - expect(getTooltip()).toBe(''); - }); }); - describe('After clicking on the delete button', () => { - beforeEach(async () => { - await clickOkAndWait(); - }); - - it('The mutation to delete is called', () => { - expect(runnerDeleteHandler).toHaveBeenCalledTimes(1); - expect(runnerDeleteHandler).toHaveBeenCalledWith({ - input: { - id: mockRunner.id, - }, - }); - }); - - it('The user can be notified with an event', () => { - const deleted = wrapper.emitted('deleted'); - - expect(deleted).toHaveLength(1); - expect(deleted[0][0].message).toMatch(`#${mockRunnerId}`); - expect(deleted[0][0].message).toMatch(`${mockRunner.shortSha}`); - }); - - it('evicts runner from apollo cache', () => { - expect(apolloCache.evict).toHaveBeenCalledWith({ - id: apolloCache.identify(mockRunner), - }); - expect(apolloCache.gc).toHaveBeenCalled(); - }); - }); + describe('When done after deleting', () => { + const doneEvent = { message: 'done!' }; - describe('When update fails', () => { - describe('On a network error', () => { - const mockErrorMsg = 'Update error!'; - - beforeEach(async () => { - runnerDeleteHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); - - await clickOkAndWait(); - }); - - it('error is reported to sentry', () => { - expect(captureException).toHaveBeenCalledWith({ - error: new Error(mockErrorMsg), - component: 'RunnerDeleteButton', - }); - }); - - it('error is shown to the user', () => { - expect(createAlert).toHaveBeenCalledTimes(1); - expect(createAlert).toHaveBeenCalledWith({ - title: expect.stringContaining(mockRunnerName), - message: mockErrorMsg, - }); - }); + beforeEach(() => { + wrapper.findComponent(RunnerDeleteAction).vm.$emit('done', doneEvent); }); - describe('On a validation error', () => { - const mockErrorMsg = 'Runner not found!'; - const mockErrorMsg2 = 'User not allowed!'; - - beforeEach(async () => { - runnerDeleteHandler.mockResolvedValueOnce({ - data: { - runnerDelete: { - errors: [mockErrorMsg, mockErrorMsg2], - }, - }, - }); - - await clickOkAndWait(); - }); - - it('error is reported to sentry', () => { - expect(captureException).toHaveBeenCalledWith({ - error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`), - component: 'RunnerDeleteButton', - }); - }); - - it('error is shown to the user', () => { - expect(createAlert).toHaveBeenCalledTimes(1); - expect(createAlert).toHaveBeenCalledWith({ - title: expect.stringContaining(mockRunnerName), - message: `${mockErrorMsg} ${mockErrorMsg2}`, - }); - }); - - it('does not evict runner from apollo cache', () => { - expect(apolloCache.evict).not.toHaveBeenCalled(); - expect(apolloCache.gc).not.toHaveBeenCalled(); - }); + it('emits deleted event', () => { + expect(wrapper.emitted('deleted')).toEqual([[doneEvent]]); }); }); - describe('When displaying a compact button for an active runner', () => { + describe('When displaying a compact button', () => { beforeEach(() => { createComponent({ - props: { - runner: { - paused: false, - }, - compact: true, - }, - mountFn: mountExtended, + props: { compact: true }, }); }); it('Displays no text', () => { expect(findBtn().text()).toBe(''); + }); + + it('Displays "x" icon', () => { + expect(findBtn().props('icon')).toBe('close'); expect(findBtn().classes('btn-icon')).toBe(true); }); @@ -254,13 +123,12 @@ describe('RunnerDeleteButton', () => { expect(getTooltip()).toBe(I18N_DELETE_RUNNER); }); - describe('Immediately after the button is clicked', () => { + describe('When loading result', () => { beforeEach(() => { - findModal().vm.$emit('primary'); - }); - - it('The button has a loading state', () => { - expect(findBtn().props('loading')).toBe(true); + createComponent({ + props: { compact: true }, + loading: true, + }); }); it('The stale tooltip is removed', () => { diff --git a/spec/frontend/ci/runner/components/runner_delete_disclosure_dropdown_item_spec.js b/spec/frontend/ci/runner/components/runner_delete_disclosure_dropdown_item_spec.js new file mode 100644 index 00000000000..e311cb4d458 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_delete_disclosure_dropdown_item_spec.js @@ -0,0 +1,68 @@ +import { GlDisclosureDropdownItem } from '@gitlab/ui'; +import { stubComponent } from 'helpers/stub_component'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { I18N_DELETE } from '~/ci/runner/constants'; + +import RunnerDeleteDisclosureDropdownItem from '~/ci/runner/components/runner_delete_disclosure_dropdown_item.vue'; +import RunnerDeleteAction from '~/ci/runner/components/runner_delete_action.vue'; +import { allRunnersData } from '../mock_data'; + +const mockRunner = allRunnersData.data.runners.nodes[0]; + +jest.mock('~/alert'); +jest.mock('~/ci/runner/sentry_utils'); + +describe('RunnerDeleteDisclosureDropdownItem', () => { + let wrapper; + let mockOnClick; + + const findDisclosureDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem); + + const createComponent = () => { + mockOnClick = jest.fn(); + + wrapper = shallowMountExtended(RunnerDeleteDisclosureDropdownItem, { + propsData: { + runner: mockRunner, + }, + stubs: { + RunnerDeleteAction: stubComponent(RunnerDeleteAction, { + render() { + return this.$scopedSlots.default({ + onClick: mockOnClick, + }); + }, + }), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('Displays a delete item', () => { + expect(findDisclosureDropdownItem().text()).toBe(I18N_DELETE); + }); + + it('Does not trigger on load', () => { + expect(mockOnClick).not.toHaveBeenCalled(); + }); + + it('Triggers delete when clicked', () => { + findDisclosureDropdownItem().vm.$emit('action'); + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + describe('When done after deleting', () => { + const doneEvent = { message: 'done!' }; + + beforeEach(() => { + wrapper.findComponent(RunnerDeleteAction).vm.$emit('done', doneEvent); + }); + + it('emits deleted event', () => { + expect(wrapper.emitted('deleted')).toEqual([[doneEvent]]); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_delete_modal_spec.js b/spec/frontend/ci/runner/components/runner_delete_modal_spec.js index 606cc46c018..e486d708fec 100644 --- a/spec/frontend/ci/runner/components/runner_delete_modal_spec.js +++ b/spec/frontend/ci/runner/components/runner_delete_modal_spec.js @@ -1,5 +1,6 @@ import { GlModal } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; import RunnerDeleteModal from '~/ci/runner/components/runner_delete_modal.vue'; describe('RunnerDeleteModal', () => { @@ -7,7 +8,7 @@ describe('RunnerDeleteModal', () => { const findGlModal = () => wrapper.findComponent(GlModal); - const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => { + const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMount) => { wrapper = mountFn(RunnerDeleteModal, { attachTo: document.body, propsData: { @@ -17,6 +18,7 @@ describe('RunnerDeleteModal', () => { attrs: { modalId: 'delete-runner-modal-99', }, + ...options, }); }; @@ -66,15 +68,35 @@ describe('RunnerDeleteModal', () => { }); }); - describe('When modal is confirmed by the user', () => { + describe('Modal API', () => { let hideModalSpy; + let showModalSpy; beforeEach(() => { - createComponent({}, mount); - hideModalSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide').mockImplementation(() => {}); + hideModalSpy = jest.fn(); + showModalSpy = jest.fn(); + + createComponent({ + stubs: { + GlModal: stubComponent(GlModal, { + methods: { + hide: hideModalSpy, + show: showModalSpy, + }, + }), + }, + }); + }); + + it('When "show" method is called, modal is shown', () => { + expect(showModalSpy).toHaveBeenCalledTimes(0); + + wrapper.vm.show(); + + expect(showModalSpy).toHaveBeenCalledTimes(1); }); - it('Modal gets hidden', () => { + it('When confirmed, modal gets hidden', () => { expect(hideModalSpy).toHaveBeenCalledTimes(0); findGlModal().vm.$emit('primary'); diff --git a/spec/frontend/ci/runner/components/runner_detail_spec.js b/spec/frontend/ci/runner/components/runner_detail_spec.js new file mode 100644 index 00000000000..b2d91af4e3b --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_detail_spec.js @@ -0,0 +1,88 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RunnerDetail from '~/ci/runner/components/runner_detail.vue'; + +describe('RunnerDetail', () => { + let wrapper; + const createWrapper = ({ props, slots }) => { + wrapper = shallowMountExtended(RunnerDetail, { + propsData: props, + slots, + }); + }; + const findLabelText = () => wrapper.findByTestId('label-slot').text(); + const findValueText = () => wrapper.findByTestId('value-slot').text(); + + it('renders the label slot when a label prop is provided', () => { + createWrapper({ props: { label: 'Field Name' } }); + + expect(findLabelText()).toBe('Field Name'); + }); + + it('does not render the label slot when no label prop is provided', () => { + createWrapper({ props: {} }); + + expect(findLabelText()).toBe(''); + }); + + it('renders the value slot when a value prop is provided', () => { + createWrapper({ props: { value: 'testValue' } }); + + expect(findValueText()).toBe('testValue'); + }); + + it('renders the emptyValue when no value prop is provided', () => { + createWrapper({ props: {} }); + + expect(findValueText()).toBe('None'); + }); + + it('renders both the label and value slots when both label and value props are provided', () => { + createWrapper({ props: { label: 'Field Name', value: 'testValue' } }); + + expect(findLabelText()).toBe('Field Name'); + expect(findValueText()).toBe('testValue'); + }); + + it('renders the label slot when a label slot is provided', () => { + createWrapper({ + slots: { + label: 'Label Slot Test', + }, + }); + + expect(findLabelText()).toBe('Label Slot Test'); + }); + + it('does not render the label slot when no label slot is provided', () => { + createWrapper({ + slots: {}, + }); + + expect(findLabelText()).toBe(''); + }); + + it('renders the value slot when a value slot is provided', () => { + createWrapper({ + slots: { + value: 'Value Slot Test', + }, + }); + + expect(findValueText()).toBe('Value Slot Test'); + }); + + it('renders the emptyValue when no value slot is provided', () => { + createWrapper({ + slots: {}, + }); + + expect(findValueText()).toBe('None'); + }); + + it('renders both the label and value slots when both label and value slots are provided', () => { + createWrapper({ slots: { label: 'Label Slot Test', value: 'Value Slot Test' } }); + + expect(findLabelText()).toBe('Label Slot Test'); + expect(findValueText()).toBe('Value Slot Test'); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_edit_button_spec.js b/spec/frontend/ci/runner/components/runner_edit_button_spec.js index 5cc1ee049f4..5e36ff77146 100644 --- a/spec/frontend/ci/runner/components/runner_edit_button_spec.js +++ b/spec/frontend/ci/runner/components/runner_edit_button_spec.js @@ -1,18 +1,25 @@ -import { shallowMount, mount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import RunnerEditButton from '~/ci/runner/components/runner_edit_button.vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { I18N_EDIT } from '~/ci/runner/constants'; describe('RunnerEditButton', () => { let wrapper; + const findButton = () => wrapper.findComponent(GlButton); const getTooltipValue = () => getBinding(wrapper.element, 'gl-tooltip').value; - const createComponent = ({ attrs = {}, mountFn = shallowMount } = {}) => { + const createComponent = ({ props = {}, mountFn = shallowMount, ...options } = {}) => { wrapper = mountFn(RunnerEditButton, { - attrs, + propsData: { + href: '/edit', + ...props, + }, directives: { GlTooltip: createMockDirective('gl-tooltip'), }, + ...options, }); }; @@ -21,17 +28,24 @@ describe('RunnerEditButton', () => { }); it('Displays Edit text', () => { - expect(wrapper.attributes('aria-label')).toBe('Edit'); + expect(wrapper.attributes('aria-label')).toBe(I18N_EDIT); }); it('Displays Edit tooltip', () => { - expect(getTooltipValue()).toBe('Edit'); + expect(getTooltipValue()).toBe(I18N_EDIT); }); it('Renders a link and adds an href attribute', () => { - createComponent({ attrs: { href: '/edit' }, mountFn: mount }); + expect(findButton().attributes('href')).toBe('/edit'); + }); - expect(wrapper.element.tagName).toBe('A'); - expect(wrapper.attributes('href')).toBe('/edit'); + describe('When no href is provided', () => { + beforeEach(() => { + createComponent({ props: { href: null } }); + }); + + it('does not render', () => { + expect(wrapper.html()).toBe(''); + }); }); }); diff --git a/spec/frontend/ci/runner/components/runner_edit_disclosure_dropdown_item_spec.js b/spec/frontend/ci/runner/components/runner_edit_disclosure_dropdown_item_spec.js new file mode 100644 index 00000000000..4c6b4d2d52a --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_edit_disclosure_dropdown_item_spec.js @@ -0,0 +1,42 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; +import RunnerEditDisclosureDropdownItem from '~/ci/runner/components/runner_edit_disclosure_dropdown_item.vue'; +import { I18N_EDIT } from '~/ci/runner/constants'; + +describe('RunnerEditDisclosureDropdownItem', () => { + let wrapper; + + const findItem = () => wrapper.findComponent(GlDisclosureDropdownItem); + + const createComponent = ({ props = {}, mountFn = shallowMount, ...options } = {}) => { + wrapper = mountFn(RunnerEditDisclosureDropdownItem, { + propsData: { + href: '/edit', + ...props, + }, + ...options, + }); + }; + + it('Displays Edit text', () => { + createComponent({ mountFn: mount }); + + expect(wrapper.text()).toBe(I18N_EDIT); + }); + + it('Renders a link and adds an href attribute', () => { + createComponent(); + + expect(findItem().props('item').href).toBe('/edit'); + }); + + describe('When no href is provided', () => { + beforeEach(() => { + createComponent({ props: { href: null } }); + }); + + it('does not render', () => { + expect(wrapper.html()).toBe(''); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_header_actions_spec.js b/spec/frontend/ci/runner/components/runner_header_actions_spec.js new file mode 100644 index 00000000000..243ada73435 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_header_actions_spec.js @@ -0,0 +1,147 @@ +import { GlDisclosureDropdown } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import RunnerHeaderActions from '~/ci/runner/components/runner_header_actions.vue'; + +import RunnerPauseButton from '~/ci/runner/components/runner_pause_button.vue'; +import RunnerDeleteButton from '~/ci/runner/components/runner_delete_button.vue'; +import RunnerEditButton from '~/ci/runner/components/runner_edit_button.vue'; + +import RunnerEditDisclosureDropdownItem from '~/ci/runner/components/runner_edit_disclosure_dropdown_item.vue'; +import RunnerPauseDisclosureDropdownItem from '~/ci/runner/components/runner_pause_disclosure_dropdown_item.vue'; +import RunnerDeleteDisclosureDropdownItem from '~/ci/runner/components/runner_delete_disclosure_dropdown_item.vue'; + +import { runnerData } from '../mock_data'; + +const mockRunner = runnerData.data.runner; +const mockRunnerEditPath = '/edit'; + +describe('RunnerHeaderActions', () => { + let wrapper; + + const findRunnerEditButton = () => wrapper.findComponent(RunnerEditButton); + const findRunnerPauseButton = () => wrapper.findComponent(RunnerPauseButton); + const findRunnerDeleteButton = () => wrapper.findComponent(RunnerDeleteButton); + + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findEditItem = () => findDropdown().findComponent(RunnerEditDisclosureDropdownItem); + const findPauseItem = () => findDropdown().findComponent(RunnerPauseDisclosureDropdownItem); + const findDeleteItem = () => findDropdown().findComponent(RunnerDeleteDisclosureDropdownItem); + + const createComponent = ({ props = {}, options = {}, mountFn = shallowMountExtended } = {}) => { + const { runner, ...propsData } = props; + + wrapper = mountFn(RunnerHeaderActions, { + propsData: { + runner: { + ...mockRunner, + ...runner, + }, + editPath: mockRunnerEditPath, + ...propsData, + }, + ...options, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders all elements', () => { + // visible on md and up screens + expect(findRunnerEditButton().exists()).toBe(true); + expect(findRunnerPauseButton().exists()).toBe(true); + expect(findRunnerDeleteButton().exists()).toBe(true); + + // visible on small screens + expect(findDropdown().exists()).toBe(true); + expect(findEditItem().exists()).toBe(true); + expect(findPauseItem().exists()).toBe(true); + expect(findDeleteItem().exists()).toBe(true); + }); + + it('renders disclosure dropdown with no caret and accesible text', () => { + expect(findDropdown().props()).toMatchObject({ + icon: 'ellipsis_v', + toggleText: s__('Runner|Runner actions'), + textSrOnly: true, + category: 'tertiary', + noCaret: true, + }); + }); + + it.each([findRunnerEditButton, findEditItem])('edit path is set (%p)', (find) => { + expect(find().props('href')).toEqual(mockRunnerEditPath); + }); + + it.each([findRunnerDeleteButton, findDeleteItem])('delete is emitted (%p)', (find) => { + const deleteEvent = { message: 'Deleted!' }; + + find().vm.$emit('deleted', deleteEvent); + + expect(wrapper.emitted('deleted')).toEqual([[deleteEvent]]); + }); + + describe('when delete is disabled', () => { + beforeEach(() => { + createComponent({ + props: { + runner: { + userPermissions: { + updateRunner: true, + deleteRunner: false, + }, + }, + }, + }); + }); + + it('does not render delete actions', () => { + expect(findRunnerDeleteButton().exists()).toBe(false); + expect(findDeleteItem().exists()).toBe(false); + }); + }); + + describe('when update is disabled', () => { + beforeEach(() => { + createComponent({ + props: { + runner: { + userPermissions: { + updateRunner: false, + deleteRunner: true, + }, + }, + }, + }); + }); + + it('does not render delete actions', () => { + expect(findRunnerEditButton().exists()).toBe(false); + expect(findRunnerPauseButton().exists()).toBe(false); + expect(findEditItem().exists()).toBe(false); + expect(findPauseItem().exists()).toBe(false); + }); + }); + + describe('when no actions are enabled', () => { + beforeEach(() => { + createComponent({ + props: { + runner: { + userPermissions: { + updateRunner: false, + deleteRunner: false, + }, + }, + }, + }); + }); + + it('does not render actions', () => { + expect(wrapper.html()).toBe(''); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js b/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js index 22797433b58..511ed88f5ab 100644 --- a/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js +++ b/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js @@ -10,7 +10,6 @@ import { I18N_CREATE_RUNNER_LINK, I18N_STILL_USING_REGISTRATION_TOKENS, I18N_CONTACT_ADMIN_TO_REGISTER, - I18N_FOLLOW_REGISTRATION_INSTRUCTIONS, I18N_NO_RESULTS, I18N_EDIT_YOUR_SEARCH, } from '~/ci/runner/constants'; @@ -59,136 +58,84 @@ describe('RunnerListEmptyState', () => { }); describe('when search is not filtered', () => { - describe.each([ - { createRunnerWorkflowForAdmin: true }, - { createRunnerWorkflowForNamespace: true }, - ])('when createRunnerWorkflow is enabled by %o', (currentGlFeatures) => { - beforeEach(() => { - glFeatures = currentGlFeatures; - }); - - describe.each` - newRunnerPath | registrationToken | expectedMessages - ${mockNewRunnerPath} | ${mockRegistrationToken} | ${[I18N_CREATE_RUNNER_LINK, I18N_STILL_USING_REGISTRATION_TOKENS]} - ${mockNewRunnerPath} | ${null} | ${[I18N_CREATE_RUNNER_LINK]} - ${null} | ${mockRegistrationToken} | ${[I18N_STILL_USING_REGISTRATION_TOKENS]} - ${null} | ${null} | ${[I18N_CONTACT_ADMIN_TO_REGISTER]} - `( - 'when newRunnerPath is $newRunnerPath and registrationToken is $registrationToken', - ({ newRunnerPath, registrationToken, expectedMessages }) => { - beforeEach(() => { - createComponent({ - props: { - newRunnerPath, - registrationToken, - }, - }); - }); - - it('shows title', () => { - expectTitleToBe(I18N_GET_STARTED); - }); - - it('renders an illustration', () => { - expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL); - }); - - it(`shows description: "${expectedMessages.join(' ')}"`, () => { - expectDescriptionToBe([I18N_RUNNERS_ARE_AGENTS, ...expectedMessages]); - }); - }, - ); - - describe('with newRunnerPath and registration token', () => { + describe.each` + newRunnerPath | registrationToken | expectedMessages + ${mockNewRunnerPath} | ${mockRegistrationToken} | ${[I18N_CREATE_RUNNER_LINK, I18N_STILL_USING_REGISTRATION_TOKENS]} + ${mockNewRunnerPath} | ${null} | ${[I18N_CREATE_RUNNER_LINK]} + ${null} | ${mockRegistrationToken} | ${[I18N_STILL_USING_REGISTRATION_TOKENS]} + ${null} | ${null} | ${[I18N_CONTACT_ADMIN_TO_REGISTER]} + `( + 'when newRunnerPath is $newRunnerPath and registrationToken is $registrationToken', + ({ newRunnerPath, registrationToken, expectedMessages }) => { beforeEach(() => { createComponent({ props: { - registrationToken: mockRegistrationToken, - newRunnerPath: mockNewRunnerPath, + newRunnerPath, + registrationToken, }, }); }); - it('shows links to the new runner page and registration instructions', () => { - expect(findLinks().at(0).attributes('href')).toBe(mockNewRunnerPath); + it('shows title', () => { + expectTitleToBe(I18N_GET_STARTED); + }); - const { value } = getBinding(findLinks().at(1).element, 'gl-modal'); - expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); + it('renders an illustration', () => { + expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL); }); - }); - describe('with newRunnerPath and no registration token', () => { - beforeEach(() => { - createComponent({ - props: { - registrationToken: mockRegistrationToken, - newRunnerPath: null, - }, - }); + it(`shows description: "${expectedMessages.join(' ')}"`, () => { + expectDescriptionToBe([I18N_RUNNERS_ARE_AGENTS, ...expectedMessages]); }); + }, + ); - it('opens a runner registration instructions modal with a link', () => { - const { value } = getBinding(findLink().element, 'gl-modal'); - expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); + describe('with newRunnerPath and registration token', () => { + beforeEach(() => { + createComponent({ + props: { + registrationToken: mockRegistrationToken, + newRunnerPath: mockNewRunnerPath, + }, }); }); - describe('with no newRunnerPath nor registration token', () => { - beforeEach(() => { - createComponent({ - props: { - registrationToken: null, - newRunnerPath: null, - }, - }); - }); + it('shows links to the new runner page and registration instructions', () => { + expect(findLinks().at(0).attributes('href')).toBe(mockNewRunnerPath); - it('has no link', () => { - expect(findLink().exists()).toBe(false); - }); + const { value } = getBinding(findLinks().at(1).element, 'gl-modal'); + expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); }); }); - describe('when createRunnerWorkflow is disabled', () => { - describe('when there is a registration token', () => { - beforeEach(() => { - createComponent({ - props: { - registrationToken: mockRegistrationToken, - }, - }); - }); - - it('renders an illustration', () => { - expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL); - }); - - it('opens a runner registration instructions modal with a link', () => { - const { value } = getBinding(findLink().element, 'gl-modal'); - expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); - }); - - it('displays text with registration instructions', () => { - expectTitleToBe(I18N_GET_STARTED); - - expectDescriptionToBe([I18N_RUNNERS_ARE_AGENTS, I18N_FOLLOW_REGISTRATION_INSTRUCTIONS]); + describe('with newRunnerPath and no registration token', () => { + beforeEach(() => { + createComponent({ + props: { + registrationToken: mockRegistrationToken, + newRunnerPath: null, + }, }); }); - describe('when there is no registration token', () => { - beforeEach(() => { - createComponent({ props: { registrationToken: null } }); - }); - - it('displays "contact admin" text', () => { - expectTitleToBe(I18N_GET_STARTED); + it('opens a runner registration instructions modal with a link', () => { + const { value } = getBinding(findLink().element, 'gl-modal'); + expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); + }); + }); - expectDescriptionToBe([I18N_RUNNERS_ARE_AGENTS, I18N_CONTACT_ADMIN_TO_REGISTER]); + describe('with no newRunnerPath nor registration token', () => { + beforeEach(() => { + createComponent({ + props: { + registrationToken: null, + newRunnerPath: null, + }, }); + }); - it('has no registration instructions link', () => { - expect(findLink().exists()).toBe(false); - }); + it('has no link', () => { + expect(findLink().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/ci/runner/components/runner_pause_action_spec.js b/spec/frontend/ci/runner/components/runner_pause_action_spec.js new file mode 100644 index 00000000000..b987eb1e310 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_pause_action_spec.js @@ -0,0 +1,180 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import runnerTogglePausedMutation from '~/ci/runner/graphql/shared/runner_toggle_paused.mutation.graphql'; +import waitForPromises from 'helpers/wait_for_promises'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { createAlert } from '~/alert'; + +import RunnerPauseAction from '~/ci/runner/components/runner_pause_action.vue'; +import { allRunnersData } from '../mock_data'; + +const mockRunner = allRunnersData.data.runners.nodes[0]; + +Vue.use(VueApollo); + +jest.mock('~/alert'); +jest.mock('~/ci/runner/sentry_utils'); + +describe('RunnerPauseAction', () => { + let wrapper; + let runnerTogglePausedHandler; + + const findBtn = () => wrapper.find('button'); + + const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => { + const { runner, ...propsData } = props; + + wrapper = mountFn(RunnerPauseAction, { + propsData: { + runner: { + id: mockRunner.id, + paused: mockRunner.paused, + ...runner, + }, + ...propsData, + }, + apolloProvider: createMockApollo([[runnerTogglePausedMutation, runnerTogglePausedHandler]]), + scopedSlots: { + default: '<button :disabled="props.loading" @click="props.onClick"/>', + }, + }); + }; + + const clickAndWait = async () => { + findBtn().trigger('click'); + await waitForPromises(); + }; + + beforeEach(() => { + runnerTogglePausedHandler = jest.fn().mockImplementation(({ input }) => { + return Promise.resolve({ + data: { + runnerUpdate: { + runner: { + id: input.id, + paused: !input.paused, + }, + errors: [], + }, + }, + }); + }); + + createComponent(); + }); + + describe('Pause/Resume action', () => { + describe.each` + runnerState | isPaused | newPausedValue + ${'paused'} | ${true} | ${false} + ${'active'} | ${false} | ${true} + `('When the runner is $runnerState', ({ isPaused, newPausedValue }) => { + beforeEach(() => { + createComponent({ + props: { + runner: { + paused: isPaused, + }, + }, + }); + }); + + it('Displays slot contents', () => { + expect(findBtn().exists()).toBe(true); + }); + + it('The mutation has not been called', () => { + expect(runnerTogglePausedHandler).not.toHaveBeenCalled(); + }); + + describe('Immediately after the action is triggered', () => { + it('The button has a loading state', async () => { + await findBtn().trigger('click'); + + expect(findBtn().attributes('disabled')).toBe('disabled'); + }); + }); + + describe('After the action is triggered', () => { + beforeEach(async () => { + await clickAndWait(); + }); + + it(`The mutation to that sets "paused" to ${newPausedValue} is called`, () => { + expect(runnerTogglePausedHandler).toHaveBeenCalledTimes(1); + expect(runnerTogglePausedHandler).toHaveBeenCalledWith({ + input: { + id: mockRunner.id, + paused: newPausedValue, + }, + }); + }); + + it('The button does not have a loading state', () => { + expect(findBtn().attributes('disabled')).toBeUndefined(); + }); + + it('The button emits "done"', () => { + expect(wrapper.emitted('done')).toHaveLength(1); + }); + }); + + describe('When update fails', () => { + describe('On a network error', () => { + const mockErrorMsg = 'Update error!'; + + beforeEach(async () => { + runnerTogglePausedHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); + + await clickAndWait(); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error(mockErrorMsg), + component: 'RunnerPauseAction', + }); + }); + + it('error is shown to the user', () => { + expect(createAlert).toHaveBeenCalledTimes(1); + }); + }); + + describe('On a validation error', () => { + const mockErrorMsg = 'Runner not found!'; + const mockErrorMsg2 = 'User not allowed!'; + + beforeEach(async () => { + runnerTogglePausedHandler.mockResolvedValueOnce({ + data: { + runnerUpdate: { + runner: { + id: mockRunner.id, + paused: isPaused, + }, + errors: [mockErrorMsg, mockErrorMsg2], + }, + }, + }); + + await clickAndWait(); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`), + component: 'RunnerPauseAction', + }); + }); + + it('error is shown to the user', () => { + expect(createAlert).toHaveBeenCalledTimes(1); + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_pause_button_spec.js b/spec/frontend/ci/runner/components/runner_pause_button_spec.js index 1ea870e004a..f1ceecd4ae4 100644 --- a/spec/frontend/ci/runner/components/runner_pause_button_spec.js +++ b/spec/frontend/ci/runner/components/runner_pause_button_spec.js @@ -1,13 +1,7 @@ -import Vue, { nextTick } from 'vue'; import { GlButton } from '@gitlab/ui'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; +import { stubComponent } from 'helpers/stub_component'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; -import runnerTogglePausedMutation from '~/ci/runner/graphql/shared/runner_toggle_paused.mutation.graphql'; -import waitForPromises from 'helpers/wait_for_promises'; -import { captureException } from '~/ci/runner/sentry_utils'; -import { createAlert } from '~/alert'; import { I18N_PAUSE, I18N_PAUSE_TOOLTIP, @@ -16,244 +10,140 @@ import { } from '~/ci/runner/constants'; import RunnerPauseButton from '~/ci/runner/components/runner_pause_button.vue'; -import { allRunnersData } from '../mock_data'; - -const mockRunner = allRunnersData.data.runners.nodes[0]; - -Vue.use(VueApollo); - -jest.mock('~/alert'); -jest.mock('~/ci/runner/sentry_utils'); +import RunnerPauseAction from '~/ci/runner/components/runner_pause_action.vue'; describe('RunnerPauseButton', () => { let wrapper; - let runnerTogglePausedHandler; const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value; + const findRunnerPauseAction = () => wrapper.findComponent(RunnerPauseAction); const findBtn = () => wrapper.findComponent(GlButton); - const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => { - const { runner, ...propsData } = props; - + const createComponent = ({ + props = {}, + loading, + onClick = jest.fn(), + mountFn = shallowMountExtended, + } = {}) => { wrapper = mountFn(RunnerPauseButton, { propsData: { - runner: { - id: mockRunner.id, - paused: mockRunner.paused, - ...runner, - }, - ...propsData, + runner: {}, + ...props, }, - apolloProvider: createMockApollo([[runnerTogglePausedMutation, runnerTogglePausedHandler]]), directives: { GlTooltip: createMockDirective('gl-tooltip'), }, + stubs: { + RunnerPauseAction: stubComponent(RunnerPauseAction, { + render() { + return this.$scopedSlots.default({ + loading, + onClick, + }); + }, + }), + }, }); }; - const clickAndWait = async () => { - findBtn().vm.$emit('click'); - await waitForPromises(); - }; - beforeEach(() => { - runnerTogglePausedHandler = jest.fn().mockImplementation(({ input }) => { - return Promise.resolve({ - data: { - runnerUpdate: { - runner: { - id: input.id, - paused: !input.paused, - }, - errors: [], - }, - }, - }); - }); - createComponent(); }); - describe('Pause/Resume action', () => { + describe('Pause/Resume button', () => { describe.each` - runnerState | icon | content | tooltip | isPaused | newPausedValue - ${'paused'} | ${'play'} | ${I18N_RESUME} | ${I18N_RESUME_TOOLTIP} | ${true} | ${false} - ${'active'} | ${'pause'} | ${I18N_PAUSE} | ${I18N_PAUSE_TOOLTIP} | ${false} | ${true} - `('When the runner is $runnerState', ({ icon, content, tooltip, isPaused, newPausedValue }) => { - beforeEach(() => { - createComponent({ - props: { - runner: { - paused: isPaused, + runnerState | paused | expectedIcon | expectedContent | expectedTooltip + ${'paused'} | ${true} | ${'play'} | ${I18N_RESUME} | ${I18N_RESUME_TOOLTIP} + ${'active'} | ${false} | ${'pause'} | ${I18N_PAUSE} | ${I18N_PAUSE_TOOLTIP} + `( + 'When the runner is $runnerState', + ({ paused, expectedIcon, expectedContent, expectedTooltip }) => { + beforeEach(() => { + createComponent({ + props: { + runner: { paused }, }, - }, - }); - }); - - it(`Displays a ${icon} button`, () => { - expect(findBtn().props('loading')).toBe(false); - expect(findBtn().props('icon')).toBe(icon); - }); - - it('Displays button content', () => { - expect(findBtn().text()).toBe(content); - expect(getTooltip()).toBe(tooltip); - }); - - it('Does not display redundant text for screen readers', () => { - expect(findBtn().attributes('aria-label')).toBe(undefined); - }); - - describe(`Before the ${icon} button is clicked`, () => { - it('The mutation has not been called', () => { - expect(runnerTogglePausedHandler).not.toHaveBeenCalled(); + }); }); - }); - - describe(`Immediately after the ${icon} button is clicked`, () => { - const setup = async () => { - findBtn().vm.$emit('click'); - await nextTick(); - }; - it('The button has a loading state', async () => { - await setup(); - - expect(findBtn().props('loading')).toBe(true); + it(`Displays a ${expectedIcon} button`, () => { + expect(findBtn().props('loading')).toBe(false); + expect(findBtn().props('icon')).toBe(expectedIcon); }); - it('The stale tooltip is removed', async () => { - await setup(); - - expect(getTooltip()).toBe(''); + it('Displays button content', () => { + expect(findBtn().text()).toBe(expectedContent); + expect(getTooltip()).toBe(expectedTooltip); }); - }); - describe(`After clicking on the ${icon} button`, () => { - beforeEach(async () => { - await clickAndWait(); + it('Does not display redundant text for screen readers', () => { + expect(findBtn().attributes('aria-label')).toBe(undefined); }); + }, + ); + }); - it(`The mutation to that sets "paused" to ${newPausedValue} is called`, () => { - expect(runnerTogglePausedHandler).toHaveBeenCalledTimes(1); - expect(runnerTogglePausedHandler).toHaveBeenCalledWith({ - input: { - id: mockRunner.id, - paused: newPausedValue, + describe('Compact button', () => { + describe.each` + runnerState | paused | expectedIcon | expectedContent | expectedTooltip + ${'paused'} | ${true} | ${'play'} | ${I18N_RESUME} | ${I18N_RESUME_TOOLTIP} + ${'active'} | ${false} | ${'pause'} | ${I18N_PAUSE} | ${I18N_PAUSE_TOOLTIP} + `( + 'When the runner is $runnerState', + ({ paused, expectedIcon, expectedContent, expectedTooltip }) => { + beforeEach(() => { + createComponent({ + props: { + runner: { paused }, + compact: true, }, + mountFn: mountExtended, }); }); - it('The button does not have a loading state', () => { + it(`Displays a ${expectedIcon} button`, () => { expect(findBtn().props('loading')).toBe(false); + expect(findBtn().props('icon')).toBe(expectedIcon); }); - it('The button emits toggledPaused', () => { - expect(wrapper.emitted('toggledPaused')).toHaveLength(1); - }); - }); - - describe('When update fails', () => { - describe('On a network error', () => { - const mockErrorMsg = 'Update error!'; - - beforeEach(async () => { - runnerTogglePausedHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); - - await clickAndWait(); - }); - - it('error is reported to sentry', () => { - expect(captureException).toHaveBeenCalledWith({ - error: new Error(mockErrorMsg), - component: 'RunnerPauseButton', - }); - }); + it('Displays button content', () => { + expect(findBtn().text()).toBe(''); + // Note: Use <template v-if> to ensure rendering a + // text-less button. Ensure we don't send even empty an + // content slot to prevent a distorted/rectangular button. + expect(wrapper.find('.gl-button-text').exists()).toBe(false); - it('error is shown to the user', () => { - expect(createAlert).toHaveBeenCalledTimes(1); - }); + expect(getTooltip()).toBe(expectedTooltip); }); - describe('On a validation error', () => { - const mockErrorMsg = 'Runner not found!'; - const mockErrorMsg2 = 'User not allowed!'; - - beforeEach(async () => { - runnerTogglePausedHandler.mockResolvedValueOnce({ - data: { - runnerUpdate: { - runner: { - id: mockRunner.id, - paused: isPaused, - }, - errors: [mockErrorMsg, mockErrorMsg2], - }, - }, - }); - - await clickAndWait(); - }); - - it('error is reported to sentry', () => { - expect(captureException).toHaveBeenCalledWith({ - error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`), - component: 'RunnerPauseButton', - }); - }); - - it('error is shown to the user', () => { - expect(createAlert).toHaveBeenCalledTimes(1); - }); + it('Does not display redundant text for screen readers', () => { + expect(findBtn().attributes('aria-label')).toBe(expectedContent); }); - }); - }); + }, + ); }); - describe('When displaying a compact button for an active runner', () => { - beforeEach(() => { - createComponent({ - props: { - runner: { - paused: false, - }, - compact: true, - }, - mountFn: mountExtended, - }); - }); - - it('Displays no text', () => { - expect(findBtn().text()).toBe(''); + it('Shows loading state', () => { + createComponent({ loading: true }); - // Note: Use <template v-if> to ensure rendering a - // text-less button. Ensure we don't send even empty an - // content slot to prevent a distorted/rectangular button. - expect(wrapper.find('.gl-button-text').exists()).toBe(false); - }); + expect(findBtn().props('loading')).toBe(true); + expect(getTooltip()).toBe(''); + }); - it('Display correctly for screen readers', () => { - expect(findBtn().attributes('aria-label')).toBe(I18N_PAUSE); - expect(getTooltip()).toBe(I18N_PAUSE_TOOLTIP); - }); + it('Triggers action', () => { + const mockOnClick = jest.fn(); - describe('Immediately after the button is clicked', () => { - const setup = async () => { - findBtn().vm.$emit('click'); - await nextTick(); - }; + createComponent({ onClick: mockOnClick }); + findBtn().vm.$emit('click'); - it('The button has a loading state', async () => { - await setup(); + expect(mockOnClick).toHaveBeenCalled(); + }); - expect(findBtn().props('loading')).toBe(true); - }); + it('Emits toggledPaused when done', () => { + createComponent(); - it('The stale tooltip is removed', async () => { - await setup(); + findRunnerPauseAction().vm.$emit('done'); - expect(getTooltip()).toBe(''); - }); - }); + expect(wrapper.emitted('toggledPaused')).toHaveLength(1); }); }); diff --git a/spec/frontend/ci/runner/components/runner_pause_disclosure_dropdown_item_spec.js b/spec/frontend/ci/runner/components/runner_pause_disclosure_dropdown_item_spec.js new file mode 100644 index 00000000000..5dc9a615b0e --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_pause_disclosure_dropdown_item_spec.js @@ -0,0 +1,71 @@ +import { GlDisclosureDropdownItem } from '@gitlab/ui'; +import { stubComponent } from 'helpers/stub_component'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +import { I18N_PAUSE, I18N_RESUME } from '~/ci/runner/constants'; + +import RunnerPauseDisclosureDropdownItem from '~/ci/runner/components/runner_pause_disclosure_dropdown_item.vue'; +import RunnerPauseAction from '~/ci/runner/components/runner_pause_action.vue'; + +describe('RunnerPauseButton', () => { + let wrapper; + + const findRunnerPauseAction = () => wrapper.findComponent(RunnerPauseAction); + const findDisclosureDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem); + + const createComponent = ({ + props = {}, + onClick = jest.fn(), + mountFn = shallowMountExtended, + } = {}) => { + wrapper = mountFn(RunnerPauseDisclosureDropdownItem, { + propsData: { + runner: {}, + ...props, + }, + stubs: { + RunnerPauseAction: stubComponent(RunnerPauseAction, { + render() { + return this.$scopedSlots.default({ + onClick, + }); + }, + }), + }, + }); + }; + + it('Displays paused runner button content', () => { + createComponent({ + props: { runner: { paused: true } }, + mountFn: mountExtended, + }); + + expect(findDisclosureDropdownItem().text()).toBe(I18N_RESUME); + }); + + it('Displays active runner button content', () => { + createComponent({ + props: { runner: { paused: false } }, + mountFn: mountExtended, + }); + + expect(findDisclosureDropdownItem().text()).toBe(I18N_PAUSE); + }); + + it('Triggers action', () => { + const mockOnClick = jest.fn(); + + createComponent({ onClick: mockOnClick }); + findDisclosureDropdownItem().vm.$emit('action'); + + expect(mockOnClick).toHaveBeenCalled(); + }); + + it('Emits toggledPaused when done', () => { + createComponent(); + + findRunnerPauseAction().vm.$emit('done'); + + expect(wrapper.emitted('toggledPaused')).toHaveLength(1); + }); +}); diff --git a/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js b/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js index 120388900b5..7438c47e32c 100644 --- a/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js +++ b/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js @@ -9,10 +9,8 @@ import { visitUrl } from '~/lib/utils/url_utility'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerHeader from '~/ci/runner/components/runner_header.vue'; +import RunnerHeaderActions from '~/ci/runner/components/runner_header_actions.vue'; import RunnerDetails from '~/ci/runner/components/runner_details.vue'; -import RunnerPauseButton from '~/ci/runner/components/runner_pause_button.vue'; -import RunnerDeleteButton from '~/ci/runner/components/runner_delete_button.vue'; -import RunnerEditButton from '~/ci/runner/components/runner_edit_button.vue'; import RunnerDetailsTabs from '~/ci/runner/components/runner_details_tabs.vue'; import RunnersJobs from '~/ci/runner/components/runner_jobs.vue'; @@ -47,9 +45,7 @@ describe('GroupRunnerShowApp', () => { const findRunnerHeader = () => wrapper.findComponent(RunnerHeader); const findRunnerDetails = () => wrapper.findComponent(RunnerDetails); - const findRunnerDeleteButton = () => wrapper.findComponent(RunnerDeleteButton); - const findRunnerEditButton = () => wrapper.findComponent(RunnerEditButton); - const findRunnerPauseButton = () => wrapper.findComponent(RunnerPauseButton); + const findRunnerHeaderActions = () => wrapper.findComponent(RunnerHeaderActions); const findRunnerDetailsTabs = () => wrapper.findComponent(RunnerDetailsTabs); const findRunnersJobs = () => wrapper.findComponent(RunnersJobs); @@ -95,10 +91,11 @@ describe('GroupRunnerShowApp', () => { expect(findRunnerHeader().text()).toContain(`#${mockRunnerId} (${mockRunnerSha})`); }); - it('displays the runner edit and pause buttons', () => { - expect(findRunnerEditButton().attributes('href')).toBe(mockEditGroupRunnerPath); - expect(findRunnerPauseButton().exists()).toBe(true); - expect(findRunnerDeleteButton().exists()).toBe(true); + it('displays the runner buttons', () => { + expect(findRunnerHeaderActions().props()).toEqual({ + runner: mockRunner, + editPath: mockEditGroupRunnerPath, + }); }); it('shows runner details', () => { @@ -127,54 +124,6 @@ describe('GroupRunnerShowApp', () => { expect(wrapper.text().replace(/\s+/g, ' ')).toContain(expected); }); - describe('when runner cannot be updated', () => { - beforeEach(async () => { - mockRunnerQueryResult({ - userPermissions: { - ...mockRunner.userPermissions, - updateRunner: false, - }, - }); - - await createComponent({ - mountFn: mountExtended, - }); - }); - - it('does not display the runner edit and pause buttons', () => { - expect(findRunnerEditButton().exists()).toBe(false); - expect(findRunnerPauseButton().exists()).toBe(false); - }); - - it('displays delete button', () => { - expect(findRunnerDeleteButton().exists()).toBe(true); - }); - }); - - describe('when runner cannot be deleted', () => { - beforeEach(async () => { - mockRunnerQueryResult({ - userPermissions: { - ...mockRunner.userPermissions, - deleteRunner: false, - }, - }); - - await createComponent({ - mountFn: mountExtended, - }); - }); - - it('does not display the delete button', () => { - expect(findRunnerDeleteButton().exists()).toBe(false); - }); - - it('displays edit and pause buttons', () => { - expect(findRunnerEditButton().exists()).toBe(true); - expect(findRunnerPauseButton().exists()).toBe(true); - }); - }); - describe('when runner is deleted', () => { beforeEach(async () => { await createComponent({ @@ -183,7 +132,7 @@ describe('GroupRunnerShowApp', () => { }); it('redirects to the runner list page', () => { - findRunnerDeleteButton().vm.$emit('deleted', { message: 'Runner deleted' }); + findRunnerHeaderActions().vm.$emit('deleted', { message: 'Runner deleted' }); expect(saveAlertToLocalStorage).toHaveBeenCalledWith({ message: 'Runner deleted', diff --git a/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js index 74eeb864cd8..f3d7ae85e0d 100644 --- a/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js @@ -483,35 +483,15 @@ describe('GroupRunnersApp', () => { expect(findRegistrationDropdown().exists()).toBe(true); }); - it('when create_runner_workflow_for_namespace is enabled', () => { + it('shows the create runner button', () => { createComponent({ props: { newRunnerPath, }, - provide: { - glFeatures: { - createRunnerWorkflowForNamespace: true, - }, - }, }); expect(findNewRunnerBtn().attributes('href')).toBe(newRunnerPath); }); - - it('when create_runner_workflow_for_namespace is disabled', () => { - createComponent({ - props: { - newRunnerPath, - }, - provide: { - glFeatures: { - createRunnerWorkflowForNamespace: false, - }, - }, - }); - - expect(findNewRunnerBtn().exists()).toBe(false); - }); }); describe('when user has no permission to register group runner', () => { @@ -524,16 +504,11 @@ describe('GroupRunnersApp', () => { expect(findRegistrationDropdown().exists()).toBe(false); }); - it('when create_runner_workflow_for_namespace is enabled', () => { + it('shows the create runner button', () => { createComponent({ props: { newRunnerPath: null, }, - provide: { - glFeatures: { - createRunnerWorkflowForNamespace: true, - }, - }, }); expect(findNewRunnerBtn().exists()).toBe(false); diff --git a/spec/frontend/clusters/agents/components/create_token_modal_spec.js b/spec/frontend/clusters/agents/components/create_token_modal_spec.js index f0fded7b7b2..40cb3b8292f 100644 --- a/spec/frontend/clusters/agents/components/create_token_modal_spec.js +++ b/spec/frontend/clusters/agents/components/create_token_modal_spec.js @@ -3,6 +3,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { stubComponent, RENDER_ALL_SLOTS_TEMPLATE } from 'helpers/stub_component'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { mockTracking } from 'helpers/tracking_helper'; import { @@ -37,6 +38,7 @@ describe('CreateTokenModal', () => { }; const agentName = 'cluster-agent'; const projectPath = 'path/to/project'; + const hideModalMock = jest.fn(); const provide = { agentName, @@ -91,10 +93,12 @@ describe('CreateTokenModal', () => { provide, propsData, stubs: { - GlModal, + GlModal: stubComponent(GlModal, { + methods: { hide: hideModalMock }, + template: RENDER_ALL_SLOTS_TEMPLATE, + }), }, }); - wrapper.vm.$refs.modal.hide = jest.fn(); trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }; @@ -138,6 +142,11 @@ describe('CreateTokenModal', () => { expectDisabledAttribute(findCancelButton(), false); }); + it('cancel button should hide the modal', () => { + findCancelButton().vm.$emit('click'); + expect(hideModalMock).toHaveBeenCalled(); + }); + it('renders a disabled next button', () => { expect(findActionButton().text()).toBe('Create token'); expectDisabledAttribute(findActionButton(), true); diff --git a/spec/frontend/clusters/agents/components/revoke_token_button_spec.js b/spec/frontend/clusters/agents/components/revoke_token_button_spec.js index 970782a8e58..de47ff78696 100644 --- a/spec/frontend/clusters/agents/components/revoke_token_button_spec.js +++ b/spec/frontend/clusters/agents/components/revoke_token_button_spec.js @@ -2,6 +2,7 @@ import { GlButton, GlModal, GlFormInput, GlTooltip } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { ENTER_KEY } from '~/lib/utils/keys'; @@ -81,12 +82,15 @@ describe('RevokeTokenButton', () => { }, propsData, stubs: { - GlModal, + GlModal: stubComponent(GlModal, { + methods: { + hide: jest.fn(), + }, + }), GlTooltip, }, mocks: { $toast: { show: toast } }, }); - wrapper.vm.$refs.modal.hide = jest.fn(); writeQuery(); await nextTick(); diff --git a/spec/frontend/clusters_list/components/clusters_actions_spec.js b/spec/frontend/clusters_list/components/clusters_actions_spec.js index e4e1986f705..6957862dc2b 100644 --- a/spec/frontend/clusters_list/components/clusters_actions_spec.js +++ b/spec/frontend/clusters_list/components/clusters_actions_spec.js @@ -1,4 +1,10 @@ -import { GlButton, GlDropdown, GlDropdownItem, GlTooltip } from '@gitlab/ui'; +import { + GlButton, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlTooltip, + GlButtonGroup, +} from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ClustersActions from '~/clusters_list/components/clusters_actions.vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; @@ -18,12 +24,13 @@ describe('ClustersActionsComponent', () => { certificateBasedClustersEnabled: true, }; + const findButtonGroup = () => wrapper.findComponent(GlButtonGroup); const findButton = () => wrapper.findComponent(GlButton); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findDropdownItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); const findTooltip = () => wrapper.findComponent(GlTooltip); const findDropdownItemIds = () => - findDropdownItems().wrappers.map((x) => x.attributes('data-testid')); + findDropdownItems().wrappers.map((x) => x.find('a').attributes('data-testid')); const findDropdownItemTexts = () => findDropdownItems().wrappers.map((x) => x.text()); const findNewClusterDocsLink = () => wrapper.findByTestId('create-cluster-link'); const findConnectClusterLink = () => wrapper.findByTestId('connect-cluster-link'); @@ -34,6 +41,10 @@ describe('ClustersActionsComponent', () => { ...defaultProvide, ...provideData, }, + stubs: { + GlDisclosureDropdown, + GlDisclosureDropdownItem, + }, directives: { GlModalDirective: createMockDirective('gl-modal-directive'), }, @@ -45,25 +56,23 @@ describe('ClustersActionsComponent', () => { }); describe('when the certificate based clusters are enabled', () => { - it('renders actions menu', () => { + it('renders actions menu button group with dropdown', () => { + expect(findButtonGroup().exists()).toBe(true); + expect(findButton().exists()).toBe(true); expect(findDropdown().exists()).toBe(true); }); - it('shows split button in dropdown', () => { - expect(findDropdown().props('split')).toBe(true); - }); - it("doesn't show the tooltip", () => { expect(findTooltip().exists()).toBe(false); }); describe('when on project level', () => { it(`displays default action as ${CLUSTERS_ACTIONS.connectWithAgent}`, () => { - expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.connectWithAgent); + expect(findButton().text()).toBe(CLUSTERS_ACTIONS.connectWithAgent); }); it('renders correct modal id for the default action', () => { - const binding = getBinding(findDropdown().element, 'gl-modal-directive'); + const binding = getBinding(findButton().element, 'gl-modal-directive'); expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID); }); @@ -91,6 +100,7 @@ describe('ClustersActionsComponent', () => { it('disables dropdown', () => { expect(findDropdown().props('disabled')).toBe(true); + expect(findButton().props('disabled')).toBe(true); }); it('shows tooltip explaining why dropdown is disabled', () => { @@ -98,7 +108,7 @@ describe('ClustersActionsComponent', () => { }); it('does not bind split dropdown button', () => { - const binding = getBinding(findDropdown().element, 'gl-modal-directive'); + const binding = getBinding(findButton().element, 'gl-modal-directive'); expect(binding.value).toBe(false); }); @@ -148,11 +158,11 @@ describe('ClustersActionsComponent', () => { }); it(`displays default action as ${CLUSTERS_ACTIONS.connectCluster}`, () => { - expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.connectCluster); + expect(findButton().text()).toBe(CLUSTERS_ACTIONS.connectCluster); }); it('renders correct modal id for the default action', () => { - const binding = getBinding(findDropdown().element, 'gl-modal-directive'); + const binding = getBinding(findButton().element, 'gl-modal-directive'); expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID); }); diff --git a/spec/frontend/clusters_list/components/delete_agent_button_spec.js b/spec/frontend/clusters_list/components/delete_agent_button_spec.js index 8bbb5ec92a7..afb12d9c856 100644 --- a/spec/frontend/clusters_list/components/delete_agent_button_spec.js +++ b/spec/frontend/clusters_list/components/delete_agent_button_spec.js @@ -9,6 +9,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import DeleteAgentButton from '~/clusters_list/components/delete_agent_button.vue'; import { DELETE_AGENT_BUTTON } from '~/clusters_list/constants'; +import { stubComponent } from 'helpers/stub_component'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { getAgentResponse, mockDeleteResponse, mockErrorDeleteResponse } from '../mocks/apollo'; @@ -84,9 +85,14 @@ describe('DeleteAgentButton', () => { }, propsData, mocks: { $toast: { show: toast } }, - stubs: { GlModal }, + stubs: { + GlModal: stubComponent(GlModal, { + methods: { + hide: jest.fn(), + }, + }), + }, }); - wrapper.vm.$refs.modal.hide = jest.fn(); writeQuery(); await nextTick(); diff --git a/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap b/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap index 8cad483e27e..d0bc7a55f8e 100644 --- a/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap +++ b/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap @@ -130,7 +130,7 @@ exports[`Comment templates list item component renders list item 1`] = ` </div> <div - class="gl-mt-3 gl-font-monospace" + class="gl-mt-3 gl-font-monospace gl-white-space-pre-wrap" > /assign_reviewer </div> diff --git a/spec/frontend/commit/commit_pipeline_status_component_spec.js b/spec/frontend/commit/commit_pipeline_status_spec.js index e474ef9c635..73031724b12 100644 --- a/spec/frontend/commit/commit_pipeline_status_component_spec.js +++ b/spec/frontend/commit/commit_pipeline_status_spec.js @@ -5,7 +5,7 @@ import { nextTick } from 'vue'; import fixture from 'test_fixtures/pipelines/pipelines.json'; import { createAlert } from '~/alert'; import Poll from '~/lib/utils/poll'; -import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; +import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; jest.mock('~/lib/utils/poll'); diff --git a/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js index 85eafa9e85c..53c098ee153 100644 --- a/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js @@ -65,7 +65,7 @@ describe('content_editor/components/bubble_menus/bubble_menu', () => { onHidden: expect.any(Function), onShow: expect.any(Function), strategy: 'fixed', - maxWidth: 'auto', + maxWidth: '400px', ...tippyOptions, }), }); diff --git a/spec/frontend/content_editor/components/bubble_menus/link_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/link_bubble_menu_spec.js index c79df9c9ed8..b219c506753 100644 --- a/spec/frontend/content_editor/components/bubble_menus/link_bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/link_bubble_menu_spec.js @@ -206,7 +206,7 @@ describe('content_editor/components/bubble_menus/link_bubble_menu', () => { await buildWrapperAndDisplayMenu(); await wrapper.findByTestId('remove-link').vm.$emit('click'); - expect(tiptapEditor.getHTML()).toBe('<p>Download PDF File</p>'); + expect(tiptapEditor.getHTML()).toBe('<p dir="auto">Download PDF File</p>'); }); }); diff --git a/spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js index 89beb76a6f2..002e19ee8cf 100644 --- a/spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js @@ -22,19 +22,19 @@ import { PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML, } from '../../test_constants'; -const TIPTAP_AUDIO_HTML = `<p> +const TIPTAP_AUDIO_HTML = `<p dir="auto"> <span class="media-container audio-container"><audio src="https://gitlab.com/favicon.png" controls="true" data-setup="{}" data-title="gitlab favicon"></audio><a href="https://gitlab.com/favicon.png" class="with-attachment-icon">gitlab favicon</a></span> </p>`; -const TIPTAP_DIAGRAM_HTML = `<p> - <img src="https://gitlab.com/favicon.png" alt="gitlab favicon" title="gitlab favicon"> +const TIPTAP_DIAGRAM_HTML = `<p dir="auto"> + <img src="https://gitlab.com/favicon.png" alt="gitlab favicon"> </p>`; -const TIPTAP_IMAGE_HTML = `<p> - <img src="https://gitlab.com/favicon.png" alt="gitlab favicon" title="gitlab favicon"> +const TIPTAP_IMAGE_HTML = `<p dir="auto"> + <img src="https://gitlab.com/favicon.png" alt="gitlab favicon"> </p>`; -const TIPTAP_VIDEO_HTML = `<p> +const TIPTAP_VIDEO_HTML = `<p dir="auto"> <span class="media-container video-container"><video src="https://gitlab.com/favicon.png" controls="true" data-setup="{}" data-title="gitlab favicon"></video><a href="https://gitlab.com/favicon.png" class="with-attachment-icon">gitlab favicon</a></span> </p>`; @@ -101,9 +101,7 @@ describe.each` const expectLinkButtonsToExist = (exist = true) => { expect(wrapper.findComponent(GlLink).exists()).toBe(exist); - expect(wrapper.findByTestId('copy-media-src').exists()).toBe(exist); expect(wrapper.findByTestId('edit-media').exists()).toBe(exist); - expect(wrapper.findByTestId('delete-media').exists()).toBe(exist); }; beforeEach(() => { @@ -128,14 +126,11 @@ describe.each` await buildWrapperAndDisplayMenu(); const link = wrapper.findComponent(GlLink); - expect(link.attributes()).toEqual( - expect.objectContaining({ - href: `/group1/project1/-/wikis/${filePath}`, - 'aria-label': filePath, - title: filePath, - target: '_blank', - }), - ); + expect(link.attributes()).toMatchObject({ + href: `/group1/project1/-/wikis/${filePath}`, + 'aria-label': filePath, + target: '_blank', + }); expect(link.text()).toBe(filePath); }); @@ -190,28 +185,6 @@ describe.each` }); }); - describe('copy button', () => { - it(`copies the canonical link to the ${mediaType} to clipboard`, async () => { - await buildWrapperAndDisplayMenu(); - - jest.spyOn(navigator.clipboard, 'writeText'); - - await wrapper.findByTestId('copy-media-src').vm.$emit('click'); - - expect(navigator.clipboard.writeText).toHaveBeenCalledWith(filePath); - }); - }); - - describe(`remove ${mediaType} button`, () => { - it(`removes the ${mediaType}`, async () => { - await buildWrapperAndDisplayMenu(); - - await wrapper.findByTestId('delete-media').vm.$emit('click'); - - expect(tiptapEditor.getHTML()).toBe('<p>\n \n</p>'); - }); - }); - describe(`replace ${mediaType} button`, () => { beforeEach(buildWrapperAndDisplayMenu); @@ -252,7 +225,6 @@ describe.each` describe('edit button', () => { let mediaSrcInput; - let mediaTitleInput; let mediaAltInput; beforeEach(async () => { @@ -261,7 +233,6 @@ describe.each` await wrapper.findByTestId('edit-media').vm.$emit('click'); mediaSrcInput = wrapper.findByTestId('media-src'); - mediaTitleInput = wrapper.findByTestId('media-title'); mediaAltInput = wrapper.findByTestId('media-alt'); }); @@ -269,11 +240,10 @@ describe.each` expectLinkButtonsToExist(false); }); - it(`shows a form to edit the ${mediaType} src/title/alt`, () => { + it(`shows a form to edit the ${mediaType} src/alt`, () => { expect(wrapper.findComponent(GlForm).exists()).toBe(true); expect(mediaSrcInput.element.value).toBe(filePath); - expect(mediaTitleInput.element.value).toBe(''); expect(mediaAltInput.element.value).toBe('test-file'); }); @@ -281,7 +251,6 @@ describe.each` beforeEach(async () => { mediaSrcInput.setValue('https://gitlab.com/favicon.png'); mediaAltInput.setValue('gitlab favicon'); - mediaTitleInput.setValue('gitlab favicon'); contentEditor.resolveUrl.mockResolvedValue('https://gitlab.com/favicon.png'); @@ -294,14 +263,11 @@ describe.each` it(`updates the link to the ${mediaType} in the bubble menu`, () => { const link = wrapper.findComponent(GlLink); - expect(link.attributes()).toEqual( - expect.objectContaining({ - href: 'https://gitlab.com/favicon.png', - 'aria-label': 'https://gitlab.com/favicon.png', - title: 'https://gitlab.com/favicon.png', - target: '_blank', - }), - ); + expect(link.attributes()).toMatchObject({ + href: 'https://gitlab.com/favicon.png', + 'aria-label': 'https://gitlab.com/favicon.png', + target: '_blank', + }); expect(link.text()).toBe('https://gitlab.com/favicon.png'); }); }); @@ -310,7 +276,6 @@ describe.each` beforeEach(async () => { mediaSrcInput.setValue('https://gitlab.com/favicon.png'); mediaAltInput.setValue('gitlab favicon'); - mediaTitleInput.setValue('gitlab favicon'); await wrapper.findByTestId('cancel-editing-media').vm.$emit('click'); }); @@ -324,12 +289,10 @@ describe.each` await wrapper.findByTestId('edit-media').vm.$emit('click'); mediaSrcInput = wrapper.findByTestId('media-src'); - mediaTitleInput = wrapper.findByTestId('media-title'); mediaAltInput = wrapper.findByTestId('media-alt'); expect(mediaSrcInput.element.value).toBe(filePath); expect(mediaAltInput.element.value).toBe('test-file'); - expect(mediaTitleInput.element.value).toBe(''); }); }); }); diff --git a/spec/frontend/content_editor/components/bubble_menus/reference_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/reference_bubble_menu_spec.js index 169f77dc054..c46aa1b657e 100644 --- a/spec/frontend/content_editor/components/bubble_menus/reference_bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/reference_bubble_menu_spec.js @@ -241,7 +241,7 @@ describe('content_editor/components/bubble_menus/reference_bubble_menu', () => { await buildWrapperAndDisplayMenu(); await wrapper.findByTestId('remove-reference').trigger('click'); - expect(tiptapEditor.getHTML()).toBe('<p></p>'); + expect(tiptapEditor.getHTML()).toBe('<p dir="auto"></p>'); }); }); }); diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index 0b8321ba8eb..816c9458201 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -14,6 +14,7 @@ import FormattingToolbar from '~/content_editor/components/formatting_toolbar.vu import LoadingIndicator from '~/content_editor/components/loading_indicator.vue'; import waitForPromises from 'helpers/wait_for_promises'; import { KEYDOWN_EVENT } from '~/content_editor/constants'; +import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue'; jest.mock('~/emoji'); @@ -92,19 +93,6 @@ describe('ContentEditor', () => { expect(wrapper.findComponent(FormattingToolbar).exists()).toBe(true); }); - it('renders footer containing quick actions help text if quick actions docs path is defined', () => { - createWrapper({ quickActionsDocsPath: '/foo/bar' }); - - expect(wrapper.text()).toContain('For quick actions, type /'); - expect(wrapper.findComponent(GlLink).attributes('href')).toBe('/foo/bar'); - }); - - it('does not render footer containing quick actions help text if quick actions docs path is not defined', () => { - createWrapper(); - - expect(findEditorElement().text()).not.toContain('For quick actions, type /'); - }); - it('displays an attachment button', () => { createWrapper(); @@ -286,4 +274,10 @@ describe('ContentEditor', () => { expect(wrapper.findComponent(component).exists()).toBe(true); }); + + it('renders an editor mode dropdown', () => { + createWrapper(); + + expect(wrapper.findComponent(EditorModeSwitcher).exists()).toBe(true); + }); }); diff --git a/spec/frontend/content_editor/components/formatting_toolbar_spec.js b/spec/frontend/content_editor/components/formatting_toolbar_spec.js index 9d835381ff4..6562cb517cd 100644 --- a/spec/frontend/content_editor/components/formatting_toolbar_spec.js +++ b/spec/frontend/content_editor/components/formatting_toolbar_spec.js @@ -2,24 +2,31 @@ import { GlTabs, GlTab } from '@gitlab/ui'; import { mockTracking } from 'helpers/tracking_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import FormattingToolbar from '~/content_editor/components/formatting_toolbar.vue'; +import CommentTemplatesDropdown from '~/vue_shared/components/markdown/comment_templates_dropdown.vue'; import { TOOLBAR_CONTROL_TRACKING_ACTION, CONTENT_EDITOR_TRACKING_LABEL, } from '~/content_editor/constants'; -import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue'; +import { createTestEditor, mockChainedCommands } from '../test_utils'; describe('content_editor/components/formatting_toolbar', () => { let wrapper; let trackingSpy; - const buildWrapper = (props) => { + const contentEditor = { + codeSuggestionsConfig: { + canSuggest: true, + }, + }; + + const buildWrapper = ({ props = {}, provide = { contentEditor } } = {}) => { wrapper = shallowMountExtended(FormattingToolbar, { stubs: { GlTabs, GlTab, - EditorModeSwitcher, }, propsData: props, + provide, }); }; @@ -28,20 +35,22 @@ describe('content_editor/components/formatting_toolbar', () => { }); describe.each` - testId | controlProps - ${'text-styles'} | ${{}} - ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }} - ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }} - ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike' }} - ${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }} - ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }} - ${'link'} | ${{}} - ${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }} - ${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }} - ${'task-list'} | ${{ contentType: 'taskList', iconName: 'list-task', label: 'Add a checklist', editorCommand: 'toggleTaskList' }} - ${'attachment'} | ${{}} - ${'table'} | ${{}} - ${'more'} | ${{}} + testId | controlProps + ${'text-styles'} | ${{}} + ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold (Ctrl+B)', editorCommand: 'toggleBold' }} + ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic (Ctrl+I)', editorCommand: 'toggleItalic' }} + ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough (Ctrl+Shift+X)', editorCommand: 'toggleStrike' }} + ${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }} + ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }} + ${'link'} | ${{ contentType: 'link', iconName: 'link', label: 'Insert link (Ctrl+K)', editorCommand: 'editLink' }} + ${'link'} | ${{}} + ${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }} + ${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }} + ${'task-list'} | ${{ contentType: 'taskList', iconName: 'list-task', label: 'Add a checklist', editorCommand: 'toggleTaskList' }} + ${'code-suggestion'} | ${{ contentType: 'codeSuggestion', iconName: 'doc-code', label: 'Insert suggestion', editorCommand: 'insertCodeSuggestion' }} + ${'attachment'} | ${{}} + ${'table'} | ${{}} + ${'more'} | ${{}} `('given a $testId toolbar control', ({ testId, controlProps }) => { beforeEach(() => { buildWrapper(); @@ -69,17 +78,70 @@ describe('content_editor/components/formatting_toolbar', () => { }); }); - it('renders an editor mode dropdown', () => { - buildWrapper(); + describe('MacOS shortcuts', () => { + beforeEach(() => { + window.gl = { client: { isMac: true } }; + + buildWrapper(); + }); - expect(wrapper.findComponent(EditorModeSwitcher).exists()).toBe(true); + it.each` + testId | label + ${'bold'} | ${'Bold (⌘B)'} + ${'italic'} | ${'Italic (⌘I)'} + ${'strike'} | ${'Strikethrough (⌘⇧X)'} + ${'link'} | ${'Insert link (⌘K)'} + `('shows label $label for $testId', ({ testId, label }) => { + expect(wrapper.findByTestId(testId).props('label')).toBe(label); + }); }); describe('when attachment button is hidden', () => { it('does not show the attachment button', () => { - buildWrapper({ hideAttachmentButton: true }); + buildWrapper({ props: { hideAttachmentButton: true } }); expect(wrapper.findByTestId('attachment').exists()).toBe(false); }); }); + + describe('when selecting a saved reply from the comment templates dropdown', () => { + it('updates the rich text editor with the saved comment', async () => { + const tiptapEditor = createTestEditor(); + + buildWrapper({ + provide: { + tiptapEditor, + contentEditor, + newCommentTemplatePath: 'some/path', + }, + }); + + const commands = mockChainedCommands(tiptapEditor, ['focus', 'pasteContent', 'run']); + await wrapper + .findComponent(CommentTemplatesDropdown) + .vm.$emit('select', 'Some saved comment'); + + expect(commands.focus).toHaveBeenCalled(); + expect(commands.pasteContent).toHaveBeenCalledWith('Some saved comment'); + expect(commands.run).toHaveBeenCalled(); + }); + + it('does not show the saved replies icon if newCommentTemplatePath is not provided', () => { + buildWrapper(); + + expect(wrapper.findComponent(CommentTemplatesDropdown).exists()).toBe(false); + }); + }); + + it('hides code suggestions icon if the user cannot make suggestions', () => { + buildWrapper({ + provide: { + contentEditor: { + codeSuggestionsConfig: { canSuggest: false }, + }, + }, + }); + + expect(wrapper.findByTestId('code-suggestion').exists()).toBe(false); + }); }); diff --git a/spec/frontend/content_editor/components/suggestions_dropdown_spec.js b/spec/frontend/content_editor/components/suggestions_dropdown_spec.js index 9d34d9d0e9e..ee3ad59bf9a 100644 --- a/spec/frontend/content_editor/components/suggestions_dropdown_spec.js +++ b/spec/frontend/content_editor/components/suggestions_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlDropdownItem, GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui'; +import { GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import SuggestionsDropdown from '~/content_editor/components/suggestions_dropdown.vue'; @@ -113,7 +113,7 @@ describe('~/content_editor/components/suggestions_dropdown', () => { ${'emoji'} | ${'emoji'} | ${':'} | ${exampleEmoji} | ${`😃`} | ${insertedEmojiProps} `( 'runs a command to insert the selected $referenceType', - ({ char, nodeType, referenceType, reference, insertedText, insertedProps }) => { + async ({ char, nodeType, referenceType, reference, insertedText, insertedProps }) => { const commandSpy = jest.fn(); buildWrapper({ @@ -129,7 +129,10 @@ describe('~/content_editor/components/suggestions_dropdown', () => { }, }); - wrapper.findComponent(GlDropdownItem).vm.$emit('click'); + await wrapper + .findByTestId('content-editor-suggestions-dropdown') + .find('li .gl-new-dropdown-item-content') + .trigger('click'); expect(commandSpy).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/spec/frontend/content_editor/components/wrappers/code_block_spec.js b/spec/frontend/content_editor/components/wrappers/code_block_spec.js index cbeea90dcb4..e802681dfc6 100644 --- a/spec/frontend/content_editor/components/wrappers/code_block_spec.js +++ b/spec/frontend/content_editor/components/wrappers/code_block_spec.js @@ -6,11 +6,26 @@ import eventHubFactory from '~/helpers/event_hub_factory'; import SandboxedMermaid from '~/behaviors/components/sandboxed_mermaid.vue'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; import Diagram from '~/content_editor/extensions/diagram'; +import CodeSuggestion from '~/content_editor/extensions/code_suggestion'; import CodeBlockWrapper from '~/content_editor/components/wrappers/code_block.vue'; import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader'; -import { emitEditorEvent, createTestEditor } from '../../test_utils'; +import { emitEditorEvent, createTestEditor, mockChainedCommands } from '../../test_utils'; + +const SAMPLE_README_CONTENT = `# Sample README + +This is a sample README. + +## Usage + +\`\`\`yaml +foo: bar +\`\`\` +`; jest.mock('~/content_editor/services/code_block_language_loader'); +jest.mock('~/content_editor/services/utils', () => ({ + memoizedGet: jest.fn().mockResolvedValue(SAMPLE_README_CONTENT), +})); describe('content/components/wrappers/code_block', () => { const language = 'yaml'; @@ -21,7 +36,7 @@ describe('content/components/wrappers/code_block', () => { let eventHub; const buildEditor = () => { - tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight, Diagram] }); + tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight, Diagram, CodeSuggestion] }); contentEditor = { renderDiagram: jest.fn().mockResolvedValue('url/to/some/diagram') }; eventHub = eventHubFactory(); }; @@ -76,7 +91,7 @@ describe('content/components/wrappers/code_block', () => { it('renders label indicating that code block is frontmatter', () => { createWrapper({ isFrontmatter: true, language }); - const label = wrapper.find('[data-testid="frontmatter-label"]'); + const label = wrapper.findByTestId('frontmatter-label'); expect(label.text()).toEqual('frontmatter:yaml'); expect(label.classes()).toEqual(['gl-absolute', 'gl-top-0', 'gl-right-3']); @@ -143,4 +158,222 @@ describe('content/components/wrappers/code_block', () => { expect(wrapper.find('img').exists()).toBe(false); }); }); + + describe('code suggestions', () => { + const nodeAttrs = { language: 'suggestion', isCodeSuggestion: true, langParams: '-0+0' }; + const findCodeSuggestionBoxText = () => + wrapper.findByTestId('code-suggestion-box').text().replace(/\s+/gm, ' '); + const findCodeDeleted = () => + wrapper + .findByTestId('suggestion-deleted') + .findAll('code') + .wrappers.map((w) => w.html()) + .join('\n'); + const findCodeAdded = () => + wrapper + .findByTestId('suggestion-added') + .findAll('code') + .wrappers.map((w) => w.html()) + .join('\n'); + + let commands; + + const clickButton = async ({ button, expectedLangParams }) => { + await button.trigger('click'); + + expect(commands.updateAttributes).toHaveBeenCalledWith('codeSuggestion', { + langParams: expectedLangParams, + }); + expect(commands.run).toHaveBeenCalled(); + + await wrapper.setProps({ node: { attrs: { ...nodeAttrs, langParams: expectedLangParams } } }); + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + }; + + beforeEach(async () => { + contentEditor = { + codeSuggestionsConfig: { + canSuggest: true, + line: { new_line: 5 }, + lines: [{ new_line: 5 }], + showPopover: false, + diffFile: { + view_path: + '/gitlab-org/gitlab-test/-/blob/468abc807a2b2572f43e72c743b76cee6db24025/README.md', + }, + }, + }; + + commands = mockChainedCommands(tiptapEditor, ['updateAttributes', 'run']); + + createWrapper(nodeAttrs); + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + }); + + it('shows a code suggestion block', () => { + expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 5 to 5'); + expect(findCodeDeleted()).toMatchInlineSnapshot( + `"<code data-line-number=\\"5\\">## Usage\u200b</code>"`, + ); + expect(findCodeAdded()).toMatchInlineSnapshot( + `"<code data-line-number=\\"5\\">\u200b</code>"`, + ); + }); + + describe('decrement line start button', () => { + let button; + + beforeEach(() => { + button = wrapper.findByTestId('decrement-line-start'); + }); + + it('decrements the start line number', async () => { + await clickButton({ button, expectedLangParams: '-1+0' }); + + expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 4 to 5'); + expect(findCodeDeleted()).toMatchInlineSnapshot(` + "<code data-line-number=\\"4\\">\u200b + </code> + <code data-line-number=\\"5\\">## Usage\u200b</code>" + `); + }); + + it('is disabled if the start line is already 1', async () => { + expect(button.attributes('disabled')).toBeUndefined(); + + await clickButton({ button, expectedLangParams: '-1+0' }); + await clickButton({ button, expectedLangParams: '-2+0' }); + await clickButton({ button, expectedLangParams: '-3+0' }); + await clickButton({ button, expectedLangParams: '-4+0' }); + + expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 1 to 5'); + expect(findCodeDeleted()).toMatchInlineSnapshot(` + "<code data-line-number=\\"1\\"># Sample README\u200b + </code> + <code data-line-number=\\"2\\">\u200b + </code> + <code data-line-number=\\"3\\">This is a sample README.\u200b + </code> + <code data-line-number=\\"4\\">\u200b + </code> + <code data-line-number=\\"5\\">## Usage\u200b</code>" + `); + + expect(button.attributes('disabled')).toBe('disabled'); + }); + }); + + describe('increment line start button', () => { + let decrementButton; + let button; + + beforeEach(() => { + decrementButton = wrapper.findByTestId('decrement-line-start'); + button = wrapper.findByTestId('increment-line-start'); + }); + + it('is disabled if the start line is already the current line', async () => { + expect(button.attributes('disabled')).toBe('disabled'); + + // decrement once, increment once + await clickButton({ button: decrementButton, expectedLangParams: '-1+0' }); + expect(button.attributes('disabled')).toBeUndefined(); + await clickButton({ button, expectedLangParams: '-0+0' }); + + expect(button.attributes('disabled')).toBe('disabled'); + }); + + it('increments the start line number', async () => { + // decrement twice, increment once + await clickButton({ button: decrementButton, expectedLangParams: '-1+0' }); + await clickButton({ button: decrementButton, expectedLangParams: '-2+0' }); + await clickButton({ button, expectedLangParams: '-1+0' }); + + expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 4 to 5'); + expect(findCodeDeleted()).toMatchInlineSnapshot(` + "<code data-line-number=\\"4\\">\u200b + </code> + <code data-line-number=\\"5\\">## Usage\u200b</code>" + `); + }); + }); + + describe('decrement line end button', () => { + let incrementButton; + let button; + + beforeEach(() => { + incrementButton = wrapper.findByTestId('increment-line-end'); + button = wrapper.findByTestId('decrement-line-end'); + }); + + it('is disabled if the line end is already the current line', async () => { + expect(button.attributes('disabled')).toBe('disabled'); + + // increment once, decrement once + await clickButton({ button: incrementButton, expectedLangParams: '-0+1' }); + expect(button.attributes('disabled')).toBeUndefined(); + await clickButton({ button, expectedLangParams: '-0+0' }); + + expect(button.attributes('disabled')).toBe('disabled'); + }); + + it('increments the end line number', async () => { + // increment twice, decrement once + await clickButton({ button: incrementButton, expectedLangParams: '-0+1' }); + await clickButton({ button: incrementButton, expectedLangParams: '-0+2' }); + await clickButton({ button, expectedLangParams: '-0+1' }); + + expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 5 to 6'); + expect(findCodeDeleted()).toMatchInlineSnapshot(` + "<code data-line-number=\\"5\\">## Usage\u200b + </code> + <code data-line-number=\\"6\\">\u200b</code>" + `); + }); + }); + + describe('increment line end button', () => { + let button; + + beforeEach(() => { + button = wrapper.findByTestId('increment-line-end'); + }); + + it('decrements the start line number', async () => { + await clickButton({ button, expectedLangParams: '-0+1' }); + + expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 5 to 6'); + expect(findCodeDeleted()).toMatchInlineSnapshot(` + "<code data-line-number=\\"5\\">## Usage\u200b + </code> + <code data-line-number=\\"6\\">\u200b</code>" + `); + }); + + it('is disabled if the end line is EOF', async () => { + expect(button.attributes('disabled')).toBeUndefined(); + + await clickButton({ button, expectedLangParams: '-0+1' }); + await clickButton({ button, expectedLangParams: '-0+2' }); + await clickButton({ button, expectedLangParams: '-0+3' }); + await clickButton({ button, expectedLangParams: '-0+4' }); + + expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 5 to 9'); + expect(findCodeDeleted()).toMatchInlineSnapshot(` + "<code data-line-number=\\"5\\">## Usage\u200b + </code> + <code data-line-number=\\"6\\">\u200b + </code> + <code data-line-number=\\"7\\">\`\`\`yaml\u200b + </code> + <code data-line-number=\\"8\\">foo: bar\u200b + </code> + <code data-line-number=\\"9\\">\`\`\`\u200b</code>" + `); + + expect(button.attributes('disabled')).toBe('disabled'); + }); + }); + }); }); diff --git a/spec/frontend/content_editor/components/wrappers/image_spec.js b/spec/frontend/content_editor/components/wrappers/image_spec.js new file mode 100644 index 00000000000..0ac3b7e9465 --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/image_spec.js @@ -0,0 +1,100 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ImageWrapper from '~/content_editor/components/wrappers/image.vue'; +import { createTestEditor, mockChainedCommands } from '../../test_utils'; + +describe('content/components/wrappers/image_spec', () => { + let wrapper; + let tiptapEditor; + + const createWrapper = (node = {}) => { + tiptapEditor = createTestEditor(); + wrapper = shallowMountExtended(ImageWrapper, { + propsData: { + editor: tiptapEditor, + node, + getPos: jest.fn().mockReturnValue(12), + }, + }); + }; + + const findHandle = (handle) => wrapper.findByTestId(`image-resize-${handle}`); + const findImage = () => wrapper.find('img'); + + it('renders an image with the given attributes', () => { + createWrapper({ + type: 'image', + attrs: { src: 'image.png', alt: 'My Image', width: 200, height: 200 }, + }); + + expect(findImage().attributes()).toMatchObject({ + src: 'image.png', + alt: 'My Image', + height: '200', + width: '200', + }); + }); + + it('sets width and height to auto if not provided', () => { + createWrapper({ type: 'image', attrs: { src: 'image.png', alt: 'My Image' } }); + + expect(findImage().attributes()).toMatchObject({ + src: 'image.png', + alt: 'My Image', + height: 'auto', + width: 'auto', + }); + }); + + it('renders corner resize handles', () => { + createWrapper({ type: 'image', attrs: { src: 'image.png', alt: 'My Image' } }); + + expect(findHandle('nw').exists()).toBe(true); + expect(findHandle('ne').exists()).toBe(true); + expect(findHandle('sw').exists()).toBe(true); + expect(findHandle('se').exists()).toBe(true); + }); + + describe.each` + handle | htmlElementAttributes | tiptapNodeAttributes + ${'nw'} | ${{ width: '300', height: '75' }} | ${{ width: 300, height: 75 }} + ${'ne'} | ${{ width: '500', height: '125' }} | ${{ width: 500, height: 125 }} + ${'sw'} | ${{ width: '300', height: '75' }} | ${{ width: 300, height: 75 }} + ${'se'} | ${{ width: '500', height: '125' }} | ${{ width: 500, height: 125 }} + `('resizing using $handle', ({ handle, htmlElementAttributes, tiptapNodeAttributes }) => { + let handleEl; + + const initialMousePosition = { screenX: 200, screenY: 200 }; + const finalMousePosition = { screenX: 300, screenY: 300 }; + + beforeEach(() => { + createWrapper({ + type: 'image', + attrs: { src: 'image.png', alt: 'My Image', width: 400, height: 100 }, + }); + + handleEl = findHandle(handle); + handleEl.element.dispatchEvent(new MouseEvent('mousedown', initialMousePosition)); + document.dispatchEvent(new MouseEvent('mousemove', finalMousePosition)); + }); + + it('resizes the image properly on mousedown+mousemove', () => { + expect(findImage().attributes()).toMatchObject(htmlElementAttributes); + }); + + it('updates prosemirror doc state on mouse release with final size', () => { + const commands = mockChainedCommands(tiptapEditor, [ + 'focus', + 'updateAttributes', + 'setNodeSelection', + 'run', + ]); + + document.dispatchEvent(new MouseEvent('mouseup')); + + expect(commands.focus).toHaveBeenCalled(); + expect(commands.updateAttributes).toHaveBeenCalledWith('image', tiptapNodeAttributes); + expect(commands.setNodeSelection).toHaveBeenCalledWith(12); + expect(commands.run).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/wrappers/reference_spec.js b/spec/frontend/content_editor/components/wrappers/reference_spec.js index 828b92a6b1e..132e0e52ae5 100644 --- a/spec/frontend/content_editor/components/wrappers/reference_spec.js +++ b/spec/frontend/content_editor/components/wrappers/reference_spec.js @@ -1,4 +1,5 @@ import { GlLink } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ReferenceWrapper from '~/content_editor/components/wrappers/reference.vue'; @@ -8,6 +9,13 @@ describe('content/components/wrappers/reference', () => { const createWrapper = (node = {}) => { wrapper = shallowMountExtended(ReferenceWrapper, { propsData: { node }, + provide: { + contentEditor: { + resolveReference: jest.fn().mockResolvedValue({ + href: 'https://gitlab.com/gitlab-org/gitlab-test/-/issues/252522', + }), + }, + }, }); }; @@ -43,4 +51,14 @@ describe('content/components/wrappers/reference', () => { expect(link.text()).toBe('@root'); expect(link.classes('current-user')).toBe(true); }); + + it('renders the href of the reference correctly', async () => { + createWrapper({ attrs: { referenceType: 'issue', text: '#252522' } }); + await waitForPromises(); + + const link = wrapper.findComponent(GlLink); + expect(link.attributes('href')).toBe( + 'https://gitlab.com/gitlab-org/gitlab-test/-/issues/252522', + ); + }); }); diff --git a/spec/frontend/content_editor/extensions/code_suggestion_spec.js b/spec/frontend/content_editor/extensions/code_suggestion_spec.js new file mode 100644 index 00000000000..86656fb96c3 --- /dev/null +++ b/spec/frontend/content_editor/extensions/code_suggestion_spec.js @@ -0,0 +1,128 @@ +import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; +import CodeSuggestion from '~/content_editor/extensions/code_suggestion'; +import { + createTestEditor, + createDocBuilder, + triggerNodeInputRule, + expectDocumentAfterTransaction, + sleep, +} from '../test_utils'; + +const SAMPLE_README_CONTENT = `# Sample README + +This is a sample README. + +## Usage + +\`\`\`yaml +foo: bar +\`\`\` +`; + +jest.mock('~/content_editor/services/utils', () => ({ + memoizedGet: jest.fn().mockResolvedValue(SAMPLE_README_CONTENT), +})); + +describe('content_editor/extensions/code_suggestion', () => { + let tiptapEditor; + let doc; + let codeSuggestion; + + const codeSuggestionConfig = { + canSuggest: true, + line: { new_line: 5 }, + lines: [{ new_line: 5 }], + showPopover: false, + diffFile: { + view_path: + '/gitlab-org/gitlab-test/-/blob/468abc807a2b2572f43e72c743b76cee6db24025/README.md', + }, + }; + + const createEditor = (config = {}) => { + tiptapEditor = createTestEditor({ + extensions: [ + CodeBlockHighlight, + CodeSuggestion.configure({ config: { ...codeSuggestionConfig, ...config } }), + ], + }); + + ({ + builders: { doc, codeSuggestion }, + } = createDocBuilder({ + tiptapEditor, + names: { + codeBlock: { nodeType: CodeBlockHighlight.name }, + codeSuggestion: { nodeType: CodeSuggestion.name }, + }, + })); + }; + + describe('insertCodeSuggestion command', () => { + it('creates a correct suggestion for a single line selection', async () => { + createEditor({ line: { new_line: 5 }, lines: [] }); + + await expectDocumentAfterTransaction({ + number: 1, + tiptapEditor, + action: () => tiptapEditor.commands.insertCodeSuggestion(), + expectedDoc: doc(codeSuggestion({ langParams: '-0+0' }, '## Usage')), + }); + }); + + it('creates a correct suggestion for a multi-line selection', async () => { + createEditor({ + line: { new_line: 9 }, + lines: [ + { new_line: 5 }, + { new_line: 6 }, + { new_line: 7 }, + { new_line: 8 }, + { new_line: 9 }, + ], + }); + + await expectDocumentAfterTransaction({ + number: 1, + tiptapEditor, + action: () => tiptapEditor.commands.insertCodeSuggestion(), + expectedDoc: doc( + codeSuggestion({ langParams: '-4+0' }, '## Usage\n\n```yaml\nfoo: bar\n```'), + ), + }); + }); + + it('does not insert a new suggestion if already inside a suggestion', async () => { + const initialDoc = codeSuggestion({ langParams: '-0+0' }, '## Usage'); + + createEditor({ line: { new_line: 5 }, lines: [] }); + + tiptapEditor.commands.setContent(doc(initialDoc).toJSON()); + + jest.spyOn(tiptapEditor, 'isActive').mockReturnValue(true); + + tiptapEditor.commands.insertCodeSuggestion(); + // wait some time to be sure no other transaction happened + await sleep(); + + expect(tiptapEditor.getJSON()).toEqual(doc(initialDoc).toJSON()); + }); + }); + + describe('when typing ```suggestion input rule', () => { + beforeEach(() => { + createEditor(); + + triggerNodeInputRule({ + tiptapEditor, + inputRuleText: '```suggestion ', + }); + }); + + it('creates a new code suggestion block with lines -0+0', () => { + const expectedDoc = doc(codeSuggestion({ language: 'suggestion', langParams: '-0+0' })); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); +}); diff --git a/spec/frontend/content_editor/extensions/comment_spec.js b/spec/frontend/content_editor/extensions/comment_spec.js deleted file mode 100644 index 7d8ff28e4d7..00000000000 --- a/spec/frontend/content_editor/extensions/comment_spec.js +++ /dev/null @@ -1,30 +0,0 @@ -import Comment from '~/content_editor/extensions/comment'; -import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; - -describe('content_editor/extensions/comment', () => { - let tiptapEditor; - let doc; - let comment; - - beforeEach(() => { - tiptapEditor = createTestEditor({ extensions: [Comment] }); - ({ - builders: { doc, comment }, - } = createDocBuilder({ - tiptapEditor, - names: { - comment: { nodeType: Comment.name }, - }, - })); - }); - - describe('when typing the comment input rule', () => { - it('inserts a comment node', () => { - const expectedDoc = doc(comment()); - - triggerNodeInputRule({ tiptapEditor, inputRuleText: '<!-- ' }); - - expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); - }); - }); -}); diff --git a/spec/frontend/content_editor/extensions/paste_markdown_spec.js b/spec/frontend/content_editor/extensions/copy_paste_spec.js index baf0919fec8..f8faa7869c0 100644 --- a/spec/frontend/content_editor/extensions/paste_markdown_spec.js +++ b/spec/frontend/content_editor/extensions/copy_paste_spec.js @@ -1,5 +1,6 @@ -import PasteMarkdown from '~/content_editor/extensions/paste_markdown'; +import CopyPaste from '~/content_editor/extensions/copy_paste'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; +import Loading from '~/content_editor/extensions/loading'; import Diagram from '~/content_editor/extensions/diagram'; import Frontmatter from '~/content_editor/extensions/frontmatter'; import Heading from '~/content_editor/extensions/heading'; @@ -10,29 +11,48 @@ import eventHubFactory from '~/helpers/event_hub_factory'; import { ALERT_EVENT } from '~/content_editor/constants'; import waitForPromises from 'helpers/wait_for_promises'; import MarkdownSerializer from '~/content_editor/services/markdown_serializer'; -import { createTestEditor, createDocBuilder, waitUntilNextDocTransaction } from '../test_utils'; +import { + createTestEditor, + createDocBuilder, + waitUntilNextDocTransaction, + sleep, +} from '../test_utils'; const CODE_BLOCK_HTML = '<pre class="js-syntax-highlight" lang="javascript">var a = 2;</pre>'; +const CODE_SUGGESTION_HTML = + '<pre data-lang-params="-0+0" class="js-syntax-highlight language-suggestion" lang="suggestion">Suggested code</pre>'; const DIAGRAM_HTML = '<img data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,WzxmcmFtZT5EZWNvcmF0b3IgcGF0dGVybl0=">'; const FRONTMATTER_HTML = '<pre lang="yaml" data-lang-params="frontmatter">key: value</pre>'; -const PARAGRAPH_HTML = '<p>Some text with <strong>bold</strong> and <em>italic</em> text.</p>'; +const PARAGRAPH_HTML = + '<p dir="auto">Some text with <strong>bold</strong> and <em>italic</em> text.</p>'; -describe('content_editor/extensions/paste_markdown', () => { +describe('content_editor/extensions/copy_paste', () => { let tiptapEditor; let doc; let p; let bold; let italic; + let loading; let heading; let codeBlock; let renderMarkdown; + let resolveRenderMarkdownPromise; + let resolveRenderMarkdownPromiseAndWait; + let eventHub; const defaultData = { 'text/plain': '**bold text**' }; beforeEach(() => { - renderMarkdown = jest.fn(); eventHub = eventHubFactory(); + renderMarkdown = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveRenderMarkdownPromise = resolve; + resolveRenderMarkdownPromiseAndWait = (data) => + waitUntilNextDocTransaction({ tiptapEditor, action: () => resolve(data) }); + }), + ); jest.spyOn(eventHub, '$emit'); @@ -40,21 +60,23 @@ describe('content_editor/extensions/paste_markdown', () => { extensions: [ Bold, Italic, + Loading, CodeBlockHighlight, Diagram, Frontmatter, Heading, - PasteMarkdown.configure({ renderMarkdown, eventHub, serializer: new MarkdownSerializer() }), + CopyPaste.configure({ renderMarkdown, eventHub, serializer: new MarkdownSerializer() }), ], }); ({ - builders: { doc, p, bold, italic, heading, codeBlock }, + builders: { doc, p, bold, italic, heading, loading, codeBlock }, } = createDocBuilder({ tiptapEditor, names: { bold: { markType: Bold.name }, italic: { markType: Italic.name }, + loading: { nodeType: Loading.name }, heading: { nodeType: Heading.name }, codeBlock: { nodeType: CodeBlockHighlight.name }, }, @@ -102,11 +124,12 @@ describe('content_editor/extensions/paste_markdown', () => { }); it.each` - nodeType | html | handled | desc - ${'codeBlock'} | ${CODE_BLOCK_HTML} | ${false} | ${'does not handle'} - ${'diagram'} | ${DIAGRAM_HTML} | ${false} | ${'does not handle'} - ${'frontmatter'} | ${FRONTMATTER_HTML} | ${false} | ${'does not handle'} - ${'paragraph'} | ${PARAGRAPH_HTML} | ${true} | ${'handles'} + nodeType | html | handled | desc + ${'codeBlock'} | ${CODE_BLOCK_HTML} | ${false} | ${'does not handle'} + ${'codeSuggestion'} | ${CODE_SUGGESTION_HTML} | ${false} | ${'does not handle'} + ${'diagram'} | ${DIAGRAM_HTML} | ${false} | ${'does not handle'} + ${'frontmatter'} | ${FRONTMATTER_HTML} | ${false} | ${'does not handle'} + ${'paragraph'} | ${PARAGRAPH_HTML} | ${true} | ${'handles'} `('$desc paste if currently a `$nodeType` is in focus', async ({ html, handled }) => { tiptapEditor.commands.insertContent(html); @@ -153,15 +176,51 @@ describe('content_editor/extensions/paste_markdown', () => { }); describe('when pasting raw markdown source', () => { + it('shows a loading indicator while markdown is being processed', async () => { + const expectedDoc = doc(p(loading({ id: expect.any(String) }))); + + await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent()); + + expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); + }); + + it('pastes in the correct position if some content is added before the markdown is processed', async () => { + const expectedDoc = doc(p(bold('some markdown'), 'some content')); + const resolvedValue = '<strong>some markdown</strong>'; + + await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent()); + + tiptapEditor.commands.insertContent('some content'); + await resolveRenderMarkdownPromiseAndWait(resolvedValue); + + expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); + expect(tiptapEditor.state.selection.from).toEqual(26); // end of the document + }); + + it('does not paste anything if the loading indicator is deleted before the markdown is processed', async () => { + const expectedDoc = doc(p()); + + await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent()); + tiptapEditor.chain().selectAll().deleteSelection().run(); + resolveRenderMarkdownPromise('some markdown'); + + // wait some time to be sure no transaction happened + await sleep(); + expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); + }); + describe('when rendering markdown succeeds', () => { + let resolvedValue; + beforeEach(() => { - renderMarkdown.mockResolvedValueOnce('<strong>bold text</strong>'); + resolvedValue = '<strong>bold text</strong>'; }); it('transforms pasted text into a prosemirror node', async () => { const expectedDoc = doc(p(bold('bold text'))); await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent()); + await resolveRenderMarkdownPromiseAndWait(resolvedValue); expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); }); @@ -173,6 +232,7 @@ describe('content_editor/extensions/paste_markdown', () => { tiptapEditor.commands.setContent('Initial text and '); await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent()); + await resolveRenderMarkdownPromiseAndWait(resolvedValue); expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); }); @@ -186,6 +246,7 @@ describe('content_editor/extensions/paste_markdown', () => { tiptapEditor.commands.setTextSelection({ from: 13, to: 17 }); await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent()); + await resolveRenderMarkdownPromiseAndWait(resolvedValue); expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); }); @@ -193,8 +254,7 @@ describe('content_editor/extensions/paste_markdown', () => { describe('when pasting block content in an existing paragraph', () => { beforeEach(() => { - renderMarkdown.mockReset(); - renderMarkdown.mockResolvedValueOnce('<h1>Heading</h1><p><strong>bold text</strong></p>'); + resolvedValue = '<h1>Heading</h1><p><strong>bold text</strong></p>'; }); it('inserts the block content after the existing paragraph', async () => { @@ -207,6 +267,7 @@ describe('content_editor/extensions/paste_markdown', () => { tiptapEditor.commands.setContent('Initial text and '); await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent()); + await resolveRenderMarkdownPromiseAndWait(resolvedValue); expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); }); @@ -215,9 +276,8 @@ describe('content_editor/extensions/paste_markdown', () => { describe('when pasting html content', () => { it('strips out any stray div, pre, span tags', async () => { - renderMarkdown.mockResolvedValueOnce( - '<div><span dir="auto"><strong>bold text</strong></span></div><pre><code>some code</code></pre>', - ); + const resolvedValue = + '<div><span dir="auto"><strong>bold text</strong></span></div><pre><code>some code</code></pre>'; const expectedDoc = doc(p(bold('bold text')), p('some code')); @@ -230,6 +290,7 @@ describe('content_editor/extensions/paste_markdown', () => { }, }), ); + await resolveRenderMarkdownPromiseAndWait(resolvedValue); expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); }); @@ -237,8 +298,7 @@ describe('content_editor/extensions/paste_markdown', () => { describe('when pasting text/x-gfm', () => { it('processes the content as markdown, even if html content exists', async () => { - renderMarkdown.mockResolvedValueOnce('<strong>bold text</strong>'); - + const resolvedValue = '<strong>bold text</strong>'; const expectedDoc = doc(p(bold('bold text'))); await triggerPasteEventHandlerAndWaitForTransaction( @@ -251,6 +311,7 @@ describe('content_editor/extensions/paste_markdown', () => { }, }), ); + await resolveRenderMarkdownPromiseAndWait(resolvedValue); expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); }); @@ -258,9 +319,8 @@ describe('content_editor/extensions/paste_markdown', () => { describe('when pasting vscode-editor-data', () => { it('pastes the content as a code block', async () => { - renderMarkdown.mockResolvedValueOnce( - '<div class="gl-relative markdown-code-block js-markdown-code">
<pre data-sourcepos="1:1-3:3" data-canonical-lang="ruby" class="code highlight js-syntax-highlight language-ruby" lang="ruby" v-pre="true"><code><span id="LC1" class="line" lang="ruby"><span class="nb">puts</span> <span class="s2">"Hello World"</span></span></code></pre>
<copy-code></copy-code>
</div>', - ); + const resolvedValue = + '<div class="gl-relative markdown-code-block js-markdown-code">
<pre data-sourcepos="1:1-3:3" data-canonical-lang="ruby" class="code highlight js-syntax-highlight language-ruby" lang="ruby" v-pre="true"><code><span id="LC1" class="line" lang="ruby"><span class="nb">puts</span> <span class="s2">"Hello World"</span></span></code></pre>
<copy-code></copy-code>
</div>'; const expectedDoc = doc( codeBlock( @@ -280,12 +340,13 @@ describe('content_editor/extensions/paste_markdown', () => { }, }), ); + await resolveRenderMarkdownPromiseAndWait(resolvedValue); expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); }); it('pastes as regular markdown if language is markdown', async () => { - renderMarkdown.mockResolvedValueOnce('<p><strong>bold text</strong></p>'); + const resolvedValue = '<p><strong>bold text</strong></p>'; const expectedDoc = doc(p(bold('bold text'))); @@ -299,6 +360,7 @@ describe('content_editor/extensions/paste_markdown', () => { }, }), ); + await resolveRenderMarkdownPromiseAndWait(resolvedValue); expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); }); diff --git a/spec/frontend/content_editor/extensions/hard_break_spec.js b/spec/frontend/content_editor/extensions/hard_break_spec.js index 9e2e28b6e72..6a57e7eaa9b 100644 --- a/spec/frontend/content_editor/extensions/hard_break_spec.js +++ b/spec/frontend/content_editor/extensions/hard_break_spec.js @@ -3,35 +3,21 @@ import { createTestEditor, createDocBuilder } from '../test_utils'; describe('content_editor/extensions/hard_break', () => { let tiptapEditor; - let eq; + let doc; let p; - let hardBreak; beforeEach(() => { tiptapEditor = createTestEditor({ extensions: [HardBreak] }); ({ - builders: { doc, p, hardBreak }, - eq, + builders: { doc, p }, } = createDocBuilder({ tiptapEditor, names: { hardBreak: { nodeType: HardBreak.name } }, })); }); - describe('Shift-Enter shortcut', () => { - it('inserts a hard break when shortcut is executed', () => { - const initialDoc = doc(p('')); - const expectedDoc = doc(p(hardBreak())); - - tiptapEditor.commands.setContent(initialDoc.toJSON()); - tiptapEditor.commands.keyboardShortcut('Shift-Enter'); - - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); - }); - }); - describe('Mod-Enter shortcut', () => { it('does not insert a hard break when shortcut is executed', () => { const initialDoc = doc(p('')); @@ -40,7 +26,7 @@ describe('content_editor/extensions/hard_break', () => { tiptapEditor.commands.setContent(initialDoc.toJSON()); tiptapEditor.commands.keyboardShortcut('Mod-Enter'); - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); }); }); }); diff --git a/spec/frontend/content_editor/extensions/html_nodes_spec.js b/spec/frontend/content_editor/extensions/html_nodes_spec.js index 24c68239025..3fe496aa708 100644 --- a/spec/frontend/content_editor/extensions/html_nodes_spec.js +++ b/spec/frontend/content_editor/extensions/html_nodes_spec.js @@ -28,9 +28,9 @@ describe('content_editor/extensions/html_nodes', () => { }); it.each` - input | insertedNodes - ${'<div><p>foo</p></div>'} | ${() => div(p('foo'))} - ${'<pre><p>foo</p></pre>'} | ${() => pre(p('foo'))} + input | insertedNodes + ${'<div><p dir="auto">foo</p></div>'} | ${() => div(p('foo'))} + ${'<pre><p dir="auto">foo</p></pre>'} | ${() => pre(p('foo'))} `('parses and creates nodes for $input', ({ input, insertedNodes }) => { const expectedDoc = doc(insertedNodes()); diff --git a/spec/frontend/content_editor/extensions/image_spec.js b/spec/frontend/content_editor/extensions/image_spec.js index f73b0143fd9..69f4f4c6d65 100644 --- a/spec/frontend/content_editor/extensions/image_spec.js +++ b/spec/frontend/content_editor/extensions/image_spec.js @@ -35,7 +35,7 @@ describe('content_editor/extensions/image', () => { tiptapEditor.commands.setContent(initialDoc.toJSON()); expect(tiptapEditor.getHTML()).toEqual( - '<p><img src="/-/wikis/uploads/image.jpg" alt="image" title="this is an image"></p>', + '<p dir="auto"><img src="/-/wikis/uploads/image.jpg" alt="image" title="this is an image"></p>', ); }); }); diff --git a/spec/frontend/content_editor/extensions/paragraph_spec.js b/spec/frontend/content_editor/extensions/paragraph_spec.js new file mode 100644 index 00000000000..d04dda1871d --- /dev/null +++ b/spec/frontend/content_editor/extensions/paragraph_spec.js @@ -0,0 +1,29 @@ +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/paragraph', () => { + let tiptapEditor; + let doc; + let p; + + beforeEach(() => { + tiptapEditor = createTestEditor(); + + ({ + builders: { doc, p }, + } = createDocBuilder({ tiptapEditor })); + }); + + describe('Shift-Enter shortcut', () => { + it('inserts a new paragraph when shortcut is executed', async () => { + const initialDoc = doc(p('hello')); + const expectedDoc = doc(p('hello'), p('')); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.keyboardShortcut('Shift-Enter'); + + await Promise.resolve(); + + expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); + }); + }); +}); diff --git a/spec/frontend/content_editor/remark_markdown_processing_spec.js b/spec/frontend/content_editor/remark_markdown_processing_spec.js index 927a7d59899..3d4d5b13120 100644 --- a/spec/frontend/content_editor/remark_markdown_processing_spec.js +++ b/spec/frontend/content_editor/remark_markdown_processing_spec.js @@ -1337,13 +1337,13 @@ content alert("Hello world") </script> `, - expectedHtml: '<p></p>', + expectedHtml: '<p dir="auto"></p>', }, { markdown: ` <foo>Hello</foo> `, - expectedHtml: '<p></p>', + expectedHtml: '<p dir="auto"></p>', }, { markdown: ` @@ -1356,7 +1356,7 @@ alert("Hello world") <a id="link-id">Header</a> and other text `, expectedHtml: - '<p><a target="_blank" rel="noopener noreferrer nofollow">Header</a> and other text</p>', + '<p dir="auto"><a target="_blank" rel="noopener noreferrer nofollow">Header</a> and other text</p>', }, { markdown: ` @@ -1366,11 +1366,11 @@ body { } </style> `, - expectedHtml: '<p></p>', + expectedHtml: '<p dir="auto"></p>', }, { markdown: '<div style="transform">div</div>', - expectedHtml: '<div><p>div</p></div>', + expectedHtml: '<div><p dir="auto">div</p></div>', }, ])( 'removes unknown tags and unsupported attributes from HTML output', @@ -1421,6 +1421,7 @@ body { }; }; + // NOTE: unicode \u001 and \u003 cannot be used in test names because they cause test report XML parsing errors it.each` desc | urlInput | urlOutput ${'protocol-based JS injection: simple, no spaces'} | ${protocolBasedInjectionSimpleNoSpaces} | ${null} @@ -1439,7 +1440,7 @@ body { ${'protocol-based JS injection: preceding colon'} | ${":javascript:alert('XSS');"} | ${":javascript:alert('XSS');"} ${'protocol-based JS injection: null char'} | ${"java\0script:alert('XSS')"} | ${"java�script:alert('XSS')"} ${'protocol-based JS injection: invalid URL char'} | ${"java\\script:alert('XSS')"} | ${"java\\script:alert('XSS')"} - `('sanitize $desc:\n\tURL "$urlInput" becomes "$urlOutput"', ({ urlInput, urlOutput }) => { + `('sanitize $desc becomes "$urlOutput"', ({ urlInput, urlOutput }) => { const exampleFactories = [docWithImageFactory, docWithLinkFactory]; exampleFactories.forEach(async (exampleFactory) => { diff --git a/spec/frontend/content_editor/services/code_suggestion_utils_spec.js b/spec/frontend/content_editor/services/code_suggestion_utils_spec.js new file mode 100644 index 00000000000..f26d33adf4c --- /dev/null +++ b/spec/frontend/content_editor/services/code_suggestion_utils_spec.js @@ -0,0 +1,53 @@ +import { + lineOffsetToLangParams, + langParamsToLineOffset, + toAbsoluteLineOffset, + getLines, + appendNewlines, +} from '~/content_editor/services/code_suggestion_utils'; + +describe('content_editor/services/code_suggestion_utils', () => { + describe('lineOffsetToLangParams', () => { + it.each` + lineOffset | expected + ${[0, 0]} | ${'-0+0'} + ${[0, 2]} | ${'-0+2'} + ${[1, 1]} | ${'+1+1'} + ${[-1, 1]} | ${'-1+1'} + `('converts line offset $lineOffset to lang params $expected', ({ lineOffset, expected }) => { + expect(lineOffsetToLangParams(lineOffset)).toBe(expected); + }); + }); + + describe('langParamsToLineOffset', () => { + it.each` + langParams | expected + ${'-0+0'} | ${[-0, 0]} + ${'-0+2'} | ${[-0, 2]} + ${'+1+1'} | ${[1, 1]} + ${'-1+1'} | ${[-1, 1]} + `('converts lang params $langParams to line offset $expected', ({ langParams, expected }) => { + expect(langParamsToLineOffset(langParams)).toEqual(expected); + }); + }); + + describe('toAbsoluteLineOffset', () => { + it('adds line number to line offset', () => { + expect(toAbsoluteLineOffset([-2, 3], 72)).toEqual([70, 75]); + }); + }); + + describe('getLines', () => { + it('returns lines from allLines', () => { + const allLines = ['foo', 'bar', 'baz', 'qux', 'quux']; + expect(getLines([2, 4], allLines)).toEqual(['bar', 'baz', 'qux']); + }); + }); + + describe('appendNewlines', () => { + it('appends zero-width space to each line', () => { + const lines = ['foo', 'bar', 'baz']; + expect(appendNewlines(lines)).toEqual(['foo\u200b\n', 'bar\u200b\n', 'baz\u200b']); + }); + }); +}); diff --git a/spec/frontend/content_editor/services/create_content_editor_spec.js b/spec/frontend/content_editor/services/create_content_editor_spec.js index b9a9c3ccd17..b68d57971b9 100644 --- a/spec/frontend/content_editor/services/create_content_editor_spec.js +++ b/spec/frontend/content_editor/services/create_content_editor_spec.js @@ -46,14 +46,6 @@ describe('content_editor/services/create_content_editor', () => { }); }); - it('sets gl-shadow-none! class selector to the tiptapEditor instance', () => { - expect(editor.tiptapEditor.options.editorProps).toMatchObject({ - attributes: { - class: 'gl-shadow-none!', - }, - }); - }); - it('allows providing external content editor extensions', () => { const labelReference = 'this is a ~group::editor'; const { tiptapExtension, serializer } = createTestContentEditorExtension(); diff --git a/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js b/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js index a9960918e62..1f7b56ef762 100644 --- a/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js +++ b/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js @@ -1,6 +1,5 @@ import createMarkdownDeserializer from '~/content_editor/services/gl_api_markdown_deserializer'; import Bold from '~/content_editor/extensions/bold'; -import Comment from '~/content_editor/extensions/comment'; import { createTestEditor, createDocBuilder } from '../test_utils'; describe('content_editor/services/gl_api_markdown_deserializer', () => { @@ -8,21 +7,19 @@ describe('content_editor/services/gl_api_markdown_deserializer', () => { let doc; let p; let bold; - let comment; let tiptapEditor; beforeEach(() => { tiptapEditor = createTestEditor({ - extensions: [Bold, Comment], + extensions: [Bold], }); ({ - builders: { doc, p, bold, comment }, + builders: { doc, p, bold }, } = createDocBuilder({ tiptapEditor, names: { bold: { markType: Bold.name }, - comment: { nodeType: Comment.name }, }, })); renderMarkdown = jest.fn(); @@ -35,16 +32,16 @@ describe('content_editor/services/gl_api_markdown_deserializer', () => { beforeEach(async () => { const deserializer = createMarkdownDeserializer({ render: renderMarkdown }); - renderMarkdown.mockResolvedValueOnce(`<p><strong>${text}</strong></p><!-- some comment -->`); + renderMarkdown.mockResolvedValueOnce(`<p><strong>${text}</strong></p>`); result = await deserializer.deserialize({ - markdown: '**Bold text**\n<!-- some comment -->', + markdown: '**Bold text**', schema: tiptapEditor.schema, }); }); it('transforms HTML returned by render function to a ProseMirror document', () => { - const document = doc(p(bold(text)), comment(' some comment ')); + const document = doc(p(bold(text))); expect(result.document.toJSON()).toEqual(document.toJSON()); }); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 4521822042c..7be8114902a 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -3,7 +3,6 @@ import Bold from '~/content_editor/extensions/bold'; import BulletList from '~/content_editor/extensions/bullet_list'; import Code from '~/content_editor/extensions/code'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; -import Comment from '~/content_editor/extensions/comment'; import DescriptionItem from '~/content_editor/extensions/description_item'; import DescriptionList from '~/content_editor/extensions/description_list'; import Details from '~/content_editor/extensions/details'; @@ -56,7 +55,6 @@ const { bulletList, code, codeBlock, - comment, details, detailsContent, div, @@ -99,7 +97,6 @@ const { bulletList: { nodeType: BulletList.name }, code: { markType: Code.name }, codeBlock: { nodeType: CodeBlockHighlight.name }, - comment: { nodeType: Comment.name }, details: { nodeType: Details.name }, detailsContent: { nodeType: DetailsContent.name }, descriptionItem: { nodeType: DescriptionItem.name }, @@ -187,30 +184,6 @@ describe('markdownSerializer', () => { ); }); - it('correctly serializes a comment node', () => { - expect(serialize(paragraph('hi'), comment(' this is a\ncomment '))).toBe( - ` -hi - -<!-- this is a -comment --> - `.trim(), - ); - }); - - it('correctly renders a comment with markdown in it without adding any slashes', () => { - expect(serialize(paragraph('hi'), comment('this is a list\n- a\n- b\n- c'))).toBe( - ` -hi - -<!--this is a list -- a -- b -- c--> - `.trim(), - ); - }); - it('escapes < and > in a paragraph', () => { expect( serialize(paragraph(text("some prose: <this> and </this> looks like code, but isn't"))), diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js index 2184a829cf0..f1c9fd47eb7 100644 --- a/spec/frontend/content_editor/test_utils.js +++ b/spec/frontend/content_editor/test_utils.js @@ -1,7 +1,4 @@ import { Node } from '@tiptap/core'; -import { Document } from '@tiptap/extension-document'; -import { Paragraph } from '@tiptap/extension-paragraph'; -import { Text } from '@tiptap/extension-text'; import { Editor } from '@tiptap/vue-2'; import { builders, eq } from 'prosemirror-test-builder'; import { nextTick } from 'vue'; @@ -12,12 +9,12 @@ import Bold from '~/content_editor/extensions/bold'; import BulletList from '~/content_editor/extensions/bullet_list'; import Code from '~/content_editor/extensions/code'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; -import Comment from '~/content_editor/extensions/comment'; import DescriptionItem from '~/content_editor/extensions/description_item'; import DescriptionList from '~/content_editor/extensions/description_list'; import Details from '~/content_editor/extensions/details'; import DetailsContent from '~/content_editor/extensions/details_content'; import Diagram from '~/content_editor/extensions/diagram'; +import Document from '~/content_editor/extensions/document'; import DrawioDiagram from '~/content_editor/extensions/drawio_diagram'; import Emoji from '~/content_editor/extensions/emoji'; import FootnoteDefinition from '~/content_editor/extensions/footnote_definition'; @@ -36,6 +33,7 @@ import Italic from '~/content_editor/extensions/italic'; import Link from '~/content_editor/extensions/link'; import ListItem from '~/content_editor/extensions/list_item'; import OrderedList from '~/content_editor/extensions/ordered_list'; +import Paragraph from '~/content_editor/extensions/paragraph'; import ReferenceDefinition from '~/content_editor/extensions/reference_definition'; import Reference from '~/content_editor/extensions/reference'; import ReferenceLabel from '~/content_editor/extensions/reference_label'; @@ -47,10 +45,13 @@ import TableRow from '~/content_editor/extensions/table_row'; import TableOfContents from '~/content_editor/extensions/table_of_contents'; import TaskItem from '~/content_editor/extensions/task_item'; import TaskList from '~/content_editor/extensions/task_list'; +import Text from '~/content_editor/extensions/text'; import Video from '~/content_editor/extensions/video'; import HTMLMarks from '~/content_editor/extensions/html_marks'; import HTMLNodes from '~/content_editor/extensions/html_nodes'; +export const DEFAULT_WAIT_TIMEOUT = 100; + export const createDocBuilder = ({ tiptapEditor, names = {} }) => { const docBuilders = builders(tiptapEditor.schema, { p: { nodeType: 'paragraph' }, @@ -239,6 +240,16 @@ export const waitUntilTransaction = ({ tiptapEditor, number, action }) => { }); }; +export const sleep = (time = DEFAULT_WAIT_TIMEOUT) => { + jest.useRealTimers(); + const promise = new Promise((resolve) => { + setTimeout(resolve, time); + }); + jest.useFakeTimers(); + + return promise; +}; + export const expectDocumentAfterTransaction = ({ tiptapEditor, number, expectedDoc, action }) => { return new Promise((resolve) => { let counter = 0; diff --git a/spec/frontend/contribution_events/components/contribution_event/contribution_event_approved_spec.js b/spec/frontend/contribution_events/components/contribution_event/contribution_event_approved_spec.js index 6672d3eb18b..5bce0ca3746 100644 --- a/spec/frontend/contribution_events/components/contribution_event/contribution_event_approved_spec.js +++ b/spec/frontend/contribution_events/components/contribution_event/contribution_event_approved_spec.js @@ -1,21 +1,18 @@ -import events from 'test_fixtures/controller/users/activity.json'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import { EVENT_TYPE_APPROVED } from '~/contribution_events/constants'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ContributionEventApproved from '~/contribution_events/components/contribution_event/contribution_event_approved.vue'; import ContributionEventBase from '~/contribution_events/components/contribution_event/contribution_event_base.vue'; -import TargetLink from '~/contribution_events/components/target_link.vue'; -import ResourceParentLink from '~/contribution_events/components/resource_parent_link.vue'; +import { eventApproved } from '../../utils'; -const eventApproved = events.find((event) => event.action === EVENT_TYPE_APPROVED); +const defaultPropsData = { + event: eventApproved(), +}; describe('ContributionEventApproved', () => { let wrapper; const createComponent = () => { - wrapper = mountExtended(ContributionEventApproved, { - propsData: { - event: eventApproved, - }, + wrapper = shallowMountExtended(ContributionEventApproved, { + propsData: defaultPropsData, }); }; @@ -25,23 +22,10 @@ describe('ContributionEventApproved', () => { it('renders `ContributionEventBase`', () => { expect(wrapper.findComponent(ContributionEventBase).props()).toEqual({ - event: eventApproved, + event: defaultPropsData.event, iconName: 'approval-solid', iconClass: 'gl-text-green-500', + message: ContributionEventApproved.i18n.message, }); }); - - it('renders message', () => { - expect(wrapper.findByTestId('event-body').text()).toBe( - `Approved merge request ${eventApproved.target.reference_link_text} in ${eventApproved.resource_parent.full_name}.`, - ); - }); - - it('renders target link', () => { - expect(wrapper.findComponent(TargetLink).props('event')).toEqual(eventApproved); - }); - - it('renders resource parent link', () => { - expect(wrapper.findComponent(ResourceParentLink).props('event')).toEqual(eventApproved); - }); }); diff --git a/spec/frontend/contribution_events/components/contribution_event/contribution_event_base_spec.js b/spec/frontend/contribution_events/components/contribution_event/contribution_event_base_spec.js index 8c951e20bed..310966243d1 100644 --- a/spec/frontend/contribution_events/components/contribution_event/contribution_event_base_spec.js +++ b/spec/frontend/contribution_events/components/contribution_event/contribution_event_base_spec.js @@ -1,23 +1,27 @@ import { GlAvatarLabeled, GlAvatarLink, GlIcon } from '@gitlab/ui'; -import events from 'test_fixtures/controller/users/activity.json'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import ContributionEventBase from '~/contribution_events/components/contribution_event/contribution_event_base.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; - -const [event] = events; +import TargetLink from '~/contribution_events/components/target_link.vue'; +import ResourceParentLink from '~/contribution_events/components/resource_parent_link.vue'; +import { eventApproved } from '../../utils'; describe('ContributionEventBase', () => { let wrapper; const defaultPropsData = { - event, + event: eventApproved(), iconName: 'approval-solid', iconClass: 'gl-text-green-500', + message: 'Approved merge request %{targetLink} in %{resourceParentLink}.', }; - const createComponent = () => { - wrapper = shallowMountExtended(ContributionEventBase, { - propsData: defaultPropsData, + const createComponent = ({ propsData = {} } = {}) => { + wrapper = mountExtended(ContributionEventBase, { + propsData: { + ...defaultPropsData, + ...propsData, + }, scopedSlots: { default: '<div data-testid="default-slot"></div>', 'additional-info': '<div data-testid="additional-info-slot"></div>', @@ -25,38 +29,75 @@ describe('ContributionEventBase', () => { }); }; - beforeEach(() => { + it('renders avatar', () => { createComponent(); - }); - it('renders avatar', () => { const avatarLink = wrapper.findComponent(GlAvatarLink); + const avatarLabeled = avatarLink.findComponent(GlAvatarLabeled); - expect(avatarLink.attributes('href')).toBe(event.author.web_url); - expect(avatarLink.findComponent(GlAvatarLabeled).attributes()).toMatchObject({ - label: event.author.name, - sublabel: `@${event.author.username}`, - src: event.author.avatar_url, + expect(avatarLink.attributes('href')).toBe(defaultPropsData.event.author.web_url); + expect(avatarLabeled.attributes()).toMatchObject({ + src: defaultPropsData.event.author.avatar_url, size: '32', }); + expect(avatarLabeled.props()).toMatchObject({ + label: defaultPropsData.event.author.name, + subLabel: `@${defaultPropsData.event.author.username}`, + }); }); it('renders time ago tooltip', () => { - expect(wrapper.findComponent(TimeAgoTooltip).props('time')).toBe(event.created_at); + createComponent(); + + expect(wrapper.findComponent(TimeAgoTooltip).props('time')).toBe( + defaultPropsData.event.created_at, + ); }); it('renders icon', () => { + createComponent(); + const icon = wrapper.findComponent(GlIcon); expect(icon.props('name')).toBe(defaultPropsData.iconName); expect(icon.classes()).toContain(defaultPropsData.iconClass); }); - it('renders `default` slot', () => { - expect(wrapper.findByTestId('default-slot').exists()).toBe(true); + describe('when `message` prop is passed', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders message', () => { + expect(wrapper.findByTestId('event-body').text()).toBe( + `Approved merge request ${defaultPropsData.event.target.reference_link_text} in ${defaultPropsData.event.resource_parent.full_name}.`, + ); + }); + + it('renders target link', () => { + expect(wrapper.findComponent(TargetLink).props('event')).toEqual(defaultPropsData.event); + }); + + it('renders resource parent link', () => { + expect(wrapper.findComponent(ResourceParentLink).props('event')).toEqual( + defaultPropsData.event, + ); + }); + }); + + describe('when `message` prop is not passed', () => { + beforeEach(() => { + createComponent({ propsData: { message: '' } }); + }); + + it('renders `default` slot', () => { + expect(wrapper.findByTestId('default-slot').exists()).toBe(true); + }); }); it('renders `additional-info` slot', () => { + createComponent(); + expect(wrapper.findByTestId('additional-info-slot').exists()).toBe(true); }); }); diff --git a/spec/frontend/contribution_events/components/contribution_event/contribution_event_expired_spec.js b/spec/frontend/contribution_events/components/contribution_event/contribution_event_expired_spec.js new file mode 100644 index 00000000000..c58fca1ad12 --- /dev/null +++ b/spec/frontend/contribution_events/components/contribution_event/contribution_event_expired_spec.js @@ -0,0 +1,30 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ContributionEventExpired from '~/contribution_events/components/contribution_event/contribution_event_expired.vue'; +import ContributionEventBase from '~/contribution_events/components/contribution_event/contribution_event_base.vue'; +import { eventExpired } from '../../utils'; + +const defaultPropsData = { + event: eventExpired(), +}; + +describe('ContributionEventExpired', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(ContributionEventExpired, { + propsData: defaultPropsData, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders `ContributionEventBase`', () => { + expect(wrapper.findComponent(ContributionEventBase).props()).toMatchObject({ + event: defaultPropsData.event, + iconName: 'expire', + message: ContributionEventExpired.i18n.message, + }); + }); +}); diff --git a/spec/frontend/contribution_events/components/contribution_event/contribution_event_joined_spec.js b/spec/frontend/contribution_events/components/contribution_event/contribution_event_joined_spec.js new file mode 100644 index 00000000000..56688e2ef27 --- /dev/null +++ b/spec/frontend/contribution_events/components/contribution_event/contribution_event_joined_spec.js @@ -0,0 +1,30 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ContributionEventJoined from '~/contribution_events/components/contribution_event/contribution_event_joined.vue'; +import ContributionEventBase from '~/contribution_events/components/contribution_event/contribution_event_base.vue'; +import { eventJoined } from '../../utils'; + +const defaultPropsData = { + event: eventJoined(), +}; + +describe('ContributionEventJoined', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(ContributionEventJoined, { + propsData: defaultPropsData, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders `ContributionEventBase`', () => { + expect(wrapper.findComponent(ContributionEventBase).props()).toMatchObject({ + event: defaultPropsData.event, + iconName: 'users', + message: ContributionEventJoined.i18n.message, + }); + }); +}); diff --git a/spec/frontend/contribution_events/components/contribution_event/contribution_event_left_spec.js b/spec/frontend/contribution_events/components/contribution_event/contribution_event_left_spec.js new file mode 100644 index 00000000000..58cb8714d03 --- /dev/null +++ b/spec/frontend/contribution_events/components/contribution_event/contribution_event_left_spec.js @@ -0,0 +1,30 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ContributionEventLeft from '~/contribution_events/components/contribution_event/contribution_event_left.vue'; +import ContributionEventBase from '~/contribution_events/components/contribution_event/contribution_event_base.vue'; +import { eventLeft } from '../../utils'; + +const defaultPropsData = { + event: eventLeft(), +}; + +describe('ContributionEventLeft', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(ContributionEventLeft, { + propsData: defaultPropsData, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders `ContributionEventBase`', () => { + expect(wrapper.findComponent(ContributionEventBase).props()).toMatchObject({ + event: defaultPropsData.event, + iconName: 'leave', + message: ContributionEventLeft.i18n.message, + }); + }); +}); diff --git a/spec/frontend/contribution_events/components/contribution_event/contribution_event_merged_spec.js b/spec/frontend/contribution_events/components/contribution_event/contribution_event_merged_spec.js new file mode 100644 index 00000000000..88494c24ddf --- /dev/null +++ b/spec/frontend/contribution_events/components/contribution_event/contribution_event_merged_spec.js @@ -0,0 +1,31 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ContributionEventMerged from '~/contribution_events/components/contribution_event/contribution_event_merged.vue'; +import ContributionEventBase from '~/contribution_events/components/contribution_event/contribution_event_base.vue'; +import { eventMerged } from '../../utils'; + +const defaultPropsData = { + event: eventMerged(), +}; + +describe('ContributionEventMerged', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(ContributionEventMerged, { + propsData: defaultPropsData, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders `ContributionEventBase`', () => { + expect(wrapper.findComponent(ContributionEventBase).props()).toEqual({ + event: defaultPropsData.event, + iconName: 'git-merge', + iconClass: 'gl-text-blue-600', + message: ContributionEventMerged.i18n.message, + }); + }); +}); diff --git a/spec/frontend/contribution_events/components/contribution_event/contribution_event_private_spec.js b/spec/frontend/contribution_events/components/contribution_event/contribution_event_private_spec.js new file mode 100644 index 00000000000..42855134a09 --- /dev/null +++ b/spec/frontend/contribution_events/components/contribution_event/contribution_event_private_spec.js @@ -0,0 +1,33 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ContributionEventPrivate from '~/contribution_events/components/contribution_event/contribution_event_private.vue'; +import ContributionEventBase from '~/contribution_events/components/contribution_event/contribution_event_base.vue'; +import { eventPrivate } from '../../utils'; + +const defaultPropsData = { + event: eventPrivate(), +}; + +describe('ContributionEventPrivate', () => { + let wrapper; + + const createComponent = () => { + wrapper = mountExtended(ContributionEventPrivate, { + propsData: defaultPropsData, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders `ContributionEventBase`', () => { + expect(wrapper.findComponent(ContributionEventBase).props()).toMatchObject({ + event: defaultPropsData.event, + iconName: 'eye-slash', + }); + }); + + it('renders message', () => { + expect(wrapper.findByTestId('event-body').text()).toBe(ContributionEventPrivate.i18n.message); + }); +}); diff --git a/spec/frontend/contribution_events/components/contribution_event/contribution_event_pushed_spec.js b/spec/frontend/contribution_events/components/contribution_event/contribution_event_pushed_spec.js new file mode 100644 index 00000000000..43f201040e3 --- /dev/null +++ b/spec/frontend/contribution_events/components/contribution_event/contribution_event_pushed_spec.js @@ -0,0 +1,141 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ContributionEventPushed from '~/contribution_events/components/contribution_event/contribution_event_pushed.vue'; +import ContributionEventBase from '~/contribution_events/components/contribution_event/contribution_event_base.vue'; +import ResourceParentLink from '~/contribution_events/components/resource_parent_link.vue'; +import { + eventPushedNewBranch, + eventPushedNewTag, + eventPushedBranch, + eventPushedTag, + eventPushedRemovedBranch, + eventPushedRemovedTag, + eventBulkPushedBranch, +} from '../../utils'; + +describe('ContributionEventPushed', () => { + let wrapper; + + const createComponent = ({ propsData }) => { + wrapper = mountExtended(ContributionEventPushed, { + propsData, + }); + }; + + describe.each` + event | expectedMessage | expectedIcon + ${eventPushedNewBranch()} | ${'Pushed a new branch'} | ${'commit'} + ${eventPushedNewTag()} | ${'Pushed a new tag'} | ${'commit'} + ${eventPushedBranch()} | ${'Pushed to branch'} | ${'commit'} + ${eventPushedTag()} | ${'Pushed to tag'} | ${'commit'} + ${eventPushedRemovedBranch()} | ${'Deleted branch'} | ${'remove'} + ${eventPushedRemovedTag()} | ${'Deleted tag'} | ${'remove'} + `('when event is $event', ({ event, expectedMessage, expectedIcon }) => { + beforeEach(() => { + createComponent({ propsData: { event } }); + }); + + it('renders `ContributionEventBase` with correct props', () => { + expect(wrapper.findComponent(ContributionEventBase).props()).toMatchObject({ + event, + iconName: expectedIcon, + }); + }); + + it('renders message', () => { + expect(wrapper.findByTestId('event-body').text()).toContain(expectedMessage); + }); + + it('renders resource parent link', () => { + expect(wrapper.findComponent(ResourceParentLink).props('event')).toEqual(event); + }); + }); + + describe('when ref has a path', () => { + const event = eventPushedNewBranch(); + const path = '/foo'; + + beforeEach(() => { + createComponent({ + propsData: { + event: { + ...event, + ref: { + ...event.ref, + path, + }, + }, + }, + }); + }); + + it('renders ref link', () => { + expect(wrapper.findByRole('link', { name: event.ref.name }).attributes('href')).toBe(path); + }); + }); + + describe('when ref does not have a path', () => { + const event = eventPushedRemovedBranch(); + + beforeEach(() => { + createComponent({ + propsData: { + event, + }, + }); + }); + + it('renders ref name without a link', () => { + expect(wrapper.findByRole('link', { name: event.ref.name }).exists()).toBe(false); + expect(wrapper.findByText(event.ref.name).exists()).toBe(true); + }); + }); + + it('renders renders a link to the commit', () => { + const event = eventPushedNewBranch(); + createComponent({ + propsData: { + event, + }, + }); + + expect( + wrapper.findByRole('link', { name: event.commit.truncated_sha }).attributes('href'), + ).toBe(event.commit.path); + }); + + it('renders commit title', () => { + const event = eventPushedNewBranch(); + createComponent({ + propsData: { + event, + }, + }); + + expect(wrapper.findByText(event.commit.title).exists()).toBe(true); + }); + + describe('when multiple commits are pushed', () => { + const event = eventBulkPushedBranch(); + beforeEach(() => { + createComponent({ + propsData: { + event, + }, + }); + }); + + it('renders message', () => { + expect(wrapper.text()).toContain('…and 4 more commits.'); + }); + + it('renders compare link', () => { + expect( + wrapper + .findByRole('link', { + name: `Compare ${event.commit.from_truncated_sha}…${event.commit.to_truncated_sha}`, + }) + .attributes('href'), + ).toBe(event.commit.compare_path); + }); + }); +}); diff --git a/spec/frontend/contribution_events/components/contribution_events_spec.js b/spec/frontend/contribution_events/components/contribution_events_spec.js index 4bc354c393f..31e1bc3e569 100644 --- a/spec/frontend/contribution_events/components/contribution_events_spec.js +++ b/spec/frontend/contribution_events/components/contribution_events_spec.js @@ -1,10 +1,21 @@ -import events from 'test_fixtures/controller/users/activity.json'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { EVENT_TYPE_APPROVED } from '~/contribution_events/constants'; import ContributionEvents from '~/contribution_events/components/contribution_events.vue'; import ContributionEventApproved from '~/contribution_events/components/contribution_event/contribution_event_approved.vue'; - -const eventApproved = events.find((event) => event.action === EVENT_TYPE_APPROVED); +import ContributionEventExpired from '~/contribution_events/components/contribution_event/contribution_event_expired.vue'; +import ContributionEventJoined from '~/contribution_events/components/contribution_event/contribution_event_joined.vue'; +import ContributionEventLeft from '~/contribution_events/components/contribution_event/contribution_event_left.vue'; +import ContributionEventPushed from '~/contribution_events/components/contribution_event/contribution_event_pushed.vue'; +import ContributionEventPrivate from '~/contribution_events/components/contribution_event/contribution_event_private.vue'; +import ContributionEventMerged from '~/contribution_events/components/contribution_event/contribution_event_merged.vue'; +import { + eventApproved, + eventExpired, + eventJoined, + eventLeft, + eventPushedBranch, + eventPrivate, + eventMerged, +} from '../utils'; describe('ContributionEvents', () => { let wrapper; @@ -12,14 +23,28 @@ describe('ContributionEvents', () => { const createComponent = () => { wrapper = shallowMountExtended(ContributionEvents, { propsData: { - events, + events: [ + eventApproved(), + eventExpired(), + eventJoined(), + eventLeft(), + eventPushedBranch(), + eventPrivate(), + eventMerged(), + ], }, }); }; it.each` expectedComponent | expectedEvent - ${ContributionEventApproved} | ${eventApproved} + ${ContributionEventApproved} | ${eventApproved()} + ${ContributionEventExpired} | ${eventExpired()} + ${ContributionEventJoined} | ${eventJoined()} + ${ContributionEventLeft} | ${eventLeft()} + ${ContributionEventPushed} | ${eventPushedBranch()} + ${ContributionEventPrivate} | ${eventPrivate()} + ${ContributionEventMerged} | ${eventMerged()} `( 'renders `$expectedComponent.name` component and passes expected event', ({ expectedComponent, expectedEvent }) => { diff --git a/spec/frontend/contribution_events/components/resource_parent_link_spec.js b/spec/frontend/contribution_events/components/resource_parent_link_spec.js index 8d586db2a30..815a1b751cf 100644 --- a/spec/frontend/contribution_events/components/resource_parent_link_spec.js +++ b/spec/frontend/contribution_events/components/resource_parent_link_spec.js @@ -1,30 +1,52 @@ import { GlLink } from '@gitlab/ui'; -import events from 'test_fixtures/controller/users/activity.json'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { EVENT_TYPE_APPROVED } from '~/contribution_events/constants'; import ResourceParentLink from '~/contribution_events/components/resource_parent_link.vue'; - -const eventApproved = events.find((event) => event.action === EVENT_TYPE_APPROVED); +import { EVENT_TYPE_PRIVATE } from '~/contribution_events/constants'; +import { eventApproved } from '../utils'; describe('ResourceParentLink', () => { let wrapper; - const createComponent = () => { + const defaultPropsData = { + event: eventApproved(), + }; + + const createComponent = ({ propsData = {} } = {}) => { wrapper = shallowMountExtended(ResourceParentLink, { propsData: { - event: eventApproved, + ...defaultPropsData, + ...propsData, }, }); }; - beforeEach(() => { - createComponent(); + describe('when resource parent is defined', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders link', () => { + const link = wrapper.findComponent(GlLink); + const { web_url, full_name } = defaultPropsData.event.resource_parent; + + expect(link.attributes('href')).toBe(web_url); + expect(link.text()).toBe(full_name); + }); }); - it('renders link', () => { - const link = wrapper.findComponent(GlLink); + describe('when resource parent is not defined', () => { + beforeEach(() => { + createComponent({ + propsData: { + event: { + type: EVENT_TYPE_PRIVATE, + }, + }, + }); + }); - expect(link.attributes('href')).toBe(eventApproved.resource_parent.web_url); - expect(link.text()).toBe(eventApproved.resource_parent.full_name); + it('renders nothing', () => { + expect(wrapper.html()).toBe(''); + }); }); }); diff --git a/spec/frontend/contribution_events/components/target_link_spec.js b/spec/frontend/contribution_events/components/target_link_spec.js index 7944375487b..b71d6eff432 100644 --- a/spec/frontend/contribution_events/components/target_link_spec.js +++ b/spec/frontend/contribution_events/components/target_link_spec.js @@ -1,33 +1,48 @@ import { GlLink } from '@gitlab/ui'; -import events from 'test_fixtures/controller/users/activity.json'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { EVENT_TYPE_APPROVED } from '~/contribution_events/constants'; import TargetLink from '~/contribution_events/components/target_link.vue'; - -const eventApproved = events.find((event) => event.action === EVENT_TYPE_APPROVED); +import { eventApproved, eventJoined } from '../utils'; describe('TargetLink', () => { let wrapper; - const createComponent = () => { + const defaultPropsData = { + event: eventApproved(), + }; + + const createComponent = ({ propsData = {} } = {}) => { wrapper = shallowMountExtended(TargetLink, { propsData: { - event: eventApproved, + ...defaultPropsData, + ...propsData, }, }); }; - beforeEach(() => { - createComponent(); + describe('when target is defined', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders link', () => { + const link = wrapper.findComponent(GlLink); + const { web_url: webUrl, title, reference_link_text } = defaultPropsData.event.target; + + expect(link.attributes()).toMatchObject({ + href: webUrl, + title, + }); + expect(link.text()).toBe(reference_link_text); + }); }); - it('renders link', () => { - const link = wrapper.findComponent(GlLink); + describe('when target is not defined', () => { + beforeEach(() => { + createComponent({ propsData: { event: eventJoined() } }); + }); - expect(link.attributes()).toMatchObject({ - href: eventApproved.target.web_url, - title: eventApproved.target.title, + it('renders nothing', () => { + expect(wrapper.html()).toBe(''); }); - expect(link.text()).toBe(eventApproved.target.reference_link_text); }); }); diff --git a/spec/frontend/contribution_events/utils.js b/spec/frontend/contribution_events/utils.js new file mode 100644 index 00000000000..6e97455582d --- /dev/null +++ b/spec/frontend/contribution_events/utils.js @@ -0,0 +1,52 @@ +import events from 'test_fixtures/controller/users/activity.json'; +import { + EVENT_TYPE_APPROVED, + EVENT_TYPE_EXPIRED, + EVENT_TYPE_JOINED, + EVENT_TYPE_LEFT, + EVENT_TYPE_PUSHED, + EVENT_TYPE_PRIVATE, + EVENT_TYPE_MERGED, + PUSH_EVENT_REF_TYPE_BRANCH, + PUSH_EVENT_REF_TYPE_TAG, +} from '~/contribution_events/constants'; + +const findEventByAction = (action) => events.find((event) => event.action === action); + +export const eventApproved = () => findEventByAction(EVENT_TYPE_APPROVED); + +export const eventExpired = () => findEventByAction(EVENT_TYPE_EXPIRED); + +export const eventJoined = () => findEventByAction(EVENT_TYPE_JOINED); + +export const eventLeft = () => findEventByAction(EVENT_TYPE_LEFT); + +export const eventMerged = () => findEventByAction(EVENT_TYPE_MERGED); + +const findPushEvent = ({ + isNew = false, + isRemoved = false, + refType = PUSH_EVENT_REF_TYPE_BRANCH, + commitCount = 1, +} = {}) => () => + events.find( + ({ action, ref, commit }) => + action === EVENT_TYPE_PUSHED && + ref.is_new === isNew && + ref.is_removed === isRemoved && + ref.type === refType && + commit.count === commitCount, + ); + +export const eventPushedNewBranch = findPushEvent({ isNew: true }); +export const eventPushedNewTag = findPushEvent({ isNew: true, refType: PUSH_EVENT_REF_TYPE_TAG }); +export const eventPushedBranch = findPushEvent(); +export const eventPushedTag = findPushEvent({ refType: PUSH_EVENT_REF_TYPE_TAG }); +export const eventPushedRemovedBranch = findPushEvent({ isRemoved: true }); +export const eventPushedRemovedTag = findPushEvent({ + isRemoved: true, + refType: PUSH_EVENT_REF_TYPE_TAG, +}); +export const eventBulkPushedBranch = findPushEvent({ commitCount: 5 }); + +export const eventPrivate = () => ({ ...events[0], action: EVENT_TYPE_PRIVATE }); diff --git a/spec/frontend/deploy_keys/components/app_spec.js b/spec/frontend/deploy_keys/components/app_spec.js index 3dfb828b449..de4112134ce 100644 --- a/spec/frontend/deploy_keys/components/app_spec.js +++ b/spec/frontend/deploy_keys/components/app_spec.js @@ -6,6 +6,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'spec/test_constants'; import deployKeysApp from '~/deploy_keys/components/app.vue'; import ConfirmModal from '~/deploy_keys/components/confirm_modal.vue'; +import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; import eventHub from '~/deploy_keys/eventhub'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; @@ -39,6 +40,7 @@ describe('Deploy keys app component', () => { const findLoadingIcon = () => wrapper.find('.gl-spinner'); const findKeyPanels = () => wrapper.findAll('.deploy-keys .gl-tabs-nav li'); const findModal = () => wrapper.findComponent(ConfirmModal); + const findNavigationTabs = () => wrapper.findComponent(NavigationTabs); it('renders loading icon while waiting for request', async () => { mock.onGet(TEST_ENDPOINT).reply(() => new Promise()); @@ -74,55 +76,61 @@ describe('Deploy keys app component', () => { }); }); - it('re-fetches deploy keys when enabling a key', async () => { - const key = data.public_keys[0]; + it('hasKeys returns true when there are keys', async () => { await mountComponent(); - jest.spyOn(wrapper.vm.service, 'getKeys').mockImplementation(() => {}); - jest.spyOn(wrapper.vm.service, 'enableKey').mockImplementation(() => Promise.resolve()); - eventHub.$emit('enable.key', key); - - await nextTick(); - expect(wrapper.vm.service.enableKey).toHaveBeenCalledWith(key.id); - expect(wrapper.vm.service.getKeys).toHaveBeenCalled(); + expect(findNavigationTabs().exists()).toBe(true); + expect(findLoadingIcon().exists()).toBe(false); }); - it('re-fetches deploy keys when disabling a key', async () => { + describe('enabling and disabling keys', () => { const key = data.public_keys[0]; - await mountComponent(); - jest.spyOn(wrapper.vm.service, 'getKeys').mockImplementation(() => {}); - jest.spyOn(wrapper.vm.service, 'disableKey').mockImplementation(() => Promise.resolve()); + let getMethodMock; + let putMethodMock; - eventHub.$emit('disable.key', key, () => {}); + const removeKey = async (keyEvent) => { + eventHub.$emit(keyEvent, key, () => {}); - await nextTick(); - expect(findModal().props('visible')).toBe(true); - findModal().vm.$emit('remove'); + await nextTick(); + expect(findModal().props('visible')).toBe(true); + findModal().vm.$emit('remove'); + }; - await nextTick(); - expect(wrapper.vm.service.disableKey).toHaveBeenCalledWith(key.id); - expect(wrapper.vm.service.getKeys).toHaveBeenCalled(); - }); + beforeEach(() => { + getMethodMock = jest.spyOn(axios, 'get'); + putMethodMock = jest.spyOn(axios, 'put'); + }); - it('calls disableKey when removing a key', async () => { - const key = data.public_keys[0]; - await mountComponent(); - jest.spyOn(wrapper.vm.service, 'getKeys').mockImplementation(() => {}); - jest.spyOn(wrapper.vm.service, 'disableKey').mockImplementation(() => Promise.resolve()); + afterEach(() => { + getMethodMock.mockClear(); + putMethodMock.mockClear(); + }); - eventHub.$emit('remove.key', key, () => {}); + it('re-fetches deploy keys when enabling a key', async () => { + await mountComponent(); - await nextTick(); - expect(findModal().props('visible')).toBe(true); - findModal().vm.$emit('remove'); + eventHub.$emit('enable.key', key); - await nextTick(); - expect(wrapper.vm.service.disableKey).toHaveBeenCalledWith(key.id); - expect(wrapper.vm.service.getKeys).toHaveBeenCalled(); - }); + expect(putMethodMock).toHaveBeenCalledWith(`${TEST_ENDPOINT}/${key.id}/enable`); + expect(getMethodMock).toHaveBeenCalled(); + }); - it('hasKeys returns true when there are keys', async () => { - await mountComponent(); - expect(wrapper.vm.hasKeys).toEqual(3); + it('re-fetches deploy keys when disabling a key', async () => { + await mountComponent(); + + await removeKey('disable.key'); + + expect(putMethodMock).toHaveBeenCalledWith(`${TEST_ENDPOINT}/${key.id}/disable`); + expect(getMethodMock).toHaveBeenCalled(); + }); + + it('calls disableKey when removing a key', async () => { + await mountComponent(); + + await removeKey('remove.key'); + + expect(putMethodMock).toHaveBeenCalledWith(`${TEST_ENDPOINT}/${key.id}/disable`); + expect(getMethodMock).toHaveBeenCalled(); + }); }); }); diff --git a/spec/frontend/design_management/components/design_description/description_form_spec.js b/spec/frontend/design_management/components/design_description/description_form_spec.js index 8c01023b1a8..a61cc2af9b6 100644 --- a/spec/frontend/design_management/components/design_description/description_form_spec.js +++ b/spec/frontend/design_management/components/design_description/description_form_spec.js @@ -1,18 +1,15 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; - import { GlAlert } from '@gitlab/ui'; - import { mountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; - import DescriptionForm from '~/design_management/components/design_description/description_form.vue'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import updateDesignDescriptionMutation from '~/design_management/graphql/mutations/update_design_description.mutation.graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; - +import { mockTracking } from 'helpers/tracking_helper'; import { designFactory, designUpdateFactory } from '../../mock_data/apollo_mock'; jest.mock('~/behaviors/markdown/render_gfm'); @@ -86,6 +83,8 @@ describe('Design description form', () => { const findAlert = () => wrapper.findComponent(GlAlert); describe('user has updateDesign permission', () => { + let trackingSpy; + const ctrlKey = { ctrlKey: true, }; @@ -96,6 +95,8 @@ describe('Design description form', () => { const errorMessage = 'Could not update description. Please try again.'; beforeEach(() => { + trackingSpy = mockTracking(undefined, null, jest.spyOn); + createComponent(); }); @@ -139,19 +140,19 @@ describe('Design description form', () => { mockDesign.id, )}`, markdownDocsPath: '/help/user/markdown', - quickActionsDocsPath: '/help/user/project/quick_actions', }); }); - it.each` + describe.each` isKeyEvent | assertionName | key | keyData ${true} | ${'Ctrl + Enter keypress'} | ${'ctrl'} | ${ctrlKey} ${true} | ${'Meta + Enter keypress'} | ${'meta'} | ${metaKey} ${false} | ${'Save button click'} | ${''} | ${null} - `( - 'hides form and calls mutation when form is submitted via $assertionName', - async ({ isKeyEvent, keyData }) => { - const mockDesignUpdateResponseHandler = jest.fn().mockResolvedValue( + `('when form is submitted via $assertionName', ({ isKeyEvent, keyData }) => { + let mockDesignUpdateResponseHandler; + + beforeEach(async () => { + mockDesignUpdateResponseHandler = jest.fn().mockResolvedValue( designUpdateFactory({ description: mockDescription, descriptionHtml: `<p data-sourcepos="1:1-1:16" dir="auto">${mockDescription}</p>`, @@ -171,7 +172,9 @@ describe('Design description form', () => { } await nextTick(); + }); + it('hides form and calls mutation', async () => { expect(mockDesignUpdateResponseHandler).toHaveBeenCalledWith({ input: { description: 'Hello world', @@ -182,8 +185,16 @@ describe('Design description form', () => { await waitForPromises(); expect(findMarkdownEditor().exists()).toBe(false); - }, - ); + }); + + it('tracks submit action', () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'editor_type_used', { + context: 'Design', + editorType: 'editor_type_plain_text_editor', + label: 'editor_tracking', + }); + }); + }); it('shows error message when mutation fails', async () => { const failureHandler = jest.fn().mockRejectedValue(new Error(errorMessage)); diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap deleted file mode 100644 index 9bb85ecf569..00000000000 --- a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap +++ /dev/null @@ -1,86 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Design note component should match the snapshot 1`] = ` -<timelineentryitem-stub - class="design-note note-form" - id="note_123" -> - <glavatarlink-stub - class="gl-float-left gl-mr-3" - href="https://gitlab.com/user" - > - <glavatar-stub - alt="avatar" - entityid="0" - entityname="foo-bar" - shape="circle" - size="32" - src="https://gitlab.com/avatar" - /> - </glavatarlink-stub> - - <div - class="gl-display-flex gl-justify-content-space-between" - > - <div> - <gllink-stub - class="js-user-link" - data-testid="user-link" - data-user-id="1" - data-username="foo-bar" - href="https://gitlab.com/user" - > - <span - class="note-header-author-name gl-font-weight-bold" - > - - </span> - - <!----> - - <span - class="note-headline-light" - > - @foo-bar - </span> - </gllink-stub> - - <span - class="note-headline-light note-headline-meta" - > - <span - class="system-note-message" - /> - - <gllink-stub - class="note-timestamp system-note-separator gl-display-block gl-mb-2 gl-font-sm" - href="#note_123" - > - <timeagotooltip-stub - cssclass="" - datetimeformat="DATE_WITH_TIME_FORMAT" - time="2019-07-26T15:02:20Z" - tooltipplacement="bottom" - /> - </gllink-stub> - </span> - </div> - - <div - class="gl-display-flex gl-align-items-baseline gl-mt-n2 gl-mr-n2" - > - - <!----> - - <!----> - </div> - </div> - - <div - class="note-text md" - data-qa-selector="note_content" - data-testid="note-text" - /> - -</timelineentryitem-stub> -`; diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js index 664a0974549..797f399eff5 100644 --- a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js @@ -29,6 +29,7 @@ const DEFAULT_TODO_COUNT = 2; describe('Design discussions component', () => { let wrapper; + const findDesignNotesList = () => wrapper.find('[data-testid="design-discussion-content"]'); const findDesignNotes = () => wrapper.findAllComponents(DesignNote); const findReplyPlaceholder = () => wrapper.findComponent(ReplyPlaceholder); const findReplyForm = () => wrapper.findComponent(DesignReplyForm); @@ -88,6 +89,9 @@ describe('Design discussions component', () => { }, }, }, + stubs: { + EmojiPicker: true, + }, }); } @@ -287,7 +291,7 @@ describe('Design discussions component', () => { describe('when any note from a discussion is active', () => { it.each([notes[0], notes[0].discussion.notes.nodes[1]])( - 'applies correct class to all notes in the active discussion', + 'applies correct class to the active discussion', (note) => { createComponent({ props: { discussion: mockDiscussion }, @@ -299,11 +303,7 @@ describe('Design discussions component', () => { }, }); - expect( - wrapper - .findAllComponents(DesignNote) - .wrappers.every((designNote) => designNote.classes('gl-bg-blue-50')), - ).toBe(true); + expect(findDesignNotesList().classes('gl-bg-blue-50')).toBe(true); }, ); }); diff --git a/spec/frontend/design_management/components/design_notes/design_note_spec.js b/spec/frontend/design_management/components/design_notes/design_note_spec.js index 661d1ac4087..8795b089551 100644 --- a/spec/frontend/design_management/components/design_notes/design_note_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_note_spec.js @@ -1,10 +1,18 @@ import { ApolloMutation } from 'vue-apollo'; import { nextTick } from 'vue'; import { GlAvatar, GlAvatarLink, GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; +import * as Sentry from '@sentry/browser'; + +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +import EmojiPicker from '~/emoji/components/picker.vue'; +import DesignNoteAwardsList from '~/design_management/components/design_notes/design_note_awards_list.vue'; import DesignNote from '~/design_management/components/design_notes/design_note.vue'; import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import designNoteAwardEmojiToggleMutation from '~/design_management/graphql/mutations/design_note_award_emoji_toggle.mutation.graphql'; +import { mockAwardEmoji } from '../../mock_data/apollo_mock'; const scrollIntoViewMock = jest.fn(); const note = { @@ -15,9 +23,11 @@ const note = { avatarUrl: 'https://gitlab.com/avatar', webUrl: 'https://gitlab.com/user', }, + awardEmoji: mockAwardEmoji, body: 'test', userPermissions: { adminNote: false, + awardEmoji: true, }, createdAt: '2019-07-26T15:02:20Z', }; @@ -27,14 +37,14 @@ const $route = { hash: '#note_123', }; -const mutate = jest.fn().mockResolvedValue({ data: { updateNote: {} } }); - describe('Design note component', () => { let wrapper; + let mutate; const findUserAvatar = () => wrapper.findComponent(GlAvatar); const findUserAvatarLink = () => wrapper.findComponent(GlAvatarLink); const findUserLink = () => wrapper.findByTestId('user-link'); + const findDesignNoteAwardsList = () => wrapper.findComponent(DesignNoteAwardsList); const findReplyForm = () => wrapper.findComponent(DesignReplyForm); const findEditButton = () => wrapper.findByTestId('note-edit'); const findNoteContent = () => wrapper.findByTestId('note-text'); @@ -43,97 +53,106 @@ describe('Design note component', () => { const findEditDropdownItem = () => findDropdownItems().at(0); const findDeleteDropdownItem = () => findDropdownItems().at(1); - function createComponent(props = {}, data = { isEditing: false }) { - wrapper = mountExtended(DesignNote, { + function createComponent({ + props = {}, + data = { isEditing: false }, + mountFn = mountExtended, + mocks = { + $route, + $apollo: { + mutate: jest.fn().mockResolvedValue({ data: { updateNote: {} } }), + }, + }, + stubs = { + ApolloMutation, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + TimelineEntryItem: true, + TimeAgoTooltip: true, + GlAvatarLink: true, + GlAvatar: true, + GlLink: true, + }, + } = {}) { + wrapper = mountFn(DesignNote, { propsData: { note: {}, noteableId: 'gid://gitlab/DesignManagement::Design/6', + designVariables: { + atVersion: null, + filenames: ['foo.jpg'], + fullPath: 'gitlab-org/gitlab-test', + iid: '1', + }, ...props, }, + provide: { + issueIid: '1', + projectPath: 'gitlab-org/gitlab-test', + }, data() { return { ...data, }; }, - mocks: { - $route, - $apollo: { - mutate, - }, - }, - stubs: { - ApolloMutation, - GlDisclosureDropdown, - GlDisclosureDropdownItem, - TimelineEntryItem: true, - TimeAgoTooltip: true, - GlAvatarLink: true, - GlAvatar: true, - GlLink: true, - }, + mocks, + stubs, }); } - it('should match the snapshot', () => { - createComponent({ - note, - }); - - expect(wrapper.element).toMatchSnapshot(); + beforeEach(() => { + window.gon = { current_user_id: 1 }; }); - it('should render avatar with correct props', () => { - createComponent({ - note, - }); - - expect(findUserAvatar().props()).toMatchObject({ - src: note.author.avatarUrl, - entityName: note.author.username, + describe('default', () => { + beforeEach(() => { + createComponent({ props: { note } }); }); - expect(findUserAvatarLink().attributes('href')).toBe(note.author.webUrl); - }); + it('should render avatar with correct props', () => { + expect(findUserAvatar().props()).toMatchObject({ + src: note.author.avatarUrl, + entityName: note.author.username, + }); - it('should render author details', () => { - createComponent({ - note, + expect(findUserAvatarLink().attributes()).toMatchObject({ + href: note.author.webUrl, + 'data-user-id': '1', + 'data-username': `${note.author.username}`, + }); }); - expect(findUserLink().exists()).toBe(true); - }); - - it('should render a time ago tooltip if note has createdAt property', () => { - createComponent({ - note, + it('should render author details', () => { + expect(findUserLink().exists()).toBe(true); }); - expect(wrapper.findComponent(TimeAgoTooltip).exists()).toBe(true); - }); - - it('should not render edit icon when user does not have a permission', () => { - createComponent({ - note, + it('should render a time ago tooltip if note has createdAt property', () => { + expect(wrapper.findComponent(TimeAgoTooltip).exists()).toBe(true); }); - expect(findEditButton().exists()).toBe(false); - }); + it('should render emoji awards list', () => { + expect(findDesignNoteAwardsList().exists()).toBe(true); + }); - it('should not display a dropdown if user does not have a permission to delete note', () => { - createComponent({ - note, + it('should not render edit icon when user does not have a permission', () => { + expect(findEditButton().exists()).toBe(false); }); - expect(findDropdown().exists()).toBe(false); + it('should not display a dropdown if user does not have a permission to delete note', () => { + expect(findDropdown().exists()).toBe(false); + }); }); describe('when user has a permission to edit note', () => { it('should open an edit form on edit button click', async () => { createComponent({ - note: { - ...note, - userPermissions: { - adminNote: true, + props: { + note: { + ...note, + userPermissions: { + adminNote: true, + awardEmoji: true, + }, }, }, }); @@ -147,25 +166,29 @@ describe('Design note component', () => { describe('when edit form is rendered', () => { beforeEach(() => { - createComponent( - { + createComponent({ + props: { note: { ...note, userPermissions: { adminNote: true, + awardEmoji: true, }, }, }, - { isEditing: true }, - ); + data: { isEditing: true }, + }); }); it('should open an edit form on edit button click', async () => { createComponent({ - note: { - ...note, - userPermissions: { - adminNote: true, + props: { + note: { + ...note, + userPermissions: { + adminNote: true, + awardEmoji: true, + }, }, }, }); @@ -203,10 +226,13 @@ describe('Design note component', () => { describe('when user has admin permissions', () => { it('should display a dropdown', () => { createComponent({ - note: { - ...note, - userPermissions: { - adminNote: true, + props: { + note: { + ...note, + userPermissions: { + adminNote: true, + awardEmoji: true, + }, }, }, }); @@ -223,12 +249,15 @@ describe('Design note component', () => { ...note, userPermissions: { adminNote: true, + awardEmoji: true, }, }; createComponent({ - note: { - ...payload, + props: { + note: { + ...payload, + }, }, }); @@ -236,4 +265,91 @@ describe('Design note component', () => { expect(wrapper.emitted()).toEqual({ 'delete-note': [[{ ...payload }]] }); }); + + describe('when user has award emoji permissions', () => { + const findEmojiPicker = () => wrapper.findComponent(EmojiPicker); + const propsData = { + note: { + ...note, + userPermissions: { + adminNote: false, + awardEmoji: true, + }, + }, + }; + + it('should render emoji-picker button', () => { + createComponent({ props: propsData, mountFn: shallowMountExtended }); + + const emojiPicker = findEmojiPicker(); + + expect(emojiPicker.exists()).toBe(true); + expect(emojiPicker.props()).toMatchObject({ + boundary: 'viewport', + right: false, + }); + }); + + it('should call mutation to add an emoji', () => { + mutate = jest.fn().mockResolvedValue({ + data: { + awardEmojiToggle: { + errors: [], + toggledOn: true, + }, + }, + }); + createComponent({ + props: propsData, + mountFn: shallowMountExtended, + mocks: { + $route, + $apollo: { + mutate, + }, + }, + }); + + findEmojiPicker().vm.$emit('click', 'thumbsup'); + + expect(mutate).toHaveBeenCalledWith({ + mutation: designNoteAwardEmojiToggleMutation, + variables: { + name: 'thumbsup', + awardableId: note.id, + }, + optimisticResponse: { + awardEmojiToggle: { + errors: [], + toggledOn: true, + }, + }, + update: expect.any(Function), + }); + }); + + it('should emit an error when mutation fails', async () => { + jest.spyOn(Sentry, 'captureException'); + mutate = jest.fn().mockRejectedValue({}); + createComponent({ + props: propsData, + mountFn: shallowMountExtended, + mocks: { + $route, + $apollo: { + mutate, + }, + }, + }); + + findEmojiPicker().vm.$emit('click', 'thumbsup'); + + expect(mutate).toHaveBeenCalled(); + + await waitForPromises(); + + expect(Sentry.captureException).toHaveBeenCalled(); + expect(wrapper.emitted('error')).toEqual([[{}]]); + }); + }); }); diff --git a/spec/frontend/design_management/components/design_presentation_spec.js b/spec/frontend/design_management/components/design_presentation_spec.js index fdcea6d88c0..e64dec14461 100644 --- a/spec/frontend/design_management/components/design_presentation_spec.js +++ b/spec/frontend/design_management/components/design_presentation_spec.js @@ -220,10 +220,6 @@ describe('Design management design presentation component', () => { ); }); - afterEach(() => { - jest.clearAllMocks(); - }); - it('sets overlay position correctly when overlay is smaller than viewport', () => { jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(200); jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(200); diff --git a/spec/frontend/design_management/components/design_todo_button_spec.js b/spec/frontend/design_management/components/design_todo_button_spec.js index 698535d8937..2262e5fdd83 100644 --- a/spec/frontend/design_management/components/design_todo_button_spec.js +++ b/spec/frontend/design_management/components/design_todo_button_spec.js @@ -50,10 +50,6 @@ describe('Design management design todo button', () => { createComponent(); }); - afterEach(() => { - jest.clearAllMocks(); - }); - it('renders TodoButton component', () => { expect(wrapper.findComponent(TodoButton).exists()).toBe(true); }); diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js index 063df9366e9..0d004baafd0 100644 --- a/spec/frontend/design_management/mock_data/apollo_mock.js +++ b/spec/frontend/design_management/mock_data/apollo_mock.js @@ -1,3 +1,27 @@ +export const mockAuthor = { + id: 'gid://gitlab/User/1', + name: 'John', + webUrl: 'link-to-john-profile', + avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + username: 'john.doe', +}; + +export const mockAwardEmoji = { + __typename: 'AwardEmojiConnection', + nodes: [ + { + __typename: 'AwardEmoji', + name: 'briefcase', + user: mockAuthor, + }, + { + __typename: 'AwardEmoji', + name: 'baseball', + user: mockAuthor, + }, + ], +}; + export const designListQueryResponseNodes = [ { __typename: 'Design', @@ -237,6 +261,9 @@ export const mockNoteSubmitSuccessMutationResponse = { webUrl: 'http://127.0.0.1:3000/root', __typename: 'UserCore', }, + awardEmoji: { + nodes: [], + }, body: 'New comment', bodyHtml: "<p data-sourcepos='1:1-1:4' dir='auto'>asdd</p>", createdAt: '2023-02-24T06:49:20Z', @@ -257,6 +284,7 @@ export const mockNoteSubmitSuccessMutationResponse = { userPermissions: { adminNote: true, repositionNote: true, + awardEmoji: true, __typename: 'NotePermissions', }, discussion: { @@ -363,6 +391,7 @@ export const designFactory = ({ }, userPermissions: { updateDesign, + awardEmoji: true, __typename: 'IssuePermissions', }, __typename: 'Issue', diff --git a/spec/frontend/design_management/mock_data/discussion.js b/spec/frontend/design_management/mock_data/discussion.js index 0e59ef29f8f..fbd5a9e0103 100644 --- a/spec/frontend/design_management/mock_data/discussion.js +++ b/spec/frontend/design_management/mock_data/discussion.js @@ -1,3 +1,5 @@ +import { mockAuthor, mockAwardEmoji } from './apollo_mock'; + export default { id: 'discussion-id-1', resolved: false, @@ -12,13 +14,12 @@ export default { x: 10, y: 15, }, - author: { - name: 'John', - webUrl: 'link-to-john-profile', - }, + author: mockAuthor, + awardEmoji: mockAwardEmoji, createdAt: '2020-05-08T07:10:45Z', userPermissions: { repositionNote: true, + awardEmoji: true, }, resolved: false, }, @@ -32,12 +33,15 @@ export default { y: 25, }, author: { + id: 'gid://gitlab/User/2', name: 'Mary', webUrl: 'link-to-mary-profile', }, + awardEmoji: mockAwardEmoji, createdAt: '2020-05-08T07:10:45Z', userPermissions: { adminNote: true, + awardEmoji: true, }, resolved: false, }, diff --git a/spec/frontend/design_management/mock_data/notes.js b/spec/frontend/design_management/mock_data/notes.js index 41cefaca05b..311ce4d1eb9 100644 --- a/spec/frontend/design_management/mock_data/notes.js +++ b/spec/frontend/design_management/mock_data/notes.js @@ -1,3 +1,4 @@ +import { mockAwardEmoji } from './apollo_mock'; import DISCUSSION_1 from './discussion'; const DISCUSSION_2 = { @@ -17,9 +18,11 @@ const DISCUSSION_2 = { name: 'Mary', webUrl: 'link-to-mary-profile', }, + awardEmoji: mockAwardEmoji, createdAt: '2020-05-08T07:10:45Z', userPermissions: { adminNote: true, + awardEmoji: true, }, resolved: true, }, diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index b69452069c0..fb5cf4dfd0a 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -73,6 +73,8 @@ describe('diffs/components/app', () => { propsData: { endpointCoverage: `${TEST_HOST}/diff/endpointCoverage`, endpointCodequality: '', + endpointSast: '', + projectPath: 'namespace/project', currentUser: {}, changesEmptyStateIllustration: '', ...props, @@ -184,6 +186,16 @@ describe('diffs/components/app', () => { }); }); + describe('SAST diff', () => { + it('does not fetch Sast data on FOSS', () => { + createComponent(); + jest.spyOn(wrapper.vm, 'fetchSast'); + wrapper.vm.fetchData(false); + + expect(wrapper.vm.fetchSast).not.toHaveBeenCalled(); + }); + }); + it('displays loading icon on loading', () => { createComponent({}, ({ state }) => { state.diffs.isLoading = true; diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js index 3c092296130..fa16af92701 100644 --- a/spec/frontend/diffs/components/commit_item_spec.js +++ b/spec/frontend/diffs/components/commit_item_spec.js @@ -5,7 +5,7 @@ import { TEST_HOST } from 'helpers/test_constants'; import { trimText } from 'helpers/text_helper'; import Component from '~/diffs/components/commit_item.vue'; import { getTimeago } from '~/lib/utils/datetime_utility'; -import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; +import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status.vue'; const TEST_AUTHOR_NAME = 'test'; const TEST_AUTHOR_EMAIL = 'test+test@gitlab.com'; diff --git a/spec/frontend/diffs/components/diff_code_quality_item_spec.js b/spec/frontend/diffs/components/diff_code_quality_item_spec.js index be9fb61a77d..085eb096239 100644 --- a/spec/frontend/diffs/components/diff_code_quality_item_spec.js +++ b/spec/frontend/diffs/components/diff_code_quality_item_spec.js @@ -2,20 +2,22 @@ import { GlIcon, GlLink } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import DiffCodeQualityItem from '~/diffs/components/diff_code_quality_item.vue'; import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants'; -import { multipleFindingsArr } from '../mock_data/diff_code_quality'; +import { multipleFindingsArrCodeQualityScale } from '../mock_data/diff_code_quality'; let wrapper; +const [codeQualityFinding] = multipleFindingsArrCodeQualityScale; const findIcon = () => wrapper.findComponent(GlIcon); const findButton = () => wrapper.findComponent(GlLink); const findDescriptionPlainText = () => wrapper.findByTestId('description-plain-text'); const findDescriptionLinkSection = () => wrapper.findByTestId('description-button-section'); describe('DiffCodeQuality', () => { - const createWrapper = ({ glFeatures = {} } = {}) => { + const createWrapper = ({ glFeatures = {}, link = true } = {}) => { return shallowMountExtended(DiffCodeQualityItem, { propsData: { - finding: multipleFindingsArr[0], + finding: codeQualityFinding, + link, }, provide: { glFeatures, @@ -28,8 +30,8 @@ describe('DiffCodeQuality', () => { expect(findIcon().exists()).toBe(true); expect(findIcon().attributes()).toMatchObject({ - class: `codequality-severity-icon ${SEVERITY_CLASSES[multipleFindingsArr[0].severity]}`, - name: SEVERITY_ICONS[multipleFindingsArr[0].severity], + class: `codequality-severity-icon ${SEVERITY_CLASSES[codeQualityFinding.severity]}`, + name: SEVERITY_ICONS[codeQualityFinding.severity], size: '12', }); }); @@ -41,26 +43,35 @@ describe('DiffCodeQuality', () => { codeQualityInlineDrawer: false, }, }); - expect(findDescriptionPlainText().text()).toContain(multipleFindingsArr[0].severity); - expect(findDescriptionPlainText().text()).toContain(multipleFindingsArr[0].description); + expect(findDescriptionPlainText().text()).toContain(codeQualityFinding.severity); + expect(findDescriptionPlainText().text()).toContain(codeQualityFinding.description); }); }); describe('with codeQualityInlineDrawer flag true', () => { - beforeEach(() => { + const [{ description, severity }] = multipleFindingsArrCodeQualityScale; + const renderedText = `${severity} - ${description}`; + it('when link prop is true, should render gl-link', () => { wrapper = createWrapper({ glFeatures: { codeQualityInlineDrawer: true, }, }); - }); - it('should render severity as plain text', () => { - expect(findDescriptionLinkSection().text()).toContain(multipleFindingsArr[0].severity); + expect(findButton().exists()).toBe(true); + expect(findButton().text()).toBe(renderedText); }); - it('should render button with description text', () => { - expect(findButton().text()).toContain(multipleFindingsArr[0].description); + it('when link prop is false, should not render gl-link', () => { + wrapper = createWrapper({ + glFeatures: { + codeQualityInlineDrawer: true, + }, + link: false, + }); + + expect(findButton().exists()).toBe(false); + expect(findDescriptionLinkSection().text()).toBe(renderedText); }); }); }); diff --git a/spec/frontend/diffs/components/diff_code_quality_spec.js b/spec/frontend/diffs/components/diff_code_quality_spec.js index 9ecfb62e1c5..73976ebd713 100644 --- a/spec/frontend/diffs/components/diff_code_quality_spec.js +++ b/spec/frontend/diffs/components/diff_code_quality_spec.js @@ -1,38 +1,61 @@ -import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import DiffCodeQuality from '~/diffs/components/diff_code_quality.vue'; -import DiffCodeQualityItem from '~/diffs/components/diff_code_quality_item.vue'; -import { NEW_CODE_QUALITY_FINDINGS } from '~/diffs/i18n'; -import { multipleFindingsArr } from '../mock_data/diff_code_quality'; +import DiffInlineFindings from '~/diffs/components/diff_inline_findings.vue'; +import { NEW_CODE_QUALITY_FINDINGS, NEW_SAST_FINDINGS } from '~/diffs/i18n'; +import { + multipleCodeQualityNoSast, + multipleSastNoCodeQuality, +} from '../mock_data/diff_code_quality'; let wrapper; -const diffItems = () => wrapper.findAllComponents(DiffCodeQualityItem); -const findHeading = () => wrapper.findByTestId(`diff-codequality-findings-heading`); +const diffInlineFindings = () => wrapper.findComponent(DiffInlineFindings); +const allDiffInlineFindings = () => wrapper.findAllComponents(DiffInlineFindings); describe('DiffCodeQuality', () => { - const createWrapper = (codeQuality, mountFunction = mountExtended) => { - return mountFunction(DiffCodeQuality, { + const createWrapper = (findings) => { + return mountExtended(DiffCodeQuality, { propsData: { expandedLines: [], - codeQuality, + codeQuality: findings.codeQuality, + sast: findings.sast, }, }); }; it('hides details and throws hideCodeQualityFindings event on close click', async () => { - wrapper = createWrapper(multipleFindingsArr); + wrapper = createWrapper(multipleCodeQualityNoSast); expect(wrapper.findByTestId('diff-codequality').exists()).toBe(true); await wrapper.findByTestId('diff-codequality-close').trigger('click'); - expect(wrapper.emitted('hideCodeQualityFindings').length).toBe(1); + expect(wrapper.emitted('hideCodeQualityFindings')).toHaveLength(1); }); - it('renders heading and correct amount of list items for codequality array and their description', () => { - wrapper = createWrapper(multipleFindingsArr, shallowMountExtended); + it('renders diff inline findings component with correct props for codequality array', () => { + wrapper = createWrapper(multipleCodeQualityNoSast); - expect(findHeading().text()).toEqual(NEW_CODE_QUALITY_FINDINGS); + expect(diffInlineFindings().props('title')).toBe(NEW_CODE_QUALITY_FINDINGS); + expect(diffInlineFindings().props('findings')).toBe(multipleCodeQualityNoSast.codeQuality); + }); + + it('does not render codeQuality section when codeQuality array is empty', () => { + wrapper = createWrapper(multipleSastNoCodeQuality); + + expect(diffInlineFindings().props('title')).toBe(NEW_SAST_FINDINGS); + expect(allDiffInlineFindings()).toHaveLength(1); + }); + + it('renders heading and correct amount of list items for sast array and their description', () => { + wrapper = createWrapper(multipleSastNoCodeQuality); + + expect(diffInlineFindings().props('title')).toBe(NEW_SAST_FINDINGS); + expect(diffInlineFindings().props('findings')).toBe(multipleSastNoCodeQuality.sast); + }); + + it('does not render sast section when sast array is empty', () => { + wrapper = createWrapper(multipleCodeQualityNoSast); - expect(diffItems()).toHaveLength(multipleFindingsArr.length); - expect(diffItems().at(0).props().finding).toEqual(multipleFindingsArr[0]); + expect(diffInlineFindings().props('title')).toBe(NEW_CODE_QUALITY_FINDINGS); + expect(allDiffInlineFindings()).toHaveLength(1); }); }); diff --git a/spec/frontend/diffs/components/diff_content_spec.js b/spec/frontend/diffs/components/diff_content_spec.js index 39d9255aaf9..3b37edbcb1d 100644 --- a/spec/frontend/diffs/components/diff_content_spec.js +++ b/spec/frontend/diffs/components/diff_content_spec.js @@ -2,6 +2,10 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; +import waitForPromises from 'helpers/wait_for_promises'; +import { sprintf } from '~/locale'; +import { createAlert } from '~/alert'; +import * as diffRowUtils from 'ee_else_ce/diffs/components/diff_row_utils'; import DiffContentComponent from '~/diffs/components/diff_content.vue'; import DiffDiscussions from '~/diffs/components/diff_discussions.vue'; import DiffView from '~/diffs/components/diff_view.vue'; @@ -10,9 +14,11 @@ import { diffViewerModes } from '~/ide/constants'; import NoteForm from '~/notes/components/note_form.vue'; import NoPreviewViewer from '~/vue_shared/components/diff_viewer/viewers/no_preview.vue'; import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_diffable.vue'; +import { SOMETHING_WENT_WRONG, SAVING_THE_COMMENT_FAILED } from '~/diffs/i18n'; import { getDiffFileMock } from '../mock_data/diff_file'; Vue.use(Vuex); +jest.mock('~/alert'); describe('DiffContent', () => { let wrapper; @@ -72,6 +78,7 @@ describe('DiffContent', () => { getCommentFormForDiffFile: getCommentFormForDiffFileGetterMock, diffLines: () => () => [...getDiffFileMock().parallel_diff_lines], fileLineCodequality: () => () => [], + fileLineSast: () => () => [], }, actions: { saveDiffDiscussion: saveDiffDiscussionMock, @@ -113,6 +120,32 @@ describe('DiffContent', () => { expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); + + it('should include Sast findings when sastReportsInInlineDiff flag is true', () => { + const mapParallelSpy = jest.spyOn(diffRowUtils, 'mapParallel'); + const mapParallelNoSastSpy = jest.spyOn(diffRowUtils, 'mapParallelNoSast'); + createComponent({ + provide: { + glFeatures: { + sastReportsInInlineDiff: true, + }, + }, + props: { diffFile: { ...textDiffFile, renderingLines: true } }, + }); + + expect(mapParallelSpy).toHaveBeenCalled(); + expect(mapParallelNoSastSpy).not.toHaveBeenCalled(); + }); + + it('should not include Sast findings when sastReportsInInlineDiff flag is false', () => { + const mapParallelSpy = jest.spyOn(diffRowUtils, 'mapParallel'); + const mapParallelNoSastSpy = jest.spyOn(diffRowUtils, 'mapParallelNoSast'); + + createComponent({ props: { diffFile: { ...textDiffFile, renderingLines: true } } }); + + expect(mapParallelNoSastSpy).toHaveBeenCalled(); + expect(mapParallelSpy).not.toHaveBeenCalled(); + }); }); describe('with whitespace only change', () => { @@ -218,5 +251,44 @@ describe('DiffContent', () => { }, }); }); + + describe('when note-form emits `handleFormUpdate`', () => { + const noteStub = {}; + const parentElement = null; + const errorCallback = jest.fn(); + + describe.each` + scenario | serverError | message + ${'with server error'} | ${{ data: { errors: 'error' } }} | ${SAVING_THE_COMMENT_FAILED} + ${'without server error'} | ${null} | ${SOMETHING_WENT_WRONG} + `('$scenario', ({ serverError, message }) => { + beforeEach(async () => { + saveDiffDiscussionMock.mockRejectedValue({ response: serverError }); + + createComponent({ + props: { + diffFile: imageDiffFile, + }, + }); + + wrapper + .findComponent(NoteForm) + .vm.$emit('handleFormUpdate', noteStub, parentElement, errorCallback); + + await waitForPromises(); + }); + + it(`renders ${serverError ? 'server' : 'generic'} error message`, () => { + expect(createAlert).toHaveBeenCalledWith({ + message: sprintf(message, { reason: serverError?.data?.errors }), + parent: parentElement, + }); + }); + + it('calls errorCallback', () => { + expect(errorCallback).toHaveBeenCalled(); + }); + }); + }); }); }); diff --git a/spec/frontend/diffs/components/diff_discussions_spec.js b/spec/frontend/diffs/components/diff_discussions_spec.js index 73d9f2d6d45..40c617da0aa 100644 --- a/spec/frontend/diffs/components/diff_discussions_spec.js +++ b/spec/frontend/diffs/components/diff_discussions_spec.js @@ -25,11 +25,13 @@ describe('DiffDiscussions', () => { }); }; + const findNoteableDiscussion = () => wrapper.findComponent(NoteableDiscussion); + describe('template', () => { it('should have notes list', () => { createComponent(); - expect(wrapper.findComponent(NoteableDiscussion).exists()).toBe(true); + expect(findNoteableDiscussion().exists()).toBe(true); expect(wrapper.findComponent(DiscussionNotes).exists()).toBe(true); expect( wrapper.findComponent(DiscussionNotes).findAllComponents(TimelineEntryItem).length, @@ -51,11 +53,11 @@ describe('DiffDiscussions', () => { it('dispatches toggleDiscussion when clicking collapse button', () => { createComponent({ shouldCollapseDiscussions: true }); - jest.spyOn(wrapper.vm.$store, 'dispatch').mockImplementation(); - const diffNotesToggle = findDiffNotesToggle(); - diffNotesToggle.trigger('click'); + jest.spyOn(store, 'dispatch').mockImplementation(); + + findDiffNotesToggle().trigger('click'); - expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('toggleDiscussion', { + expect(store.dispatch).toHaveBeenCalledWith('toggleDiscussion', { discussionId: discussionsMockData.id, }); }); @@ -77,12 +79,12 @@ describe('DiffDiscussions', () => { discussions[0].expanded = false; createComponent({ discussions, shouldCollapseDiscussions: true }); - expect(wrapper.findComponent(NoteableDiscussion).isVisible()).toBe(false); + expect(findNoteableDiscussion().isVisible()).toBe(false); }); it('renders badge on avatar', () => { createComponent({ renderAvatarBadge: true }); - const noteableDiscussion = wrapper.findComponent(NoteableDiscussion); + const noteableDiscussion = findNoteableDiscussion(); expect(noteableDiscussion.find('.design-note-pin').exists()).toBe(true); expect(noteableDiscussion.find('.design-note-pin').text().trim()).toBe('1'); diff --git a/spec/frontend/diffs/components/diff_file_header_spec.js b/spec/frontend/diffs/components/diff_file_header_spec.js index 3f75b086368..d3afaab492d 100644 --- a/spec/frontend/diffs/components/diff_file_header_spec.js +++ b/spec/frontend/diffs/components/diff_file_header_spec.js @@ -644,22 +644,14 @@ describe('DiffFileHeader component', () => { ); }); - it.each` - commentOnFiles | exists | existsText - ${false} | ${false} | ${'does not'} - ${true} | ${true} | ${'does'} - `( - '$existsText render comment on files button when commentOnFiles is $commentOnFiles', - ({ commentOnFiles, exists }) => { - window.gon = { current_user_id: 1 }; - createComponent({ - props: { - addMergeRequestButtons: true, - }, - options: { provide: { glFeatures: { commentOnFiles } } }, - }); + it('should render the comment on files button', () => { + window.gon = { current_user_id: 1 }; + createComponent({ + props: { + addMergeRequestButtons: true, + }, + }); - expect(wrapper.find('[data-testid="comment-files-button"]').exists()).toEqual(exists); - }, - ); + expect(wrapper.find('[data-testid="comment-files-button"]').exists()).toEqual(true); + }); }); diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js index d9c57ed1470..db6cde883f3 100644 --- a/spec/frontend/diffs/components/diff_file_spec.js +++ b/spec/frontend/diffs/components/diff_file_spec.js @@ -1,7 +1,11 @@ -import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import waitForPromises from 'helpers/wait_for_promises'; +import { sprintf } from '~/locale'; +import { createAlert } from '~/alert'; import DiffContentComponent from 'jh_else_ce/diffs/components/diff_content.vue'; import DiffFileComponent from '~/diffs/components/diff_file.vue'; @@ -11,19 +15,33 @@ import { EVT_EXPAND_ALL_FILES, EVT_PERF_MARK_DIFF_FILES_END, EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN, + FILE_DIFF_POSITION_TYPE, } from '~/diffs/constants'; import eventHub from '~/diffs/event_hub'; -import createDiffsStore from '~/diffs/store/modules'; import { diffViewerModes, diffViewerErrors } from '~/ide/constants'; import axios from '~/lib/utils/axios_utils'; import { scrollToElement } from '~/lib/utils/common_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import createNotesStore from '~/notes/stores/modules'; +import diffsModule from '~/diffs/store/modules'; +import { SOMETHING_WENT_WRONG, SAVING_THE_COMMENT_FAILED } from '~/diffs/i18n'; +import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; import { getDiffFileMock } from '../mock_data/diff_file'; import diffFileMockDataUnreadable from '../mock_data/diff_file_unreadable'; +import diffsMockData from '../mock_data/merge_request_diffs'; jest.mock('~/lib/utils/common_utils'); +jest.mock('~/alert'); +jest.mock('~/notes/mixins/diff_line_note_form', () => ({ + methods: { + addToReview: jest.fn(), + }, +})); + +Vue.use(Vuex); + +const saveDiffDiscussionMock = jest.fn(); function changeViewer(store, index, { automaticallyCollapsed, manuallyCollapsed, name }) { const file = store.state.diffs.diffFiles[index]; @@ -70,18 +88,29 @@ function markFileToBeRendered(store, index = 0) { } function createComponent({ file, first = false, last = false, options = {}, props = {} }) { - Vue.use(Vuex); + const diffs = diffsModule(); + diffs.actions = { + ...diffs.actions, + saveDiffDiscussion: saveDiffDiscussionMock, + }; + + diffs.getters = { + ...diffs.getters, + diffCompareDropdownTargetVersions: () => [], + diffCompareDropdownSourceVersions: () => [], + }; const store = new Vuex.Store({ ...createNotesStore(), - modules: { - diffs: createDiffsStore(), - }, + modules: { diffs }, }); - store.state.diffs.diffFiles = [file]; + store.state.diffs = { + mergeRequestDiff: diffsMockData[0], + diffFiles: [file], + }; - const wrapper = shallowMount(DiffFileComponent, { + const wrapper = shallowMountExtended(DiffFileComponent, { store, propsData: { file, @@ -101,9 +130,10 @@ function createComponent({ file, first = false, last = false, options = {}, prop } const findDiffHeader = (wrapper) => wrapper.findComponent(DiffFileHeaderComponent); -const findDiffContentArea = (wrapper) => wrapper.find('[data-testid="content-area"]'); -const findLoader = (wrapper) => wrapper.find('[data-testid="loader-icon"]'); -const findToggleButton = (wrapper) => wrapper.find('[data-testid="expand-button"]'); +const findDiffContentArea = (wrapper) => wrapper.findByTestId('content-area'); +const findLoader = (wrapper) => wrapper.findByTestId('loader-icon'); +const findToggleButton = (wrapper) => wrapper.findByTestId('expand-button'); +const findNoteForm = (wrapper) => wrapper.findByTestId('file-note-form'); const toggleFile = (wrapper) => findDiffHeader(wrapper).vm.$emit('toggleFile'); const getReadableFile = () => getDiffFileMock(); @@ -118,6 +148,12 @@ const makeFileManuallyCollapsed = (store, index = 0) => const changeViewerType = (store, newType, index = 0) => changeViewer(store, index, { name: diffViewerModes[newType] }); +const triggerSaveNote = (wrapper, note, parent, error) => + findNoteForm(wrapper).vm.$emit('handleFormUpdate', note, parent, error); + +const triggerSaveDraftNote = (wrapper, note, parent, error) => + findNoteForm(wrapper).vm.$emit('handleFormUpdateAddToReview', note, false, parent, error); + describe('DiffFile', () => { let wrapper; let store; @@ -502,7 +538,7 @@ describe('DiffFile', () => { await nextTick(); - const button = wrapper.find('[data-testid="blob-button"]'); + const button = wrapper.findByTestId('blob-button'); expect(wrapper.text()).toContain('Changes are too large to be shown.'); expect(button.html()).toContain('View file @'); @@ -510,24 +546,6 @@ describe('DiffFile', () => { }); }); - it('loads collapsed file on mounted when single file mode is enabled', async () => { - const file = { - ...getReadableFile(), - load_collapsed_diff_url: '/diff_for_path', - highlighted_diff_lines: [], - parallel_diff_lines: [], - viewer: { name: 'collapsed', automaticallyCollapsed: true }, - }; - - axiosMock.onGet(file.load_collapsed_diff_url).reply(HTTP_STATUS_OK, getReadableFile()); - - ({ wrapper, store } = createComponent({ file, props: { viewDiffsFileByFile: true } })); - - await nextTick(); - - expect(findLoader(wrapper).exists()).toBe(true); - }); - describe('merge conflicts', () => { it('does not render conflict alert', () => { const file = { @@ -538,7 +556,7 @@ describe('DiffFile', () => { ({ wrapper, store } = createComponent({ file })); - expect(wrapper.find('[data-testid="conflictsAlert"]').exists()).toBe(false); + expect(wrapper.findByTestId('conflictsAlert').exists()).toBe(false); }); it('renders conflict alert when conflict_type is present', () => { @@ -550,7 +568,7 @@ describe('DiffFile', () => { ({ wrapper, store } = createComponent({ file })); - expect(wrapper.find('[data-testid="conflictsAlert"]').exists()).toBe(true); + expect(wrapper.findByTestId('conflictsAlert').exists()).toBe(true); }); }); @@ -572,10 +590,9 @@ describe('DiffFile', () => { ({ wrapper, store } = createComponent({ file, - options: { provide: { glFeatures: { commentOnFiles: true } } }, })); - expect(wrapper.find('[data-testid="file-discussions"]').exists()).toEqual(exists); + expect(wrapper.findByTestId('file-discussions').exists()).toEqual(exists); }, ); @@ -593,10 +610,9 @@ describe('DiffFile', () => { ({ wrapper, store } = createComponent({ file, - options: { provide: { glFeatures: { commentOnFiles: true } } }, })); - expect(wrapper.find('[data-testid="file-note-form"]').exists()).toEqual(exists); + expect(findNoteForm(wrapper).exists()).toEqual(exists); }, ); @@ -612,10 +628,99 @@ describe('DiffFile', () => { ({ wrapper, store } = createComponent({ file, - options: { provide: { glFeatures: { commentOnFiles: true } } }, })); - expect(wrapper.find('[data-testid="diff-file-discussions"]').exists()).toEqual(exists); + expect(wrapper.findByTestId('diff-file-discussions').exists()).toEqual(exists); + }); + + describe('when note-form emits `handleFormUpdate`', () => { + const file = { + ...getReadableFile(), + hasCommentForm: true, + }; + + const note = {}; + const parentElement = null; + const errorCallback = jest.fn(); + + beforeEach(() => { + ({ wrapper, store } = createComponent({ + file, + options: { provide: { glFeatures: { commentOnFiles: true } } }, + })); + }); + + it('calls saveDiffDiscussionMock', () => { + triggerSaveNote(wrapper, note, parentElement, errorCallback); + + expect(saveDiffDiscussionMock).toHaveBeenCalledWith(expect.any(Object), { + note, + formData: { + noteableData: expect.any(Object), + diffFile: file, + positionType: FILE_DIFF_POSITION_TYPE, + noteableType: store.getters.noteableType, + }, + }); + }); + + describe('when saveDiffDiscussionMock throws an error', () => { + describe.each` + scenario | serverError | message + ${'with server error'} | ${{ data: { errors: 'error' } }} | ${SAVING_THE_COMMENT_FAILED} + ${'without server error'} | ${{}} | ${SOMETHING_WENT_WRONG} + `('$scenario', ({ serverError, message }) => { + beforeEach(async () => { + saveDiffDiscussionMock.mockRejectedValue({ response: serverError }); + + triggerSaveNote(wrapper, note, parentElement, errorCallback); + + await waitForPromises(); + }); + + it(`renders ${serverError ? 'server' : 'generic'} error message`, () => { + expect(createAlert).toHaveBeenCalledWith({ + message: sprintf(message, { reason: serverError?.data?.errors }), + parent: parentElement, + }); + }); + + it('calls errorCallback', () => { + expect(errorCallback).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('when note-form emits `handleFormUpdateAddToReview`', () => { + const file = { + ...getReadableFile(), + hasCommentForm: true, + }; + + const note = {}; + const parentElement = null; + const errorCallback = jest.fn(); + + beforeEach(async () => { + ({ wrapper, store } = createComponent({ + file, + options: { provide: { glFeatures: { commentOnFiles: true } } }, + })); + + triggerSaveDraftNote(wrapper, note, parentElement, errorCallback); + + await nextTick(); + }); + + it('calls addToReview mixin', () => { + expect(diffLineNoteFormMixin.methods.addToReview).toHaveBeenCalledWith( + note, + FILE_DIFF_POSITION_TYPE, + parentElement, + errorCallback, + ); + }); }); }); }); diff --git a/spec/frontend/diffs/components/diff_inline_findings_spec.js b/spec/frontend/diffs/components/diff_inline_findings_spec.js new file mode 100644 index 00000000000..9ccfb2a613d --- /dev/null +++ b/spec/frontend/diffs/components/diff_inline_findings_spec.js @@ -0,0 +1,33 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import DiffInlineFindings from '~/diffs/components/diff_inline_findings.vue'; +import DiffCodeQualityItem from '~/diffs/components/diff_code_quality_item.vue'; +import { NEW_CODE_QUALITY_FINDINGS } from '~/diffs/i18n'; +import { multipleCodeQualityNoSast } from '../mock_data/diff_code_quality'; + +let wrapper; +const heading = () => wrapper.findByTestId('diff-inline-findings-heading'); +const diffCodeQualityItems = () => wrapper.findAllComponents(DiffCodeQualityItem); + +describe('DiffInlineFindings', () => { + const createWrapper = () => { + return shallowMountExtended(DiffInlineFindings, { + propsData: { + title: NEW_CODE_QUALITY_FINDINGS, + findings: multipleCodeQualityNoSast.codeQuality, + }, + }); + }; + + it('renders the title correctly', () => { + wrapper = createWrapper(); + expect(heading().text()).toBe(NEW_CODE_QUALITY_FINDINGS); + }); + + it('renders the correct number of DiffCodeQualityItem components with correct props', () => { + wrapper = createWrapper(); + expect(diffCodeQualityItems()).toHaveLength(multipleCodeQualityNoSast.codeQuality.length); + expect(diffCodeQualityItems().wrappers[0].props('finding')).toEqual( + wrapper.props('findings')[0], + ); + }); +}); diff --git a/spec/frontend/diffs/components/diff_line_note_form_spec.js b/spec/frontend/diffs/components/diff_line_note_form_spec.js index e42b98e4d68..0ca48db2497 100644 --- a/spec/frontend/diffs/components/diff_line_note_form_spec.js +++ b/spec/frontend/diffs/components/diff_line_note_form_spec.js @@ -1,15 +1,20 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import waitForPromises from 'helpers/wait_for_promises'; +import { sprintf } from '~/locale'; +import { createAlert } from '~/alert'; import DiffLineNoteForm from '~/diffs/components/diff_line_note_form.vue'; import store from '~/mr_notes/stores'; import NoteForm from '~/notes/components/note_form.vue'; import MultilineCommentForm from '~/notes/components/multiline_comment_form.vue'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { noteableDataMock } from 'jest/notes/mock_data'; +import { SOMETHING_WENT_WRONG, SAVING_THE_COMMENT_FAILED } from '~/diffs/i18n'; import { getDiffFileMock } from '../mock_data/diff_file'; jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); jest.mock('~/mr_notes/stores', () => jest.requireActual('helpers/mocks/mr_notes/stores')); +jest.mock('~/alert'); describe('DiffLineNoteForm', () => { let wrapper; @@ -17,6 +22,8 @@ describe('DiffLineNoteForm', () => { let diffLines; beforeEach(() => { + store.reset(); + diffFile = getDiffFileMock(); diffLines = diffFile.highlighted_diff_lines; @@ -214,5 +221,38 @@ describe('DiffLineNoteForm', () => { fileHash: diffFile.file_hash, }); }); + + describe('when note-form emits `handleFormUpdate`', () => { + const noteStub = 'invalid note'; + const parentElement = null; + const errorCallback = jest.fn(); + + describe.each` + scenario | serverError | message + ${'with server error'} | ${{ data: { errors: 'error' } }} | ${SAVING_THE_COMMENT_FAILED} + ${'without server error'} | ${null} | ${SOMETHING_WENT_WRONG} + `('$scenario', ({ serverError, message }) => { + beforeEach(async () => { + store.dispatch.mockRejectedValue({ response: serverError }); + + createComponent(); + + await findNoteForm().vm.$emit('handleFormUpdate', noteStub, parentElement, errorCallback); + + await waitForPromises(); + }); + + it(`renders ${serverError ? 'server' : 'generic'} error message`, () => { + expect(createAlert).toHaveBeenCalledWith({ + message: sprintf(message, { reason: serverError?.data?.errors }), + parent: parentElement, + }); + }); + + it('calls errorCallback', () => { + expect(errorCallback).toHaveBeenCalled(); + }); + }); + }); }); }); diff --git a/spec/frontend/diffs/components/diff_line_spec.js b/spec/frontend/diffs/components/diff_line_spec.js index 37368eb1461..a552a9d3e7f 100644 --- a/spec/frontend/diffs/components/diff_line_spec.js +++ b/spec/frontend/diffs/components/diff_line_spec.js @@ -16,6 +16,13 @@ const left = { severity: EXAMPLE_SEVERITY, }, ], + sast: [ + { + line: EXAMPLE_LINE_NUMBER, + description: EXAMPLE_DESCRIPTION, + severity: EXAMPLE_SEVERITY, + }, + ], }, }, }; @@ -30,6 +37,13 @@ const right = { severity: EXAMPLE_SEVERITY, }, ], + sast: [ + { + line: EXAMPLE_LINE_NUMBER, + description: EXAMPLE_DESCRIPTION, + severity: EXAMPLE_SEVERITY, + }, + ], }, }, }; @@ -60,6 +74,13 @@ describe('DiffLine', () => { severity: EXAMPLE_SEVERITY, }, ]); + expect(wrapper.findComponent(DiffCodeQuality).props('sast')).toEqual([ + { + line: EXAMPLE_LINE_NUMBER, + description: EXAMPLE_DESCRIPTION, + severity: EXAMPLE_SEVERITY, + }, + ]); }); }); }); diff --git a/spec/frontend/diffs/components/diff_row_spec.js b/spec/frontend/diffs/components/diff_row_spec.js index 356c7ef925a..119b8f9ad7f 100644 --- a/spec/frontend/diffs/components/diff_row_spec.js +++ b/spec/frontend/diffs/components/diff_row_spec.js @@ -33,6 +33,14 @@ describe('DiffRow', () => { left: { old_line: 1, discussions: [] }, right: { new_line: 1, discussions: [] }, }, + { + left: {}, + right: {}, + isMetaLineLeft: true, + isMetaLineRight: false, + isContextLineLeft: true, + isContextLineRight: false, + }, ]; const createWrapper = ({ props, state = {}, actions, isLoggedIn = true }) => { @@ -273,6 +281,12 @@ describe('DiffRow', () => { expect(findInteropAttributes(wrapper, '[data-testid="right-side"]')).toEqual(rightSide); }); }); + + it('renders comment button when isMetaLineLeft is false and isMetaLineRight is true', () => { + wrapper = createWrapper({ props: { line: testLines[4], inline: false } }); + + expect(wrapper.find('.add-diff-note').exists()).toBe(true); + }); }); describe('coverage state memoization', () => { diff --git a/spec/frontend/diffs/components/tree_list_spec.js b/spec/frontend/diffs/components/tree_list_spec.js index 1ec8547d325..f56dd28ce9c 100644 --- a/spec/frontend/diffs/components/tree_list_spec.js +++ b/spec/frontend/diffs/components/tree_list_spec.js @@ -1,4 +1,3 @@ -import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import TreeList from '~/diffs/components/tree_list.vue'; @@ -6,18 +5,21 @@ import createStore from '~/diffs/store/modules'; import batchComments from '~/batch_comments/stores/modules/batch_comments'; import DiffFileRow from '~/diffs/components//diff_file_row.vue'; import { stubComponent } from 'helpers/stub_component'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; describe('Diffs tree list component', () => { let wrapper; let store; const getScroller = () => wrapper.findComponent({ name: 'RecycleScroller' }); const getFileRow = () => wrapper.findComponent(DiffFileRow); + const findDiffTreeSearch = () => wrapper.findByTestId('diff-tree-search'); + Vue.use(Vuex); - const createComponent = () => { - wrapper = shallowMount(TreeList, { + const createComponent = ({ hideFileStats = false } = {}) => { + wrapper = shallowMountExtended(TreeList, { store, - propsData: { hideFileStats: false }, + propsData: { hideFileStats }, stubs: { // eslint will fail if we import the real component RecycleScroller: stubComponent( @@ -116,7 +118,10 @@ describe('Diffs tree list component', () => { describe('search by file extension', () => { it('hides scroller for no matches', async () => { - wrapper.find('[data-testid="diff-tree-search"]').setValue('*.md'); + const input = findDiffTreeSearch(); + + input.element.value = '*.md'; + input.trigger('input'); await nextTick(); @@ -131,7 +136,10 @@ describe('Diffs tree list component', () => { ${'app/*.js'} | ${2} ${'*.js, *.rb'} | ${3} `('returns $itemSize item for $extension', async ({ extension, itemSize }) => { - wrapper.find('[data-testid="diff-tree-search"]').setValue(extension); + const input = findDiffTreeSearch(); + + input.element.value = extension; + input.trigger('input'); await nextTick(); @@ -143,23 +151,21 @@ describe('Diffs tree list component', () => { expect(getScroller().props('items')).toHaveLength(2); }); - it('hides file stats', async () => { - wrapper.setProps({ hideFileStats: true }); - - await nextTick(); - expect(wrapper.find('.file-row-stats').exists()).toBe(false); + it('hides file stats', () => { + createComponent({ hideFileStats: true }); + expect(getFileRow().props('hideFileStats')).toBe(true); }); it('calls toggleTreeOpen when clicking folder', () => { - jest.spyOn(wrapper.vm.$store, 'dispatch').mockReturnValue(undefined); + jest.spyOn(store, 'dispatch').mockReturnValue(undefined); getFileRow().vm.$emit('toggleTreeOpen', 'app'); - expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('diffs/toggleTreeOpen', 'app'); + expect(store.dispatch).toHaveBeenCalledWith('diffs/toggleTreeOpen', 'app'); }); it('renders when renderTreeList is false', async () => { - wrapper.vm.$store.state.diffs.renderTreeList = false; + store.state.diffs.renderTreeList = false; await nextTick(); expect(getScroller().props('items')).toHaveLength(3); @@ -178,7 +184,7 @@ describe('Diffs tree list component', () => { createComponent(); await nextTick(); - expect(wrapper.findComponent(DiffFileRow).props('viewedFiles')).toBe(viewedDiffFileIds); + expect(getFileRow().props('viewedFiles')).toBe(viewedDiffFileIds); }); }); }); diff --git a/spec/frontend/diffs/mock_data/diff_code_quality.js b/spec/frontend/diffs/mock_data/diff_code_quality.js index 29f16da8d89..5b9ed538e01 100644 --- a/spec/frontend/diffs/mock_data/diff_code_quality.js +++ b/spec/frontend/diffs/mock_data/diff_code_quality.js @@ -1,49 +1,120 @@ -export const multipleFindingsArr = [ +export const multipleFindingsArrCodeQualityScale = [ { severity: 'minor', description: 'mocked minor Issue', line: 2, + scale: 'codeQuality', }, { severity: 'major', description: 'mocked major Issue', line: 3, + scale: 'codeQuality', }, { severity: 'info', description: 'mocked info Issue', line: 3, + scale: 'codeQuality', }, { severity: 'critical', description: 'mocked critical Issue', line: 3, + scale: 'codeQuality', }, { severity: 'blocker', description: 'mocked blocker Issue', line: 3, + scale: 'codeQuality', }, { severity: 'unknown', description: 'mocked unknown Issue', line: 3, + scale: 'codeQuality', }, ]; -export const fiveFindings = { +export const multipleFindingsArrSastScale = [ + { + severity: 'low', + description: 'mocked low Issue', + line: 2, + scale: 'sast', + }, + { + severity: 'medium', + description: 'mocked medium Issue', + line: 3, + scale: 'sast', + }, + { + severity: 'info', + description: 'mocked info Issue', + line: 3, + scale: 'sast', + }, + { + severity: 'high', + description: 'mocked high Issue', + line: 3, + scale: 'sast', + }, + { + severity: 'critical', + description: 'mocked critical Issue', + line: 3, + scale: 'sast', + }, + { + severity: 'unknown', + description: 'mocked unknown Issue', + line: 3, + scale: 'sast', + }, +]; + +export const multipleCodeQualityNoSast = { + codeQuality: multipleFindingsArrCodeQualityScale, + sast: [], +}; + +export const multipleSastNoCodeQuality = { + codeQuality: [], + sast: multipleFindingsArrSastScale, +}; + +export const fiveCodeQualityFindings = { + filePath: 'index.js', + codequality: multipleFindingsArrCodeQualityScale.slice(0, 5), +}; + +export const threeCodeQualityFindings = { + filePath: 'index.js', + codequality: multipleFindingsArrCodeQualityScale.slice(0, 3), +}; + +export const singularCodeQualityFinding = { + filePath: 'index.js', + codequality: [multipleFindingsArrCodeQualityScale[0]], +}; + +export const singularFindingSast = { filePath: 'index.js', - codequality: multipleFindingsArr.slice(0, 5), + sast: [multipleFindingsArrSastScale[0]], }; -export const threeFindings = { +export const threeSastFindings = { filePath: 'index.js', - codequality: multipleFindingsArr.slice(0, 3), + sast: multipleFindingsArrSastScale.slice(0, 3), }; -export const singularFinding = { +export const oneCodeQualityTwoSastFindings = { filePath: 'index.js', - codequality: [multipleFindingsArr[0]], + sast: multipleFindingsArrSastScale.slice(0, 2), + codequality: [multipleFindingsArrCodeQualityScale[0]], }; export const diffCodeQuality = { @@ -73,7 +144,7 @@ export const diffCodeQuality = { old_line: null, new_line: 2, - codequality: [multipleFindingsArr[0]], + codequality: [multipleFindingsArrCodeQualityScale[0]], lineDrafts: [], }, }, diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index 7534fe741e7..bbe748b8e1f 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -11,7 +11,7 @@ import { PARALLEL_DIFF_VIEW_TYPE, EVT_MR_PREPARED, } from '~/diffs/constants'; -import { LOAD_SINGLE_DIFF_FAILED } from '~/diffs/i18n'; +import { LOAD_SINGLE_DIFF_FAILED, BUILDING_YOUR_MR, SOMETHING_WENT_WRONG } from '~/diffs/i18n'; import * as diffActions from '~/diffs/store/actions'; import * as types from '~/diffs/store/mutation_types'; import * as utils from '~/diffs/store/utils'; @@ -87,6 +87,7 @@ describe('DiffsStoreActions', () => { a: ['z', 'hash:a'], b: ['y', 'hash:a'], }; + const diffViewType = 'inline'; return testAction( diffActions.setBaseConfig, @@ -100,6 +101,7 @@ describe('DiffsStoreActions', () => { dismissEndpoint, showSuggestPopover, mrReviews, + diffViewType, }, { endpoint: '', @@ -124,6 +126,7 @@ describe('DiffsStoreActions', () => { dismissEndpoint, showSuggestPopover, mrReviews, + diffViewType, }, }, { @@ -362,7 +365,7 @@ describe('DiffsStoreActions', () => { { type: types.SET_RETRIEVING_BATCHES, payload: false }, { type: types.SET_BATCH_LOADING_STATE, payload: 'error' }, ], - [{ type: 'startRenderDiffsQueue' }, { type: 'startRenderDiffsQueue' }], + [], ); }); }); @@ -418,9 +421,7 @@ describe('DiffsStoreActions', () => { expect(createAlert).toHaveBeenCalledTimes(1); expect(createAlert).toHaveBeenCalledWith({ - message: expect.stringMatching( - 'Building your merge request… This page will update when the build is complete.', - ), + message: BUILDING_YOUR_MR, variant: 'warning', }); }); @@ -482,7 +483,7 @@ describe('DiffsStoreActions', () => { await testAction(diffActions.fetchCoverageFiles, {}, { endpointCoverage }, [], []); expect(createAlert).toHaveBeenCalledTimes(1); expect(createAlert).toHaveBeenCalledWith({ - message: expect.stringMatching('Something went wrong'), + message: SOMETHING_WENT_WRONG, }); }); }); @@ -663,41 +664,6 @@ describe('DiffsStoreActions', () => { }); }); - describe('startRenderDiffsQueue', () => { - it('should set all files to RENDER_FILE', () => { - const state = { - diffFiles: [ - { - id: 1, - renderIt: false, - viewer: { - automaticallyCollapsed: false, - }, - }, - { - id: 2, - renderIt: false, - viewer: { - automaticallyCollapsed: false, - }, - }, - ], - }; - - const pseudoCommit = (commitType, file) => { - expect(commitType).toBe(types.RENDER_FILE); - Object.assign(file, { - renderIt: true, - }); - }; - - diffActions.startRenderDiffsQueue({ state, commit: pseudoCommit }); - - expect(state.diffFiles[0].renderIt).toBe(true); - expect(state.diffFiles[1].renderIt).toBe(true); - }); - }); - describe('setInlineDiffViewType', () => { it('should set diff view type to inline and also set the cookie properly', async () => { await testAction( @@ -1285,12 +1251,11 @@ describe('DiffsStoreActions', () => { $emit = jest.spyOn(eventHub, '$emit'); }); - it('renders and expands file for the given discussion id', () => { + it('expands the file for the given discussion id', () => { const localState = state({ collapsed: true, renderIt: false }); diffActions.renderFileForDiscussionId({ rootState, state: localState, commit }, '123'); - expect(commit).toHaveBeenCalledWith('RENDER_FILE', localState.diffFiles[0]); expect($emit).toHaveBeenCalledTimes(1); expect(commonUtils.scrollToElement).toHaveBeenCalledTimes(1); }); @@ -1377,18 +1342,6 @@ describe('DiffsStoreActions', () => { }); }); - describe('setRenderIt', () => { - it('commits RENDER_FILE', () => { - return testAction( - diffActions.setRenderIt, - 'file', - {}, - [{ type: types.RENDER_FILE, payload: 'file' }], - [], - ); - }); - }); - describe('receiveFullDiffError', () => { it('updates state with the file that did not load', () => { return testAction( @@ -1513,7 +1466,7 @@ describe('DiffsStoreActions', () => { payload: { filePath: testFilePath, lines: [preparedLine, preparedLine] }, }, ], - [{ type: 'startRenderDiffsQueue' }], + [], ); }, ); diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js index b089cf22b14..274cb40dac8 100644 --- a/spec/frontend/diffs/store/mutations_spec.js +++ b/spec/frontend/diffs/store/mutations_spec.js @@ -12,6 +12,7 @@ describe('DiffsStoreMutations', () => { ${'endpoint'} | ${'/diffs/endpoint'} ${'projectPath'} | ${'/root/project'} ${'endpointUpdateUser'} | ${'/user/preferences'} + ${'diffViewType'} | ${'parallel'} `('should set the $prop property into state', ({ prop, value }) => { const state = {}; @@ -104,7 +105,6 @@ describe('DiffsStoreMutations', () => { mutations[types.SET_DIFF_DATA_BATCH](state, diffMock); - expect(state.diffFiles[0].renderIt).toEqual(true); expect(state.diffFiles[0].collapsed).toEqual(false); expect(state.treeEntries[mockFile.file_path].diffLoaded).toBe(true); }); diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index 888df06d6b9..117ed56e347 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -437,7 +437,7 @@ describe('DiffsStoreUtils', () => { }); }); - it('sets the renderIt and collapsed attribute on files', () => { + it('sets the collapsed attribute on files', () => { const checkLine = preparedDiff.diff_files[0][INLINE_DIFF_LINES_KEY][0]; expect(checkLine.discussions.length).toBe(0); @@ -448,7 +448,6 @@ describe('DiffsStoreUtils', () => { expect(firstChar).not.toBe('+'); expect(firstChar).not.toBe('-'); - expect(preparedDiff.diff_files[0].renderIt).toBe(true); expect(preparedDiff.diff_files[0].collapsed).toBe(false); }); @@ -529,8 +528,7 @@ describe('DiffsStoreUtils', () => { preparedDiffFiles = utils.prepareDiffData({ diff: mock, meta: true }); }); - it('sets the renderIt and collapsed attribute on files', () => { - expect(preparedDiffFiles[0].renderIt).toBe(true); + it('sets the collapsed attribute on files', () => { expect(preparedDiffFiles[0].collapsed).toBeUndefined(); }); diff --git a/spec/frontend/drawio/drawio_editor_spec.js b/spec/frontend/drawio/drawio_editor_spec.js index 4d93908b757..5a77b9d4689 100644 --- a/spec/frontend/drawio/drawio_editor_spec.js +++ b/spec/frontend/drawio/drawio_editor_spec.js @@ -66,7 +66,6 @@ describe('drawio/drawio_editor', () => { }); afterEach(() => { - jest.clearAllMocks(); findDrawioIframe()?.remove(); }); diff --git a/spec/frontend/dropzone_input_spec.js b/spec/frontend/dropzone_input_spec.js index 57debf79c7b..ba4d838e44b 100644 --- a/spec/frontend/dropzone_input_spec.js +++ b/spec/frontend/dropzone_input_spec.js @@ -1,6 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; -import htmlNewMilestone from 'test_fixtures/milestones/new-milestone.html'; import mock from 'xhr-mock'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; @@ -9,6 +8,7 @@ import PasteMarkdownTable from '~/behaviors/markdown/paste_markdown_table'; import dropzoneInput from '~/dropzone_input'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import htmlNewMilestone from 'test_fixtures_static/textarea.html'; const TEST_FILE = new File([], 'somefile.jpg'); TEST_FILE.upload = {}; diff --git a/spec/frontend/editor/source_editor_extension_base_spec.js b/spec/frontend/editor/source_editor_extension_base_spec.js index 70bc1dee0ee..c820d6ac63d 100644 --- a/spec/frontend/editor/source_editor_extension_base_spec.js +++ b/spec/frontend/editor/source_editor_extension_base_spec.js @@ -56,7 +56,6 @@ describe('The basis for an Source Editor extension', () => { }); afterEach(() => { - jest.clearAllMocks(); resetHTMLFixture(); }); diff --git a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js index 512b298bbbd..d9e1a22d60d 100644 --- a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js +++ b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js @@ -182,10 +182,6 @@ describe('Markdown Live Preview Extension for Source Editor', () => { instance.togglePreview(); }); - afterEach(() => { - jest.clearAllMocks(); - }); - it('does not do anything if there is no model', () => { instance.setModel(null); @@ -199,9 +195,6 @@ describe('Markdown Live Preview Extension for Source Editor', () => { mockAxios.onPost().reply(HTTP_STATUS_OK, { body: responseData }); await togglePreview(); }); - afterEach(() => { - jest.clearAllMocks(); - }); it('removes the registered buttons from the toolbar', () => { expect(instance.toolbar.removeItems).not.toHaveBeenCalled(); diff --git a/spec/frontend/editor/source_editor_yaml_ext_spec.js b/spec/frontend/editor/source_editor_yaml_ext_spec.js index 14ec7f8b93f..4b1ed0fbb42 100644 --- a/spec/frontend/editor/source_editor_yaml_ext_spec.js +++ b/spec/frontend/editor/source_editor_yaml_ext_spec.js @@ -368,10 +368,6 @@ abc: def let highlightLinesSpy; let removeHighlightsSpy; - afterEach(() => { - jest.clearAllMocks(); - }); - it.each` highlightPathOnSetup | path | keepOnNotFound | expectHighlightLinesToBeCalled | withLines | expectRemoveHighlightsToBeCalled | storedHighlightPath ${null} | ${undefined} | ${false} | ${false} | ${undefined} | ${true} | ${null} diff --git a/spec/frontend/emoji/index_spec.js b/spec/frontend/emoji/index_spec.js index 36c3eeb5a52..1b948cce73a 100644 --- a/spec/frontend/emoji/index_spec.js +++ b/spec/frontend/emoji/index_spec.js @@ -7,6 +7,7 @@ import { clearEmojiMock, } from 'helpers/emoji'; import { trimText } from 'helpers/text_helper'; +import { createMockClient } from 'helpers/mock_apollo_helper'; import { glEmojiTag, searchEmoji, @@ -14,6 +15,8 @@ import { sortEmoji, initEmojiMap, getAllEmoji, + emojiFallbackImageSrc, + loadCustomEmojiWithNames, } from '~/emoji'; import isEmojiUnicodeSupported, { @@ -25,6 +28,12 @@ import isEmojiUnicodeSupported, { isPersonZwjEmoji, } from '~/emoji/support/is_emoji_unicode_supported'; import { NEUTRAL_INTENT_MULTIPLIER } from '~/emoji/constants'; +import customEmojiQuery from '~/emoji/queries/custom_emoji.query.graphql'; + +let mockClient; +jest.mock('~/lib/graphql', () => { + return () => mockClient; +}); const emptySupportMap = { personZwj: false, @@ -45,12 +54,35 @@ const emptySupportMap = { 1.1: false, }; +function createMockEmojiClient() { + mockClient = createMockClient([ + [ + customEmojiQuery, + jest.fn().mockResolvedValue({ + data: { + group: { + id: 1, + customEmoji: { + nodes: [{ id: 1, name: 'parrot', url: 'parrot.gif' }], + }, + }, + }, + }), + ], + ]); + + window.gon = { features: { customEmoji: true } }; + document.body.dataset.groupFullPath = 'test-group'; +} + describe('emoji', () => { beforeEach(async () => { await initEmojiMock(); }); afterEach(() => { + window.gon = {}; + delete document.body.dataset.groupFullPath; clearEmojiMock(); }); @@ -690,4 +722,67 @@ describe('emoji', () => { expect(scoredItems.sort(sortEmoji)).toEqual(expected); }); }); + + describe('emojiFallbackImageSrc', () => { + beforeEach(async () => { + createMockEmojiClient(); + + await initEmojiMock(); + }); + + it.each` + emoji | src + ${'thumbsup'} | ${'/-/emojis/2/thumbsup.png'} + ${'parrot'} | ${'parrot.gif'} + `('returns $src for emoji with name $emoji', ({ emoji, src }) => { + expect(emojiFallbackImageSrc(emoji)).toBe(src); + }); + }); + + describe('loadCustomEmojiWithNames', () => { + beforeEach(() => { + createMockEmojiClient(); + }); + + describe('flag disabled', () => { + beforeEach(() => { + window.gon = {}; + }); + + it('returns empty object', async () => { + const result = await loadCustomEmojiWithNames(); + + expect(result).toEqual({}); + }); + }); + + describe('when not in a group', () => { + beforeEach(() => { + delete document.body.dataset.groupFullPath; + }); + + it('returns empty object', async () => { + const result = await loadCustomEmojiWithNames(); + + expect(result).toEqual({}); + }); + }); + + describe('when in a group with flag enabled', () => { + it('returns empty object', async () => { + const result = await loadCustomEmojiWithNames(); + + expect(result).toEqual({ + parrot: { + c: 'custom', + d: 'parrot', + e: undefined, + name: 'parrot', + src: 'parrot.gif', + u: 'custom', + }, + }); + }); + }); + }); }); diff --git a/spec/frontend/environments/edit_environment_spec.js b/spec/frontend/environments/edit_environment_spec.js index f436c96f4a5..93fe9ed9400 100644 --- a/spec/frontend/environments/edit_environment_spec.js +++ b/spec/frontend/environments/edit_environment_spec.js @@ -1,15 +1,13 @@ import { GlLoadingIcon } from '@gitlab/ui'; -import MockAdapter from 'axios-mock-adapter'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import EditEnvironment from '~/environments/components/edit_environment.vue'; import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { visitUrl } from '~/lib/utils/url_utility'; import getEnvironment from '~/environments/graphql/queries/environment.query.graphql'; +import getEnvironmentWithNamespace from '~/environments/graphql/queries/environment_with_namespace.graphql'; import updateEnvironment from '~/environments/graphql/mutations/update_environment.mutation.graphql'; import { __ } from '~/locale'; import createMockApollo from '../__helpers__/mock_apollo_helper'; @@ -17,15 +15,15 @@ import createMockApollo from '../__helpers__/mock_apollo_helper'; jest.mock('~/lib/utils/url_utility'); jest.mock('~/alert'); -const newExternalUrl = 'https://google.ca'; const environment = { id: '1', name: 'foo', externalUrl: 'https://foo.example.com', clusterAgent: null, + kubernetesNamespace: null, }; const resolvedEnvironment = { project: { id: '1', environment } }; -const environmentUpdate = { +const environmentUpdateSuccess = { environment: { id: '1', path: 'path/to/environment', clusterAgentId: null }, errors: [], }; @@ -36,46 +34,51 @@ const environmentUpdateError = { const provide = { projectEnvironmentsPath: '/projects/environments', - updateEnvironmentPath: '/projects/environments/1', protectedEnvironmentSettingsPath: '/projects/1/settings/ci_cd', projectPath: '/path/to/project', + environmentName: 'foo', }; describe('~/environments/components/edit.vue', () => { let wrapper; - let mock; - const createMockApolloProvider = (mutationResult) => { + const getEnvironmentQuery = jest.fn().mockResolvedValue({ data: resolvedEnvironment }); + const getEnvironmentWithNamespaceQuery = jest + .fn() + .mockResolvedValue({ data: resolvedEnvironment }); + + const updateEnvironmentSuccess = jest + .fn() + .mockResolvedValue({ data: { environmentUpdate: environmentUpdateSuccess } }); + const updateEnvironmentFail = jest + .fn() + .mockResolvedValue({ data: { environmentUpdate: environmentUpdateError } }); + + const createMockApolloProvider = (mutationHandler) => { Vue.use(VueApollo); const mocks = [ - [getEnvironment, jest.fn().mockResolvedValue({ data: resolvedEnvironment })], - [ - updateEnvironment, - jest.fn().mockResolvedValue({ data: { environmentUpdate: mutationResult } }), - ], + [getEnvironment, getEnvironmentQuery], + [getEnvironmentWithNamespace, getEnvironmentWithNamespaceQuery], + [updateEnvironment, mutationHandler], ]; return createMockApollo(mocks); }; - const createWrapper = () => { - wrapper = mountExtended(EditEnvironment, { - propsData: { environment: { id: '1', name: 'foo', external_url: 'https://foo.example.com' } }, - provide, - }); - }; - - const createWrapperWithApollo = async ({ mutationResult = environmentUpdate } = {}) => { + const createWrapperWithApollo = async ({ + mutationHandler = updateEnvironmentSuccess, + kubernetesNamespaceForEnvironment = false, + } = {}) => { wrapper = mountExtended(EditEnvironment, { propsData: { environment: {} }, provide: { ...provide, glFeatures: { - environmentSettingsToGraphql: true, + kubernetesNamespaceForEnvironment, }, }, - apolloProvider: createMockApolloProvider(mutationResult), + apolloProvider: createMockApolloProvider(mutationHandler), }); await waitForPromises(); @@ -87,43 +90,46 @@ describe('~/environments/components/edit.vue', () => { const showsLoading = () => wrapper.findComponent(GlLoadingIcon).exists(); - const submitForm = async () => { - await findExternalUrlInput().setValue(newExternalUrl); - await findForm().trigger('submit'); - }; - describe('default', () => { - beforeEach(async () => { - await createWrapper(); + it('performs the environment apollo query', () => { + createWrapperWithApollo(); + expect(getEnvironmentQuery).toHaveBeenCalled(); + }); + + it('renders loading icon when environment query is loading', () => { + createWrapperWithApollo(); + expect(showsLoading()).toBe(true); }); - it('sets the title to Edit environment', () => { + it('sets the title to Edit environment', async () => { + await createWrapperWithApollo(); + const header = wrapper.findByRole('heading', { name: __('Edit environment') }); expect(header.exists()).toBe(true); }); - it('renders a disabled "Name" field', () => { - const nameInput = findNameInput(); + it('renders a disabled "Name" field', async () => { + await createWrapperWithApollo(); + const nameInput = findNameInput(); expect(nameInput.attributes().disabled).toBe('disabled'); expect(nameInput.element.value).toBe(environment.name); }); - it('renders an "External URL" field', () => { - const urlInput = findExternalUrlInput(); + it('renders an "External URL" field', async () => { + await createWrapperWithApollo(); + const urlInput = findExternalUrlInput(); expect(urlInput.element.value).toBe(environment.externalUrl); }); }); - describe('when environmentSettingsToGraphql feature is enabled', () => { - describe('when mounted', () => { - beforeEach(() => { - createWrapperWithApollo(); - }); - it('renders loading icon when environment query is loading', () => { - expect(showsLoading()).toBe(true); - }); + describe('on submit', () => { + it('performs the updateEnvironment apollo mutation', async () => { + await createWrapperWithApollo(); + await findForm().trigger('submit'); + + expect(updateEnvironmentSuccess).toHaveBeenCalled(); }); describe('when mutation successful', () => { @@ -134,28 +140,28 @@ describe('~/environments/components/edit.vue', () => { it('shows loader after form is submitted', async () => { expect(showsLoading()).toBe(false); - await submitForm(); + await findForm().trigger('submit'); expect(showsLoading()).toBe(true); }); it('submits the updated environment on submit', async () => { - await submitForm(); + await findForm().trigger('submit'); await waitForPromises(); - expect(visitUrl).toHaveBeenCalledWith(environmentUpdate.environment.path); + expect(visitUrl).toHaveBeenCalledWith(environmentUpdateSuccess.environment.path); }); }); describe('when mutation failed', () => { beforeEach(async () => { await createWrapperWithApollo({ - mutationResult: environmentUpdateError, + mutationHandler: updateEnvironmentFail, }); }); it('shows errors on error', async () => { - await submitForm(); + await findForm().trigger('submit'); await waitForPromises(); expect(createAlert).toHaveBeenCalledWith({ message: 'uh oh!' }); @@ -164,58 +170,10 @@ describe('~/environments/components/edit.vue', () => { }); }); - describe('when environmentSettingsToGraphql feature is disabled', () => { - beforeEach(() => { - mock = new MockAdapter(axios); - createWrapper(); - }); - - afterEach(() => { - mock.restore(); - }); - - it('shows loader after form is submitted', async () => { - expect(showsLoading()).toBe(false); - - mock - .onPut(provide.updateEnvironmentPath, { - external_url: newExternalUrl, - id: environment.id, - }) - .reply(...[HTTP_STATUS_OK, { path: '/test' }]); - - await submitForm(); - - expect(showsLoading()).toBe(true); - }); - - it('submits the updated environment on submit', async () => { - mock - .onPut(provide.updateEnvironmentPath, { - external_url: newExternalUrl, - id: environment.id, - }) - .reply(...[HTTP_STATUS_OK, { path: '/test' }]); - - await submitForm(); - await waitForPromises(); - - expect(visitUrl).toHaveBeenCalledWith('/test'); - }); - - it('shows errors on error', async () => { - mock - .onPut(provide.updateEnvironmentPath, { - external_url: newExternalUrl, - id: environment.id, - }) - .reply(...[HTTP_STATUS_BAD_REQUEST, { message: ['uh oh!'] }]); - - await submitForm(); - await waitForPromises(); - - expect(createAlert).toHaveBeenCalledWith({ message: 'uh oh!' }); - expect(showsLoading()).toBe(false); + describe('when `kubernetesNamespaceForEnvironment` is enabled', () => { + it('calls the `getEnvironmentWithNamespace` query', () => { + createWrapperWithApollo({ kubernetesNamespaceForEnvironment: true }); + expect(getEnvironmentWithNamespaceQuery).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/environments/environment_form_spec.js b/spec/frontend/environments/environment_form_spec.js index db81c490747..803207bcce8 100644 --- a/spec/frontend/environments/environment_form_spec.js +++ b/spec/frontend/environments/environment_form_spec.js @@ -1,4 +1,4 @@ -import { GlLoadingIcon, GlCollapsibleListbox } from '@gitlab/ui'; +import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import waitForPromises from 'helpers/wait_for_promises'; @@ -6,6 +6,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import EnvironmentForm from '~/environments/components/environment_form.vue'; import getUserAuthorizedAgents from '~/environments/graphql/queries/user_authorized_agents.query.graphql'; import createMockApollo from '../__helpers__/mock_apollo_helper'; +import { mockKasTunnelUrl } from './mock_data'; jest.mock('~/lib/utils/csrf'); @@ -15,7 +16,10 @@ const DEFAULT_PROPS = { cancelPath: '/cancel', }; -const PROVIDE = { protectedEnvironmentSettingsPath: '/projects/not_real/settings/ci_cd' }; +const PROVIDE = { + protectedEnvironmentSettingsPath: '/projects/not_real/settings/ci_cd', + kasTunnelUrl: mockKasTunnelUrl, +}; const userAccessAuthorizedAgents = [ { agent: { id: '1', name: 'agent-1' } }, { agent: { id: '2', name: 'agent-2' } }, @@ -24,6 +28,10 @@ const userAccessAuthorizedAgents = [ describe('~/environments/components/form.vue', () => { let wrapper; + const getNamespacesQueryResult = jest + .fn() + .mockReturnValue([{ metadata: { name: 'default' } }, { metadata: { name: 'agent' } }]); + const createWrapper = (propsData = {}, options = {}) => mountExtended(EnvironmentForm, { provide: PROVIDE, @@ -34,37 +42,57 @@ describe('~/environments/components/form.vue', () => { }, }); - const createWrapperWithApollo = ({ propsData = {} } = {}) => { + const createWrapperWithApollo = ({ + propsData = {}, + kubernetesNamespaceForEnvironment = false, + queryResult = null, + } = {}) => { Vue.use(VueApollo); + const requestHandlers = [ + [ + getUserAuthorizedAgents, + jest.fn().mockResolvedValue({ + data: { + project: { + id: '1', + userAccessAuthorizedAgents: { nodes: userAccessAuthorizedAgents }, + }, + }, + }), + ], + ]; + + const mockResolvers = { + Query: { + k8sNamespaces: queryResult || getNamespacesQueryResult, + }, + }; + return mountExtended(EnvironmentForm, { provide: { ...PROVIDE, glFeatures: { - environmentSettingsToGraphql: true, + kubernetesNamespaceForEnvironment, }, }, propsData: { ...DEFAULT_PROPS, ...propsData, }, - apolloProvider: createMockApollo([ - [ - getUserAuthorizedAgents, - jest.fn().mockResolvedValue({ - data: { - project: { - id: '1', - userAccessAuthorizedAgents: { nodes: userAccessAuthorizedAgents }, - }, - }, - }), - ], - ]), + apolloProvider: createMockApollo(requestHandlers, mockResolvers), }); }; - const findAgentSelector = () => wrapper.findComponent(GlCollapsibleListbox); + const findAgentSelector = () => wrapper.findByTestId('agent-selector'); + const findNamespaceSelector = () => wrapper.findByTestId('namespace-selector'); + const findAlert = () => wrapper.findComponent(GlAlert); + + const selectAgent = async () => { + findAgentSelector().vm.$emit('shown'); + await waitForPromises(); + await findAgentSelector().vm.$emit('select', '2'); + }; describe('default', () => { beforeEach(() => { @@ -207,12 +235,6 @@ describe('~/environments/components/form.vue', () => { expect(urlInput.element.value).toBe('https://example.com'); }); - }); - - describe('when `environmentSettingsToGraphql feature flag is enabled', () => { - beforeEach(() => { - wrapper = createWrapperWithApollo(); - }); it('renders an agent selector listbox', () => { expect(findAgentSelector().props()).toMatchObject({ @@ -224,6 +246,12 @@ describe('~/environments/components/form.vue', () => { items: [], }); }); + }); + + describe('agent selector', () => { + beforeEach(() => { + wrapper = createWrapperWithApollo(); + }); it('sets the items prop of the agent selector after fetching the list', async () => { findAgentSelector().vm.$emit('shown'); @@ -253,24 +281,146 @@ describe('~/environments/components/form.vue', () => { }); it('updates agent selector field with the name of selected agent', async () => { - findAgentSelector().vm.$emit('shown'); - await waitForPromises(); - await findAgentSelector().vm.$emit('select', '2'); + await selectAgent(); expect(findAgentSelector().props('toggleText')).toBe('agent-2'); }); it('emits changes to the clusterAgentId', async () => { - findAgentSelector().vm.$emit('shown'); - await waitForPromises(); - await findAgentSelector().vm.$emit('select', '2'); + await selectAgent(); expect(wrapper.emitted('change')).toEqual([ - [{ name: '', externalUrl: '', clusterAgentId: '2' }], + [{ name: '', externalUrl: '', clusterAgentId: '2', kubernetesNamespace: null }], ]); }); }); + describe('namespace selector', () => { + it("doesn't render namespace selector if `kubernetesNamespaceForEnvironment` feature flag is disabled", () => { + wrapper = createWrapperWithApollo(); + expect(findNamespaceSelector().exists()).toBe(false); + }); + + describe('when `kubernetesNamespaceForEnvironment` feature flag is enabled', () => { + beforeEach(() => { + wrapper = createWrapperWithApollo({ + kubernetesNamespaceForEnvironment: true, + }); + }); + + it("doesn't render namespace selector by default", () => { + expect(findNamespaceSelector().exists()).toBe(false); + }); + + describe('when the agent was selected', () => { + beforeEach(async () => { + await selectAgent(); + }); + + it('renders namespace selector', () => { + expect(findNamespaceSelector().exists()).toBe(true); + }); + + it('requests the kubernetes namespaces with the correct configuration', async () => { + const configuration = { + basePath: mockKasTunnelUrl.replace(/\/$/, ''), + baseOptions: { + headers: { + 'GitLab-Agent-Id': 2, + }, + withCredentials: true, + }, + }; + + await waitForPromises(); + + expect(getNamespacesQueryResult).toHaveBeenCalledWith( + {}, + { configuration }, + expect.anything(), + expect.anything(), + ); + }); + + it('sets the loading prop while fetching the list', async () => { + expect(findNamespaceSelector().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findNamespaceSelector().props('loading')).toBe(false); + }); + + it('renders a list of available namespaces', async () => { + await waitForPromises(); + + expect(findNamespaceSelector().props('items')).toEqual([ + { text: 'default', value: 'default' }, + { text: 'agent', value: 'agent' }, + ]); + }); + + it('filters the namespaces list on user search', async () => { + await waitForPromises(); + await findNamespaceSelector().vm.$emit('search', 'default'); + + expect(findNamespaceSelector().props('items')).toEqual([ + { value: 'default', text: 'default' }, + ]); + }); + + it('updates namespace selector field with the name of selected namespace', async () => { + await waitForPromises(); + await findNamespaceSelector().vm.$emit('select', 'agent'); + + expect(findNamespaceSelector().props('toggleText')).toBe('agent'); + }); + + it('emits changes to the kubernetesNamespace', async () => { + await waitForPromises(); + await findNamespaceSelector().vm.$emit('select', 'agent'); + + expect(wrapper.emitted('change')[1]).toEqual([ + { name: '', externalUrl: '', kubernetesNamespace: 'agent' }, + ]); + }); + + it('clears namespace selector when another agent was selected', async () => { + await waitForPromises(); + await findNamespaceSelector().vm.$emit('select', 'agent'); + + expect(findNamespaceSelector().props('toggleText')).toBe('agent'); + + await findAgentSelector().vm.$emit('select', '1'); + expect(findNamespaceSelector().props('toggleText')).toBe( + EnvironmentForm.i18n.namespaceHelpText, + ); + }); + }); + + describe('when cannot connect to the cluster', () => { + const error = new Error('Error from the cluster_client API'); + + beforeEach(async () => { + wrapper = createWrapperWithApollo({ + kubernetesNamespaceForEnvironment: true, + queryResult: jest.fn().mockRejectedValueOnce(error), + }); + + await selectAgent(); + await waitForPromises(); + }); + + it("doesn't render the namespace selector", () => { + expect(findNamespaceSelector().exists()).toBe(false); + }); + + it('renders an alert', () => { + expect(findAlert().text()).toBe('Error from the cluster_client API'); + }); + }); + }); + }); + describe('when environment has an associated agent', () => { const environmentWithAgent = { ...DEFAULT_PROPS.environment, @@ -280,11 +430,46 @@ describe('~/environments/components/form.vue', () => { beforeEach(() => { wrapper = createWrapperWithApollo({ propsData: { environment: environmentWithAgent }, + kubernetesNamespaceForEnvironment: true, }); }); it('updates agent selector field with the name of the associated agent', () => { expect(findAgentSelector().props('toggleText')).toBe('agent-1'); }); + + it('renders namespace selector', async () => { + await waitForPromises(); + expect(findNamespaceSelector().exists()).toBe(true); + }); + + it('renders a list of available namespaces', async () => { + await waitForPromises(); + + expect(findNamespaceSelector().props('items')).toEqual([ + { text: 'default', value: 'default' }, + { text: 'agent', value: 'agent' }, + ]); + }); + }); + + describe('when environment has an associated kubernetes namespace', () => { + const environmentWithAgentAndNamespace = { + ...DEFAULT_PROPS.environment, + clusterAgent: { id: '1', name: 'agent-1' }, + clusterAgentId: '1', + kubernetesNamespace: 'default', + }; + beforeEach(() => { + wrapper = createWrapperWithApollo({ + propsData: { environment: environmentWithAgentAndNamespace }, + kubernetesNamespaceForEnvironment: true, + }); + }); + + it('updates namespace selector with the name of the associated namespace', async () => { + await waitForPromises(); + expect(findNamespaceSelector().props('toggleText')).toBe('default'); + }); }); }); diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js index 91268ade1e9..c2eafa5f51e 100644 --- a/spec/frontend/environments/graphql/mock_data.js +++ b/spec/frontend/environments/graphql/mock_data.js @@ -909,3 +909,8 @@ export const k8sWorkloadsMock = { JobList: [completedJob, completedJob, failedJob], CronJobList: [completedCronJob, suspendedCronJob, failedCronJob], }; + +export const k8sNamespacesMock = [ + { metadata: { name: 'default' } }, + { metadata: { name: 'agent' } }, +]; diff --git a/spec/frontend/environments/graphql/resolvers_spec.js b/spec/frontend/environments/graphql/resolvers_spec.js index edffc00e185..be210ed619e 100644 --- a/spec/frontend/environments/graphql/resolvers_spec.js +++ b/spec/frontend/environments/graphql/resolvers_spec.js @@ -12,6 +12,7 @@ import pollIntervalQuery from '~/environments/graphql/queries/poll_interval.quer import isEnvironmentStoppingQuery from '~/environments/graphql/queries/is_environment_stopping.query.graphql'; import pageInfoQuery from '~/environments/graphql/queries/page_info.query.graphql'; import { TEST_HOST } from 'helpers/test_constants'; +import { CLUSTER_AGENT_ERROR_MESSAGES } from '~/environments/constants'; import { environmentsApp, resolvedEnvironmentsApp, @@ -20,6 +21,7 @@ import { resolvedFolder, k8sPodsMock, k8sServicesMock, + k8sNamespacesMock, } from './mock_data'; const ENDPOINT = `${TEST_HOST}/environments`; @@ -319,6 +321,50 @@ describe('~/frontend/environments/graphql/resolvers', () => { ); }); }); + describe('k8sNamespaces', () => { + const mockNamespacesListFn = jest.fn().mockImplementation(() => { + return Promise.resolve({ + data: { + items: k8sNamespacesMock, + }, + }); + }); + + beforeEach(() => { + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1Namespace') + .mockImplementation(mockNamespacesListFn); + }); + + it('should request all namespaces from the cluster_client library', async () => { + const namespaces = await mockResolvers.Query.k8sNamespaces(null, { configuration }); + + expect(mockNamespacesListFn).toHaveBeenCalled(); + + expect(namespaces).toEqual(k8sNamespacesMock); + }); + it.each([ + ['Unauthorized', CLUSTER_AGENT_ERROR_MESSAGES.unauthorized], + ['Forbidden', CLUSTER_AGENT_ERROR_MESSAGES.forbidden], + ['Not found', CLUSTER_AGENT_ERROR_MESSAGES['not found']], + ['Unknown', CLUSTER_AGENT_ERROR_MESSAGES.other], + ])( + 'should throw an error if the API call fails with the reason "%s"', + async (reason, message) => { + jest.spyOn(CoreV1Api.prototype, 'listCoreV1Namespace').mockRejectedValue({ + response: { + data: { + reason, + }, + }, + }); + + await expect(mockResolvers.Query.k8sNamespaces(null, { configuration })).rejects.toThrow( + message, + ); + }, + ); + }); describe('stopEnvironmentREST', () => { it('should post to the stop environment path', async () => { mock.onPost(ENDPOINT).reply(HTTP_STATUS_OK); diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js index eb6990ba8a8..387bc31c9aa 100644 --- a/spec/frontend/environments/new_environment_item_spec.js +++ b/spec/frontend/environments/new_environment_item_spec.js @@ -13,6 +13,7 @@ import Deployment from '~/environments/components/deployment.vue'; import DeployBoardWrapper from '~/environments/components/deploy_board_wrapper.vue'; import KubernetesOverview from '~/environments/components/kubernetes_overview.vue'; import getEnvironmentClusterAgent from '~/environments/graphql/queries/environment_cluster_agent.query.graphql'; +import getEnvironmentClusterAgentWithNamespace from '~/environments/graphql/queries/environment_cluster_agent_with_namespace.query.graphql'; import { resolvedEnvironment, rolloutStatus, agent } from './graphql/mock_data'; import { mockKasTunnelUrl } from './mock_data'; @@ -21,6 +22,7 @@ Vue.use(VueApollo); describe('~/environments/components/new_environment_item.vue', () => { let wrapper; let queryResponseHandler; + let queryWithNamespaceResponseHandler; const projectPath = '/1'; @@ -37,7 +39,21 @@ describe('~/environments/components/new_environment_item.vue', () => { }, }; queryResponseHandler = jest.fn().mockResolvedValue(response); - return createMockApollo([[getEnvironmentClusterAgent, queryResponseHandler]]); + queryWithNamespaceResponseHandler = jest.fn().mockResolvedValue({ + data: { + project: { + id: response.data.project.id, + environment: { + ...response.data.project.environment, + kubernetesNamespace: 'default', + }, + }, + }, + }); + return createMockApollo([ + [getEnvironmentClusterAgent, queryResponseHandler], + [getEnvironmentClusterAgentWithNamespace, queryWithNamespaceResponseHandler], + ]); }; const createWrapper = ({ propsData = {}, provideData = {}, apolloProvider } = {}) => @@ -521,11 +537,6 @@ describe('~/environments/components/new_environment_item.vue', () => { it('should request agent data when the environment is visible if the feature flag is enabled', async () => { wrapper = createWrapper({ propsData: { environment: resolvedEnvironment }, - provideData: { - glFeatures: { - kasUserAccessProject: true, - }, - }, apolloProvider: createApolloProvider(agent), }); @@ -537,45 +548,62 @@ describe('~/environments/components/new_environment_item.vue', () => { }); }); - it('should render if the feature flag is enabled and the environment has an agent associated', async () => { + it('should request agent data with kubernetes namespace when `kubernetesNamespaceForEnvironment` feature flag is enabled', async () => { wrapper = createWrapper({ propsData: { environment: resolvedEnvironment }, provideData: { glFeatures: { - kasUserAccessProject: true, + kubernetesNamespaceForEnvironment: true, }, }, apolloProvider: createApolloProvider(agent), }); await expandCollapsedSection(); - await waitForPromises(); - expect(findKubernetesOverview().props()).toMatchObject({ - clusterAgent: agent, + expect(queryWithNamespaceResponseHandler).toHaveBeenCalledWith({ + environmentName: resolvedEnvironment.name, + projectFullPath: projectPath, }); }); - it('should not render if the feature flag is not enabled', async () => { + it('should render if the environment has an agent associated', async () => { wrapper = createWrapper({ propsData: { environment: resolvedEnvironment }, apolloProvider: createApolloProvider(agent), }); await expandCollapsedSection(); + await waitForPromises(); - expect(queryResponseHandler).not.toHaveBeenCalled(); - expect(findKubernetesOverview().exists()).toBe(false); + expect(findKubernetesOverview().props()).toMatchObject({ + clusterAgent: agent, + }); }); - it('should not render if the environment has no agent object', async () => { + it('should render with the namespace if `kubernetesNamespaceForEnvironment` feature flag is enabled and the environment has an agent associated', async () => { wrapper = createWrapper({ propsData: { environment: resolvedEnvironment }, provideData: { glFeatures: { - kasUserAccessProject: true, + kubernetesNamespaceForEnvironment: true, }, }, + apolloProvider: createApolloProvider(agent), + }); + + await expandCollapsedSection(); + await waitForPromises(); + + expect(findKubernetesOverview().props()).toMatchObject({ + clusterAgent: agent, + namespace: 'default', + }); + }); + + it('should not render if the environment has no agent object', async () => { + wrapper = createWrapper({ + propsData: { environment: resolvedEnvironment }, apolloProvider: createApolloProvider(), }); diff --git a/spec/frontend/environments/new_environment_spec.js b/spec/frontend/environments/new_environment_spec.js index 749e4e5caa4..30cd9265d0d 100644 --- a/spec/frontend/environments/new_environment_spec.js +++ b/spec/frontend/environments/new_environment_spec.js @@ -1,5 +1,4 @@ import { GlLoadingIcon } from '@gitlab/ui'; -import MockAdapter from 'axios-mock-adapter'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { mountExtended } from 'helpers/vue_test_utils_helper'; @@ -7,8 +6,6 @@ import waitForPromises from 'helpers/wait_for_promises'; import NewEnvironment from '~/environments/components/new_environment.vue'; import createEnvironment from '~/environments/graphql/mutations/create_environment.mutation.graphql'; import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { visitUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import createMockApollo from '../__helpers__/mock_apollo_helper'; @@ -16,9 +13,6 @@ import createMockApollo from '../__helpers__/mock_apollo_helper'; jest.mock('~/lib/utils/url_utility'); jest.mock('~/alert'); -const newName = 'test'; -const newExternalUrl = 'https://google.ca'; - const provide = { projectEnvironmentsPath: '/projects/environments', projectPath: '/path/to/project', @@ -32,7 +26,6 @@ const environmentCreateError = { describe('~/environments/components/new.vue', () => { let wrapper; - let mock; const createMockApolloProvider = (mutationResult) => { Vue.use(VueApollo); @@ -47,29 +40,13 @@ describe('~/environments/components/new.vue', () => { const createWrapperWithApollo = async (mutationResult = environmentCreate) => { wrapper = mountExtended(NewEnvironment, { - provide: { - ...provide, - glFeatures: { - environmentSettingsToGraphql: true, - }, - }, + provide, apolloProvider: createMockApolloProvider(mutationResult), }); await waitForPromises(); }; - const createWrapperWithAxios = () => { - wrapper = mountExtended(NewEnvironment, { - provide: { - ...provide, - glFeatures: { - environmentSettingsToGraphql: false, - }, - }, - }); - }; - const findNameInput = () => wrapper.findByLabelText(__('Name')); const findExternalUrlInput = () => wrapper.findByLabelText(__('External URL')); const findForm = () => wrapper.findByRole('form', { name: __('New environment') }); @@ -84,7 +61,7 @@ describe('~/environments/components/new.vue', () => { describe('default', () => { beforeEach(() => { - createWrapperWithAxios(); + createWrapperWithApollo(); }); it('sets the title to New environment', () => { @@ -103,93 +80,36 @@ describe('~/environments/components/new.vue', () => { }); }); - describe('when environmentSettingsToGraphql feature is enabled', () => { - describe('when mutation successful', () => { - beforeEach(() => { - createWrapperWithApollo(); - }); - - it('shows loader after form is submitted', async () => { - expect(showsLoading()).toBe(false); - - await submitForm(); - - expect(showsLoading()).toBe(true); - }); - - it('submits the new environment on submit', async () => { - submitForm(); - await waitForPromises(); - - expect(visitUrl).toHaveBeenCalledWith('path/to/environment'); - }); - }); - - describe('when failed', () => { - beforeEach(async () => { - createWrapperWithApollo(environmentCreateError); - submitForm(); - await waitForPromises(); - }); - - it('shows errors on error', () => { - expect(createAlert).toHaveBeenCalledWith({ message: 'uh oh!' }); - expect(showsLoading()).toBe(false); - }); - }); - }); - - describe('when environmentSettingsToGraphql feature is disabled', () => { + describe('when mutation successful', () => { beforeEach(() => { - mock = new MockAdapter(axios); - createWrapperWithAxios(); - }); - - afterEach(() => { - mock.restore(); + createWrapperWithApollo(); }); it('shows loader after form is submitted', async () => { expect(showsLoading()).toBe(false); - mock - .onPost(provide.projectEnvironmentsPath, { - name: newName, - external_url: newExternalUrl, - }) - .reply(HTTP_STATUS_OK, { path: '/test' }); - await submitForm(); expect(showsLoading()).toBe(true); }); it('submits the new environment on submit', async () => { - mock - .onPost(provide.projectEnvironmentsPath, { - name: newName, - external_url: newExternalUrl, - }) - .reply(HTTP_STATUS_OK, { path: '/test' }); - - await submitForm(); + submitForm(); await waitForPromises(); - expect(visitUrl).toHaveBeenCalledWith('/test'); + expect(visitUrl).toHaveBeenCalledWith('path/to/environment'); }); + }); - it('shows errors on error', async () => { - mock - .onPost(provide.projectEnvironmentsPath, { - name: newName, - external_url: newExternalUrl, - }) - .reply(HTTP_STATUS_BAD_REQUEST, { message: ['name taken'] }); - - await submitForm(); + describe('when failed', () => { + beforeEach(async () => { + createWrapperWithApollo(environmentCreateError); + submitForm(); await waitForPromises(); + }); - expect(createAlert).toHaveBeenCalledWith({ message: 'name taken' }); + it('display errors', () => { + expect(createAlert).toHaveBeenCalledWith({ message: 'uh oh!' }); expect(showsLoading()).toBe(false); }); }); diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js index c9238c4b636..6ef34504da7 100644 --- a/spec/frontend/error_tracking/components/error_details_spec.js +++ b/spec/frontend/error_tracking/components/error_details_spec.js @@ -6,6 +6,9 @@ import { GlFormInput, GlAlert, GlSprintf, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; @@ -46,7 +49,13 @@ describe('ErrorDetails', () => { function mountComponent({ integratedErrorTrackingEnabled = false } = {}) { wrapper = shallowMount(ErrorDetails, { - stubs: { GlButton, GlSprintf }, + stubs: { + GlButton, + GlSprintf, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, + }, store, mocks, propsData: { diff --git a/spec/frontend/fixtures/groups.rb b/spec/frontend/fixtures/groups.rb index 9c22ff176ff..e69287c879b 100644 --- a/spec/frontend/fixtures/groups.rb +++ b/spec/frontend/fixtures/groups.rb @@ -2,24 +2,39 @@ require 'spec_helper' -RSpec.describe 'Groups (JavaScript fixtures)', type: :controller do +RSpec.describe 'Groups (JavaScript fixtures)', feature_category: :groups_and_projects do + include ApiHelpers include JavaScriptFixturesHelpers - let(:user) { create(:user) } - let(:group) { create(:group, name: 'frontend-fixtures-group', runners_token: 'runnerstoken:intabulasreferre') } + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group, name: 'frontend-fixtures-group', runners_token: 'runnerstoken:intabulasreferre') } + let_it_be(:projects) { create_list(:project, 2, namespace: group) } - before do - group.add_owner(user) - sign_in(user) - end + describe GroupsController, '(JavaScript fixtures)', type: :controller do + render_views - render_views + before do + group.add_owner(user) + sign_in(user) + end - describe GroupsController, '(JavaScript fixtures)', type: :controller do it 'groups/edit.html' do get :edit, params: { id: group } expect(response).to be_successful end end + + describe API::Groups, '(JavaScript fixtures)', type: :request do + before do + group.add_owner(user) + sign_in(user) + end + + it 'api/groups/projects/get.json' do + get api("/groups/#{group.id}/projects", user) + + expect(response).to be_successful + end + end end diff --git a/spec/frontend/fixtures/issues.rb b/spec/frontend/fixtures/issues.rb index e85e683b599..73594ddf686 100644 --- a/spec/frontend/fixtures/issues.rb +++ b/spec/frontend/fixtures/issues.rb @@ -105,7 +105,6 @@ RSpec.describe GraphQL::Query, type: :request do let_it_be(:user) { create(:user) } let_it_be(:project) { create(:project) } - let_it_be(:issue_type) { 'issue' } before_all do project.add_reporter(user) @@ -128,8 +127,7 @@ RSpec.describe GraphQL::Query, type: :request do title: '15.2', start_date: Date.new(2020, 7, 1), due_date: Date.new(2020, 7, 30) - ), - issue_type: issue_type + ) ) post_graphql(query, current_user: user, variables: { projectPath: project.full_path, iid: issue.iid.to_s }) diff --git a/spec/frontend/fixtures/metrics_dashboard.rb b/spec/frontend/fixtures/metrics_dashboard.rb deleted file mode 100644 index 036ce9eea3a..00000000000 --- a/spec/frontend/fixtures/metrics_dashboard.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe MetricsDashboard, '(JavaScript fixtures)', type: :controller do - include JavaScriptFixturesHelpers - include MetricsDashboardHelpers - - let_it_be(:user) { create(:user) } - let_it_be(:namespace) { create(:namespace, name: 'monitoring') } - let_it_be(:project) { project_with_dashboard_namespace('.gitlab/dashboards/test.yml', nil, namespace: namespace) } - let_it_be(:environment) { create(:environment, id: 1, project: project) } - let_it_be(:params) { { environment: environment } } - - controller(::ApplicationController) do - include MetricsDashboard - end - - before do - stub_feature_flags(remove_monitor_metrics: false) - sign_in(user) - project.add_maintainer(user) - - allow(controller).to receive(:project).and_return(project) - allow(controller).to receive(:environment).and_return(environment) - allow(controller) - .to receive(:metrics_dashboard_params) - .and_return(params) - end - - after do - remove_repository(project) - end - - it 'metrics_dashboard/environment_metrics_dashboard.json' do - routes.draw { get "metrics_dashboard" => "anonymous#metrics_dashboard" } - - response = get :metrics_dashboard, format: :json - - expect(response).to be_successful - end -end diff --git a/spec/frontend/fixtures/milestones.rb b/spec/frontend/fixtures/milestones.rb deleted file mode 100644 index 5e39dcf190a..00000000000 --- a/spec/frontend/fixtures/milestones.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Projects::MilestonesController, '(JavaScript fixtures)', :with_license, feature_category: :team_planning, type: :controller do - include JavaScriptFixturesHelpers - - let_it_be(:user) { create(:user, feed_token: 'feedtoken:coldfeed') } - let_it_be(:namespace) { create(:namespace, name: 'frontend-fixtures') } - let_it_be(:project) { create(:project_empty_repo, namespace: namespace, path: 'milestones-project') } - - render_views - - before do - project.add_maintainer(user) - sign_in(user) - end - - after do - remove_repository(project) - end - - it 'milestones/new-milestone.html' do - get :new, params: { - namespace_id: project.namespace.to_param, - project_id: project - } - - expect(response).to be_successful - end - - private - - def render_milestone(milestone) - get :show, params: { - namespace_id: project.namespace.to_param, - project_id: project, - id: milestone.to_param - } - - expect(response).to be_successful - end -end diff --git a/spec/frontend/fixtures/pipeline_schedules.rb b/spec/frontend/fixtures/pipeline_schedules.rb index 3bfe9113e83..7bba7910b87 100644 --- a/spec/frontend/fixtures/pipeline_schedules.rb +++ b/spec/frontend/fixtures/pipeline_schedules.rb @@ -63,6 +63,12 @@ RSpec.describe 'Pipeline schedules (JavaScript fixtures)' do expect_graphql_errors_to_be_empty end + it "#{fixtures_path}#{get_pipeline_schedules_query}.single.json" do + post_graphql(query, current_user: user, variables: { projectPath: project.full_path, ids: pipeline_schedule_populated.id }) + + expect_graphql_errors_to_be_empty + end + it "#{fixtures_path}#{get_pipeline_schedules_query}.as_guest.json" do guest = create(:user) project.add_guest(user) diff --git a/spec/frontend/fixtures/static/line_highlighter.html b/spec/frontend/fixtures/static/line_highlighter.html index 1667097bc3b..4e1795dfcfa 100644 --- a/spec/frontend/fixtures/static/line_highlighter.html +++ b/spec/frontend/fixtures/static/line_highlighter.html @@ -1,154 +1,79 @@ <div class="file-holder"> <div class="file-content"> <div class="line-numbers"> -<a data-line-number="1" href="#L1" id="L1"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> +<a data-line-number="1" href="#L1" id="L1"> 1 </a> -<a data-line-number="2" href="#L2" id="L2"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> +<a data-line-number="2" href="#L2" id="L2"> 2 </a> -<a data-line-number="3" href="#L3" id="L3"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> +<a data-line-number="3" href="#L3" id="L3"> 3 </a> -<a data-line-number="4" href="#L4" id="L4"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> +<a data-line-number="4" href="#L4" id="L4"> 4 </a> -<a data-line-number="5" href="#L5" id="L5"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> +<a data-line-number="5" href="#L5" id="L5"> 5 </a> <a data-line-number="6" href="#L6" id="L6"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> 6 </a> <a data-line-number="7" href="#L7" id="L7"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> 7 </a> <a data-line-number="8" href="#L8" id="L8"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> 8 </a> <a data-line-number="9" href="#L9" id="L9"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> 9 </a> <a data-line-number="10" href="#L10" id="L10"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> 10 </a> <a data-line-number="11" href="#L11" id="L11"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> 11 </a> <a data-line-number="12" href="#L12" id="L12"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> 12 </a> <a data-line-number="13" href="#L13" id="L13"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> 13 </a> <a data-line-number="14" href="#L14" id="L14"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> 14 </a> <a data-line-number="15" href="#L15" id="L15"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> 15 </a> <a data-line-number="16" href="#L16" id="L16"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> 16 </a> <a data-line-number="17" href="#L17" id="L17"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> 17 </a> <a data-line-number="18" href="#L18" id="L18"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> 18 </a> <a data-line-number="19" href="#L19" id="L19"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> 19 </a> <a data-line-number="20" href="#L20" id="L20"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> 20 </a> <a data-line-number="21" href="#L21" id="L21"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> 21 </a> <a data-line-number="22" href="#L22" id="L22"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> 22 </a> <a data-line-number="23" href="#L23" id="L23"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> 23 </a> <a data-line-number="24" href="#L24" id="L24"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> 24 </a> <a data-line-number="25" href="#L25" id="L25"> -<svg data-testid="link-icon" class="s12"> -<use href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#link"></use> -</svg> 25 </a> </div> diff --git a/spec/frontend/fixtures/static/textarea.html b/spec/frontend/fixtures/static/textarea.html new file mode 100644 index 00000000000..68d5a0f2d4d --- /dev/null +++ b/spec/frontend/fixtures/static/textarea.html @@ -0,0 +1,27 @@ +<body> +<meta charset="utf-8"> +<title>Document with Textarea</title> +<form class="milestone-form common-note-form js-quick-submit js-requires-input" id="new_milestone" + action="http://test.host/frontend-fixtures/milestones-project/-/milestones" + accept-charset="UTF-8" method="post"> + <div class="form-group"> + <div class="md-write-holder"> + <div class="zen-backdrop"> + <textarea class="note-textarea js-gfm-input js-autosize markdown-area" + placeholder="Write milestone description..." dir="auto" + data-supports-quick-actions="false" data-supports-autocomplete="true" + data-qa-selector="milestone_description_field" data-autofocus="false" + name="milestone[description]" + id="milestone_description"></textarea> + <a class="zen-control zen-control-leave js-zen-leave gl-text-gray-500" + href="#"> + <svg class="s16" data-testid="minimize-icon"> + <use href="http://test.host/assets/icons-b8c5a9711f73b1de3c81754da0aca72f43b0e6844aa06dd03092b601a493f45b.svg#minimize"></use> + </svg> + </a> + </div> + </div> + </div> +</form> + +</body> diff --git a/spec/frontend/fixtures/timezones.rb b/spec/frontend/fixtures/timezones.rb index 2393f4e797d..f04e647c8eb 100644 --- a/spec/frontend/fixtures/timezones.rb +++ b/spec/frontend/fixtures/timezones.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe TimeZoneHelper, '(JavaScript fixtures)' do include JavaScriptFixturesHelpers - include TimeZoneHelper + include described_class let(:response) { @timezones.sort_by! { |tz| tz[:name] }.to_json } diff --git a/spec/frontend/fixtures/users.rb b/spec/frontend/fixtures/users.rb index 89bffea7e4c..800a9af194e 100644 --- a/spec/frontend/fixtures/users.rb +++ b/spec/frontend/fixtures/users.rb @@ -7,7 +7,8 @@ RSpec.describe 'Users (JavaScript fixtures)', feature_category: :user_profile do include ApiHelpers let_it_be(:followers) { create_list(:user, 5) } - let_it_be(:user) { create(:user, followers: followers) } + let_it_be(:followees) { create_list(:user, 5) } + let_it_be(:user) { create(:user, followers: followers, followees: followees) } describe API::Users, '(JavaScript fixtures)', type: :request do it 'api/users/followers/get.json' do @@ -15,6 +16,12 @@ RSpec.describe 'Users (JavaScript fixtures)', feature_category: :user_profile do expect(response).to be_successful end + + it 'api/users/following/get.json' do + get api("/users/#{user.id}/following", user) + + expect(response).to be_successful + end end describe UsersController, '(JavaScript fixtures)', type: :controller do diff --git a/spec/frontend/frequent_items/mock_data.js b/spec/frontend/frequent_items/mock_data.js index 5e15b4b33e0..6563daee6c3 100644 --- a/spec/frontend/frequent_items/mock_data.js +++ b/spec/frontend/frequent_items/mock_data.js @@ -69,7 +69,7 @@ export const mockFrequentGroups = [ }, ]; -export const mockSearchedGroups = [mockRawGroup]; +export const mockSearchedGroups = { data: [mockRawGroup] }; export const mockProcessedSearchedGroups = [mockGroup]; export const mockProject = { diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 73284fbe5e5..2d19c9871b6 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -866,7 +866,7 @@ describe('GfmAutoComplete', () => { it('should return a correct template', () => { const actual = GfmAutoComplete.Emoji.templateFunction(mockItem); const glEmojiTag = `<gl-emoji data-name="${mockItem.emoji.name}"></gl-emoji>`; - const expected = `<li>${mockItem.fieldValue} ${glEmojiTag}</li>`; + const expected = `<li>${glEmojiTag} ${mockItem.fieldValue}</li>`; expect(actual).toBe(expected); }); diff --git a/spec/frontend/gitlab_version_check/components/security_patch_upgrade_alert_modal_spec.js b/spec/frontend/gitlab_version_check/components/security_patch_upgrade_alert_modal_spec.js index f1ed32a5f79..b1a1d2d1372 100644 --- a/spec/frontend/gitlab_version_check/components/security_patch_upgrade_alert_modal_spec.js +++ b/spec/frontend/gitlab_version_check/components/security_patch_upgrade_alert_modal_spec.js @@ -1,6 +1,7 @@ import { GlModal, GlLink, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { stubComponent, RENDER_ALL_SLOTS_TEMPLATE } from 'helpers/stub_component'; import { sprintf } from '~/locale'; import SecurityPatchUpgradeAlertModal from '~/gitlab_version_check/components/security_patch_upgrade_alert_modal.vue'; import * as utils from '~/gitlab_version_check/utils'; @@ -14,6 +15,8 @@ import { describe('SecurityPatchUpgradeAlertModal', () => { let wrapper; let trackingSpy; + const hideMock = jest.fn(); + const { i18n } = SecurityPatchUpgradeAlertModal; const defaultProps = { currentVersion: '11.1.1', @@ -28,14 +31,20 @@ describe('SecurityPatchUpgradeAlertModal', () => { ...props, }, stubs: { - GlModal, GlSprintf, + GlModal: stubComponent(GlModal, { + methods: { + hide: hideMock, + }, + template: RENDER_ALL_SLOTS_TEMPLATE, + }), }, }); }; afterEach(() => { unmockTracking(); + hideMock.mockClear(); }); const expectDispatchedTracking = (action, label) => { @@ -63,12 +72,12 @@ describe('SecurityPatchUpgradeAlertModal', () => { }); it('renders the modal title correctly', () => { - expect(findGlModalTitle().text()).toBe(wrapper.vm.$options.i18n.modalTitle); + expect(findGlModalTitle().text()).toBe(i18n.modalTitle); }); it('renders modal body without suggested versions', () => { expect(findGlModalBody().text()).toBe( - sprintf(wrapper.vm.$options.i18n.modalBodyNoStableVersions, { + sprintf(i18n.modalBodyNoStableVersions, { currentVersion: defaultProps.currentVersion, }), ); @@ -90,7 +99,7 @@ describe('SecurityPatchUpgradeAlertModal', () => { describe('Learn more link', () => { it('renders with correct text and link', () => { - expect(findGlLink().text()).toBe(wrapper.vm.$options.i18n.learnMore); + expect(findGlLink().text()).toBe(i18n.learnMore); expect(findGlLink().attributes('href')).toBe(ABOUT_RELEASES_PAGE); }); @@ -102,12 +111,8 @@ describe('SecurityPatchUpgradeAlertModal', () => { }); describe('Remind me button', () => { - beforeEach(() => { - wrapper.vm.$refs.alertModal.hide = jest.fn(); - }); - it('renders with correct text', () => { - expect(findGlRemindButton().text()).toBe(wrapper.vm.$options.i18n.secondaryButtonText); + expect(findGlRemindButton().text()).toBe(i18n.secondaryButtonText); }); it(`tracks click ${TRACKING_LABELS.REMIND_ME_BTN} when clicked`, async () => { @@ -126,13 +131,13 @@ describe('SecurityPatchUpgradeAlertModal', () => { it('hides the modal', async () => { await findGlRemindButton().vm.$emit('click'); - expect(wrapper.vm.$refs.alertModal.hide).toHaveBeenCalled(); + expect(hideMock).toHaveBeenCalled(); }); }); describe('Upgrade button', () => { it('renders with correct text and link', () => { - expect(findGlUpgradeButton().text()).toBe(wrapper.vm.$options.i18n.primaryButtonText); + expect(findGlUpgradeButton().text()).toBe(i18n.primaryButtonText); expect(findGlUpgradeButton().attributes('href')).toBe(UPGRADE_DOCS_URL); }); @@ -160,7 +165,7 @@ describe('SecurityPatchUpgradeAlertModal', () => { it('renders modal body with suggested versions', () => { expect(findGlModalBody().text()).toBe( - sprintf(wrapper.vm.$options.i18n.modalBodyStableVersions, { + sprintf(i18n.modalBodyStableVersions, { currentVersion: defaultProps.currentVersion, latestStableVersions: latestStableVersions.join(', '), }), @@ -176,9 +181,7 @@ describe('SecurityPatchUpgradeAlertModal', () => { }); it('renders modal details', () => { - expect(findGlModalDetails().text()).toBe( - sprintf(wrapper.vm.$options.i18n.modalDetails, { details }), - ); + expect(findGlModalDetails().text()).toBe(sprintf(i18n.modalDetails, { details })); }); }); diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js index b474745790e..e32c50db8bf 100644 --- a/spec/frontend/groups/components/app_spec.js +++ b/spec/frontend/groups/components/app_spec.js @@ -93,10 +93,9 @@ describe('AppComponent', () => { page: 2, filterGroupsBy: 'git', sortBy: 'created_desc', - archived: true, }) .then(() => { - expect(getGroupsSpy).toHaveBeenCalledWith(1, 2, 'git', 'created_desc', true); + expect(getGroupsSpy).toHaveBeenCalledWith(1, 2, 'git', 'created_desc'); }); }); @@ -154,7 +153,6 @@ describe('AppComponent', () => { filterGroupsBy: 'foobar', sortBy: null, updatePagination: true, - archived: null, }); return fetchPromise.then(() => { expect(vm.updateGroups).toHaveBeenCalledWith(mockSearchedGroups, true); @@ -177,7 +175,6 @@ describe('AppComponent', () => { page: 2, filterGroupsBy: null, sortBy: null, - archived: true, }); expect(vm.isLoading).toBe(true); @@ -186,7 +183,6 @@ describe('AppComponent', () => { filterGroupsBy: null, sortBy: null, updatePagination: true, - archived: true, }); return fetchPagePromise.then(() => { @@ -471,7 +467,7 @@ describe('AppComponent', () => { it('calls API with expected params', () => { emitFetchFilteredAndSortedGroups(); - expect(getGroupsSpy).toHaveBeenCalledWith(undefined, undefined, search, sort, undefined); + expect(getGroupsSpy).toHaveBeenCalledWith(undefined, undefined, search, sort); }); it('updates pagination', () => { diff --git a/spec/frontend/groups/components/overview_tabs_spec.js b/spec/frontend/groups/components/overview_tabs_spec.js index ca852f398d0..8db69295ac4 100644 --- a/spec/frontend/groups/components/overview_tabs_spec.js +++ b/spec/frontend/groups/components/overview_tabs_spec.js @@ -10,26 +10,29 @@ import SharedProjectsEmptyState from '~/groups/components/empty_states/shared_pr import ArchivedProjectsEmptyState from '~/groups/components/empty_states/archived_projects_empty_state.vue'; import GroupsStore from '~/groups/store/groups_store'; import GroupsService from '~/groups/service/groups_service'; +import ArchivedProjectsService from '~/groups/service/archived_projects_service'; import { createRouter } from '~/groups/init_overview_tabs'; import eventHub from '~/groups/event_hub'; import { ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED, - OVERVIEW_TABS_SORTING_ITEMS, + SORTING_ITEM_NAME, + SORTING_ITEM_UPDATED, + SORTING_ITEM_STARS, } from '~/groups/constants'; import axios from '~/lib/utils/axios_utils'; import waitForPromises from 'helpers/wait_for_promises'; Vue.component('GroupFolder', GroupFolderComponent); const router = createRouter(); -const [SORTING_ITEM_NAME, , SORTING_ITEM_UPDATED] = OVERVIEW_TABS_SORTING_ITEMS; describe('OverviewTabs', () => { let wrapper; let axiosMock; const defaultProvide = { + groupId: '1', endpoints: { subgroups_and_projects: '/groups/foobar/-/children.json', shared: '/groups/foobar/-/shared_projects.json', @@ -92,7 +95,10 @@ describe('OverviewTabs', () => { expect(tabPanel.findComponent(GroupsApp).props()).toMatchObject({ action: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, store: new GroupsStore({ showSchemaMarkup: true }), - service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]), + service: new GroupsService( + defaultProvide.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS], + defaultProvide.initialSort, + ), }); await waitForPromises(); @@ -115,7 +121,10 @@ describe('OverviewTabs', () => { expect(tabPanel.findComponent(GroupsApp).props()).toMatchObject({ action: ACTIVE_TAB_SHARED, store: new GroupsStore(), - service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_SHARED]), + service: new GroupsService( + defaultProvide.endpoints[ACTIVE_TAB_SHARED], + defaultProvide.initialSort, + ), }); expect(tabPanel.vm.$attrs.lazy).toBe(false); @@ -140,7 +149,7 @@ describe('OverviewTabs', () => { expect(tabPanel.findComponent(GroupsApp).props()).toMatchObject({ action: ACTIVE_TAB_ARCHIVED, store: new GroupsStore(), - service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_ARCHIVED]), + service: new ArchivedProjectsService(defaultProvide.groupId, defaultProvide.initialSort), }); expect(tabPanel.vm.$attrs.lazy).toBe(false); @@ -219,7 +228,7 @@ describe('OverviewTabs', () => { it(`pushes expected route when ${tabToClick} tab is clicked`, async () => { await findTab(tabToClick).trigger('click'); - expect(routerMock.push).toHaveBeenCalledWith(expectedRoute); + expect(routerMock.push).toHaveBeenCalledWith(expect.objectContaining(expectedRoute)); }); }); @@ -304,6 +313,52 @@ describe('OverviewTabs', () => { sharedAssertions({ search: '', sort: SORTING_ITEM_UPDATED.asc }); }); + describe('when tab is changed', () => { + describe('when selected sort is supported', () => { + beforeEach(async () => { + await createComponent({ + route: { + name: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, + params: { group: 'foo/bar/baz' }, + query: { sort: SORTING_ITEM_NAME.asc }, + }, + }); + }); + + it('adds sort query string', async () => { + await findTab(OverviewTabs.i18n[ACTIVE_TAB_ARCHIVED]).trigger('click'); + + expect(routerMock.push).toHaveBeenCalledWith( + expect.objectContaining({ + query: { sort: SORTING_ITEM_NAME.asc }, + }), + ); + }); + }); + + describe('when selected sort is not supported', () => { + beforeEach(async () => { + await createComponent({ + route: { + name: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, + params: { group: 'foo/bar/baz' }, + query: { sort: SORTING_ITEM_STARS.asc }, + }, + }); + }); + + it('defaults to sorting by name', async () => { + await findTab(OverviewTabs.i18n[ACTIVE_TAB_ARCHIVED]).trigger('click'); + + expect(routerMock.push).toHaveBeenCalledWith( + expect.objectContaining({ + query: { sort: SORTING_ITEM_NAME.asc }, + }), + ); + }); + }); + }); + describe('when sort direction is changed', () => { beforeEach(async () => { await setup(); diff --git a/spec/frontend/groups/service/archived_projects_service_spec.js b/spec/frontend/groups/service/archived_projects_service_spec.js new file mode 100644 index 00000000000..3aec9d57ee1 --- /dev/null +++ b/spec/frontend/groups/service/archived_projects_service_spec.js @@ -0,0 +1,90 @@ +import projects from 'test_fixtures/api/groups/projects/get.json'; +import ArchivedProjectsService from '~/groups/service/archived_projects_service'; +import Api from '~/api'; + +jest.mock('~/api'); + +describe('ArchivedProjectsService', () => { + const groupId = 1; + let service; + + beforeEach(() => { + service = new ArchivedProjectsService(groupId, 'name_asc'); + }); + + describe('getGroups', () => { + const headers = { 'x-next-page': '2', 'x-page': '1', 'x-per-page': '20' }; + const page = 2; + const query = 'git'; + const sort = 'created_asc'; + + beforeEach(() => { + Api.groupProjects.mockResolvedValueOnce({ data: projects, headers }); + }); + + it('returns promise the resolves with formatted project', async () => { + await expect(service.getGroups(undefined, page, query, sort)).resolves.toEqual({ + data: projects.map((project) => { + return { + id: project.id, + name: project.name, + full_name: project.name_with_namespace, + markdown_description: project.description_html, + visibility: project.visibility, + avatar_url: project.avatar_url, + relative_path: `/${project.path_with_namespace}`, + edit_path: null, + leave_path: null, + can_edit: false, + can_leave: false, + can_remove: false, + type: 'project', + permission: null, + children: [], + parent_id: project.namespace.id, + project_count: 0, + subgroup_count: 0, + number_users_with_delimiter: 0, + star_count: project.star_count, + updated_at: project.updated_at, + marked_for_deletion: project.marked_for_deletion_at !== null, + last_activity_at: project.last_activity_at, + }; + }), + headers, + }); + + expect(Api.groupProjects).toHaveBeenCalledWith(groupId, query, { + archived: true, + page, + order_by: 'created_at', + sort: 'asc', + }); + }); + + describe.each` + sortArgument | expectedOrderByParameter | expectedSortParameter + ${'name_asc'} | ${'name'} | ${'asc'} + ${'name_desc'} | ${'name'} | ${'desc'} + ${'created_asc'} | ${'created_at'} | ${'asc'} + ${'created_desc'} | ${'created_at'} | ${'desc'} + ${'latest_activity_asc'} | ${'last_activity_at'} | ${'asc'} + ${'latest_activity_desc'} | ${'last_activity_at'} | ${'desc'} + ${undefined} | ${'name'} | ${'asc'} + `( + 'when the sort argument is $sortArgument', + ({ sortArgument, expectedSortParameter, expectedOrderByParameter }) => { + it(`calls the API with sort parameter set to ${expectedSortParameter} and order_by parameter set to ${expectedOrderByParameter}`, () => { + service.getGroups(undefined, page, query, sortArgument); + + expect(Api.groupProjects).toHaveBeenCalledWith(groupId, query, { + archived: true, + page, + order_by: expectedOrderByParameter, + sort: expectedSortParameter, + }); + }); + }, + ); + }); +}); diff --git a/spec/frontend/groups/service/groups_service_spec.js b/spec/frontend/groups/service/groups_service_spec.js index e037a6df1e2..ef0a7fde70a 100644 --- a/spec/frontend/groups/service/groups_service_spec.js +++ b/spec/frontend/groups/service/groups_service_spec.js @@ -7,7 +7,7 @@ describe('GroupsService', () => { let service; beforeEach(() => { - service = new GroupsService(mockEndpoint); + service = new GroupsService(mockEndpoint, 'created_asc'); }); describe('getGroups', () => { @@ -17,17 +17,28 @@ describe('GroupsService', () => { page: 2, filter: 'git', sort: 'created_asc', - archived: true, }; - service.getGroups(55, 2, 'git', 'created_asc', true); + service.getGroups(55, 2, 'git', 'created_asc'); expect(axios.get).toHaveBeenCalledWith(mockEndpoint, { params: { parent_id: 55 } }); - service.getGroups(null, 2, 'git', 'created_asc', true); + service.getGroups(null, 2, 'git', 'created_asc'); expect(axios.get).toHaveBeenCalledWith(mockEndpoint, { params }); }); + + describe('when sort argument is undefined', () => { + it('calls API with `initialSort` argument', () => { + jest.spyOn(axios, 'get').mockResolvedValue(); + + service.getGroups(undefined, 2, 'git', undefined); + + expect(axios.get).toHaveBeenCalledWith(mockEndpoint, { + params: { sort: 'created_asc', filter: 'git', page: 2 }, + }); + }); + }); }); describe('leaveGroup', () => { diff --git a/spec/frontend/header_search/init_spec.js b/spec/frontend/header_search/init_spec.js index baf3c6f08b2..459ca33ee66 100644 --- a/spec/frontend/header_search/init_spec.js +++ b/spec/frontend/header_search/init_spec.js @@ -5,7 +5,6 @@ import initHeaderSearch, { eventHandler, cleanEventListeners } from '~/header_se describe('Header Search EventListener', () => { beforeEach(() => { jest.resetModules(); - jest.restoreAllMocks(); setHTMLFixture(` <div class="js-header-content"> <div class="header-search-form" id="js-header-search" data-autocomplete-path="/search/autocomplete" data-issues-path="/dashboard/issues" data-mr-path="/dashboard/merge_requests" data-search-context="{}" data-search-path="/search"> @@ -16,7 +15,6 @@ describe('Header Search EventListener', () => { afterEach(() => { resetHTMLFixture(); - jest.clearAllMocks(); }); it('attached event listener', () => { diff --git a/spec/frontend/ide/components/ide_status_bar_spec.js b/spec/frontend/ide/components/ide_status_bar_spec.js index 0ee16f98e7e..fe392a64013 100644 --- a/spec/frontend/ide/components/ide_status_bar_spec.js +++ b/spec/frontend/ide/components/ide_status_bar_spec.js @@ -1,6 +1,6 @@ -import { mount } from '@vue/test-utils'; import _ from 'lodash'; import { TEST_HOST } from 'helpers/test_constants'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import IdeStatusBar from '~/ide/components/ide_status_bar.vue'; import IdeStatusMR from '~/ide/components/ide_status_mr.vue'; import { rightSidebarViews } from '~/ide/constants'; @@ -15,6 +15,8 @@ jest.mock('~/lib/utils/poll'); describe('IdeStatusBar component', () => { let wrapper; + const dummyIntervalId = 1337; + let dispatchMock; const findMRStatus = () => wrapper.findComponent(IdeStatusMR); @@ -31,14 +33,21 @@ describe('IdeStatusBar component', () => { ...state, }); - wrapper = mount(IdeStatusBar, { store }); + wrapper = mountExtended(IdeStatusBar, { store }); + dispatchMock = jest.spyOn(store, 'dispatch'); }; + beforeEach(() => { + jest.spyOn(window, 'setInterval').mockReturnValue(dummyIntervalId); + }); + + const findCommitShaLink = () => wrapper.findByTestId('commit-sha-content'); + describe('default', () => { it('triggers a setInterval', () => { mountComponent(); - expect(wrapper.vm.intervalId).not.toBe(null); + expect(window.setInterval).toHaveBeenCalledTimes(1); }); it('renders the statusbar', () => { @@ -47,34 +56,10 @@ describe('IdeStatusBar component', () => { expect(wrapper.classes()).toEqual(['ide-status-bar']); }); - describe('commitAgeUpdate', () => { - beforeEach(() => { - mountComponent(); - jest.spyOn(wrapper.vm, 'commitAgeUpdate').mockImplementation(() => {}); - }); - - afterEach(() => { - jest.clearAllTimers(); - }); - - it('gets called every second', () => { - expect(wrapper.vm.commitAgeUpdate).not.toHaveBeenCalled(); - - jest.advanceTimersByTime(1000); - - expect(wrapper.vm.commitAgeUpdate.mock.calls).toHaveLength(1); - - jest.advanceTimersByTime(1000); - - expect(wrapper.vm.commitAgeUpdate.mock.calls).toHaveLength(2); - }); - }); - describe('getCommitPath', () => { it('returns the path to the commit details', () => { mountComponent(); - - expect(wrapper.vm.getCommitPath('abc123de')).toBe('/commit/abc123de'); + expect(findCommitShaLink().attributes('href')).toBe('/commit/abc123de'); }); }); @@ -95,11 +80,10 @@ describe('IdeStatusBar component', () => { }, }; mountComponent({ pipelines }); - jest.spyOn(wrapper.vm, 'openRightPane').mockImplementation(() => {}); wrapper.find('button').trigger('click'); - expect(wrapper.vm.openRightPane).toHaveBeenCalledWith(rightSidebarViews.pipelines); + expect(dispatchMock).toHaveBeenCalledWith('rightPane/open', rightSidebarViews.pipelines); }); }); diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index 6747ec97050..aa99b1cacef 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -158,7 +158,6 @@ describe('RepoEditor', () => { }); afterEach(() => { - jest.clearAllMocks(); // create a new model each time, otherwise tests conflict with each other // because of same model being used in multiple tests monacoEditor.getModels().forEach((model) => model.dispose()); diff --git a/spec/frontend/ide/mock_data.js b/spec/frontend/ide/mock_data.js index 557626b3cca..b1f192e1d98 100644 --- a/spec/frontend/ide/mock_data.js +++ b/spec/frontend/ide/mock_data.js @@ -13,6 +13,7 @@ export const projectData = { can_push: true, commit: { id: '123', + short_id: 'abc123de', }, }, }, @@ -79,6 +80,7 @@ export const jobs = [ path: 'testing', status: { icon: 'status_success', + group: 'success', text: 'passed', }, stage: 'test', diff --git a/spec/frontend/invite_members/components/group_select_spec.js b/spec/frontend/invite_members/components/group_select_spec.js index a1ca9a69926..bd90832f497 100644 --- a/spec/frontend/invite_members/components/group_select_spec.js +++ b/spec/frontend/invite_members/components/group_select_spec.js @@ -1,82 +1,76 @@ -import { GlAvatarLabeled, GlDropdown, GlSearchBoxByType } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { GlAvatarLabeled, GlCollapsibleListbox } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; -import * as groupsApi from '~/api/groups_api'; +import { getGroups } from '~/api/groups_api'; import GroupSelect from '~/invite_members/components/group_select.vue'; +jest.mock('~/api/groups_api'); + const group1 = { id: 1, full_name: 'Group One', avatar_url: 'test' }; const group2 = { id: 2, full_name: 'Group Two', avatar_url: 'test' }; const allGroups = [group1, group2]; - -const createComponent = (props = {}) => { - return mount(GroupSelect, { - propsData: { - invalidGroups: [], - ...props, - }, - }); +const headers = { + 'X-Next-Page': 2, + 'X-Page': 1, + 'X-Per-Page': 20, + 'X-Prev-Page': '', + 'X-Total': 40, + 'X-Total-Pages': 2, }; describe('GroupSelect', () => { let wrapper; - beforeEach(() => { - jest.spyOn(groupsApi, 'getGroups').mockResolvedValue(allGroups); + const createComponent = (props = {}) => { + wrapper = mount(GroupSelect, { + propsData: { + selectedGroup: {}, + invalidGroups: [], + ...props, + }, + }); + }; - wrapper = createComponent(); + beforeEach(() => { + getGroups.mockResolvedValueOnce({ data: allGroups, headers }); }); - const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownToggle = () => findDropdown().find('button[aria-haspopup="menu"]'); + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const findListboxToggle = () => findListbox().find('button[aria-haspopup="listbox"]'); const findAvatarByLabel = (text) => wrapper .findAllComponents(GlAvatarLabeled) .wrappers.find((dropdownItemWrapper) => dropdownItemWrapper.props('label') === text); - it('renders GlSearchBoxByType with default attributes', () => { - expect(findSearchBoxByType().exists()).toBe(true); - expect(findSearchBoxByType().vm.$attrs).toMatchObject({ - placeholder: 'Search groups', - }); - }); - describe('when user types in the search input', () => { - let resolveApiRequest; - - beforeEach(() => { - jest.spyOn(groupsApi, 'getGroups').mockImplementation( - () => - new Promise((resolve) => { - resolveApiRequest = resolve; - }), - ); - - findSearchBoxByType().vm.$emit('input', group1.name); + beforeEach(async () => { + createComponent(); + await waitForPromises(); + getGroups.mockClear(); + getGroups.mockReturnValueOnce(new Promise(() => {})); + findListbox().vm.$emit('search', group1.name); + await nextTick(); }); it('calls the API', () => { - resolveApiRequest({ data: allGroups }); - - expect(groupsApi.getGroups).toHaveBeenCalledWith(group1.name, { + expect(getGroups).toHaveBeenCalledWith(group1.name, { exclude_internal: true, active: true, order_by: 'similarity', }); }); - it('displays loading icon while waiting for API call to resolve', async () => { - expect(findSearchBoxByType().props('isLoading')).toBe(true); - - resolveApiRequest({ data: allGroups }); - await waitForPromises(); - - expect(findSearchBoxByType().props('isLoading')).toBe(false); + it('displays loading icon while waiting for API call to resolve', () => { + expect(findListbox().props('searching')).toBe(true); }); }); describe('avatar label', () => { - it('includes the correct attributes with name and avatar_url', () => { + it('includes the correct attributes with name and avatar_url', async () => { + createComponent(); + await waitForPromises(); + expect(findAvatarByLabel(group1.full_name).attributes()).toMatchObject({ src: group1.avatar_url, 'entity-id': `${group1.id}`, @@ -86,8 +80,9 @@ describe('GroupSelect', () => { }); describe('when filtering out the group from results', () => { - beforeEach(() => { - wrapper = createComponent({ invalidGroups: [group1.id] }); + beforeEach(async () => { + createComponent({ invalidGroups: [group1.id] }); + await waitForPromises(); }); it('does not find an invalid group', () => { @@ -101,16 +96,93 @@ describe('GroupSelect', () => { }); describe('when group is selected from the dropdown', () => { - beforeEach(() => { - findAvatarByLabel(group1.full_name).trigger('click'); + beforeEach(async () => { + createComponent({ + selectedGroup: { + value: group1.id, + id: group1.id, + name: group1.full_name, + path: group1.path, + avatarUrl: group1.avatar_url, + }, + }); + await waitForPromises(); + findListbox().vm.$emit('select', group1.id); + await nextTick(); }); it('emits `input` event used by `v-model`', () => { - expect(wrapper.emitted('input')[0][0].id).toEqual(group1.id); + expect(wrapper.emitted('input')).toMatchObject([ + [ + { + value: group1.id, + id: group1.id, + name: group1.full_name, + path: group1.path, + avatarUrl: group1.avatar_url, + }, + ], + ]); }); it('sets dropdown toggle text to selected item', () => { - expect(findDropdownToggle().text()).toBe(group1.full_name); + expect(findListboxToggle().text()).toBe(group1.full_name); + }); + }); + + describe('infinite scroll', () => { + it('sets infinite scroll related props', async () => { + createComponent(); + await waitForPromises(); + + expect(findListbox().props()).toMatchObject({ + infiniteScroll: true, + infiniteScrollLoading: false, + totalItems: 40, + }); + }); + + describe('when `bottom-reached` event is fired', () => { + it('indicates new groups are loading and adds them to the listbox', async () => { + createComponent(); + await waitForPromises(); + + const infiniteScrollGroup = { + id: 3, + full_name: 'Infinite scroll group', + avatar_url: 'test', + }; + + getGroups.mockResolvedValueOnce({ data: [infiniteScrollGroup], headers }); + + findListbox().vm.$emit('bottom-reached'); + await nextTick(); + + expect(findListbox().props('infiniteScrollLoading')).toBe(true); + + await waitForPromises(); + + expect(findListbox().props('items')[2]).toMatchObject({ + value: infiniteScrollGroup.id, + id: infiniteScrollGroup.id, + name: infiniteScrollGroup.full_name, + avatarUrl: infiniteScrollGroup.avatar_url, + }); + }); + + describe('when API request fails', () => { + it('emits `error` event', async () => { + createComponent(); + await waitForPromises(); + + getGroups.mockRejectedValueOnce(); + + findListbox().vm.$emit('bottom-reached'); + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[GroupSelect.i18n.errorMessage]]); + }); + }); }); }); }); diff --git a/spec/frontend/invite_members/components/invite_groups_modal_spec.js b/spec/frontend/invite_members/components/invite_groups_modal_spec.js index 4f082145562..4136de75545 100644 --- a/spec/frontend/invite_members/components/invite_groups_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_groups_modal_spec.js @@ -1,4 +1,4 @@ -import { GlModal, GlSprintf } from '@gitlab/ui'; +import { GlModal, GlSprintf, GlAlert } from '@gitlab/ui'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import Api from '~/api'; @@ -24,6 +24,7 @@ jest.mock('~/invite_members/utils/trigger_successful_invite_alert'); describe('InviteGroupsModal', () => { let wrapper; + const mockToastShow = jest.fn(); const createComponent = (props = {}) => { wrapper = shallowMountExtended(InviteGroupsModal, { @@ -39,9 +40,18 @@ describe('InviteGroupsModal', () => { template: '<div><slot></slot><slot name="modal-footer"></slot></div>', }), }, + mocks: { + $toast: { + show: mockToastShow, + }, + }, }); }; + afterEach(() => { + mockToastShow.mockClear(); + }); + const createInviteGroupToProjectWrapper = () => { createComponent({ isProject: true }); }; @@ -133,7 +143,6 @@ describe('InviteGroupsModal', () => { createComponent(); triggerGroupSelect(sharedGroup); - wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'groupShareWithGroup').mockImplementation( () => new Promise((resolve, reject) => { @@ -167,7 +176,7 @@ describe('InviteGroupsModal', () => { }); it('displays the successful toastMessage', () => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', { + expect(mockToastShow).toHaveBeenCalledWith('Members were successfully added', { onComplete: expect.any(Function), }); }); @@ -187,7 +196,7 @@ describe('InviteGroupsModal', () => { }); it('does not show the toast message on failure', () => { - expect(wrapper.vm.$toast.show).not.toHaveBeenCalled(); + expect(mockToastShow).not.toHaveBeenCalled(); }); it('displays the generic error for http server error', () => { @@ -222,7 +231,6 @@ describe('InviteGroupsModal', () => { createComponent({ reloadPageOnSubmit: true }); triggerGroupSelect(sharedGroup); - wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData }); clickInviteButton(); @@ -238,8 +246,19 @@ describe('InviteGroupsModal', () => { }); it('does not show the toast message on failure', () => { - expect(wrapper.vm.$toast.show).not.toHaveBeenCalled(); + expect(mockToastShow).not.toHaveBeenCalled(); }); }); }); + + describe('when group select emits an error event', () => { + it('displays error alert', async () => { + createComponent(); + + findGroupSelect().vm.$emit('error', GroupSelect.i18n.errorMessage); + await nextTick(); + + expect(wrapper.findComponent(GlAlert).text()).toBe(GroupSelect.i18n.errorMessage); + }); + }); }); diff --git a/spec/frontend/invite_members/components/members_token_select_spec.js b/spec/frontend/invite_members/components/members_token_select_spec.js index ff0313cc49e..925534edd7c 100644 --- a/spec/frontend/invite_members/components/members_token_select_spec.js +++ b/spec/frontend/invite_members/components/members_token_select_spec.js @@ -143,12 +143,19 @@ describe('MembersTokenSelect', () => { }); describe('when input text is an email', () => { - it('allows user defined tokens', async () => { - tokenSelector.vm.$emit('text-input', 'foo@bar.com'); + it.each` + email | result + ${'foo@bar.com'} | ${true} + ${'foo@bar.com '} | ${false} + ${' foo@bar.com'} | ${false} + ${'foo@ba r.com'} | ${false} + ${'fo o@bar.com'} | ${false} + `(`with token creation validation on $email`, async ({ email, result }) => { + tokenSelector.vm.$emit('text-input', email); await nextTick(); - expect(tokenSelector.props('allowUserDefinedTokens')).toBe(true); + expect(tokenSelector.props('allowUserDefinedTokens')).toBe(result); }); }); }); diff --git a/spec/frontend/issuable/components/related_issuable_item_spec.js b/spec/frontend/issuable/components/related_issuable_item_spec.js index 7322894164b..bfb0aaa1c67 100644 --- a/spec/frontend/issuable/components/related_issuable_item_spec.js +++ b/spec/frontend/issuable/components/related_issuable_item_spec.js @@ -236,23 +236,21 @@ describe('RelatedIssuableItem', () => { describe('when work item is issue and the related issue title is clicked', () => { it('does not open', () => { mountComponent({ props: { workItemType: 'ISSUE' } }); - wrapper.vm.$refs.modal.show = jest.fn(); findTitleLink().vm.$emit('click', { preventDefault: () => {} }); - expect(wrapper.vm.$refs.modal.show).not.toHaveBeenCalled(); + expect(showModalSpy).not.toHaveBeenCalled(); }); }); describe('when work item is task and the related issue title is clicked', () => { beforeEach(() => { mountComponent({ props: { workItemType: 'TASK' } }); - wrapper.vm.$refs.modal.show = jest.fn(); findTitleLink().vm.$emit('click', { preventDefault: () => {} }); }); it('opens', () => { - expect(wrapper.vm.$refs.modal.show).toHaveBeenCalled(); + expect(showModalSpy).toHaveBeenCalled(); }); it('updates the url params with the work item id', () => { diff --git a/spec/frontend/issuable/components/status_box_spec.js b/spec/frontend/issuable/components/status_box_spec.js index d26f287d90c..0d47595c9e6 100644 --- a/spec/frontend/issuable/components/status_box_spec.js +++ b/spec/frontend/issuable/components/status_box_spec.js @@ -18,6 +18,8 @@ describe('Merge request status box component', () => { ${'merge_request'} | ${'Merged'} | ${'merged'} | ${'issuable-status-badge-merged'} | ${'info'} | ${'merge'} ${'issue'} | ${'Open'} | ${'opened'} | ${'issuable-status-badge-open'} | ${'success'} | ${'issues'} ${'issue'} | ${'Closed'} | ${'closed'} | ${'issuable-status-badge-closed'} | ${'info'} | ${'issue-closed'} + ${'epic'} | ${'Open'} | ${'opened'} | ${'issuable-status-badge-open'} | ${'success'} | ${'epic'} + ${'epic'} | ${'Closed'} | ${'closed'} | ${'issuable-status-badge-closed'} | ${'info'} | ${'epic-closed'} `( 'with issuableType set to "$issuableType" and state set to "$initialState"', ({ issuableType, badgeText, initialState, badgeClass, badgeVariant, badgeIcon }) => { diff --git a/spec/frontend/issuable/issuable_form_spec.js b/spec/frontend/issuable/issuable_form_spec.js index d7e5f9083b0..b9652327e3d 100644 --- a/spec/frontend/issuable/issuable_form_spec.js +++ b/spec/frontend/issuable/issuable_form_spec.js @@ -4,6 +4,9 @@ import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import IssuableForm from '~/issuable/issuable_form'; import setWindowLocation from 'helpers/set_window_location_helper'; import { confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection'; +import { mockTracking } from 'helpers/tracking_helper'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { TEST_HOST } from 'helpers/test_constants'; import { getSaveableFormChildren } from './helpers'; jest.mock('~/autosave'); @@ -20,9 +23,12 @@ const createIssuable = (form) => { }; describe('IssuableForm', () => { + let trackingSpy; let $form; let instance; + useLocalStorageSpy(); + beforeEach(() => { setHTMLFixture(` <form> @@ -32,6 +38,7 @@ describe('IssuableForm', () => { </form> `); $form = $('form'); + trackingSpy = mockTracking(undefined, null, jest.spyOn); }); afterEach(() => { @@ -266,6 +273,34 @@ describe('IssuableForm', () => { expect(resetAutosave).toHaveBeenCalled(); }); + it.each` + windowLocation | context | localStorageValue | editorType + ${'/gitlab-org/gitlab/-/issues/412699'} | ${'Issue'} | ${'contentEditor'} | ${'editor_type_rich_text_editor'} + ${'/gitlab-org/gitlab/-/merge_requests/125979/diffs'} | ${'MergeRequest'} | ${'contentEditor'} | ${'editor_type_rich_text_editor'} + ${'/groups/gitlab-org/-/milestones/8/edit'} | ${'Other'} | ${'contentEditor'} | ${'editor_type_rich_text_editor'} + ${'/gitlab-org/gitlab/-/issues/412699'} | ${'Issue'} | ${'markdownField'} | ${'editor_type_plain_text_editor'} + ${'/gitlab-org/gitlab/-/merge_requests/125979/diffs'} | ${'MergeRequest'} | ${'markdownField'} | ${'editor_type_plain_text_editor'} + ${'/groups/gitlab-org/-/milestones/8/edit'} | ${'Other'} | ${'markdownField'} | ${'editor_type_plain_text_editor'} + `( + 'tracks event on form submit', + ({ windowLocation, context, localStorageValue, editorType }) => { + setWindowLocation(`${TEST_HOST}/${windowLocation}`); + localStorage.setItem('gl-markdown-editor-mode', localStorageValue); + + issueDescription.value = 'sample message'; + + createIssuable($form); + + $form.submit(); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'editor_type_used', { + context, + editorType, + label: 'editor_tracking', + }); + }, + ); + it('prevents form submission when token is present', () => { issueDescription.value = sensitiveMessage; diff --git a/spec/frontend/issuable/popover/components/issue_popover_spec.js b/spec/frontend/issuable/popover/components/issue_popover_spec.js index a7605016039..0596433ce9a 100644 --- a/spec/frontend/issuable/popover/components/issue_popover_spec.js +++ b/spec/frontend/issuable/popover/components/issue_popover_spec.js @@ -26,7 +26,7 @@ describe('Issue Popover', () => { apolloProvider: createMockApollo([[issueQuery, queryResponse]]), propsData: { target: document.createElement('a'), - projectPath: 'foo/bar', + namespacePath: 'foo/bar', iid: '1', cachedTitle: 'Cached title', }, diff --git a/spec/frontend/issuable/popover/components/mr_popover_spec.js b/spec/frontend/issuable/popover/components/mr_popover_spec.js index 5b29ecfc0ba..4ed783da853 100644 --- a/spec/frontend/issuable/popover/components/mr_popover_spec.js +++ b/spec/frontend/issuable/popover/components/mr_popover_spec.js @@ -64,7 +64,7 @@ describe('MR Popover', () => { apolloProvider: createMockApollo([[mergeRequestQuery, queryResponse]]), propsData: { target: document.createElement('a'), - projectPath: 'foo/bar', + namespacePath: 'foo/bar', iid: '1', cachedTitle: 'Cached Title', }, diff --git a/spec/frontend/issuable/popover/index_spec.js b/spec/frontend/issuable/popover/index_spec.js index b1aa7f0f0b0..bf9dce4867f 100644 --- a/spec/frontend/issuable/popover/index_spec.js +++ b/spec/frontend/issuable/popover/index_spec.js @@ -1,6 +1,6 @@ import { setHTMLFixture } from 'helpers/fixtures'; import * as createDefaultClient from '~/lib/graphql'; -import initIssuablePopovers from '~/issuable/popover/index'; +import initIssuablePopovers, * as popover from '~/issuable/popover/index'; createDefaultClient.default = jest.fn(); @@ -9,6 +9,7 @@ describe('initIssuablePopovers', () => { let mr2; let mr3; let issue1; + let workItem1; beforeEach(() => { setHTMLFixture(` @@ -24,30 +25,69 @@ describe('initIssuablePopovers', () => { <div id="four" class="gfm-issue" title="title" data-iid="1" data-project-path="group/project" data-reference-type="issue"> MR3 </div> + <div id="five" class="gfm-work_item" title="title" data-iid="1" data-project-path="group/project" data-reference-type="work_item"> + MR3 + </div> `); mr1 = document.querySelector('#one'); mr2 = document.querySelector('#two'); mr3 = document.querySelector('#three'); issue1 = document.querySelector('#four'); - - mr1.addEventListener = jest.fn(); - mr2.addEventListener = jest.fn(); - mr3.addEventListener = jest.fn(); - issue1.addEventListener = jest.fn(); + workItem1 = document.querySelector('#five'); }); - it('does not add the same event listener twice', () => { - initIssuablePopovers([mr1, mr1, mr2, issue1]); + describe('init function', () => { + beforeEach(() => { + mr1.addEventListener = jest.fn(); + mr2.addEventListener = jest.fn(); + mr3.addEventListener = jest.fn(); + issue1.addEventListener = jest.fn(); + workItem1.addEventListener = jest.fn(); + }); + + it('does not add the same event listener twice', () => { + initIssuablePopovers([mr1, mr1, mr2, issue1, workItem1]); + + expect(mr1.addEventListener).toHaveBeenCalledTimes(1); + expect(mr2.addEventListener).toHaveBeenCalledTimes(1); + expect(issue1.addEventListener).toHaveBeenCalledTimes(1); + expect(workItem1.addEventListener).toHaveBeenCalledTimes(1); + }); - expect(mr1.addEventListener).toHaveBeenCalledTimes(1); - expect(mr2.addEventListener).toHaveBeenCalledTimes(1); - expect(issue1.addEventListener).toHaveBeenCalledTimes(1); + it('does not add listener if it does not have the necessary data attributes', () => { + initIssuablePopovers([mr1, mr2, mr3]); + + expect(mr3.addEventListener).not.toHaveBeenCalled(); + }); }); - it('does not add listener if it does not have the necessary data attributes', () => { - initIssuablePopovers([mr1, mr2, mr3]); + describe('mount function', () => { + const expectedMountObject = { + apolloProvider: expect.anything(), + iid: '1', + namespacePath: 'group/project', + title: 'title', + }; + + beforeEach(() => { + jest.spyOn(popover, 'handleIssuablePopoverMount').mockImplementation(jest.fn()); + }); + + it('calls popover mount function with components for Issue, MR, and Work Item', () => { + initIssuablePopovers([mr1, issue1, workItem1], popover.handleIssuablePopoverMount); + + [mr1, issue1, workItem1].forEach(async (el) => { + await el.dispatchEvent(new Event('mouseenter', { target: el })); - expect(mr3.addEventListener).not.toHaveBeenCalled(); + expect(popover.handleIssuablePopoverMount).toHaveBeenCalledWith( + expect.objectContaining({ + ...expectedMountObject, + referenceType: el.dataset.referenceType, + target: el, + }), + ); + }); + }); }); }); diff --git a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js index e97c0312181..a24bffdd363 100644 --- a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js +++ b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js @@ -1,6 +1,6 @@ import { nextTick } from 'vue'; import { GlIcon, GlCard } from '@gitlab/ui'; -import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { issuable1, issuable2, @@ -14,6 +14,7 @@ import { linkedIssueTypesTextMap, PathIdSeparator, } from '~/related_issues/constants'; +import RelatedIssuesList from '~/related_issues/components/related_issues_list.vue'; describe('RelatedIssuesBlock', () => { let wrapper; @@ -21,9 +22,10 @@ describe('RelatedIssuesBlock', () => { const findToggleButton = () => wrapper.findByTestId('toggle-links'); const findRelatedIssuesBody = () => wrapper.findByTestId('related-issues-body'); const findIssueCountBadgeAddButton = () => wrapper.findByTestId('related-issues-plus-button'); + const findAllRelatedIssuesList = () => wrapper.findAllComponents(RelatedIssuesList); + const findRelatedIssuesList = (index) => findAllRelatedIssuesList().at(index); const createComponent = ({ - mountFn = mountExtended, pathIdSeparator = PathIdSeparator.Issue, issuableType = TYPE_ISSUE, canAdmin = false, @@ -35,7 +37,7 @@ describe('RelatedIssuesBlock', () => { autoCompleteEpics = true, slots = '', } = {}) => { - wrapper = mountFn(RelatedIssuesBlock, { + wrapper = shallowMountExtended(RelatedIssuesBlock, { propsData: { pathIdSeparator, issuableType, @@ -76,7 +78,7 @@ describe('RelatedIssuesBlock', () => { helpPath: '/help/user/project/issues/related_issues', }); - expect(wrapper.find('.card-title').text()).toContain(titleText); + expect(wrapper.findByTestId('card-title').text()).toContain(titleText); expect(findIssueCountBadgeAddButton().attributes('aria-label')).toBe(addButtonText); }, ); @@ -94,12 +96,9 @@ describe('RelatedIssuesBlock', () => { it('displays header text slot data', () => { const headerText = '<div>custom header text</div>'; - createComponent({ - mountFn: shallowMountExtended, - slots: { 'header-text': headerText }, - }); + createComponent({ slots: { 'header-text': headerText } }); - expect(wrapper.find('.card-title').html()).toContain(headerText); + expect(wrapper.findByTestId('card-title').html()).toContain(headerText); }); }); @@ -107,10 +106,7 @@ describe('RelatedIssuesBlock', () => { it('displays header actions slot data', () => { const headerActions = '<button data-testid="custom-button">custom button</button>'; - createComponent({ - mountFn: shallowMountExtended, - slots: { 'header-actions': headerActions }, - }); + createComponent({ slots: { 'header-actions': headerActions } }); expect(wrapper.findByTestId('custom-button').html()).toBe(headerActions); }); @@ -153,10 +149,6 @@ describe('RelatedIssuesBlock', () => { }); describe('showCategorizedIssues prop', () => { - const issueList = () => wrapper.findAll('.js-related-issues-token-list-item'); - const categorizedHeadings = () => wrapper.findAll('h4'); - const headingTextAt = (index) => categorizedHeadings().at(index).text(); - describe('when showCategorizedIssues=true', () => { beforeEach(() => createComponent({ @@ -166,25 +158,25 @@ describe('RelatedIssuesBlock', () => { ); it('should render issue tokens items', () => { - expect(issueList()).toHaveLength(3); + expect(findAllRelatedIssuesList()).toHaveLength(3); }); it('shows "Blocks" heading', () => { - const blocks = linkedIssueTypesTextMap[linkedIssueTypesMap.BLOCKS]; - - expect(headingTextAt(0)).toBe(blocks); + expect(findRelatedIssuesList(0).props('heading')).toBe( + linkedIssueTypesTextMap[linkedIssueTypesMap.BLOCKS], + ); }); it('shows "Is blocked by" heading', () => { - const isBlockedBy = linkedIssueTypesTextMap[linkedIssueTypesMap.IS_BLOCKED_BY]; - - expect(headingTextAt(1)).toBe(isBlockedBy); + expect(findRelatedIssuesList(1).props('heading')).toBe( + linkedIssueTypesTextMap[linkedIssueTypesMap.IS_BLOCKED_BY], + ); }); it('shows "Relates to" heading', () => { - const relatesTo = linkedIssueTypesTextMap[linkedIssueTypesMap.RELATES_TO]; - - expect(headingTextAt(2)).toBe(relatesTo); + expect(findRelatedIssuesList(2).props('heading')).toBe( + linkedIssueTypesTextMap[linkedIssueTypesMap.RELATES_TO], + ); }); }); @@ -194,8 +186,9 @@ describe('RelatedIssuesBlock', () => { showCategorizedIssues: false, relatedIssues: [issuable1, issuable2, issuable3], }); - expect(issueList()).toHaveLength(3); - expect(categorizedHeadings()).toHaveLength(0); + expect(findAllRelatedIssuesList()).toHaveLength(1); + expect(findRelatedIssuesList(0).props('relatedIssues')).toHaveLength(3); + expect(findRelatedIssuesList(0).props('heading')).toBe(''); }); }); }); diff --git a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js index b119c836411..6638f3d6289 100644 --- a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js +++ b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js @@ -1,6 +1,5 @@ -import { mount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; import { defaultProps, @@ -17,7 +16,6 @@ import { import { linkedIssueTypesMap } from '~/related_issues/constants'; import RelatedIssuesBlock from '~/related_issues/components/related_issues_block.vue'; import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue'; -import relatedIssuesService from '~/related_issues/services/related_issues_service'; jest.mock('~/alert'); @@ -37,7 +35,7 @@ describe('RelatedIssuesRoot', () => { }); const createComponent = ({ props = {}, data = {} } = {}) => { - wrapper = mount(RelatedIssuesRoot, { + wrapper = shallowMount(RelatedIssuesRoot, { propsData: { ...defaultProps, ...props, @@ -58,14 +56,13 @@ describe('RelatedIssuesRoot', () => { describe('when "relatedIssueRemoveRequest" event is emitted', () => { describe('when emitted value is a numerical issue', () => { beforeEach(async () => { - jest - .spyOn(relatedIssuesService.prototype, 'fetchRelatedIssues') - .mockReturnValue(Promise.reject()); + mock.onGet(defaultProps.endpoint).reply(HTTP_STATUS_OK, [issuable1]); await createComponent(); - wrapper.vm.store.setRelatedIssues([issuable1]); }); - it('removes related issue on API success', async () => { + // quarantine: https://gitlab.com/gitlab-org/gitlab/-/issues/417177 + // eslint-disable-next-line jest/no-disabled-tests + it.skip('removes related issue on API success', async () => { mock.onDelete(issuable1.referencePath).reply(HTTP_STATUS_OK, { issues: [] }); findRelatedIssuesBlock().vm.$emit('relatedIssueRemoveRequest', issuable1.id); @@ -91,8 +88,7 @@ describe('RelatedIssuesRoot', () => { const workItem = `gid://gitlab/WorkItem/${issuable1.id}`; createComponent({ data: { state: { relatedIssues: [issuable1] } } }); - findRelatedIssuesBlock().vm.$emit('relatedIssueRemoveRequest', workItem); - await nextTick(); + await findRelatedIssuesBlock().vm.$emit('relatedIssueRemoveRequest', workItem); expect(findRelatedIssuesBlock().props('relatedIssues')).toEqual([]); }); @@ -103,8 +99,7 @@ describe('RelatedIssuesRoot', () => { it('toggles related issues form to visible from hidden', async () => { createComponent(); - findRelatedIssuesBlock().vm.$emit('toggleAddRelatedIssuesForm'); - await nextTick(); + await findRelatedIssuesBlock().vm.$emit('toggleAddRelatedIssuesForm'); expect(findRelatedIssuesBlock().props('isFormVisible')).toBe(true); }); @@ -112,24 +107,25 @@ describe('RelatedIssuesRoot', () => { it('toggles related issues form to hidden from visible', async () => { createComponent({ data: { isFormVisible: true } }); - findRelatedIssuesBlock().vm.$emit('toggleAddRelatedIssuesForm'); - await nextTick(); + await findRelatedIssuesBlock().vm.$emit('toggleAddRelatedIssuesForm'); expect(findRelatedIssuesBlock().props('isFormVisible')).toBe(false); }); }); describe('when "pendingIssuableRemoveRequest" event is emitted', () => { - beforeEach(() => { + beforeEach(async () => { createComponent(); - wrapper.vm.store.setPendingReferences([issuable1.reference]); + await findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { + untouchedRawReferences: [issuable1.reference], + touchedReference: '', + }); }); it('removes pending related issue', async () => { expect(findRelatedIssuesBlock().props('pendingReferences')).toHaveLength(1); - findRelatedIssuesBlock().vm.$emit('pendingIssuableRemoveRequest', 0); - await nextTick(); + await findRelatedIssuesBlock().vm.$emit('pendingIssuableRemoveRequest', 0); expect(findRelatedIssuesBlock().props('pendingReferences')).toHaveLength(0); }); @@ -137,33 +133,24 @@ describe('RelatedIssuesRoot', () => { describe('when "addIssuableFormSubmit" event is emitted', () => { beforeEach(async () => { - jest - .spyOn(relatedIssuesService.prototype, 'fetchRelatedIssues') - .mockReturnValue(Promise.reject()); await createComponent(); - jest.spyOn(wrapper.vm, 'processAllReferences'); - jest.spyOn(wrapper.vm.service, 'addRelatedIssues'); createAlert.mockClear(); }); - it('processes references before submitting', () => { + it('processes references before submitting', async () => { const input = '#123'; const linkedIssueType = linkedIssueTypesMap.RELATES_TO; const emitObj = { pendingReferences: input, linkedIssueType, }; - - findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', emitObj); - - expect(wrapper.vm.processAllReferences).toHaveBeenCalledWith(input); - expect(wrapper.vm.service.addRelatedIssues).toHaveBeenCalledWith([input], linkedIssueType); + await findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', emitObj); + expect(findRelatedIssuesBlock().props('pendingReferences')).toEqual([input]); }); - it('submits zero pending issues as related issue', () => { - wrapper.vm.store.setPendingReferences([]); - - findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', {}); + it('submits zero pending issues as related issue', async () => { + await findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', {}); + await waitForPromises(); expect(findRelatedIssuesBlock().props('pendingReferences')).toHaveLength(0); expect(findRelatedIssuesBlock().props('relatedIssues')).toHaveLength(0); @@ -177,9 +164,11 @@ describe('RelatedIssuesRoot', () => { status: 'success', }, }); - wrapper.vm.store.setPendingReferences([issuable1.reference]); - - findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', {}); + await findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { + untouchedRawReferences: [issuable1], + touchedReference: '', + }); + await findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', {}); await waitForPromises(); expect(findRelatedIssuesBlock().props('pendingReferences')).toHaveLength(0); @@ -196,9 +185,11 @@ describe('RelatedIssuesRoot', () => { status: 'success', }, }); - wrapper.vm.store.setPendingReferences([issuable1.reference, issuable2.reference]); - - findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', {}); + await findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { + untouchedRawReferences: [issuable1.reference, issuable2.reference], + touchedReference: '', + }); + await findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', {}); await waitForPromises(); expect(findRelatedIssuesBlock().props('pendingReferences')).toHaveLength(0); @@ -212,12 +203,15 @@ describe('RelatedIssuesRoot', () => { const input = '#123'; const message = 'error'; mock.onPost(defaultProps.endpoint).reply(HTTP_STATUS_CONFLICT, { message }); - wrapper.vm.store.setPendingReferences([issuable1.reference, issuable2.reference]); + await findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { + untouchedRawReferences: [issuable1.reference, issuable2.reference], + touchedReference: '', + }); expect(findRelatedIssuesBlock().props('hasError')).toBe(false); expect(findRelatedIssuesBlock().props('itemAddFailureMessage')).toBe(null); - findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', input); + await findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', input); await waitForPromises(); expect(findRelatedIssuesBlock().props('hasError')).toBe(true); @@ -229,8 +223,7 @@ describe('RelatedIssuesRoot', () => { beforeEach(() => createComponent({ data: { isFormVisible: true, inputValue: 'foo' } })); it('hides form and resets input', async () => { - findRelatedIssuesBlock().vm.$emit('addIssuableFormCancel'); - await nextTick(); + await findRelatedIssuesBlock().vm.$emit('addIssuableFormCancel'); expect(findRelatedIssuesBlock().props('isFormVisible')).toBe(false); expect(findRelatedIssuesBlock().props('inputValue')).toBe(''); @@ -243,11 +236,10 @@ describe('RelatedIssuesRoot', () => { const input = '#123 '; createComponent(); - findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { + await findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { untouchedRawReferences: [input.trim()], touchedReference: input, }); - await nextTick(); expect(findRelatedIssuesBlock().props('pendingReferences')).toEqual([input.trim()]); }); @@ -256,11 +248,10 @@ describe('RelatedIssuesRoot', () => { const input = 'asdf/qwer#444 '; createComponent(); - findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { + await findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { untouchedRawReferences: [input.trim()], touchedReference: input, }); - await nextTick(); expect(findRelatedIssuesBlock().props('pendingReferences')).toEqual([input.trim()]); }); @@ -270,11 +261,10 @@ describe('RelatedIssuesRoot', () => { const input = `${link} `; createComponent(); - findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { + await findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { untouchedRawReferences: [input.trim()], touchedReference: input, }); - await nextTick(); expect(findRelatedIssuesBlock().props('pendingReferences')).toEqual([link]); }); @@ -283,11 +273,10 @@ describe('RelatedIssuesRoot', () => { const input = 'asdf/qwer#444 #12 '; createComponent(); - findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { + await findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { untouchedRawReferences: input.trim().split(/\s/), touchedReference: '2', }); - await nextTick(); expect(findRelatedIssuesBlock().props('pendingReferences')).toEqual([ 'asdf/qwer#444', @@ -299,11 +288,10 @@ describe('RelatedIssuesRoot', () => { const input = 'something random '; createComponent(); - findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { + await findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { untouchedRawReferences: input.trim().split(/\s/), touchedReference: '2', }); - await nextTick(); expect(findRelatedIssuesBlock().props('pendingReferences')).toEqual([ 'something', @@ -317,11 +305,10 @@ describe('RelatedIssuesRoot', () => { const input = '23'; createComponent({ props: { pathIdSeparator } }); - findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { + await findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', { untouchedRawReferences: input.trim().split(/\s/), touchedReference: input, }); - await nextTick(); expect(findRelatedIssuesBlock().props('inputValue')).toBe(`${pathIdSeparator}${input}`); }, @@ -331,15 +318,13 @@ describe('RelatedIssuesRoot', () => { describe('when "addIssuableFormBlur" event is emitted', () => { beforeEach(() => { createComponent(); - jest.spyOn(wrapper.vm, 'processAllReferences').mockImplementation(() => {}); }); - it('adds any references to pending when blurring', () => { + it('adds any references to pending when blurring', async () => { const input = '#123'; - - findRelatedIssuesBlock().vm.$emit('addIssuableFormBlur', input); - - expect(wrapper.vm.processAllReferences).toHaveBeenCalledWith(input); + expect(findRelatedIssuesBlock().props('pendingReferences')).toEqual([]); + await findRelatedIssuesBlock().vm.$emit('addIssuableFormBlur', input); + expect(findRelatedIssuesBlock().props('pendingReferences')).toEqual([input]); }); }); }); diff --git a/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js b/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js index c152a5ef9a8..148c6230b9f 100644 --- a/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js +++ b/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js @@ -54,6 +54,7 @@ describe('IssuesDashboardApp component', () => { const defaultProvide = { autocompleteAwardEmojisPath: 'autocomplete/award/emojis/path', + autocompleteUsersPath: 'autocomplete/users.json', calendarPath: 'calendar/path', dashboardLabelsPath: 'dashboard/labels/path', dashboardMilestonesPath: 'dashboard/milestones/path', @@ -120,7 +121,7 @@ describe('IssuesDashboardApp component', () => { await waitForPromises(); }); - // https://gitlab.com/gitlab-org/gitlab/-/issues/391722 + // quarantine: https://gitlab.com/gitlab-org/gitlab/-/issues/391722 // eslint-disable-next-line jest/no-disabled-tests it.skip('renders IssuableList component', () => { expect(findIssuableList().props()).toMatchObject({ diff --git a/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js b/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js index a61e7ed1e86..8e69213ebba 100644 --- a/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js +++ b/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js @@ -23,6 +23,7 @@ describe('EmptyStateWithoutAnyIssues component', () => { newProjectPath: 'new/project/path', showNewIssueLink: false, signInPath: 'sign/in/path', + groupId: '', }; const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons); diff --git a/spec/frontend/issues/list/components/issues_list_app_spec.js b/spec/frontend/issues/list/components/issues_list_app_spec.js index 0e87e5e6595..72bf4826056 100644 --- a/spec/frontend/issues/list/components/issues_list_app_spec.js +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -115,6 +115,7 @@ describe('CE IssuesListApp component', () => { rssPath: 'rss/path', showNewIssueLink: true, signInPath: 'sign/in/path', + groupId: '', }; let defaultQueryResponse = getIssuesQueryResponse; diff --git a/spec/frontend/issues/show/components/delete_issue_modal_spec.js b/spec/frontend/issues/show/components/delete_issue_modal_spec.js index b8adeb24005..f122180a403 100644 --- a/spec/frontend/issues/show/components/delete_issue_modal_spec.js +++ b/spec/frontend/issues/show/components/delete_issue_modal_spec.js @@ -37,11 +37,14 @@ describe('DeleteIssueModal component', () => { }); describe('when "primary" event is emitted', () => { - let formSubmitSpy; + const submitMock = jest.fn(); + // Mock the form submit method + Object.defineProperty(HTMLFormElement.prototype, 'submit', { + value: submitMock, + }); beforeEach(() => { wrapper = mountComponent(); - formSubmitSpy = jest.spyOn(wrapper.vm.$refs.form, 'submit'); findModal().vm.$emit('primary'); }); @@ -50,7 +53,7 @@ describe('DeleteIssueModal component', () => { }); it('submits the form', () => { - expect(formSubmitSpy).toHaveBeenCalled(); + expect(submitMock).toHaveBeenCalledTimes(1); }); }); }); diff --git a/spec/frontend/issues/show/components/fields/description_spec.js b/spec/frontend/issues/show/components/fields/description_spec.js index c7116f380a1..5e329d44acb 100644 --- a/spec/frontend/issues/show/components/fields/description_spec.js +++ b/spec/frontend/issues/show/components/fields/description_spec.js @@ -3,20 +3,19 @@ import DescriptionField from '~/issues/show/components/fields/description.vue'; import eventHub from '~/issues/show/event_hub'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; +import { mockTracking } from 'helpers/tracking_helper'; describe('Description field component', () => { let wrapper; + let trackingSpy; - const findTextarea = () => wrapper.findComponent({ ref: 'textarea' }); const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor); - - const mountComponent = ({ description = 'test', contentEditorOnIssues = false } = {}) => - shallowMount(DescriptionField, { + const mountComponent = ({ description = 'test', contentEditorOnIssues = false } = {}) => { + wrapper = shallowMount(DescriptionField, { attachTo: document.body, propsData: { markdownPreviewPath: '/', markdownDocsPath: '/', - quickActionsDocsPath: '/', value: description, }, provide: { @@ -28,90 +27,66 @@ describe('Description field component', () => { MarkdownField, }, }); + }; beforeEach(() => { + trackingSpy = mockTracking(undefined, null, jest.spyOn); jest.spyOn(eventHub, '$emit'); - }); - - it('renders markdown field with description', () => { - wrapper = mountComponent(); - - expect(findTextarea().element.value).toBe('test'); - }); - - it('renders markdown field with a markdown description', () => { - const markdown = '**test**'; - - wrapper = mountComponent({ description: markdown }); - expect(findTextarea().element.value).toBe(markdown); + mountComponent({ contentEditorOnIssues: true }); }); - it('focuses field when mounted', () => { - wrapper = mountComponent(); + it('passes feature flag to the MarkdownEditorComponent', () => { + expect(findMarkdownEditor().props('enableContentEditor')).toBe(true); - expect(document.activeElement).toBe(findTextarea().element); - }); - - it('triggers update with meta+enter', () => { - wrapper = mountComponent(); + mountComponent({ contentEditorOnIssues: false }); - findTextarea().trigger('keydown.enter', { metaKey: true }); - - expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); + expect(findMarkdownEditor().props('enableContentEditor')).toBe(false); }); - it('triggers update with ctrl+enter', () => { - wrapper = mountComponent(); - - findTextarea().trigger('keydown.enter', { ctrlKey: true }); - - expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); + it('uses the MarkdownEditor component to edit markdown', () => { + expect(findMarkdownEditor().props()).toMatchObject({ + value: 'test', + renderMarkdownPath: '/', + autofocus: true, + supportsQuickActions: true, + markdownDocsPath: '/', + enableAutocomplete: true, + }); }); - describe('when contentEditorOnIssues feature flag is on', () => { + describe.each` + testDescription | metaKey | ctrlKey + ${'when meta+enter is pressed'} | ${true} | ${false} + ${'when ctrl+enter is pressed'} | ${false} | ${true} + `('$testDescription', ({ metaKey, ctrlKey }) => { beforeEach(() => { - wrapper = mountComponent({ contentEditorOnIssues: true }); - }); - - it('uses the MarkdownEditor component to edit markdown', () => { - expect(findMarkdownEditor().props()).toMatchObject({ - value: 'test', - renderMarkdownPath: '/', - autofocus: true, - supportsQuickActions: true, - quickActionsDocsPath: expect.any(String), - markdownDocsPath: '/', - enableAutocomplete: true, - }); - }); - - it('triggers update with meta+enter', () => { findMarkdownEditor().vm.$emit('keydown', { type: 'keydown', keyCode: 13, - metaKey: true, + metaKey, + ctrlKey, }); + }); + it('triggers update', () => { expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); }); - it('triggers update with ctrl+enter', () => { - findMarkdownEditor().vm.$emit('keydown', { - type: 'keydown', - keyCode: 13, - ctrlKey: true, + it('tracks event', () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'editor_type_used', { + context: 'Issue', + editorType: 'editor_type_plain_text_editor', + label: 'editor_tracking', }); - - expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); }); + }); - it('emits input event when MarkdownEditor emits input event', () => { - const markdown = 'markdown'; + it('emits input event when MarkdownEditor emits input event', () => { + const markdown = 'markdown'; - findMarkdownEditor().vm.$emit('input', markdown); + findMarkdownEditor().vm.$emit('input', markdown); - expect(wrapper.emitted('input')).toEqual([[markdown]]); - }); + expect(wrapper.emitted('input')).toEqual([[markdown]]); }); }); diff --git a/spec/frontend/issues/show/components/header_actions_spec.js b/spec/frontend/issues/show/components/header_actions_spec.js index 9a503a2d882..8a98b2b702a 100644 --- a/spec/frontend/issues/show/components/header_actions_spec.js +++ b/spec/frontend/issues/show/components/header_actions_spec.js @@ -1,12 +1,20 @@ import Vue, { nextTick } from 'vue'; -import { GlDropdownItem, GlLink, GlModal, GlButton } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlLink, GlModal, GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; import VueApollo from 'vue-apollo'; import waitForPromises from 'helpers/wait_for_promises'; import { mockTracking } from 'helpers/tracking_helper'; import { createAlert, VARIANT_SUCCESS } from '~/alert'; -import { STATUS_CLOSED, STATUS_OPEN, TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants'; +import { + STATUS_CLOSED, + STATUS_OPEN, + TYPE_INCIDENT, + TYPE_ISSUE, + TYPE_TEST_CASE, + TYPE_ALERT, + TYPE_MERGE_REQUEST, +} from '~/issues/constants'; import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue'; import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; import HeaderActions from '~/issues/show/components/header_actions.vue'; @@ -14,6 +22,7 @@ import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show import issuesEventHub from '~/issues/show/event_hub'; import promoteToEpicMutation from '~/issues/show/queries/promote_to_epic.mutation.graphql'; import * as urlUtility from '~/lib/utils/url_utility'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import eventHub from '~/notes/event_hub'; import createStore from '~/notes/stores'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -690,4 +699,27 @@ describe('HeaderActions component', () => { }, ); }); + + describe('issue type text', () => { + it.each` + issueType | expectedText + ${TYPE_ISSUE} | ${'issue'} + ${TYPE_INCIDENT} | ${'incident'} + ${TYPE_MERGE_REQUEST} | ${'merge request'} + ${TYPE_ALERT} | ${'alert'} + ${TYPE_TEST_CASE} | ${'test case'} + ${'unknown'} | ${'unknown'} + `('$issueType', ({ issueType, expectedText }) => { + wrapper = mountComponent({ + movedMrSidebarEnabled: true, + props: { issueType, issuableEmailAddress: 'mock-email-address' }, + }); + + expect(wrapper.findComponent(GlDropdown).attributes('text')).toBe( + `${capitalizeFirstCharacter(expectedText)} actions`, + ); + expect(findDropdownBy('copy-email').text()).toBe(`Copy ${expectedText} email address`); + expect(findDesktopDropdownItems().at(0).text()).toBe(`New related ${expectedText}`); + }); + }); }); diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_item_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_item_spec.js index 24653a23036..2500c808073 100644 --- a/spec/frontend/issues/show/components/incidents/timeline_events_item_spec.js +++ b/spec/frontend/issues/show/components/incidents/timeline_events_item_spec.js @@ -1,5 +1,5 @@ import timezoneMock from 'timezone-mock'; -import { GlIcon, GlDropdown, GlBadge } from '@gitlab/ui'; +import { GlIcon, GlDisclosureDropdown, GlBadge } from '@gitlab/ui'; import { nextTick } from 'vue'; import { timelineItemI18n } from '~/issues/show/components/incidents/constants'; import { mountExtended } from 'helpers/vue_test_utils_helper'; @@ -28,7 +28,7 @@ describe('IncidentTimelineEventList', () => { const findCommentIcon = () => wrapper.findComponent(GlIcon); const findEventTime = () => wrapper.findByTestId('event-time'); const findEventTags = () => wrapper.findAllComponents(GlBadge); - const findDropdown = () => wrapper.findComponent(GlDropdown); + const findGlDropdown = () => wrapper.findComponent(GlDisclosureDropdown); const findDeleteButton = () => wrapper.findByText(timelineItemI18n.delete); const findEditButton = () => wrapper.findByText(timelineItemI18n.edit); @@ -85,7 +85,7 @@ describe('IncidentTimelineEventList', () => { describe('action dropdown', () => { it('does not show the action dropdown by default', () => { - expect(findDropdown().exists()).toBe(false); + expect(findGlDropdown().exists()).toBe(false); expect(findDeleteButton().exists()).toBe(false); }); @@ -100,14 +100,14 @@ describe('IncidentTimelineEventList', () => { mockEvent: systemGeneratedMockEvent, }); - expect(findDropdown().exists()).toBe(true); + expect(findGlDropdown().exists()).toBe(true); expect(findEditButton().exists()).toBe(false); }); it('shows dropdown and delete item when user has update permission', () => { mountComponent({ provide: { canUpdateTimelineEvent: true } }); - expect(findDropdown().exists()).toBe(true); + expect(findGlDropdown().exists()).toBe(true); expect(findDeleteButton().exists()).toBe(true); }); diff --git a/spec/frontend/issues/show/components/task_list_item_actions_spec.js b/spec/frontend/issues/show/components/task_list_item_actions_spec.js index 0b3ff0667b1..93cb7b5ae16 100644 --- a/spec/frontend/issues/show/components/task_list_item_actions_spec.js +++ b/spec/frontend/issues/show/components/task_list_item_actions_spec.js @@ -3,6 +3,8 @@ import { shallowMount } from '@vue/test-utils'; import TaskListItemActions from '~/issues/show/components/task_list_item_actions.vue'; import eventHub from '~/issues/show/event_hub'; +jest.mock('~/issues/show/event_hub'); + describe('TaskListItemActions component', () => { let wrapper; @@ -37,16 +39,12 @@ describe('TaskListItemActions component', () => { }); it('emits event when `Convert to task` dropdown item is clicked', () => { - jest.spyOn(eventHub, '$emit'); - findConvertToTaskItem().vm.$emit('action'); expect(eventHub.$emit).toHaveBeenCalledWith('convert-task-list-item', '3:1-3:10'); }); it('emits event when `Delete` dropdown item is clicked', () => { - jest.spyOn(eventHub, '$emit'); - findDeleteItem().vm.$emit('action'); expect(eventHub.$emit).toHaveBeenCalledWith('delete-task-list-item', '3:1-3:10'); diff --git a/spec/frontend/issues/show/issue_spec.js b/spec/frontend/issues/show/issue_spec.js index 2980a6c33ee..561035242eb 100644 --- a/spec/frontend/issues/show/issue_spec.js +++ b/spec/frontend/issues/show/issue_spec.js @@ -19,7 +19,7 @@ const setupHTML = (initialData) => { describe('Issue show index', () => { describe('initIssueApp', () => { - // https://gitlab.com/gitlab-org/gitlab/-/issues/390368 + // quarantine: https://gitlab.com/gitlab-org/gitlab/-/issues/390368 // eslint-disable-next-line jest/no-disabled-tests it.skip('should initialize app with no potential XSS attack', async () => { const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {}); diff --git a/spec/frontend/jira_connect/branches/components/project_dropdown_spec.js b/spec/frontend/jira_connect/branches/components/project_dropdown_spec.js index 0a887efee4b..f4f4936a134 100644 --- a/spec/frontend/jira_connect/branches/components/project_dropdown_spec.js +++ b/spec/frontend/jira_connect/branches/components/project_dropdown_spec.js @@ -137,7 +137,6 @@ describe('ProjectDropdown', () => { describe('when searching branches', () => { it('triggers a refetch', async () => { createComponent({ mountFn: mount }); - jest.clearAllMocks(); const mockSearchTerm = 'gitl'; await findDropdown().vm.$emit('search', mockSearchTerm); diff --git a/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js b/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js index a3bc8e861b2..cf2dacb50d8 100644 --- a/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js +++ b/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js @@ -104,7 +104,6 @@ describe('SourceBranchDropdown', () => { it('triggers a refetch', async () => { createComponent({ mountFn: mount, props: { selectedProject: mockSelectedProject } }); await waitForPromises(); - jest.clearAllMocks(); const mockSearchTerm = 'mai'; await findListbox().vm.$emit('search', mockSearchTerm); diff --git a/spec/frontend/jira_connect/subscriptions/components/app_spec.js b/spec/frontend/jira_connect/subscriptions/components/app_spec.js index 26a9d07321c..ea578836a12 100644 --- a/spec/frontend/jira_connect/subscriptions/components/app_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/app_spec.js @@ -7,6 +7,7 @@ import SignInPage from '~/jira_connect/subscriptions/pages/sign_in/sign_in_page. import SubscriptionsPage from '~/jira_connect/subscriptions/pages/subscriptions_page.vue'; import UserLink from '~/jira_connect/subscriptions/components/user_link.vue'; import BrowserSupportAlert from '~/jira_connect/subscriptions/components/browser_support_alert.vue'; +import FeedbackBanner from '~/jira_connect/subscriptions/components/feedback_banner.vue'; import createStore from '~/jira_connect/subscriptions/store'; import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types'; import { I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE } from '~/jira_connect/subscriptions/constants'; @@ -31,6 +32,7 @@ describe('JiraConnectApp', () => { const findSubscriptionsPage = () => wrapper.findComponent(SubscriptionsPage); const findUserLink = () => wrapper.findComponent(UserLink); const findBrowserSupportAlert = () => wrapper.findComponent(BrowserSupportAlert); + const findFeedbackBanner = () => wrapper.findComponent(FeedbackBanner); const createComponent = ({ provide, initialState = {} } = {}) => { store = createStore({ ...initialState, subscriptions: [mockSubscription] }); @@ -66,6 +68,12 @@ describe('JiraConnectApp', () => { expect(findJiraConnectApp().exists()).toBe(false); }); + it('renders FeedbackBanner', () => { + createComponent(); + + expect(findFeedbackBanner().exists()).toBe(true); + }); + describe.each` scenario | currentUser | expectUserLink | expectSignInPage | expectSubscriptionsPage ${'user is not signed in'} | ${undefined} | ${false} | ${true} | ${false} diff --git a/spec/frontend/jira_connect/subscriptions/components/feedback_banner_spec.js b/spec/frontend/jira_connect/subscriptions/components/feedback_banner_spec.js new file mode 100644 index 00000000000..8debfaad5bb --- /dev/null +++ b/spec/frontend/jira_connect/subscriptions/components/feedback_banner_spec.js @@ -0,0 +1,45 @@ +import { GlBanner } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import FeedbackBanner from '~/jira_connect/subscriptions/components/feedback_banner.vue'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; + +describe('FeedbackBanner', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(FeedbackBanner); + }; + + const findBanner = () => wrapper.findComponent(GlBanner); + const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); + + beforeEach(() => { + createComponent(); + }); + + it('renders a banner with button', () => { + expect(findBanner().props()).toMatchObject({ + title: FeedbackBanner.i18n.title, + buttonText: FeedbackBanner.i18n.buttonText, + buttonLink: FeedbackBanner.feedbackIssueUrl, + }); + }); + + it('uses localStorage with default value as false', () => { + expect(findLocalStorageSync().props().value).toBe(false); + }); + + describe('when banner is dimsissed', () => { + beforeEach(() => { + findBanner().vm.$emit('close'); + }); + + it('hides the banner', () => { + expect(findBanner().exists()).toBe(false); + }); + + it('updates localStorage value to true', () => { + expect(findLocalStorageSync().props().value).toBe(true); + }); + }); +}); diff --git a/spec/frontend/jobs/components/job/job_app_spec.js b/spec/frontend/jobs/components/job/job_app_spec.js index 394fc8ad43c..c925131dd9c 100644 --- a/spec/frontend/jobs/components/job/job_app_spec.js +++ b/spec/frontend/jobs/components/job/job_app_spec.js @@ -9,7 +9,7 @@ import EnvironmentsBlock from '~/jobs/components/job/environments_block.vue'; import ErasedBlock from '~/jobs/components/job/erased_block.vue'; import JobApp from '~/jobs/components/job/job_app.vue'; import JobLog from '~/jobs/components/log/log.vue'; -import JobLogTopBar from '~/jobs/components/job/job_log_controllers.vue'; +import JobLogTopBar from 'ee_else_ce/jobs/components/job/job_log_controllers.vue'; import Sidebar from '~/jobs/components/job/sidebar/sidebar.vue'; import StuckBlock from '~/jobs/components/job/stuck_block.vue'; import UnmetPrerequisitesBlock from '~/jobs/components/job/unmet_prerequisites_block.vue'; diff --git a/spec/frontend/jobs/components/job/job_container_item_spec.js b/spec/frontend/jobs/components/job/job_container_item_spec.js index 8121aa1172f..39782130d38 100644 --- a/spec/frontend/jobs/components/job/job_container_item_spec.js +++ b/spec/frontend/jobs/components/job/job_container_item_spec.js @@ -9,8 +9,8 @@ import job from '../../mock_data'; describe('JobContainerItem', () => { let wrapper; - const findCiIconComponent = () => wrapper.findComponent(CiIcon); - const findGlIconComponent = () => wrapper.findComponent(GlIcon); + const findCiIcon = () => wrapper.findComponent(CiIcon); + const findGlIcon = () => wrapper.findComponent(GlIcon); function createComponent(jobData = {}, props = { isActive: false, retried: false }) { wrapper = shallowMount(JobContainerItem, { @@ -30,9 +30,7 @@ describe('JobContainerItem', () => { }); it('displays a status icon', () => { - const ciIcon = findCiIconComponent(); - - expect(ciIcon.props('status')).toBe(job.status); + expect(findCiIcon().props('status')).toBe(job.status); }); it('displays the job name', () => { @@ -52,9 +50,7 @@ describe('JobContainerItem', () => { }); it('displays an arrow sprite icon', () => { - const icon = findGlIconComponent(); - - expect(icon.props('name')).toBe('arrow-right'); + expect(findGlIcon().props('name')).toBe('arrow-right'); }); }); @@ -64,9 +60,7 @@ describe('JobContainerItem', () => { }); it('displays a retry icon', () => { - const icon = findGlIconComponent(); - - expect(icon.props('name')).toBe('retry'); + expect(findGlIcon().props('name')).toBe('retry'); }); }); diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js index b4ec00ab766..444d4a96f9c 100644 --- a/spec/frontend/lib/utils/common_utils_spec.js +++ b/spec/frontend/lib/utils/common_utils_spec.js @@ -1140,4 +1140,38 @@ describe('common_utils', () => { expect(result).toEqual([{ hello: '' }, { helloWorld: '' }]); }); }); + + describe('isCurrentUser', () => { + describe('when user is not signed in', () => { + it('returns `false`', () => { + window.gon.current_user_id = null; + + expect(commonUtils.isCurrentUser(1)).toBe(false); + }); + }); + + describe('when current user id does not match the provided user id', () => { + it('returns `false`', () => { + window.gon.current_user_id = 2; + + expect(commonUtils.isCurrentUser(1)).toBe(false); + }); + }); + + describe('when current user id matches the provided user id', () => { + it('returns `true`', () => { + window.gon.current_user_id = 1; + + expect(commonUtils.isCurrentUser(1)).toBe(true); + }); + }); + + describe('when provided user id is a string and it matches current user id', () => { + it('returns `true`', () => { + window.gon.current_user_id = 1; + + expect(commonUtils.isCurrentUser('1')).toBe(true); + }); + }); + }); }); diff --git a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js index e7a6367eeac..65018fe1625 100644 --- a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js +++ b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js @@ -152,3 +152,18 @@ describe('formatUtcOffset', () => { expect(utils.formatUtcOffset(offset)).toEqual(expected); }); }); + +describe('humanTimeframe', () => { + it.each` + startDate | dueDate | returnValue + ${'2021-1-1'} | ${'2021-2-28'} | ${'Jan 1 – Feb 28, 2021'} + ${'2021-1-1'} | ${'2022-2-28'} | ${'Jan 1, 2021 – Feb 28, 2022'} + ${'2021-1-1'} | ${null} | ${'Jan 1, 2021 – No due date'} + ${null} | ${'2021-2-28'} | ${'No start date – Feb 28, 2021'} + `( + 'returns string "$returnValue" when startDate is $startDate and dueDate is $dueDate', + ({ startDate, dueDate, returnValue }) => { + expect(utils.humanTimeframe(startDate, dueDate)).toBe(returnValue); + }, + ); +}); diff --git a/spec/frontend/lib/utils/downloader_spec.js b/spec/frontend/lib/utils/downloader_spec.js index c14cba3a62b..a95b46d1440 100644 --- a/spec/frontend/lib/utils/downloader_spec.js +++ b/spec/frontend/lib/utils/downloader_spec.js @@ -8,10 +8,6 @@ describe('Downloader', () => { jest.spyOn(document, 'createElement').mockImplementation(() => a); }); - afterEach(() => { - jest.clearAllMocks(); - }); - describe('when inline file content is provided', () => { const fileData = 'inline content'; const fileName = 'test.csv'; diff --git a/spec/frontend/lib/utils/forms_spec.js b/spec/frontend/lib/utils/forms_spec.js index 2f71b26b29a..b97f5bf3c51 100644 --- a/spec/frontend/lib/utils/forms_spec.js +++ b/spec/frontend/lib/utils/forms_spec.js @@ -1,7 +1,12 @@ import { serializeForm, serializeFormObject, + safeTrim, isEmptyValue, + hasMinimumLength, + isParseableAsInteger, + isIntegerGreaterThan, + isEmail, parseRailsFormFields, } from '~/lib/utils/forms'; @@ -99,6 +104,22 @@ describe('lib/utils/forms', () => { }); }); + describe('safeTrim', () => { + it.each` + input | returnValue + ${''} | ${''} + ${[]} | ${[]} + ${null} | ${null} + ${undefined} | ${undefined} + ${' '} | ${''} + ${'hello '} | ${'hello'} + ${'hello'} | ${'hello'} + ${0} | ${0} + `('returns $returnValue for value $input', ({ input, returnValue }) => { + expect(safeTrim(input)).toEqual(returnValue); + }); + }); + describe('isEmptyValue', () => { it.each` input | returnValue @@ -106,14 +127,102 @@ describe('lib/utils/forms', () => { ${[]} | ${true} ${null} | ${true} ${undefined} | ${true} + ${' '} | ${true} ${'hello'} | ${false} - ${' '} | ${false} ${0} | ${false} `('returns $returnValue for value $input', ({ input, returnValue }) => { expect(isEmptyValue(input)).toBe(returnValue); }); }); + describe('hasMinimumLength', () => { + it.each` + input | minLength | returnValue + ${['o', 't']} | ${1} | ${true} + ${'hello'} | ${3} | ${true} + ${' '} | ${2} | ${false} + ${''} | ${0} | ${false} + ${''} | ${8} | ${false} + ${[]} | ${0} | ${false} + ${null} | ${8} | ${false} + ${undefined} | ${8} | ${false} + ${'hello'} | ${8} | ${false} + ${0} | ${8} | ${false} + ${4} | ${1} | ${false} + `( + 'returns $returnValue for value $input and minLength $minLength', + ({ input, minLength, returnValue }) => { + expect(hasMinimumLength(input, minLength)).toBe(returnValue); + }, + ); + }); + + describe('isPareseableInteger', () => { + it.each` + input | returnValue + ${'0'} | ${true} + ${'12'} | ${true} + ${''} | ${false} + ${[]} | ${false} + ${null} | ${false} + ${undefined} | ${false} + ${'hello'} | ${false} + ${' '} | ${false} + ${'12.4'} | ${false} + ${'12ef'} | ${false} + `('returns $returnValue for value $input', ({ input, returnValue }) => { + expect(isParseableAsInteger(input)).toBe(returnValue); + }); + }); + + describe('isIntegerGreaterThan', () => { + it.each` + input | greaterThan | returnValue + ${25} | ${8} | ${true} + ${'25'} | ${8} | ${true} + ${'4'} | ${1} | ${true} + ${'4'} | ${8} | ${false} + ${'9.5'} | ${8} | ${false} + ${'9.5e'} | ${8} | ${false} + ${['o', 't']} | ${0} | ${false} + ${'hello'} | ${0} | ${false} + ${' '} | ${0} | ${false} + ${''} | ${0} | ${false} + ${''} | ${8} | ${false} + ${[]} | ${0} | ${false} + ${null} | ${0} | ${false} + ${undefined} | ${0} | ${false} + ${'hello'} | ${0} | ${false} + ${0} | ${0} | ${false} + `( + 'returns $returnValue for value $input and greaterThan $greaterThan', + ({ input, greaterThan, returnValue }) => { + expect(isIntegerGreaterThan(input, greaterThan)).toBe(returnValue); + }, + ); + }); + + describe('isEmail', () => { + it.each` + input | returnValue + ${'user-with_special-chars@example.com'} | ${true} + ${'user@subdomain.example.com'} | ${true} + ${'user@example.com'} | ${true} + ${'user@example.co'} | ${true} + ${'user@example.c'} | ${false} + ${'user@example'} | ${false} + ${''} | ${false} + ${[]} | ${false} + ${null} | ${false} + ${undefined} | ${false} + ${'hello'} | ${false} + ${' '} | ${false} + ${'12'} | ${false} + `('returns $returnValue for value $input', ({ input, returnValue }) => { + expect(isEmail(input)).toBe(returnValue); + }); + }); + describe('serializeFormObject', () => { it('returns an serialized object', () => { const form = { diff --git a/spec/frontend/lib/utils/ref_validator_spec.js b/spec/frontend/lib/utils/ref_validator_spec.js index 7185ebf0a24..97896d74dff 100644 --- a/spec/frontend/lib/utils/ref_validator_spec.js +++ b/spec/frontend/lib/utils/ref_validator_spec.js @@ -65,9 +65,6 @@ describe('~/lib/utils/ref_validator', () => { ['foo.123.', validationMessages.DisallowedSequencePostfixesValidationMessage], ['foo/', validationMessages.DisallowedPostfixesValidationMessage], - - ['control-character\x7f', validationMessages.ControlCharactersValidationMessage], - ['control-character\x15', validationMessages.ControlCharactersValidationMessage], ])('tag with name "%s"', (tagName, validationMessage) => { it(`should be invalid with validation message "${validationMessage}"`, () => { const result = validateTag(tagName); @@ -75,5 +72,25 @@ describe('~/lib/utils/ref_validator', () => { expect(result.validationErrors).toContain(validationMessage); }); }); + + // NOTE: control characters cannot be used in test names because they cause test report XML parsing errors + describe.each([ + [ + 'control-character x7f', + 'control-character\x7f', + validationMessages.ControlCharactersValidationMessage, + ], + [ + 'control-character x15', + 'control-character\x15', + validationMessages.ControlCharactersValidationMessage, + ], + ])('tag with name "%s"', (_, tagName, validationMessage) => { + it(`should be invalid with validation message "${validationMessage}"`, () => { + const result = validateTag(tagName); + expect(result.isValid).toBe(false); + expect(result.validationErrors).toContain(validationMessage); + }); + }); }); }); diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js index e3c89bfed53..efc8c9b4459 100644 --- a/spec/frontend/members/components/table/members_table_spec.js +++ b/spec/frontend/members/components/table/members_table_spec.js @@ -221,9 +221,11 @@ describe('MembersTable', () => { 'col-actions', 'gl-display-none!', 'gl-lg-display-table-cell!', + 'gl-vertical-align-middle!', ]); expect(findTableCellByMemberId('Actions', members[1].id).classes()).toStrictEqual([ 'col-actions', + 'gl-vertical-align-middle!', ]); }); }); diff --git a/spec/frontend/members/components/table/role_dropdown_spec.js b/spec/frontend/members/components/table/role_dropdown_spec.js index 1285404fd9f..fa188f50d54 100644 --- a/spec/frontend/members/components/table/role_dropdown_spec.js +++ b/spec/frontend/members/components/table/role_dropdown_spec.js @@ -238,6 +238,16 @@ describe('RoleDropdown', () => { it('does not call updateMemberRole', () => { expect(actions.updateMemberRole).not.toHaveBeenCalled(); }); + + it('re-enables dropdown', async () => { + await waitForPromises(); + + expect(findListbox().props('disabled')).toBe(false); + }); + + it('resets selected dropdown item', () => { + expect(findListbox().props('selected')).toBe(member.validRoles.Owner); + }); }); }); }); diff --git a/spec/frontend/merge_requests/generated_content_spec.js b/spec/frontend/merge_requests/generated_content_spec.js new file mode 100644 index 00000000000..f56a67ec466 --- /dev/null +++ b/spec/frontend/merge_requests/generated_content_spec.js @@ -0,0 +1,310 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; + +import { MergeRequestGeneratedContent } from '~/merge_requests/generated_content'; + +function findWarningElement() { + return document.querySelector('.js-ai-description-warning'); +} + +function findCloseButton() { + return findWarningElement()?.querySelector('.js-close-btn'); +} + +function findApprovalButton() { + return findWarningElement()?.querySelector('.js-ai-override-description'); +} + +function findCancelButton() { + return findWarningElement()?.querySelector('.js-cancel-btn'); +} + +function clickButton(button) { + button.dispatchEvent(new Event('click')); +} + +describe('MergeRequestGeneratedContent', () => { + const warningDOM = ` + +<div class="js-ai-description-warning hidden"> + <button class="js-close-btn">X</button> + <button class="js-ai-override-description">Do AI</button> + <button class="js-cancel-btn">Cancel</button> +</div> + +`; + + describe('class basics', () => { + let gen; + + beforeEach(() => { + gen = new MergeRequestGeneratedContent(); + }); + + it.each` + description | property + ${'with no editor'} | ${'hasEditor'} + ${'with no warning'} | ${'hasWarning'} + ${'unable to replace the content'} | ${'canReplaceContent'} + `('begins $description', ({ property }) => { + expect(gen[property]).toBe(false); + }); + }); + + describe('the internal editor representation', () => { + let gen; + + it('accepts an editor during construction', () => { + gen = new MergeRequestGeneratedContent({ editor: {} }); + + expect(gen.hasEditor).toBe(true); + }); + + it('allows adding an editor through a public API after construction', () => { + gen = new MergeRequestGeneratedContent(); + + expect(gen.hasEditor).toBe(false); + + gen.setEditor({}); + + expect(gen.hasEditor).toBe(true); + }); + }); + + describe('generated content', () => { + let gen; + + beforeEach(() => { + gen = new MergeRequestGeneratedContent(); + }); + + it('can be provided to the instance through a public API', () => { + expect(gen.generatedContent).toBe(null); + + gen.setGeneratedContent('generated content'); + + expect(gen.generatedContent).toBe('generated content'); + }); + + it('can be cleared from the instance through a public API', () => { + gen.setGeneratedContent('generated content'); + + expect(gen.generatedContent).toBe('generated content'); + + gen.clearGeneratedContent(); + + expect(gen.generatedContent).toBe(null); + }); + }); + + describe('warning element', () => { + let gen; + + afterEach(() => { + resetHTMLFixture(); + }); + + it.each` + presence | withFixture + ${'is'} | ${true} + ${'is not'} | ${false} + `('`.hasWarning` is $withFixture when the element $presence in the DOM', ({ withFixture }) => { + if (withFixture) { + setHTMLFixture(warningDOM); + } + + gen = new MergeRequestGeneratedContent(); + + expect(gen.hasWarning).toBe(withFixture); + }); + }); + + describe('special cases', () => { + it.each` + description | value | props + ${'there is no internal editor representation, and no generated content'} | ${false} | ${{}} + ${'there is an internal editor representation, but no generated content'} | ${false} | ${{ editor: {} }} + ${'there is no internal editor representation, but there is generated content'} | ${false} | ${{ content: 'generated content' }} + ${'there is an internal editor representation, and there is generated content'} | ${true} | ${{ editor: {}, content: 'generated content' }} + `('`.canReplaceContent` is $value when $description', ({ value, props }) => { + const gen = new MergeRequestGeneratedContent(); + + if (props.editor) { + gen.setEditor(props.editor); + } + if (props.content) { + gen.setGeneratedContent(props.content); + } + + expect(gen.canReplaceContent).toBe(value); + }); + }); + + describe('behaviors', () => { + describe('UI', () => { + describe('warning element', () => { + let gen; + + beforeEach(() => { + setHTMLFixture(warningDOM); + gen = new MergeRequestGeneratedContent({ editor: {} }); + + gen.setGeneratedContent('generated content'); + }); + + describe('#showWarning', () => { + it("shows the warning if it exists in the DOM and if it's possible to replace the description", () => { + gen.showWarning(); + + expect(findWarningElement().classList.contains('hidden')).toBe(false); + }); + + it("does nothing if the warning doesn't exist or if it's not possible to replace the description", () => { + gen.setEditor(null); + + gen.showWarning(); + + expect(findWarningElement().classList.contains('hidden')).toBe(true); + + gen.setEditor({}); + gen.setGeneratedContent(null); + + gen.showWarning(); + + expect(findWarningElement().classList.contains('hidden')).toBe(true); + + resetHTMLFixture(); + gen = new MergeRequestGeneratedContent({ editor: {} }); + gen.setGeneratedContent('generated content'); + + expect(() => gen.showWarning()).not.toThrow(); + expect(findWarningElement()).toBe(null); + }); + }); + + describe('#hideWarning', () => { + it('hides the warning', () => { + findWarningElement().classList.remove('hidden'); + + gen.hideWarning(); + + expect(findWarningElement().classList.contains('hidden')).toBe(true); + }); + + it("does nothing if there's no warning element", () => { + resetHTMLFixture(); + gen = new MergeRequestGeneratedContent(); + + expect(() => gen.hideWarning()).not.toThrow(); + expect(findWarningElement()).toBe(null); + }); + }); + }); + }); + + describe('content', () => { + const editor = {}; + let gen; + + beforeEach(() => { + editor.setValue = jest.fn(); + gen = new MergeRequestGeneratedContent({ editor }); + }); + + describe('#replaceDescription', () => { + it("sets the instance's generated content value to the internal representation of the editor", () => { + gen.setGeneratedContent('generated content'); + + gen.replaceDescription(); + + expect(editor.setValue).toHaveBeenCalledWith('generated content'); + }); + + it("does nothing if there's no editor or no generated content", () => { + // Starts with editor, but no content + gen.replaceDescription(); + + expect(editor.setValue).not.toHaveBeenCalled(); + + gen.setGeneratedContent('generated content'); + gen.setEditor(null); + + gen.replaceDescription(); + + expect(editor.setValue).not.toHaveBeenCalled(); + }); + + it("clears the generated content so the warning can't be re-shown with stale content", () => { + gen.setGeneratedContent('generated content'); + + gen.replaceDescription(); + + expect(editor.setValue).toHaveBeenCalledWith('generated content'); + expect(gen.hasEditor).toBe(true); + expect(gen.canReplaceContent).toBe(false); + expect(gen.generatedContent).toBe(null); + }); + }); + }); + }); + + describe('events', () => { + describe('UI clicks', () => { + const editor = {}; + let gen; + + beforeEach(() => { + setHTMLFixture(warningDOM); + editor.setValue = jest.fn(); + gen = new MergeRequestGeneratedContent({ editor }); + + gen.setGeneratedContent('generated content'); + }); + + describe('banner close button', () => { + it('hides the warning element', () => { + const close = findCloseButton(); + + gen.showWarning(); + + expect(findWarningElement().classList.contains('hidden')).toBe(false); + + clickButton(close); + + expect(findWarningElement().classList.contains('hidden')).toBe(true); + }); + }); + + describe('banner approval button', () => { + it('sends the generated content to the editor, clears the internal generated content, and hides the warning', () => { + const approve = findApprovalButton(); + + gen.showWarning(); + + expect(findWarningElement().classList.contains('hidden')).toBe(false); + expect(gen.generatedContent).toBe('generated content'); + expect(editor.setValue).not.toHaveBeenCalled(); + + clickButton(approve); + + expect(findWarningElement().classList.contains('hidden')).toBe(true); + expect(gen.generatedContent).toBe(null); + expect(editor.setValue).toHaveBeenCalledWith('generated content'); + }); + }); + + describe('banner cancel button', () => { + it('hides the warning element', () => { + const cancel = findCancelButton(); + + gen.showWarning(); + + expect(findWarningElement().classList.contains('hidden')).toBe(false); + + clickButton(cancel); + + expect(findWarningElement().classList.contains('hidden')).toBe(true); + }); + }); + }); + }); +}); diff --git a/spec/frontend/ml/model_registry/routes/models/index/components/ml_models_index_spec.js b/spec/frontend/ml/model_registry/routes/models/index/components/ml_models_index_spec.js new file mode 100644 index 00000000000..d1715ccd8f1 --- /dev/null +++ b/spec/frontend/ml/model_registry/routes/models/index/components/ml_models_index_spec.js @@ -0,0 +1,39 @@ +import { GlLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import MlModelsIndexApp from '~/ml/model_registry/routes/models/index'; +import { TITLE_LABEL } from '~/ml/model_registry/routes/models/index/translations'; +import { mockModels } from './mock_data'; + +let wrapper; +const createWrapper = (models = mockModels) => { + wrapper = shallowMountExtended(MlModelsIndexApp, { + propsData: { models }, + }); +}; + +const findModelLink = (index) => wrapper.findAllComponents(GlLink).at(index); +const modelLinkText = (index) => findModelLink(index).text(); +const modelLinkHref = (index) => findModelLink(index).attributes('href'); +const findTitle = () => wrapper.findByText(TITLE_LABEL); + +describe('MlModelsIndex', () => { + beforeEach(() => { + createWrapper(); + }); + + describe('header', () => { + it('displays the title', () => { + expect(findTitle().exists()).toBe(true); + }); + }); + + describe('model list', () => { + it('displays the models', () => { + expect(modelLinkHref(0)).toBe(mockModels[0].path); + expect(modelLinkText(0)).toBe(`${mockModels[0].name} / ${mockModels[0].version}`); + + expect(modelLinkHref(1)).toBe(mockModels[1].path); + expect(modelLinkText(1)).toBe(`${mockModels[1].name} / ${mockModels[1].version}`); + }); + }); +}); diff --git a/spec/frontend/ml/model_registry/routes/models/index/components/mock_data.js b/spec/frontend/ml/model_registry/routes/models/index/components/mock_data.js new file mode 100644 index 00000000000..b8a999abbbd --- /dev/null +++ b/spec/frontend/ml/model_registry/routes/models/index/components/mock_data.js @@ -0,0 +1,12 @@ +export const mockModels = [ + { + name: 'model_1', + version: '1.0', + path: 'path/to/model_1', + }, + { + name: 'model_2', + version: '1.0', + path: 'path/to/model_2', + }, +]; diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap deleted file mode 100644 index 3b4554700b4..00000000000 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ /dev/null @@ -1,155 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Dashboard template matches the default snapshot 1`] = ` -<div - class="prometheus-graphs" - data-testid="prometheus-graphs" - environmentstate="available" - metricsdashboardbasepath="/monitoring/monitor-project/-/metrics?environment=1" - metricsendpoint="/monitoring/monitor-project/-/environments/1/additional_metrics.json" -> - <div> - <gl-alert-stub - class="mb-3" - dismissible="true" - dismisslabel="Dismiss" - primarybuttonlink="" - primarybuttontext="" - secondarybuttonlink="" - secondarybuttontext="" - showicon="true" - title="Feature deprecation" - variant="warning" - > - <gl-sprintf-stub - message="The metrics feature was deprecated in GitLab 14.7." - /> - - <gl-sprintf-stub - message="For information on a possible replacement %{epicStart} learn more about Opstrace %{epicEnd}." - /> - </gl-alert-stub> - </div> - - <div - class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light" - > - <div - class="gl-mb-3 gl-mr-3 gl-display-flex gl-sm-display-block" - > - <dashboards-dropdown-stub - class="flex-grow-1" - defaultbranch="master" - id="monitor-dashboards-dropdown" - toggle-class="dropdown-menu-toggle" - /> - </div> - - <span - aria-hidden="true" - class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block" - /> - - <div - class="mb-2 pr-2 d-flex d-sm-block" - > - <gl-dropdown-stub - category="primary" - class="flex-grow-1" - clearalltext="Clear all" - clearalltextclass="gl-px-5" - data-testid="environments-dropdown" - headertext="" - hideheaderborder="true" - highlighteditemstitle="Selected" - highlighteditemstitleclass="gl-px-5" - id="monitor-environments-dropdown" - menu-class="monitor-environment-dropdown-menu" - size="medium" - text="production" - toggleclass="dropdown-menu-toggle" - variant="default" - > - <div - class="d-flex flex-column overflow-hidden" - > - <gl-dropdown-section-header-stub> - Environment - </gl-dropdown-section-header-stub> - - <gl-search-box-by-type-stub - clearbuttontitle="Clear" - value="" - /> - - <div - class="flex-fill overflow-auto" - /> - - <div - class="text-secondary no-matches-message" - > - - No matching results - - </div> - </div> - </gl-dropdown-stub> - </div> - - <div - class="mb-2 pr-2 d-flex d-sm-block" - > - <date-time-picker-stub - class="flex-grow-1 show-last-dropdown" - customenabled="true" - options="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]" - value="[object Object]" - /> - </div> - - <div - class="mb-2 pr-2 d-flex d-sm-block" - > - <refresh-button-stub /> - </div> - - <div - class="flex-grow-1" - /> - - <div - class="d-sm-flex" - > - <!----> - - <!----> - - <div - class="gl-mb-3 gl-mr-3 d-flex d-sm-block" - > - <actions-menu-stub - custommetricspath="/monitoring/monitor-project/prometheus/metrics" - defaultbranch="master" - isootbdashboard="true" - validatequerypath="/monitoring/monitor-project/prometheus/metrics/validate_query" - /> - </div> - - <!----> - </div> - </div> - - <empty-state-stub - clusterspath="/monitoring/monitor-project/-/clusters" - documentationpath="/help/administration/monitoring/prometheus/index.md" - emptygettingstartedsvgpath="/images/illustrations/monitoring/getting_started.svg" - emptyloadingsvgpath="/images/illustrations/monitoring/loading.svg" - emptynodatasmallsvgpath="/images/illustrations/chart-empty-state-small.svg" - emptynodatasvgpath="/images/illustrations/monitoring/no_data.svg" - emptyunabletoconnectsvgpath="/images/illustrations/monitoring/unable_to_connect.svg" - selectedstate="gettingStarted" - settingspath="/monitoring/monitor-project/-/settings/integrations/prometheus/edit" - /> -</div> -`; diff --git a/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap deleted file mode 100644 index 4483c9fd39f..00000000000 --- a/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap +++ /dev/null @@ -1,55 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EmptyState shows gettingStarted state 1`] = ` -<div> - <!----> - - <gl-empty-state-stub - contentclass="" - description="Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments." - invertindarkmode="true" - primarybuttonlink="/clustersPath" - primarybuttontext="Install on clusters" - secondarybuttonlink="/settingsPath" - secondarybuttontext="Configure existing installation" - svgpath="/path/to/getting-started.svg" - title="Get started with performance monitoring" - /> -</div> -`; - -exports[`EmptyState shows noData state 1`] = ` -<div> - <!----> - - <gl-empty-state-stub - contentclass="" - description="You are connected to the Prometheus server, but there is currently no data to display." - invertindarkmode="true" - primarybuttonlink="/settingsPath" - primarybuttontext="Configure Prometheus" - secondarybuttonlink="" - secondarybuttontext="" - svgpath="/path/to/no-data.svg" - title="No data found" - /> -</div> -`; - -exports[`EmptyState shows unableToConnect state 1`] = ` -<div> - <!----> - - <gl-empty-state-stub - contentclass="" - description="Ensure connectivity is available from the GitLab server to the Prometheus server" - invertindarkmode="true" - primarybuttonlink="/documentationPath" - primarybuttontext="View documentation" - secondarybuttonlink="/settingsPath" - secondarybuttontext="Configure Prometheus" - svgpath="/path/to/unable-to-connect.svg" - title="Unable to connect to Prometheus server" - /> -</div> -`; diff --git a/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap deleted file mode 100644 index 42a16a39dfd..00000000000 --- a/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap +++ /dev/null @@ -1,160 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`GroupEmptyState given state BAD_QUERY passes the expected props to GlEmptyState 1`] = ` -Object { - "compact": true, - "contentClass": Array [], - "description": null, - "invertInDarkMode": true, - "primaryButtonLink": "/path/to/settings", - "primaryButtonText": "Verify configuration", - "secondaryButtonLink": null, - "secondaryButtonText": null, - "svgHeight": null, - "svgPath": "/path/to/empty-group-illustration.svg", - "title": "Query cannot be processed", -} -`; - -exports[`GroupEmptyState given state BAD_QUERY renders the slotted content 1`] = ` -<div> - <div> - The Prometheus server responded with "bad request". Please check your queries are correct and are supported in your Prometheus version. - <a - href="/path/to/docs" - > - More information - </a> - </div> -</div> -`; - -exports[`GroupEmptyState given state CONNECTION_FAILED passes the expected props to GlEmptyState 1`] = ` -Object { - "compact": true, - "contentClass": Array [], - "description": "We couldn't reach the Prometheus server. Either the server no longer exists or the configuration details need updating.", - "invertInDarkMode": true, - "primaryButtonLink": "/path/to/settings", - "primaryButtonText": "Verify configuration", - "secondaryButtonLink": null, - "secondaryButtonText": null, - "svgHeight": null, - "svgPath": "/path/to/empty-group-illustration.svg", - "title": "Connection failed", -} -`; - -exports[`GroupEmptyState given state CONNECTION_FAILED renders the slotted content 1`] = `<div />`; - -exports[`GroupEmptyState given state FOO STATE passes the expected props to GlEmptyState 1`] = ` -Object { - "compact": true, - "contentClass": Array [], - "description": "An error occurred while loading the data. Please try again.", - "invertInDarkMode": true, - "primaryButtonLink": null, - "primaryButtonText": null, - "secondaryButtonLink": null, - "secondaryButtonText": null, - "svgHeight": null, - "svgPath": "/path/to/empty-group-illustration.svg", - "title": "An error has occurred", -} -`; - -exports[`GroupEmptyState given state FOO STATE renders the slotted content 1`] = `<div />`; - -exports[`GroupEmptyState given state LOADING passes the expected props to GlEmptyState 1`] = ` -Object { - "compact": true, - "contentClass": Array [], - "description": "Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available.", - "invertInDarkMode": true, - "primaryButtonLink": null, - "primaryButtonText": null, - "secondaryButtonLink": null, - "secondaryButtonText": null, - "svgHeight": null, - "svgPath": "/path/to/empty-group-illustration.svg", - "title": "Waiting for performance data", -} -`; - -exports[`GroupEmptyState given state LOADING renders the slotted content 1`] = `<div />`; - -exports[`GroupEmptyState given state NO_DATA passes the expected props to GlEmptyState 1`] = ` -Object { - "compact": true, - "contentClass": Array [], - "description": null, - "invertInDarkMode": true, - "primaryButtonLink": null, - "primaryButtonText": null, - "secondaryButtonLink": null, - "secondaryButtonText": null, - "svgHeight": null, - "svgPath": "/path/to/empty-group-illustration.svg", - "title": "No data to display", -} -`; - -exports[`GroupEmptyState given state NO_DATA renders the slotted content 1`] = ` -<div> - <div> - The data source is connected, but there is no data to display. - <a - href="/path/to/docs" - > - More information - </a> - </div> -</div> -`; - -exports[`GroupEmptyState given state TIMEOUT passes the expected props to GlEmptyState 1`] = ` -Object { - "compact": true, - "contentClass": Array [], - "description": null, - "invertInDarkMode": true, - "primaryButtonLink": null, - "primaryButtonText": null, - "secondaryButtonLink": null, - "secondaryButtonText": null, - "svgHeight": null, - "svgPath": "/path/to/empty-group-illustration.svg", - "title": "Connection timed out", -} -`; - -exports[`GroupEmptyState given state TIMEOUT renders the slotted content 1`] = ` -<div> - <div> - Charts can't be displayed as the request for data has timed out. - <a - href="/path/to/docs" - > - More information - </a> - </div> -</div> -`; - -exports[`GroupEmptyState given state UNKNOWN_ERROR passes the expected props to GlEmptyState 1`] = ` -Object { - "compact": true, - "contentClass": Array [], - "description": "An error occurred while loading the data. Please try again.", - "invertInDarkMode": true, - "primaryButtonLink": null, - "primaryButtonText": null, - "secondaryButtonLink": null, - "secondaryButtonText": null, - "svgHeight": null, - "svgPath": "/path/to/empty-group-illustration.svg", - "title": "An error has occurred", -} -`; - -exports[`GroupEmptyState given state UNKNOWN_ERROR renders the slotted content 1`] = `<div />`; diff --git a/spec/frontend/monitoring/components/charts/annotations_spec.js b/spec/frontend/monitoring/components/charts/annotations_spec.js deleted file mode 100644 index 1eac0935fe4..00000000000 --- a/spec/frontend/monitoring/components/charts/annotations_spec.js +++ /dev/null @@ -1,95 +0,0 @@ -import { generateAnnotationsSeries } from '~/monitoring/components/charts/annotations'; -import { deploymentData, annotationsData } from '../../mock_data'; - -describe('annotations spec', () => { - describe('generateAnnotationsSeries', () => { - it('with default options', () => { - const annotations = generateAnnotationsSeries(); - - expect(annotations).toEqual( - expect.objectContaining({ - type: 'scatter', - yAxisIndex: 1, - data: [], - markLine: { - data: [], - symbol: 'none', - silent: true, - }, - }), - ); - }); - - it('when only deployments data is passed', () => { - const annotations = generateAnnotationsSeries({ deployments: deploymentData }); - - expect(annotations).toEqual( - expect.objectContaining({ - type: 'scatter', - yAxisIndex: 1, - data: expect.any(Array), - markLine: { - data: [], - symbol: 'none', - silent: true, - }, - }), - ); - - annotations.data.forEach((annotation) => { - expect(annotation).toEqual(expect.any(Object)); - }); - - expect(annotations.data).toHaveLength(deploymentData.length); - }); - - it('when only annotations data is passed', () => { - const annotations = generateAnnotationsSeries({ - annotations: annotationsData, - }); - - expect(annotations).toEqual( - expect.objectContaining({ - type: 'scatter', - yAxisIndex: 1, - data: expect.any(Array), - markLine: expect.any(Object), - markPoint: expect.any(Object), - }), - ); - - annotations.markLine.data.forEach((annotation) => { - expect(annotation).toEqual(expect.any(Object)); - }); - - expect(annotations.data).toHaveLength(0); - expect(annotations.markLine.data).toHaveLength(annotationsData.length); - expect(annotations.markPoint.data).toHaveLength(annotationsData.length); - }); - - it('when deployments and annotations data is passed', () => { - const annotations = generateAnnotationsSeries({ - deployments: deploymentData, - annotations: annotationsData, - }); - - expect(annotations).toEqual( - expect.objectContaining({ - type: 'scatter', - yAxisIndex: 1, - data: expect.any(Array), - markLine: expect.any(Object), - markPoint: expect.any(Object), - }), - ); - - annotations.markLine.data.forEach((annotation) => { - expect(annotation).toEqual(expect.any(Object)); - }); - - expect(annotations.data).toHaveLength(deploymentData.length); - expect(annotations.markLine.data).toHaveLength(annotationsData.length); - expect(annotations.markPoint.data).toHaveLength(annotationsData.length); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/charts/anomaly_spec.js b/spec/frontend/monitoring/components/charts/anomaly_spec.js deleted file mode 100644 index 3674a49f42c..00000000000 --- a/spec/frontend/monitoring/components/charts/anomaly_spec.js +++ /dev/null @@ -1,304 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { TEST_HOST } from 'helpers/test_constants'; -import Anomaly from '~/monitoring/components/charts/anomaly.vue'; - -import MonitorTimeSeriesChart from '~/monitoring/components/charts/time_series.vue'; -import { colorValues } from '~/monitoring/constants'; -import { anomalyGraphData } from '../../graph_data'; -import { anomalyDeploymentData, mockProjectDir } from '../../mock_data'; - -const mockProjectPath = `${TEST_HOST}${mockProjectDir}`; - -const TEST_UPPER = 11; -const TEST_LOWER = 9; - -describe('Anomaly chart component', () => { - let wrapper; - - const setupAnomalyChart = (props) => { - wrapper = shallowMount(Anomaly, { - propsData: { ...props }, - }); - }; - const findTimeSeries = () => wrapper.findComponent(MonitorTimeSeriesChart); - const getTimeSeriesProps = () => findTimeSeries().props(); - - describe('wrapped monitor-time-series-chart component', () => { - const mockValues = ['10', '10', '10']; - - const mockGraphData = anomalyGraphData( - {}, - { - upper: mockValues.map(() => String(TEST_UPPER)), - values: mockValues, - lower: mockValues.map(() => String(TEST_LOWER)), - }, - ); - - const inputThresholds = ['some threshold']; - - beforeEach(() => { - setupAnomalyChart({ - graphData: mockGraphData, - deploymentData: anomalyDeploymentData, - thresholds: inputThresholds, - projectPath: mockProjectPath, - }); - }); - - it('renders correctly', () => { - expect(findTimeSeries().exists()).toBe(true); - }); - - describe('receives props correctly', () => { - describe('graph-data', () => { - it('receives a single "metric" series', () => { - const { graphData } = getTimeSeriesProps(); - expect(graphData.metrics.length).toBe(1); - }); - - it('receives "metric" with all data', () => { - const { graphData } = getTimeSeriesProps(); - const metric = graphData.metrics[0]; - const expectedMetric = mockGraphData.metrics[0]; - expect(metric).toEqual(expectedMetric); - }); - - it('receives the "metric" results', () => { - const { graphData } = getTimeSeriesProps(); - const { result } = graphData.metrics[0]; - const { values } = result[0]; - - expect(values).toEqual([ - [expect.any(String), 10], - [expect.any(String), 10], - [expect.any(String), 10], - ]); - }); - }); - - describe('option', () => { - let option; - let series; - - beforeEach(() => { - ({ option } = getTimeSeriesProps()); - ({ series } = option); - }); - - it('contains a boundary band', () => { - expect(series).toEqual(expect.any(Array)); - expect(series.length).toEqual(2); // 1 upper + 1 lower boundaries - expect(series[0].stack).toEqual(series[1].stack); - - series.forEach((s) => { - expect(s.type).toBe('line'); - expect(s.lineStyle.width).toBe(0); - expect(s.lineStyle.color).toMatch(/rgba\(.+\)/); - expect(s.lineStyle.color).toMatch(s.color); - expect(s.symbol).toEqual('none'); - }); - }); - - it('upper boundary values are stacked on top of lower boundary', () => { - const [lowerSeries, upperSeries] = series; - - lowerSeries.data.forEach(([, y]) => { - expect(y).toBeCloseTo(TEST_LOWER); - }); - - upperSeries.data.forEach(([, y]) => { - expect(y).toBeCloseTo(TEST_UPPER - TEST_LOWER); - }); - }); - }); - - describe('series-config', () => { - let seriesConfig; - - beforeEach(() => { - ({ seriesConfig } = getTimeSeriesProps()); - }); - - it('display symbols is enabled', () => { - expect(seriesConfig).toEqual( - expect.objectContaining({ - type: 'line', - symbol: 'circle', - showSymbol: true, - symbolSize: expect.any(Function), - itemStyle: { - color: expect.any(Function), - }, - }), - ); - }); - - it('does not display anomalies', () => { - const { symbolSize, itemStyle } = seriesConfig; - mockValues.forEach((v, dataIndex) => { - const size = symbolSize(null, { dataIndex }); - const color = itemStyle.color({ dataIndex }); - - // normal color and small size - expect(size).toBeCloseTo(0); - expect(color).toBe(colorValues.primaryColor); - }); - }); - - it('can format y values (to use in tooltips)', () => { - mockValues.forEach((v, dataIndex) => { - const formatted = wrapper.vm.yValueFormatted(0, dataIndex); - expect(parseFloat(formatted)).toEqual(parseFloat(v)); - }); - }); - }); - - describe('inherited properties', () => { - it('"deployment-data" keeps the same value', () => { - const { deploymentData } = getTimeSeriesProps(); - expect(deploymentData).toEqual(anomalyDeploymentData); - }); - it('"projectPath" keeps the same value', () => { - const { projectPath } = getTimeSeriesProps(); - expect(projectPath).toEqual(mockProjectPath); - }); - }); - }); - }); - - describe('with no boundary data', () => { - const noBoundaryData = anomalyGraphData( - {}, - { - upper: [], - values: ['10', '10', '10'], - lower: [], - }, - ); - - beforeEach(() => { - setupAnomalyChart({ - graphData: noBoundaryData, - deploymentData: anomalyDeploymentData, - }); - }); - - describe('option', () => { - let option; - let series; - - beforeEach(() => { - ({ option } = getTimeSeriesProps()); - ({ series } = option); - }); - - it('does not display a boundary band', () => { - expect(series).toEqual(expect.any(Array)); - expect(series.length).toEqual(0); // no boundaries - }); - - it('can format y values (to use in tooltips)', () => { - expect(parseFloat(wrapper.vm.yValueFormatted(0, 0))).toEqual(10); - expect(wrapper.vm.yValueFormatted(1, 0)).toBe(''); // missing boundary - expect(wrapper.vm.yValueFormatted(2, 0)).toBe(''); // missing boundary - }); - }); - }); - - describe('with one anomaly', () => { - const mockValues = ['10', '20', '10']; - - const oneAnomalyData = anomalyGraphData( - {}, - { - upper: mockValues.map(() => TEST_UPPER), - values: mockValues, - lower: mockValues.map(() => TEST_LOWER), - }, - ); - - beforeEach(() => { - setupAnomalyChart({ - graphData: oneAnomalyData, - deploymentData: anomalyDeploymentData, - }); - }); - - describe('series-config', () => { - it('displays one anomaly', () => { - const { seriesConfig } = getTimeSeriesProps(); - const { symbolSize, itemStyle } = seriesConfig; - - const bigDots = mockValues.filter((v, dataIndex) => { - const size = symbolSize(null, { dataIndex }); - return size > 0.1; - }); - const redDots = mockValues.filter((v, dataIndex) => { - const color = itemStyle.color({ dataIndex }); - return color === colorValues.anomalySymbol; - }); - - expect(bigDots.length).toBe(1); - expect(redDots.length).toBe(1); - }); - }); - }); - - describe('with offset', () => { - const mockValues = ['10', '11', '12']; - const mockUpper = ['20', '20', '20']; - const mockLower = ['-1', '-2', '-3.70']; - const expectedOffset = 4; // Lowest point in mock data is -3.70, it gets rounded - - beforeEach(() => { - setupAnomalyChart({ - graphData: anomalyGraphData( - {}, - { - upper: mockUpper, - values: mockValues, - lower: mockLower, - }, - ), - deploymentData: anomalyDeploymentData, - }); - }); - - describe('receives props correctly', () => { - describe('graph-data', () => { - it('receives a single "metric" series', () => { - const { graphData } = getTimeSeriesProps(); - expect(graphData.metrics.length).toBe(1); - }); - - it('receives "metric" results and applies the offset to them', () => { - const { graphData } = getTimeSeriesProps(); - const { result } = graphData.metrics[0]; - const { values } = result[0]; - - expect(values).toEqual(expect.any(Array)); - - values.forEach(([, y], index) => { - expect(y).toBeCloseTo(parseFloat(mockValues[index]) + expectedOffset); - }); - }); - }); - }); - - describe('option', () => { - it('upper boundary values are stacked on top of lower boundary, plus the offset', () => { - const { option } = getTimeSeriesProps(); - const { series } = option; - const [lowerSeries, upperSeries] = series; - lowerSeries.data.forEach(([, y], i) => { - expect(y).toBeCloseTo(parseFloat(mockLower[i]) + expectedOffset); - }); - - upperSeries.data.forEach(([, y], i) => { - expect(y).toBeCloseTo(parseFloat(mockUpper[i] - mockLower[i])); - }); - }); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/charts/bar_spec.js b/spec/frontend/monitoring/components/charts/bar_spec.js deleted file mode 100644 index 5339a7a525b..00000000000 --- a/spec/frontend/monitoring/components/charts/bar_spec.js +++ /dev/null @@ -1,53 +0,0 @@ -import { GlBarChart } from '@gitlab/ui/dist/charts'; -import { shallowMount } from '@vue/test-utils'; -import Bar from '~/monitoring/components/charts/bar.vue'; -import { barGraphData } from '../../graph_data'; - -jest.mock('~/lib/utils/icon_utils', () => ({ - getSvgIconPathContent: jest.fn().mockResolvedValue('mockSvgPathContent'), -})); - -describe('Bar component', () => { - let barChart; - let store; - let graphData; - - beforeEach(() => { - graphData = barGraphData(); - - barChart = shallowMount(Bar, { - propsData: { - graphData, - }, - store, - }); - }); - - afterEach(() => { - barChart.destroy(); - }); - - describe('wrapped components', () => { - describe('GitLab UI bar chart', () => { - let glbarChart; - let chartData; - - beforeEach(() => { - glbarChart = barChart.findComponent(GlBarChart); - chartData = barChart.vm.chartData[graphData.metrics[0].label]; - }); - - it('should display a label on the x axis', () => { - expect(glbarChart.props('xAxisTitle')).toBe(graphData.xLabel); - }); - - it('should return chartData as array of arrays', () => { - expect(chartData).toBeInstanceOf(Array); - - chartData.forEach((item) => { - expect(item).toBeInstanceOf(Array); - }); - }); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/charts/column_spec.js b/spec/frontend/monitoring/components/charts/column_spec.js deleted file mode 100644 index cc38a3fd8a1..00000000000 --- a/spec/frontend/monitoring/components/charts/column_spec.js +++ /dev/null @@ -1,118 +0,0 @@ -import { GlColumnChart } from '@gitlab/ui/dist/charts'; -import { shallowMount } from '@vue/test-utils'; -import timezoneMock from 'timezone-mock'; -import ColumnChart from '~/monitoring/components/charts/column.vue'; - -jest.mock('~/lib/utils/icon_utils', () => ({ - getSvgIconPathContent: jest.fn().mockResolvedValue('mockSvgPathContent'), -})); - -const yAxisName = 'Y-axis mock name'; -const yAxisFormat = 'bytes'; -const yAxisPrecistion = 3; -const dataValues = [ - [1495700554.925, '8.0390625'], - [1495700614.925, '8.0390625'], - [1495700674.925, '8.0390625'], -]; - -describe('Column component', () => { - let wrapper; - - const createWrapper = (props = {}) => { - wrapper = shallowMount(ColumnChart, { - propsData: { - graphData: { - yAxis: { - name: yAxisName, - format: yAxisFormat, - precision: yAxisPrecistion, - }, - metrics: [ - { - label: 'Mock data', - result: [ - { - metric: {}, - values: dataValues, - }, - ], - }, - ], - }, - ...props, - }, - }); - }; - const findChart = () => wrapper.findComponent(GlColumnChart); - const chartProps = (prop) => findChart().props(prop); - - beforeEach(() => { - createWrapper(); - }); - - describe('xAxisLabel', () => { - const mockDate = Date.UTC(2020, 4, 26, 20); // 8:00 PM in GMT - - const useXAxisFormatter = (date) => { - const { xAxis } = chartProps('option'); - const { formatter } = xAxis.axisLabel; - return formatter(date); - }; - - it('x-axis is formatted correctly in m/d h:MM TT format', () => { - expect(useXAxisFormatter(mockDate)).toEqual('5/26 8:00 PM'); - }); - - describe('when in PT timezone', () => { - beforeAll(() => { - timezoneMock.register('US/Pacific'); - }); - - afterAll(() => { - timezoneMock.unregister(); - }); - - it('by default, values are formatted in PT', () => { - createWrapper(); - expect(useXAxisFormatter(mockDate)).toEqual('5/26 1:00 PM'); - }); - - it('when the chart uses local timezone, y-axis is formatted in PT', () => { - createWrapper({ timezone: 'LOCAL' }); - expect(useXAxisFormatter(mockDate)).toEqual('5/26 1:00 PM'); - }); - - it('when the chart uses UTC, y-axis is formatted in UTC', () => { - createWrapper({ timezone: 'UTC' }); - expect(useXAxisFormatter(mockDate)).toEqual('5/26 8:00 PM'); - }); - }); - }); - - describe('wrapped components', () => { - describe('GitLab UI column chart', () => { - it('receives data properties needed for proper chart render', () => { - expect(chartProps('bars')).toEqual([{ name: 'Mock data', data: dataValues }]); - }); - - it('passes the y axis name correctly', () => { - expect(chartProps('yAxisTitle')).toBe(yAxisName); - }); - - it('passes the y axis configuration correctly', () => { - expect(chartProps('option').yAxis).toMatchObject({ - name: yAxisName, - axisLabel: { - formatter: expect.any(Function), - }, - scale: false, - }); - }); - - it('passes a dataZoom configuration', () => { - expect(chartProps('option').dataZoom).toBeDefined(); - }); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/charts/empty_chart_spec.js b/spec/frontend/monitoring/components/charts/empty_chart_spec.js deleted file mode 100644 index d755ed7c104..00000000000 --- a/spec/frontend/monitoring/components/charts/empty_chart_spec.js +++ /dev/null @@ -1,21 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import EmptyChart from '~/monitoring/components/charts/empty_chart.vue'; - -describe('Empty Chart component', () => { - let emptyChart; - const graphTitle = 'Memory Usage'; - - beforeEach(() => { - emptyChart = shallowMount(EmptyChart, { - propsData: { - graphTitle, - }, - }); - }); - - describe('Computed props', () => { - it('sets the height for the svg container', () => { - expect(emptyChart.vm.svgContainerStyle.height).toBe('300px'); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/charts/gauge_spec.js b/spec/frontend/monitoring/components/charts/gauge_spec.js deleted file mode 100644 index 33ea5e83598..00000000000 --- a/spec/frontend/monitoring/components/charts/gauge_spec.js +++ /dev/null @@ -1,210 +0,0 @@ -import { GlGaugeChart } from '@gitlab/ui/dist/charts'; -import { shallowMount } from '@vue/test-utils'; -import GaugeChart from '~/monitoring/components/charts/gauge.vue'; -import { gaugeChartGraphData } from '../../graph_data'; - -describe('Gauge Chart component', () => { - const defaultGraphData = gaugeChartGraphData(); - - let wrapper; - - const findGaugeChart = () => wrapper.findComponent(GlGaugeChart); - - const createWrapper = ({ ...graphProps } = {}) => { - wrapper = shallowMount(GaugeChart, { - propsData: { - graphData: { - ...defaultGraphData, - ...graphProps, - }, - }, - }); - }; - - describe('chart component', () => { - it('is rendered when props are passed', () => { - createWrapper(); - - expect(findGaugeChart().exists()).toBe(true); - }); - }); - - describe('min and max', () => { - const MIN_DEFAULT = 0; - const MAX_DEFAULT = 100; - - it('are passed to chart component', () => { - createWrapper(); - - expect(findGaugeChart().props('min')).toBe(100); - expect(findGaugeChart().props('max')).toBe(1000); - }); - - const invalidCases = [undefined, NaN, 'a string']; - - it.each(invalidCases)( - 'if min has invalid value, defaults are used for both min and max', - (invalidValue) => { - createWrapper({ minValue: invalidValue }); - - expect(findGaugeChart().props('min')).toBe(MIN_DEFAULT); - expect(findGaugeChart().props('max')).toBe(MAX_DEFAULT); - }, - ); - - it.each(invalidCases)( - 'if max has invalid value, defaults are used for both min and max', - (invalidValue) => { - createWrapper({ minValue: invalidValue }); - - expect(findGaugeChart().props('min')).toBe(MIN_DEFAULT); - expect(findGaugeChart().props('max')).toBe(MAX_DEFAULT); - }, - ); - - it('if min is bigger than max, defaults are used for both min and max', () => { - createWrapper({ minValue: 100, maxValue: 0 }); - - expect(findGaugeChart().props('min')).toBe(MIN_DEFAULT); - expect(findGaugeChart().props('max')).toBe(MAX_DEFAULT); - }); - }); - - describe('thresholds', () => { - it('thresholds are set on chart', () => { - createWrapper(); - - expect(findGaugeChart().props('thresholds')).toEqual([500, 800]); - }); - - it('when no thresholds are defined, a default threshold is defined at 95% of max_value', () => { - createWrapper({ - minValue: 0, - maxValue: 100, - thresholds: {}, - }); - - expect(findGaugeChart().props('thresholds')).toEqual([95]); - }); - - it('when out of min-max bounds thresholds are defined, a default threshold is defined at 95% of the range between min_value and max_value', () => { - createWrapper({ - thresholds: { - values: [-10, 1500], - }, - }); - - expect(findGaugeChart().props('thresholds')).toEqual([855]); - }); - - describe('when mode is absolute', () => { - it('only valid threshold values are used', () => { - createWrapper({ - thresholds: { - mode: 'absolute', - values: [undefined, 10, 110, NaN, 'a string', 400], - }, - }); - - expect(findGaugeChart().props('thresholds')).toEqual([110, 400]); - }); - - it('if all threshold values are invalid, a default threshold is defined at 95% of the range between min_value and max_value', () => { - createWrapper({ - thresholds: { - mode: 'absolute', - values: [NaN, undefined, 'a string', 1500], - }, - }); - - expect(findGaugeChart().props('thresholds')).toEqual([855]); - }); - }); - - describe('when mode is percentage', () => { - it('when values outside of 0-100 bounds are used, a default threshold is defined at 95% of max_value', () => { - createWrapper({ - thresholds: { - mode: 'percentage', - values: [110], - }, - }); - - expect(findGaugeChart().props('thresholds')).toEqual([855]); - }); - - it('if all threshold values are invalid, a default threshold is defined at 95% of max_value', () => { - createWrapper({ - thresholds: { - mode: 'percentage', - values: [NaN, undefined, 'a string', 1500], - }, - }); - - expect(findGaugeChart().props('thresholds')).toEqual([855]); - }); - }); - }); - - describe('split (the number of ticks on the chart arc)', () => { - const SPLIT_DEFAULT = 10; - - it('is passed to chart as prop', () => { - createWrapper(); - - expect(findGaugeChart().props('splitNumber')).toBe(20); - }); - - it('if not explicitly set, passes a default value to chart', () => { - createWrapper({ split: '' }); - - expect(findGaugeChart().props('splitNumber')).toBe(SPLIT_DEFAULT); - }); - - it('if set as a number that is not an integer, passes the default value to chart', () => { - createWrapper({ split: 10.5 }); - - expect(findGaugeChart().props('splitNumber')).toBe(SPLIT_DEFAULT); - }); - - it('if set as a negative number, passes the default value to chart', () => { - createWrapper({ split: -10 }); - - expect(findGaugeChart().props('splitNumber')).toBe(SPLIT_DEFAULT); - }); - }); - - describe('text (the text displayed on the gauge for the current value)', () => { - it('displays the query result value when format is not set', () => { - createWrapper({ format: '' }); - - expect(findGaugeChart().props('text')).toBe('3'); - }); - - it('displays the query result value when format is set to invalid value', () => { - createWrapper({ format: 'invalid' }); - - expect(findGaugeChart().props('text')).toBe('3'); - }); - - it('displays a formatted query result value when format is set', () => { - createWrapper(); - - expect(findGaugeChart().props('text')).toBe('3kB'); - }); - - it('displays a placeholder value when metric is empty', () => { - createWrapper({ metrics: [] }); - - expect(findGaugeChart().props('text')).toBe('--'); - }); - }); - - describe('value', () => { - it('correct value is passed', () => { - createWrapper(); - - expect(findGaugeChart().props('value')).toBe(3); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/charts/heatmap_spec.js b/spec/frontend/monitoring/components/charts/heatmap_spec.js deleted file mode 100644 index 54245cbdbc1..00000000000 --- a/spec/frontend/monitoring/components/charts/heatmap_spec.js +++ /dev/null @@ -1,93 +0,0 @@ -import { GlHeatmap } from '@gitlab/ui/dist/charts'; -import { shallowMount } from '@vue/test-utils'; -import timezoneMock from 'timezone-mock'; -import Heatmap from '~/monitoring/components/charts/heatmap.vue'; -import { heatmapGraphData } from '../../graph_data'; - -describe('Heatmap component', () => { - let wrapper; - let store; - - const findChart = () => wrapper.findComponent(GlHeatmap); - - const graphData = heatmapGraphData(); - - const createWrapper = (props = {}) => { - wrapper = shallowMount(Heatmap, { - propsData: { - graphData: heatmapGraphData(), - containerWidth: 100, - ...props, - }, - store, - }); - }; - - describe('wrapped chart', () => { - beforeEach(() => { - createWrapper(); - }); - - it('should display a label on the x axis', () => { - expect(wrapper.vm.xAxisName).toBe(graphData.xLabel); - }); - - it('should display a label on the y axis', () => { - expect(wrapper.vm.yAxisName).toBe(graphData.y_label); - }); - - // According to the echarts docs https://echarts.apache.org/en/option.html#series-heatmap.data - // each row of the heatmap chart is represented by an array inside another parent array - // e.g. [[0, 0, 10]], the format represents the column, the row and finally the value - // corresponding to the cell - - it('should return chartData with a length of x by y, with a length of 3 per array', () => { - const row = wrapper.vm.chartData[0]; - - expect(row.length).toBe(3); - expect(wrapper.vm.chartData.length).toBe(6); - }); - - it('returns a series of labels for the x axis', () => { - const { xAxisLabels } = wrapper.vm; - - expect(xAxisLabels.length).toBe(2); - }); - - describe('y axis labels', () => { - const gmtLabels = ['8:10 PM', '8:12 PM', '8:14 PM']; - - it('y-axis labels are formatted in AM/PM format', () => { - expect(findChart().props('yAxisLabels')).toEqual(gmtLabels); - }); - - describe('when in PT timezone', () => { - const ptLabels = ['1:10 PM', '1:12 PM', '1:14 PM']; - const utcLabels = gmtLabels; // Identical in this case - - beforeAll(() => { - timezoneMock.register('US/Pacific'); - }); - - afterAll(() => { - timezoneMock.unregister(); - }); - - it('by default, y-axis is formatted in PT', () => { - createWrapper(); - expect(findChart().props('yAxisLabels')).toEqual(ptLabels); - }); - - it('when the chart uses local timezone, y-axis is formatted in PT', () => { - createWrapper({ timezone: 'LOCAL' }); - expect(findChart().props('yAxisLabels')).toEqual(ptLabels); - }); - - it('when the chart uses UTC, y-axis is formatted in UTC', () => { - createWrapper({ timezone: 'UTC' }); - expect(findChart().props('yAxisLabels')).toEqual(utcLabels); - }); - }); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/charts/options_spec.js b/spec/frontend/monitoring/components/charts/options_spec.js deleted file mode 100644 index 064ce6f204c..00000000000 --- a/spec/frontend/monitoring/components/charts/options_spec.js +++ /dev/null @@ -1,327 +0,0 @@ -import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; -import { - getYAxisOptions, - getTooltipFormatter, - getValidThresholds, -} from '~/monitoring/components/charts/options'; - -describe('options spec', () => { - describe('getYAxisOptions', () => { - it('default options', () => { - const options = getYAxisOptions(); - - expect(options).toMatchObject({ - name: expect.any(String), - axisLabel: { - formatter: expect.any(Function), - }, - scale: true, - boundaryGap: [expect.any(Number), expect.any(Number)], - }); - - expect(options.name).not.toHaveLength(0); - }); - - it('name options', () => { - const yAxisName = 'My axis values'; - const options = getYAxisOptions({ - name: yAxisName, - }); - - expect(options).toMatchObject({ - name: yAxisName, - nameLocation: 'center', - nameGap: expect.any(Number), - }); - }); - - it('formatter options defaults to engineering notation', () => { - const options = getYAxisOptions(); - - expect(options.axisLabel.formatter).toEqual(expect.any(Function)); - expect(options.axisLabel.formatter(3002.1)).toBe('3k'); - }); - - it('formatter options allows for precision to be set explicitly', () => { - const options = getYAxisOptions({ - precision: 4, - }); - - expect(options.axisLabel.formatter).toEqual(expect.any(Function)); - expect(options.axisLabel.formatter(5002.1)).toBe('5.0021k'); - }); - - it('formatter options allows for overrides in milliseconds', () => { - const options = getYAxisOptions({ - format: SUPPORTED_FORMATS.milliseconds, - }); - - expect(options.axisLabel.formatter).toEqual(expect.any(Function)); - expect(options.axisLabel.formatter(1.1234)).toBe('1.12ms'); - }); - - it('formatter options allows for overrides in bytes', () => { - const options = getYAxisOptions({ - format: SUPPORTED_FORMATS.bytes, - }); - - expect(options.axisLabel.formatter).toEqual(expect.any(Function)); - expect(options.axisLabel.formatter(1)).toBe('1.00B'); - }); - }); - - describe('getTooltipFormatter', () => { - it('default format', () => { - const formatter = getTooltipFormatter(); - - expect(formatter).toEqual(expect.any(Function)); - expect(formatter(0.11111)).toBe('111.1m'); - }); - - it('defined format', () => { - const formatter = getTooltipFormatter({ - format: SUPPORTED_FORMATS.bytes, - }); - - expect(formatter(1)).toBe('1.000B'); - }); - }); - - describe('getValidThresholds', () => { - const invalidCases = [null, undefined, NaN, 'a string', true, false]; - - let thresholds; - - afterEach(() => { - thresholds = null; - }); - - it('returns same thresholds when passed values within range', () => { - thresholds = getValidThresholds({ - mode: 'absolute', - range: { min: 0, max: 100 }, - values: [10, 50], - }); - - expect(thresholds).toEqual([10, 50]); - }); - - it('filters out thresholds that are out of range', () => { - thresholds = getValidThresholds({ - mode: 'absolute', - range: { min: 0, max: 100 }, - values: [-5, 10, 110], - }); - - expect(thresholds).toEqual([10]); - }); - it('filters out duplicate thresholds', () => { - thresholds = getValidThresholds({ - mode: 'absolute', - range: { min: 0, max: 100 }, - values: [5, 5, 10, 10], - }); - - expect(thresholds).toEqual([5, 10]); - }); - - it('sorts passed thresholds and applies only the first two in ascending order', () => { - thresholds = getValidThresholds({ - mode: 'absolute', - range: { min: 0, max: 100 }, - values: [10, 1, 35, 20, 5], - }); - - expect(thresholds).toEqual([1, 5]); - }); - - it('thresholds equal to min or max are filtered out', () => { - thresholds = getValidThresholds({ - mode: 'absolute', - range: { min: 0, max: 100 }, - values: [0, 100], - }); - - expect(thresholds).toEqual([]); - }); - - it.each(invalidCases)('invalid values for thresholds are filtered out', (invalidValue) => { - thresholds = getValidThresholds({ - mode: 'absolute', - range: { min: 0, max: 100 }, - values: [10, invalidValue], - }); - - expect(thresholds).toEqual([10]); - }); - - describe('range', () => { - it('when range is not defined, empty result is returned', () => { - thresholds = getValidThresholds({ - mode: 'absolute', - values: [10, 20], - }); - - expect(thresholds).toEqual([]); - }); - - it('when min is not defined, empty result is returned', () => { - thresholds = getValidThresholds({ - mode: 'absolute', - range: { max: 100 }, - values: [10, 20], - }); - - expect(thresholds).toEqual([]); - }); - - it('when max is not defined, empty result is returned', () => { - thresholds = getValidThresholds({ - mode: 'absolute', - range: { min: 0 }, - values: [10, 20], - }); - - expect(thresholds).toEqual([]); - }); - - it('when min is larger than max, empty result is returned', () => { - thresholds = getValidThresholds({ - mode: 'absolute', - range: { min: 100, max: 0 }, - values: [10, 20], - }); - - expect(thresholds).toEqual([]); - }); - - it.each(invalidCases)( - 'when min has invalid value, empty result is returned', - (invalidValue) => { - thresholds = getValidThresholds({ - mode: 'absolute', - range: { min: invalidValue, max: 100 }, - values: [10, 20], - }); - - expect(thresholds).toEqual([]); - }, - ); - - it.each(invalidCases)( - 'when max has invalid value, empty result is returned', - (invalidValue) => { - thresholds = getValidThresholds({ - mode: 'absolute', - range: { min: 0, max: invalidValue }, - values: [10, 20], - }); - - expect(thresholds).toEqual([]); - }, - ); - }); - - describe('values', () => { - it('if values parameter is omitted, empty result is returned', () => { - thresholds = getValidThresholds({ - mode: 'absolute', - range: { min: 0, max: 100 }, - }); - - expect(thresholds).toEqual([]); - }); - - it('if there are no values passed, empty result is returned', () => { - thresholds = getValidThresholds({ - mode: 'absolute', - range: { min: 0, max: 100 }, - values: [], - }); - - expect(thresholds).toEqual([]); - }); - - it.each(invalidCases)( - 'if invalid values are passed, empty result is returned', - (invalidValue) => { - thresholds = getValidThresholds({ - mode: 'absolute', - range: { min: 0, max: 100 }, - values: [invalidValue], - }); - - expect(thresholds).toEqual([]); - }, - ); - }); - - describe('mode', () => { - it.each(invalidCases)( - 'if invalid values are passed, empty result is returned', - (invalidValue) => { - thresholds = getValidThresholds({ - mode: invalidValue, - range: { min: 0, max: 100 }, - values: [10, 50], - }); - - expect(thresholds).toEqual([]); - }, - ); - - it('if mode is not passed, empty result is returned', () => { - thresholds = getValidThresholds({ - range: { min: 0, max: 100 }, - values: [10, 50], - }); - - expect(thresholds).toEqual([]); - }); - - describe('absolute mode', () => { - it('absolute mode behaves correctly', () => { - thresholds = getValidThresholds({ - mode: 'absolute', - range: { min: 0, max: 100 }, - values: [10, 50], - }); - - expect(thresholds).toEqual([10, 50]); - }); - }); - - describe('percentage mode', () => { - it('percentage mode behaves correctly', () => { - thresholds = getValidThresholds({ - mode: 'percentage', - range: { min: 0, max: 1000 }, - values: [10, 50], - }); - - expect(thresholds).toEqual([100, 500]); - }); - - const outOfPercentBoundsValues = [-1, 0, 100, 101]; - it.each(outOfPercentBoundsValues)( - 'when values out of 0-100 range are passed, empty result is returned', - (invalidValue) => { - thresholds = getValidThresholds({ - mode: 'percentage', - range: { min: 0, max: 1000 }, - values: [invalidValue], - }); - - expect(thresholds).toEqual([]); - }, - ); - }); - }); - - it('calling without passing object parameter returns empty array', () => { - thresholds = getValidThresholds(); - - expect(thresholds).toEqual([]); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/charts/single_stat_spec.js b/spec/frontend/monitoring/components/charts/single_stat_spec.js deleted file mode 100644 index fa31b479296..00000000000 --- a/spec/frontend/monitoring/components/charts/single_stat_spec.js +++ /dev/null @@ -1,94 +0,0 @@ -import { GlSingleStat } from '@gitlab/ui/dist/charts'; -import { shallowMount } from '@vue/test-utils'; -import SingleStatChart from '~/monitoring/components/charts/single_stat.vue'; -import { singleStatGraphData } from '../../graph_data'; - -describe('Single Stat Chart component', () => { - let wrapper; - - const createComponent = (props = {}) => { - wrapper = shallowMount(SingleStatChart, { - propsData: { - graphData: singleStatGraphData({}, { unit: 'MB' }), - ...props, - }, - }); - }; - - const findChart = () => wrapper.findComponent(GlSingleStat); - - beforeEach(() => { - createComponent(); - }); - - describe('computed', () => { - describe('statValue', () => { - it('should display the correct value', () => { - expect(findChart().props('value')).toBe('1.00'); - }); - - it('should display the correct value unit', () => { - expect(findChart().props('unit')).toBe('MB'); - }); - - it('should change the value representation to a percentile one', () => { - createComponent({ - graphData: singleStatGraphData({ max_value: 120 }, { value: 91 }), - }); - - expect(findChart().props('value')).toBe('75.83'); - expect(findChart().props('unit')).toBe('%'); - }); - - it('should display NaN for non numeric maxValue values', () => { - createComponent({ - graphData: singleStatGraphData({ max_value: 'not a number' }), - }); - - expect(findChart().props('value')).toContain('NaN'); - }); - - it('should display NaN for missing query values', () => { - createComponent({ - graphData: singleStatGraphData({ max_value: 120 }, { value: 'NaN' }), - }); - - expect(findChart().props('value')).toContain('NaN'); - }); - - it('should not display `unit` when `unit` is undefined', () => { - createComponent({ - graphData: singleStatGraphData({}, { unit: undefined }), - }); - - expect(findChart().props('value')).not.toContain('undefined'); - }); - - it('should not display `unit` when `unit` is null', () => { - createComponent({ - graphData: singleStatGraphData({}, { unit: null }), - }); - - expect(findChart().props('value')).not.toContain('null'); - }); - - describe('when a field attribute is set', () => { - it('displays a label value instead of metric value when field attribute is used', () => { - createComponent({ - graphData: singleStatGraphData({ field: 'job' }, { isVector: true }), - }); - - expect(findChart().props('value')).toContain('prometheus'); - }); - - it('displays No data to display if field attribute is not present', () => { - createComponent({ - graphData: singleStatGraphData({ field: 'this-does-not-exist' }), - }); - - expect(findChart().props('value')).toContain('No data to display'); - }); - }); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/charts/stacked_column_spec.js b/spec/frontend/monitoring/components/charts/stacked_column_spec.js deleted file mode 100644 index 779ded090c2..00000000000 --- a/spec/frontend/monitoring/components/charts/stacked_column_spec.js +++ /dev/null @@ -1,193 +0,0 @@ -import { GlStackedColumnChart, GlChartLegend } from '@gitlab/ui/dist/charts'; -import { shallowMount, mount } from '@vue/test-utils'; -import { cloneDeep } from 'lodash'; -import timezoneMock from 'timezone-mock'; -import { nextTick } from 'vue'; -import StackedColumnChart from '~/monitoring/components/charts/stacked_column.vue'; -import { stackedColumnGraphData } from '../../graph_data'; - -jest.mock('~/lib/utils/icon_utils', () => ({ - getSvgIconPathContent: jest.fn().mockImplementation((icon) => Promise.resolve(`${icon}-content`)), -})); - -describe('Stacked column chart component', () => { - const stackedColumnMockedData = stackedColumnGraphData(); - - let wrapper; - - const findChart = () => wrapper.findComponent(GlStackedColumnChart); - const findLegend = () => wrapper.findComponent(GlChartLegend); - - const createWrapper = (props = {}, mountingMethod = shallowMount) => - mountingMethod(StackedColumnChart, { - propsData: { - graphData: stackedColumnMockedData, - ...props, - }, - stubs: { - GlPopover: true, - }, - attachTo: document.body, - }); - - beforeEach(() => { - wrapper = createWrapper({}, mount); - }); - - describe('when graphData is present', () => { - beforeEach(async () => { - createWrapper(); - await nextTick(); - }); - - it('chart is rendered', () => { - expect(findChart().exists()).toBe(true); - }); - - it('data should match the graphData y value for each series', () => { - const data = findChart().props('bars'); - - data.forEach((series, index) => { - const { values } = stackedColumnMockedData.metrics[index].result[0]; - expect(series.data).toEqual(values.map((value) => value[1])); - }); - }); - - it('data should be the same length as the graphData metrics labels', () => { - const barDataProp = findChart().props('bars'); - - expect(barDataProp).toHaveLength(stackedColumnMockedData.metrics.length); - barDataProp.forEach(({ name }, index) => { - expect(stackedColumnMockedData.metrics[index].label).toBe(name); - }); - }); - - it('group by should be the same as the graphData first metric results', () => { - const groupBy = findChart().props('groupBy'); - - expect(groupBy).toEqual([ - '2015-07-01T20:10:50.000Z', - '2015-07-01T20:12:50.000Z', - '2015-07-01T20:14:50.000Z', - ]); - }); - - it('chart options should configure data zoom and axis label', () => { - const chartOptions = findChart().props('option'); - const xAxisType = findChart().props('xAxisType'); - - expect(chartOptions).toMatchObject({ - dataZoom: [{ handleIcon: 'path://scroll-handle-content' }], - xAxis: { - axisLabel: { formatter: expect.any(Function) }, - }, - }); - - expect(xAxisType).toBe('category'); - }); - - it('chart options should configure category as x axis type', () => { - const chartOptions = findChart().props('option'); - const xAxisType = findChart().props('xAxisType'); - - expect(chartOptions).toMatchObject({ - xAxis: { - type: 'category', - }, - }); - expect(xAxisType).toBe('category'); - }); - - it('format date is correct', () => { - const { xAxis } = findChart().props('option'); - expect(xAxis.axisLabel.formatter('2020-01-30T12:01:00.000Z')).toBe('12:01 PM'); - }); - - describe('when in PT timezone', () => { - beforeAll(() => { - timezoneMock.register('US/Pacific'); - }); - - afterAll(() => { - timezoneMock.unregister(); - }); - - it('date is shown in local time', () => { - const { xAxis } = findChart().props('option'); - expect(xAxis.axisLabel.formatter('2020-01-30T12:01:00.000Z')).toBe('4:01 AM'); - }); - - it('date is shown in UTC', async () => { - wrapper.setProps({ timezone: 'UTC' }); - - await nextTick(); - const { xAxis } = findChart().props('option'); - expect(xAxis.axisLabel.formatter('2020-01-30T12:01:00.000Z')).toBe('12:01 PM'); - }); - }); - }); - - describe('when graphData has results missing', () => { - beforeEach(async () => { - const graphData = cloneDeep(stackedColumnMockedData); - - graphData.metrics[0].result = null; - - createWrapper({ graphData }); - await nextTick(); - }); - - it('chart is rendered', () => { - expect(findChart().exists()).toBe(true); - }); - }); - - describe('legend', () => { - beforeEach(() => { - wrapper = createWrapper({}, mount); - }); - - it('allows user to override legend label texts using props', async () => { - const legendRelatedProps = { - legendMinText: 'legendMinText', - legendMaxText: 'legendMaxText', - legendAverageText: 'legendAverageText', - legendCurrentText: 'legendCurrentText', - }; - wrapper.setProps({ - ...legendRelatedProps, - }); - - await nextTick(); - expect(findChart().props()).toMatchObject(legendRelatedProps); - }); - - it('should render a tabular legend layout by default', () => { - expect(findLegend().props('layout')).toBe('table'); - }); - - describe('when inline legend layout prop is set', () => { - beforeEach(() => { - wrapper.setProps({ - legendLayout: 'inline', - }); - }); - - it('should render an inline legend layout', () => { - expect(findLegend().props('layout')).toBe('inline'); - }); - }); - - describe('when table legend layout prop is set', () => { - beforeEach(() => { - wrapper.setProps({ - legendLayout: 'table', - }); - }); - - it('should render a tabular legend layout', () => { - expect(findLegend().props('layout')).toBe('table'); - }); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js deleted file mode 100644 index c1b51f71a7e..00000000000 --- a/spec/frontend/monitoring/components/charts/time_series_spec.js +++ /dev/null @@ -1,748 +0,0 @@ -import { GlLink } from '@gitlab/ui'; -import { - GlAreaChart, - GlLineChart, - GlChartSeriesLabel, - GlChartLegend, -} from '@gitlab/ui/dist/charts'; -import { mount, shallowMount } from '@vue/test-utils'; -import timezoneMock from 'timezone-mock'; -import { nextTick } from 'vue'; -import { TEST_HOST } from 'helpers/test_constants'; -import { shallowWrapperContainsSlotText } from 'helpers/vue_test_utils_helper'; -import TimeSeries from '~/monitoring/components/charts/time_series.vue'; -import { panelTypes, chartHeight } from '~/monitoring/constants'; -import { timeSeriesGraphData } from '../../graph_data'; -import { - deploymentData, - mockProjectDir, - annotationsData, - mockFixedTimeRange, -} from '../../mock_data'; - -jest.mock('lodash/throttle', () => - // this throttle mock executes immediately - jest.fn((func) => { - // eslint-disable-next-line no-param-reassign - func.cancel = jest.fn(); - return func; - }), -); -jest.mock('~/lib/utils/icon_utils', () => ({ - getSvgIconPathContent: jest.fn().mockImplementation((icon) => Promise.resolve(`${icon}-content`)), -})); - -describe('Time series component', () => { - const defaultGraphData = timeSeriesGraphData(); - let wrapper; - - const createWrapper = ( - { graphData = defaultGraphData, ...props } = {}, - mountingMethod = shallowMount, - ) => { - wrapper = mountingMethod(TimeSeries, { - propsData: { - graphData, - deploymentData, - annotations: annotationsData, - projectPath: `${TEST_HOST}${mockProjectDir}`, - timeRange: mockFixedTimeRange, - ...props, - }, - stubs: { - GlPopover: true, - GlLineChart, - GlAreaChart, - }, - attachTo: document.body, - }); - }; - - describe('With a single time series', () => { - describe('general functions', () => { - const findChart = () => wrapper.findComponent({ ref: 'chart' }); - - beforeEach(async () => { - createWrapper({}, mount); - await nextTick(); - }); - - it('allows user to override legend label texts using props', async () => { - const legendRelatedProps = { - legendMinText: 'legendMinText', - legendMaxText: 'legendMaxText', - legendAverageText: 'legendAverageText', - legendCurrentText: 'legendCurrentText', - }; - wrapper.setProps({ - ...legendRelatedProps, - }); - - await nextTick(); - expect(findChart().props()).toMatchObject(legendRelatedProps); - }); - - it('chart sets a default height', () => { - createWrapper(); - expect(wrapper.props('height')).toBe(chartHeight); - }); - - it('chart has a configurable height', async () => { - const mockHeight = 599; - createWrapper(); - - wrapper.setProps({ height: mockHeight }); - await nextTick(); - expect(wrapper.props('height')).toBe(mockHeight); - }); - - describe('events', () => { - describe('datazoom', () => { - let eChartMock; - let startValue; - let endValue; - - beforeEach(async () => { - eChartMock = { - handlers: {}, - getOption: () => ({ - dataZoom: [ - { - startValue, - endValue, - }, - ], - }), - off: jest.fn((eChartEvent) => { - delete eChartMock.handlers[eChartEvent]; - }), - on: jest.fn((eChartEvent, fn) => { - eChartMock.handlers[eChartEvent] = fn; - }), - }; - - createWrapper({}, mount); - await nextTick(); - findChart().vm.$emit('created', eChartMock); - }); - - it('handles datazoom event from chart', () => { - startValue = 1577836800000; // 2020-01-01T00:00:00.000Z - endValue = 1577840400000; // 2020-01-01T01:00:00.000Z - eChartMock.handlers.datazoom(); - - expect(wrapper.emitted('datazoom')).toHaveLength(1); - expect(wrapper.emitted('datazoom')[0]).toEqual([ - { - start: new Date(startValue).toISOString(), - end: new Date(endValue).toISOString(), - }, - ]); - }); - }); - }); - - describe('methods', () => { - describe('formatTooltipText', () => { - const mockCommitUrl = deploymentData[0].commitUrl; - const mockDate = deploymentData[0].created_at; - const mockSha = 'f5bcd1d9'; - const mockLineSeriesData = () => ({ - seriesData: [ - { - seriesName: wrapper.vm.chartData[0].name, - componentSubType: 'line', - value: [mockDate, 5.55555], - dataIndex: 0, - }, - ], - value: mockDate, - }); - - const annotationsMetadata = { - tooltipData: { - sha: mockSha, - commitUrl: mockCommitUrl, - }, - }; - - const mockAnnotationsSeriesData = { - seriesData: [ - { - componentSubType: 'scatter', - seriesName: 'series01', - dataIndex: 0, - value: [mockDate, 5.55555], - type: 'scatter', - name: 'deployments', - }, - ], - value: mockDate, - }; - - it('does not throw error if data point is outside the zoom range', () => { - const seriesDataWithoutValue = { - ...mockLineSeriesData(), - seriesData: mockLineSeriesData().seriesData.map((data) => ({ - ...data, - value: undefined, - })), - }; - expect(wrapper.vm.formatTooltipText(seriesDataWithoutValue)).toBeUndefined(); - }); - - describe('when series is of line type', () => { - beforeEach(async () => { - createWrapper({}, mount); - wrapper.vm.formatTooltipText(mockLineSeriesData()); - await nextTick(); - }); - - it('formats tooltip title', () => { - expect(wrapper.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM (UTC)'); - }); - - it('formats tooltip content', () => { - const name = 'Metric 1'; - const value = '5.556'; - const dataIndex = 0; - const seriesLabel = wrapper.findComponent(GlChartSeriesLabel); - - expect(seriesLabel.vm.color).toBe(''); - - expect(shallowWrapperContainsSlotText(seriesLabel, 'default', name)).toBe(true); - expect(wrapper.vm.tooltip.content).toEqual([ - { name, value, dataIndex, color: undefined }, - ]); - - expect( - shallowWrapperContainsSlotText( - wrapper.findComponent(GlLineChart), - 'tooltip-content', - value, - ), - ).toBe(true); - }); - - describe('when in PT timezone', () => { - beforeAll(() => { - // Note: node.js env renders (GMT-0700), in the browser we see (PDT) - timezoneMock.register('US/Pacific'); - }); - - afterAll(() => { - timezoneMock.unregister(); - }); - - it('formats tooltip title in local timezone by default', async () => { - createWrapper(); - wrapper.vm.formatTooltipText(mockLineSeriesData()); - await nextTick(); - expect(wrapper.vm.tooltip.title).toBe('16 Jul 2019, 3:14AM (GMT-0700)'); - }); - - it('formats tooltip title in local timezone', async () => { - createWrapper({ timezone: 'LOCAL' }); - wrapper.vm.formatTooltipText(mockLineSeriesData()); - await nextTick(); - expect(wrapper.vm.tooltip.title).toBe('16 Jul 2019, 3:14AM (GMT-0700)'); - }); - - it('formats tooltip title in UTC format', async () => { - createWrapper({ timezone: 'UTC' }); - wrapper.vm.formatTooltipText(mockLineSeriesData()); - await nextTick(); - expect(wrapper.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM (UTC)'); - }); - }); - }); - - describe('when series is of scatter type, for deployments', () => { - beforeEach(async () => { - wrapper.vm.formatTooltipText({ - ...mockAnnotationsSeriesData, - seriesData: mockAnnotationsSeriesData.seriesData.map((data) => ({ - ...data, - data: annotationsMetadata, - })), - }); - await nextTick(); - }); - - it('set tooltip type to deployments', () => { - expect(wrapper.vm.tooltip.type).toBe('deployments'); - }); - - it('formats tooltip title', () => { - expect(wrapper.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM (UTC)'); - }); - - it('formats tooltip sha', () => { - expect(wrapper.vm.tooltip.sha).toBe('f5bcd1d9'); - }); - - it('formats tooltip commit url', () => { - expect(wrapper.vm.tooltip.commitUrl).toBe(mockCommitUrl); - }); - }); - - describe('when series is of scatter type and deployments data is missing', () => { - beforeEach(async () => { - wrapper.vm.formatTooltipText(mockAnnotationsSeriesData); - await nextTick(); - }); - - it('formats tooltip title', () => { - expect(wrapper.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM (UTC)'); - }); - - it('formats tooltip sha', () => { - expect(wrapper.vm.tooltip.sha).toBeUndefined(); - }); - - it('formats tooltip commit url', () => { - expect(wrapper.vm.tooltip.commitUrl).toBeUndefined(); - }); - }); - }); - - describe('formatAnnotationsTooltipText', () => { - const annotationsMetadata = { - name: 'annotations', - xAxis: annotationsData[0].from, - yAxis: 0, - tooltipData: { - title: '2020/02/19 10:01:41', - content: annotationsData[0].description, - }, - }; - - const mockMarkPoint = { - componentType: 'markPoint', - name: 'annotations', - value: undefined, - data: annotationsMetadata, - }; - - it('formats tooltip title and sets tooltip content', () => { - const formattedTooltipData = wrapper.vm.formatAnnotationsTooltipText(mockMarkPoint); - expect(formattedTooltipData.title).toBe('19 Feb 2020, 10:01AM (UTC)'); - expect(formattedTooltipData.content).toBe(annotationsMetadata.tooltipData.content); - }); - }); - }); - - describe('computed', () => { - const getChartOptions = () => findChart().props('option'); - - describe('chartData', () => { - let chartData; - const seriesData = () => chartData[0]; - - beforeEach(() => { - ({ chartData } = wrapper.vm); - }); - - it('utilizes all data points', () => { - expect(chartData.length).toBe(1); - expect(seriesData().data.length).toBe(3); - }); - - it('creates valid data', () => { - const { data } = seriesData(); - - expect( - data.filter( - ([time, value]) => new Date(time).getTime() > 0 && typeof value === 'number', - ).length, - ).toBe(data.length); - }); - - it('formats line width correctly', () => { - expect(chartData[0].lineStyle.width).toBe(2); - }); - }); - - describe('chartOptions', () => { - describe('x-Axis bounds', () => { - it('is set to the time range bounds', () => { - expect(getChartOptions().xAxis).toMatchObject({ - min: mockFixedTimeRange.start, - max: mockFixedTimeRange.end, - }); - }); - - it('is not set if time range is not set or incorrectly set', async () => { - wrapper.setProps({ - timeRange: {}, - }); - await nextTick(); - expect(getChartOptions().xAxis).not.toHaveProperty('min'); - expect(getChartOptions().xAxis).not.toHaveProperty('max'); - }); - }); - - describe('dataZoom', () => { - it('renders with scroll handle icons', () => { - expect(getChartOptions().dataZoom).toHaveLength(1); - expect(getChartOptions().dataZoom[0]).toMatchObject({ - handleIcon: 'path://scroll-handle-content', - }); - }); - }); - - describe('xAxis pointer', () => { - it('snap is set to false by default', () => { - expect(getChartOptions().xAxis.axisPointer.snap).toBe(false); - }); - }); - - describe('are extended by `option`', () => { - const mockSeriesName = 'Extra series 1'; - const mockOption = { - option1: 'option1', - option2: 'option2', - }; - - it('arbitrary options', async () => { - wrapper.setProps({ - option: mockOption, - }); - - await nextTick(); - expect(getChartOptions()).toEqual(expect.objectContaining(mockOption)); - }); - - it('additional series', async () => { - wrapper.setProps({ - option: { - series: [ - { - name: mockSeriesName, - type: 'line', - data: [], - }, - ], - }, - }); - - await nextTick(); - const optionSeries = getChartOptions().series; - - expect(optionSeries.length).toEqual(2); - expect(optionSeries[0].name).toEqual(mockSeriesName); - }); - - it('additional y-axis data', async () => { - const mockCustomYAxisOption = { - name: 'Custom y-axis label', - axisLabel: { - formatter: jest.fn(), - }, - }; - - wrapper.setProps({ - option: { - yAxis: mockCustomYAxisOption, - }, - }); - - await nextTick(); - const { yAxis } = getChartOptions(); - - expect(yAxis[0]).toMatchObject(mockCustomYAxisOption); - }); - - it('additional x axis data', async () => { - const mockCustomXAxisOption = { - name: 'Custom x axis label', - }; - - wrapper.setProps({ - option: { - xAxis: mockCustomXAxisOption, - }, - }); - - await nextTick(); - const { xAxis } = getChartOptions(); - - expect(xAxis).toMatchObject(mockCustomXAxisOption); - }); - }); - - describe('yAxis formatter', () => { - let dataFormatter; - let deploymentFormatter; - - beforeEach(() => { - dataFormatter = getChartOptions().yAxis[0].axisLabel.formatter; - deploymentFormatter = getChartOptions().yAxis[1].axisLabel.formatter; - }); - - it('formats by default to precision notation', () => { - expect(dataFormatter(0.88888)).toBe('889m'); - }); - - it('deployment formatter is set as is required to display a tooltip', () => { - expect(deploymentFormatter).toEqual(expect.any(Function)); - }); - }); - }); - - describe('annotationSeries', () => { - it('utilizes deployment data', () => { - const annotationSeries = wrapper.vm.chartOptionSeries[0]; - expect(annotationSeries.yAxisIndex).toBe(1); // same as annotations y axis - expect(annotationSeries.data).toEqual([ - expect.objectContaining({ - symbolSize: 14, - symbol: 'path://rocket-content', - value: ['2019-07-16T10:14:25.589Z', expect.any(Number)], - }), - expect.objectContaining({ - symbolSize: 14, - symbol: 'path://rocket-content', - value: ['2019-07-16T11:14:25.589Z', expect.any(Number)], - }), - expect.objectContaining({ - symbolSize: 14, - symbol: 'path://rocket-content', - value: ['2019-07-16T12:14:25.589Z', expect.any(Number)], - }), - ]); - }); - }); - - describe('xAxisLabel', () => { - const mockDate = Date.UTC(2020, 4, 26, 20); // 8:00 PM in GMT - - const useXAxisFormatter = (date) => { - const { xAxis } = getChartOptions(); - const { formatter } = xAxis.axisLabel; - return formatter(date); - }; - - it('x-axis is formatted correctly in m/d h:MM TT format', () => { - expect(useXAxisFormatter(mockDate)).toEqual('5/26 8:00 PM'); - }); - - describe('when in PT timezone', () => { - beforeAll(() => { - timezoneMock.register('US/Pacific'); - }); - - afterAll(() => { - timezoneMock.unregister(); - }); - - it('by default, values are formatted in PT', () => { - createWrapper(); - expect(useXAxisFormatter(mockDate)).toEqual('5/26 1:00 PM'); - }); - - it('when the chart uses local timezone, y-axis is formatted in PT', () => { - createWrapper({ timezone: 'LOCAL' }); - expect(useXAxisFormatter(mockDate)).toEqual('5/26 1:00 PM'); - }); - - it('when the chart uses UTC, y-axis is formatted in UTC', () => { - createWrapper({ timezone: 'UTC' }); - expect(useXAxisFormatter(mockDate)).toEqual('5/26 8:00 PM'); - }); - }); - }); - - describe('yAxisLabel', () => { - it('y-axis is configured correctly', () => { - const { yAxis } = getChartOptions(); - - expect(yAxis).toHaveLength(2); - - const [dataAxis, deploymentAxis] = yAxis; - - expect(dataAxis.boundaryGap).toHaveLength(2); - expect(dataAxis.scale).toBe(true); - - expect(deploymentAxis.show).toBe(false); - expect(deploymentAxis.min).toEqual(expect.any(Number)); - expect(deploymentAxis.max).toEqual(expect.any(Number)); - expect(deploymentAxis.min).toBeLessThan(deploymentAxis.max); - }); - - it('constructs a label for the chart y-axis', () => { - const { yAxis } = getChartOptions(); - - expect(yAxis[0].name).toBe('Y Axis'); - }); - }); - }); - }); - - describe('wrapped components', () => { - const glChartComponents = [ - { - chartType: panelTypes.AREA_CHART, - component: GlAreaChart, - }, - { - chartType: panelTypes.LINE_CHART, - component: GlLineChart, - }, - ]; - - glChartComponents.forEach((dynamicComponent) => { - describe(`GitLab UI: ${dynamicComponent.chartType}`, () => { - const findChartComponent = () => wrapper.findComponent(dynamicComponent.component); - - beforeEach(async () => { - createWrapper( - { graphData: timeSeriesGraphData({ type: dynamicComponent.chartType }) }, - mount, - ); - await nextTick(); - }); - - it('exists', () => { - expect(findChartComponent().exists()).toBe(true); - }); - - it('receives data properties needed for proper chart render', () => { - const props = findChartComponent().props(); - - expect(props.data).toBe(wrapper.vm.chartData); - expect(props.option).toBe(wrapper.vm.chartOptions); - expect(props.formatTooltipText).toBe(wrapper.vm.formatTooltipText); - }); - - it('receives a tooltip title', async () => { - const mockTitle = 'mockTitle'; - wrapper.vm.tooltip.title = mockTitle; - - await nextTick(); - expect( - shallowWrapperContainsSlotText(findChartComponent(), 'tooltip-title', mockTitle), - ).toBe(true); - }); - - describe('when tooltip is showing deployment data', () => { - const mockSha = 'mockSha'; - const commitUrl = `${mockProjectDir}/-/commit/${mockSha}`; - - beforeEach(async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - tooltip: { - type: 'deployments', - }, - }); - await nextTick(); - }); - - it('uses deployment title', () => { - expect( - shallowWrapperContainsSlotText(findChartComponent(), 'tooltip-title', 'Deployed'), - ).toBe(true); - }); - - it('renders clickable commit sha in tooltip content', async () => { - wrapper.vm.tooltip.sha = mockSha; - wrapper.vm.tooltip.commitUrl = commitUrl; - - await nextTick(); - const commitLink = wrapper.findComponent(GlLink); - - expect(shallowWrapperContainsSlotText(commitLink, 'default', mockSha)).toBe(true); - expect(commitLink.attributes('href')).toEqual(commitUrl); - }); - }); - }); - }); - }); - }); - - describe('with multiple time series', () => { - describe('General functions', () => { - beforeEach(async () => { - const graphData = timeSeriesGraphData({ type: panelTypes.AREA_CHART, multiMetric: true }); - - createWrapper({ graphData }, mount); - await nextTick(); - }); - - describe('Color match', () => { - let lineColors; - - beforeEach(() => { - lineColors = wrapper - .findComponent(GlAreaChart) - .vm.series.map((item) => item.lineStyle.color); - }); - - it('should contain different colors for contiguous time series', () => { - lineColors.forEach((color, index) => { - expect(color).not.toBe(lineColors[index + 1]); - }); - }); - - it('should match series color with tooltip label color', () => { - const labels = wrapper.findAllComponents(GlChartSeriesLabel); - - lineColors.forEach((color, index) => { - const labelColor = labels.at(index).props('color'); - expect(color).toBe(labelColor); - }); - }); - - it('should match series color with legend color', () => { - const legendColors = wrapper - .findComponent(GlChartLegend) - .props('seriesInfo') - .map((item) => item.color); - - lineColors.forEach((color, index) => { - expect(color).toBe(legendColors[index]); - }); - }); - }); - }); - }); - - describe('legend layout', () => { - const findLegend = () => wrapper.findComponent(GlChartLegend); - - beforeEach(async () => { - createWrapper({}, mount); - await nextTick(); - }); - - it('should render a tabular legend layout by default', () => { - expect(findLegend().props('layout')).toBe('table'); - }); - - describe('when inline legend layout prop is set', () => { - beforeEach(() => { - wrapper.setProps({ - legendLayout: 'inline', - }); - }); - - it('should render an inline legend layout', () => { - expect(findLegend().props('layout')).toBe('inline'); - }); - }); - - describe('when table legend layout prop is set', () => { - beforeEach(() => { - wrapper.setProps({ - legendLayout: 'table', - }); - }); - - it('should render a tabular legend layout', () => { - expect(findLegend().props('layout')).toBe('table'); - }); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/create_dashboard_modal_spec.js b/spec/frontend/monitoring/components/create_dashboard_modal_spec.js deleted file mode 100644 index eb05b1f184a..00000000000 --- a/spec/frontend/monitoring/components/create_dashboard_modal_spec.js +++ /dev/null @@ -1,44 +0,0 @@ -import { GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import CreateDashboardModal from '~/monitoring/components/create_dashboard_modal.vue'; - -describe('Create dashboard modal', () => { - let wrapper; - - const defaultProps = { - modalId: 'id', - projectPath: 'https://localhost/', - addDashboardDocumentationPath: 'https://link/to/docs', - }; - - const findDocsButton = () => wrapper.find('[data-testid="create-dashboard-modal-docs-button"]'); - const findRepoButton = () => wrapper.find('[data-testid="create-dashboard-modal-repo-button"]'); - - const createWrapper = (props = {}, options = {}) => { - wrapper = shallowMount(CreateDashboardModal, { - propsData: { ...defaultProps, ...props }, - stubs: { - GlModal, - }, - ...options, - }); - }; - - beforeEach(() => { - createWrapper(); - }); - - it('has button that links to the project url', async () => { - findRepoButton().trigger('click'); - - await nextTick(); - expect(findRepoButton().exists()).toBe(true); - expect(findRepoButton().attributes('href')).toBe(defaultProps.projectPath); - }); - - it('has button that links to the docs', () => { - expect(findDocsButton().exists()).toBe(true); - expect(findDocsButton().attributes('href')).toBe(defaultProps.addDashboardDocumentationPath); - }); -}); diff --git a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js deleted file mode 100644 index 4d290922707..00000000000 --- a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js +++ /dev/null @@ -1,421 +0,0 @@ -import { GlDropdownItem, GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; -import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated -import ActionsMenu from '~/monitoring/components/dashboard_actions_menu.vue'; -import { DASHBOARD_PAGE, PANEL_NEW_PAGE } from '~/monitoring/router/constants'; -import { createStore } from '~/monitoring/stores'; -import * as types from '~/monitoring/stores/mutation_types'; -import Tracking from '~/tracking'; -import { dashboardActionsMenuProps, dashboardGitResponse } from '../mock_data'; -import { setupAllDashboards, setupStoreWithData } from '../store_utils'; - -jest.mock('~/lib/utils/url_utility', () => ({ - redirectTo: jest.fn(), - queryToObject: jest.fn(), -})); - -describe('Actions menu', () => { - const ootbDashboards = [dashboardGitResponse[0], dashboardGitResponse[2]]; - const customDashboard = dashboardGitResponse[1]; - - let store; - let wrapper; - - const findAddMetricItem = () => wrapper.find('[data-testid="add-metric-item"]'); - const findAddPanelItemEnabled = () => wrapper.find('[data-testid="add-panel-item-enabled"]'); - const findAddPanelItemDisabled = () => wrapper.find('[data-testid="add-panel-item-disabled"]'); - const findAddMetricModal = () => wrapper.find('[data-testid="add-metric-modal"]'); - const findAddMetricModalSubmitButton = () => - wrapper.find('[data-testid="add-metric-modal-submit-button"]'); - const findStarDashboardItem = () => wrapper.find('[data-testid="star-dashboard-item"]'); - const findEditDashboardItemEnabled = () => - wrapper.find('[data-testid="edit-dashboard-item-enabled"]'); - const findEditDashboardItemDisabled = () => - wrapper.find('[data-testid="edit-dashboard-item-disabled"]'); - const findDuplicateDashboardItem = () => wrapper.find('[data-testid="duplicate-dashboard-item"]'); - const findDuplicateDashboardModal = () => - wrapper.find('[data-testid="duplicate-dashboard-modal"]'); - const findCreateDashboardItem = () => wrapper.find('[data-testid="create-dashboard-item"]'); - const findCreateDashboardModal = () => wrapper.find('[data-testid="create-dashboard-modal"]'); - - const createShallowWrapper = (props = {}, options = {}) => { - wrapper = shallowMount(ActionsMenu, { - propsData: { ...dashboardActionsMenuProps, ...props }, - store, - stubs: { - GlModal, - }, - ...options, - }); - }; - - beforeEach(() => { - store = createStore(); - }); - - describe('add metric item', () => { - it('is rendered when custom metrics are available', async () => { - createShallowWrapper(); - - await nextTick(); - expect(findAddMetricItem().exists()).toBe(true); - }); - - it('is not rendered when custom metrics are not available', async () => { - createShallowWrapper({ - addingMetricsAvailable: false, - }); - - await nextTick(); - expect(findAddMetricItem().exists()).toBe(false); - }); - - describe('when available', () => { - beforeEach(() => { - createShallowWrapper(); - }); - - it('modal for custom metrics form is rendered', () => { - expect(findAddMetricModal().exists()).toBe(true); - expect(findAddMetricModal().props('modalId')).toBe('addMetric'); - }); - - it('add metric modal submit button exists', () => { - expect(findAddMetricModalSubmitButton().exists()).toBe(true); - }); - - it('renders custom metrics form fields', () => { - expect(wrapper.findComponent(CustomMetricsFormFields).exists()).toBe(true); - }); - }); - - describe('when not available', () => { - beforeEach(() => { - createShallowWrapper({ addingMetricsAvailable: false }); - }); - - it('modal for custom metrics form is not rendered', () => { - expect(findAddMetricModal().exists()).toBe(false); - }); - }); - - describe('adding new metric from modal', () => { - let origPage; - - beforeEach(() => { - jest.spyOn(Tracking, 'event').mockReturnValue(); - createShallowWrapper(); - - setupStoreWithData(store); - - origPage = document.body.dataset.page; - document.body.dataset.page = 'projects:environments:metrics'; - - return nextTick(); - }); - - afterEach(() => { - document.body.dataset.page = origPage; - }); - - it('is tracked', async () => { - const submitButton = findAddMetricModalSubmitButton().vm; - - await nextTick(); - submitButton.$el.click(); - await nextTick(); - expect(Tracking.event).toHaveBeenCalledWith(document.body.dataset.page, 'click_button', { - label: 'add_new_metric', - property: 'modal', - value: undefined, - }); - }); - }); - }); - - describe('add panel item', () => { - const GlDropdownItemStub = { - extends: GlDropdownItem, - props: { - to: [String, Object], - }, - }; - - let $route; - - beforeEach(() => { - $route = { name: DASHBOARD_PAGE, params: { dashboard: 'my_dashboard.yml' } }; - - createShallowWrapper( - { - isOotbDashboard: false, - }, - { - mocks: { $route }, - stubs: { GlDropdownItem: GlDropdownItemStub }, - }, - ); - }); - - it('is disabled for ootb dashboards', async () => { - createShallowWrapper({ - isOotbDashboard: true, - }); - - await nextTick(); - expect(findAddPanelItemDisabled().exists()).toBe(true); - }); - - it('is visible for custom dashboards', () => { - expect(findAddPanelItemEnabled().exists()).toBe(true); - }); - - it('renders a link to the new panel page for custom dashboards', () => { - expect(findAddPanelItemEnabled().props('to')).toEqual({ - name: PANEL_NEW_PAGE, - params: { - dashboard: 'my_dashboard.yml', - }, - }); - }); - }); - - describe('edit dashboard yml item', () => { - beforeEach(() => { - createShallowWrapper(); - }); - - describe('when current dashboard is custom', () => { - beforeEach(() => { - setupAllDashboards(store, customDashboard.path); - }); - - it('enabled item is rendered and has falsy disabled attribute', () => { - expect(findEditDashboardItemEnabled().exists()).toBe(true); - expect(findEditDashboardItemEnabled().attributes('disabled')).toBe(undefined); - }); - - it('enabled item links to their edit path', () => { - expect(findEditDashboardItemEnabled().attributes('href')).toBe( - customDashboard.project_blob_path, - ); - }); - - it('disabled item is not rendered', () => { - expect(findEditDashboardItemDisabled().exists()).toBe(false); - }); - }); - - describe.each(ootbDashboards)('when current dashboard is OOTB', (dashboard) => { - beforeEach(() => { - setupAllDashboards(store, dashboard.path); - }); - - it('disabled item is rendered and has disabled attribute set on it', () => { - expect(findEditDashboardItemDisabled().exists()).toBe(true); - expect(findEditDashboardItemDisabled().attributes('disabled')).toBe(''); - }); - - it('enabled item is not rendered', () => { - expect(findEditDashboardItemEnabled().exists()).toBe(false); - }); - }); - }); - - describe('duplicate dashboard item', () => { - beforeEach(() => { - createShallowWrapper(); - }); - - describe.each(ootbDashboards)('when current dashboard is OOTB', (dashboard) => { - beforeEach(() => { - setupAllDashboards(store, dashboard.path); - }); - - it('is rendered', () => { - expect(findDuplicateDashboardItem().exists()).toBe(true); - }); - - it('duplicate dashboard modal is rendered', () => { - expect(findDuplicateDashboardModal().exists()).toBe(true); - }); - - it('clicking on item opens up the duplicate dashboard modal', async () => { - const modalId = 'duplicateDashboard'; - const modalTrigger = findDuplicateDashboardItem(); - const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit'); - - modalTrigger.trigger('click'); - - await nextTick(); - expect(rootEmit.mock.calls[0]).toContainEqual(modalId); - }); - }); - - describe('when current dashboard is custom', () => { - beforeEach(() => { - setupAllDashboards(store, customDashboard.path); - }); - - it('is not rendered', () => { - expect(findDuplicateDashboardItem().exists()).toBe(false); - }); - - it('duplicate dashboard modal is not rendered', () => { - expect(findDuplicateDashboardModal().exists()).toBe(false); - }); - }); - - describe('when no dashboard is set', () => { - it('is not rendered', () => { - expect(findDuplicateDashboardItem().exists()).toBe(false); - }); - - it('duplicate dashboard modal is not rendered', () => { - expect(findDuplicateDashboardModal().exists()).toBe(false); - }); - }); - - describe('when a dashboard has been duplicated in the duplicate dashboard modal', () => { - beforeEach(() => { - store.state.monitoringDashboard.projectPath = 'root/sandbox'; - - setupAllDashboards(store, dashboardGitResponse[0].path); - }); - - it('redirects to the newly created dashboard', async () => { - const newDashboard = dashboardGitResponse[1]; - - const newDashboardUrl = 'root/sandbox/-/metrics/dashboard.yml'; - findDuplicateDashboardModal().vm.$emit('dashboardDuplicated', newDashboard); - - await nextTick(); - expect(redirectTo).toHaveBeenCalled(); // eslint-disable-line import/no-deprecated - expect(redirectTo).toHaveBeenCalledWith(newDashboardUrl); // eslint-disable-line import/no-deprecated - }); - }); - }); - - describe('star dashboard item', () => { - beforeEach(() => { - createShallowWrapper(); - setupAllDashboards(store); - - jest.spyOn(store, 'dispatch').mockResolvedValue(); - }); - - it('is shown', () => { - expect(findStarDashboardItem().exists()).toBe(true); - }); - - it('is not disabled', () => { - expect(findStarDashboardItem().attributes('disabled')).toBeUndefined(); - }); - - it('is disabled when starring is taking place', async () => { - store.commit(`monitoringDashboard/${types.REQUEST_DASHBOARD_STARRING}`); - - await nextTick(); - expect(findStarDashboardItem().exists()).toBe(true); - expect(findStarDashboardItem().attributes('disabled')).toBeDefined(); - }); - - it('on click it dispatches a toggle star action', async () => { - findStarDashboardItem().vm.$emit('click'); - - await nextTick(); - expect(store.dispatch).toHaveBeenCalledWith( - 'monitoringDashboard/toggleStarredValue', - undefined, - ); - }); - - describe('when dashboard is not starred', () => { - beforeEach(async () => { - store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { - currentDashboard: dashboardGitResponse[0].path, - }); - await nextTick(); - }); - - it('item text shows "Star dashboard"', () => { - expect(findStarDashboardItem().html()).toMatch(/Star dashboard/); - }); - }); - - describe('when dashboard is starred', () => { - beforeEach(async () => { - store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { - currentDashboard: dashboardGitResponse[1].path, - }); - await nextTick(); - }); - - it('item text shows "Unstar dashboard"', () => { - expect(findStarDashboardItem().html()).toMatch(/Unstar dashboard/); - }); - }); - }); - - describe('create dashboard item', () => { - beforeEach(() => { - createShallowWrapper(); - }); - - it('is rendered by default but it is disabled', () => { - expect(findCreateDashboardItem().attributes('disabled')).toBeDefined(); - }); - - describe('when project path is set', () => { - const mockProjectPath = 'root/sandbox'; - const mockAddDashboardDocPath = '/doc/add-dashboard'; - - beforeEach(() => { - store.state.monitoringDashboard.projectPath = mockProjectPath; - store.state.monitoringDashboard.addDashboardDocumentationPath = mockAddDashboardDocPath; - }); - - it('is not disabled', () => { - expect(findCreateDashboardItem().attributes('disabled')).toBe(undefined); - }); - - it('renders a modal for creating a dashboard', () => { - expect(findCreateDashboardModal().exists()).toBe(true); - }); - - it('clicking opens up the modal', async () => { - const modalId = 'createDashboard'; - const modalTrigger = findCreateDashboardItem(); - const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit'); - - modalTrigger.trigger('click'); - - await nextTick(); - expect(rootEmit.mock.calls[0]).toContainEqual(modalId); - }); - - it('modal gets passed correct props', () => { - expect(findCreateDashboardModal().props('projectPath')).toBe(mockProjectPath); - expect(findCreateDashboardModal().props('addDashboardDocumentationPath')).toBe( - mockAddDashboardDocPath, - ); - }); - }); - - describe('when project path is not set', () => { - beforeEach(() => { - store.state.monitoringDashboard.projectPath = null; - }); - - it('is disabled', () => { - expect(findCreateDashboardItem().attributes('disabled')).toBeDefined(); - }); - - it('does not render a modal for creating a dashboard', () => { - expect(findCreateDashboardModal().exists()).toBe(false); - }); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/dashboard_header_spec.js b/spec/frontend/monitoring/components/dashboard_header_spec.js deleted file mode 100644 index 091e05ab271..00000000000 --- a/spec/frontend/monitoring/components/dashboard_header_spec.js +++ /dev/null @@ -1,395 +0,0 @@ -import { GlDropdownItem, GlSearchBoxByType, GlLoadingIcon, GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated -import ActionsMenu from '~/monitoring/components/dashboard_actions_menu.vue'; -import DashboardHeader from '~/monitoring/components/dashboard_header.vue'; -import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue'; -import RefreshButton from '~/monitoring/components/refresh_button.vue'; -import { createStore } from '~/monitoring/stores'; -import * as types from '~/monitoring/stores/mutation_types'; -import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; -import { environmentData, dashboardGitResponse, dashboardHeaderProps } from '../mock_data'; -import { setupAllDashboards, setupStoreWithDashboard, setupStoreWithData } from '../store_utils'; - -const mockProjectPath = 'https://path/to/project'; - -jest.mock('~/lib/utils/url_utility', () => ({ - redirectTo: jest.fn(), - queryToObject: jest.fn(), - mergeUrlParams: jest.requireActual('~/lib/utils/url_utility').mergeUrlParams, -})); - -describe('Dashboard header', () => { - let store; - let wrapper; - - const findDashboardDropdown = () => wrapper.findComponent(DashboardsDropdown); - - const findEnvsDropdown = () => wrapper.findComponent({ ref: 'monitorEnvironmentsDropdown' }); - const findEnvsDropdownItems = () => findEnvsDropdown().findAllComponents(GlDropdownItem); - const findEnvsDropdownSearch = () => findEnvsDropdown().findComponent(GlSearchBoxByType); - const findEnvsDropdownSearchMsg = () => - wrapper.findComponent({ ref: 'monitorEnvironmentsDropdownMsg' }); - const findEnvsDropdownLoadingIcon = () => findEnvsDropdown().findComponent(GlLoadingIcon); - - const findDateTimePicker = () => wrapper.findComponent(DateTimePicker); - const findRefreshButton = () => wrapper.findComponent(RefreshButton); - - const findActionsMenu = () => wrapper.findComponent(ActionsMenu); - - const setSearchTerm = (searchTerm) => { - store.commit(`monitoringDashboard/${types.SET_ENVIRONMENTS_FILTER}`, searchTerm); - }; - - const createShallowWrapper = (props = {}, options = {}) => { - wrapper = shallowMount(DashboardHeader, { - propsData: { ...dashboardHeaderProps, ...props }, - store, - ...options, - }); - }; - - beforeEach(() => { - store = createStore(); - }); - - describe('dashboards dropdown', () => { - beforeEach(() => { - store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { - projectPath: mockProjectPath, - }); - - createShallowWrapper(); - }); - - it('shows the dashboard dropdown', () => { - expect(findDashboardDropdown().exists()).toBe(true); - }); - - it('when an out of the box dashboard is selected, encodes dashboard path', () => { - findDashboardDropdown().vm.$emit('selectDashboard', { - path: '.gitlab/dashboards/dashboard©.yml', - out_of_the_box_dashboard: true, - display_name: 'A display name', - }); - - // eslint-disable-next-line import/no-deprecated - expect(redirectTo).toHaveBeenCalledWith( - `${mockProjectPath}/-/metrics/.gitlab%2Fdashboards%2Fdashboard%26copy.yml`, - ); - }); - - it('when a custom dashboard is selected, encodes dashboard display name', () => { - findDashboardDropdown().vm.$emit('selectDashboard', { - path: '.gitlab/dashboards/file&path.yml', - display_name: 'dashboard©.yml', - }); - - // eslint-disable-next-line import/no-deprecated - expect(redirectTo).toHaveBeenCalledWith(`${mockProjectPath}/-/metrics/dashboard%26copy.yml`); - }); - }); - - describe('environments dropdown', () => { - beforeEach(() => { - createShallowWrapper(); - }); - - it('shows the environments dropdown', () => { - expect(findEnvsDropdown().exists()).toBe(true); - }); - - it('renders a search input', () => { - expect(findEnvsDropdownSearch().exists()).toBe(true); - }); - - describe('when environments data is not loaded', () => { - beforeEach(async () => { - setupStoreWithDashboard(store); - await nextTick(); - }); - - it('there are no environments listed', () => { - expect(findEnvsDropdownItems()).toHaveLength(0); - }); - }); - - describe('when environments data is loaded', () => { - const currentDashboard = dashboardGitResponse[0].path; - const currentEnvironmentName = environmentData[0].name; - - beforeEach(async () => { - setupStoreWithData(store); - store.state.monitoringDashboard.projectPath = mockProjectPath; - store.state.monitoringDashboard.currentDashboard = currentDashboard; - store.state.monitoringDashboard.currentEnvironmentName = currentEnvironmentName; - - await nextTick(); - }); - - it('renders dropdown items with the environment name', () => { - const path = `${mockProjectPath}/-/metrics/${encodeURIComponent(currentDashboard)}`; - - findEnvsDropdownItems().wrappers.forEach((itemWrapper, index) => { - const { name, id } = environmentData[index]; - const idParam = encodeURIComponent(id); - - expect(itemWrapper.text()).toBe(name); - expect(itemWrapper.attributes('href')).toBe(`${path}?environment=${idParam}`); - }); - }); - - it('environments dropdown items can be checked', () => { - const items = findEnvsDropdownItems(); - const checkItems = findEnvsDropdownItems().filter((item) => item.props('isCheckItem')); - - expect(items).toHaveLength(checkItems.length); - }); - - it('checks the currently selected environment', () => { - const selectedItems = findEnvsDropdownItems().filter((item) => item.props('isChecked')); - - expect(selectedItems).toHaveLength(1); - expect(selectedItems.at(0).text()).toBe(currentEnvironmentName); - }); - - it('filters rendered dropdown items', async () => { - const searchTerm = 'production'; - const resultEnvs = environmentData.filter(({ name }) => name.indexOf(searchTerm) !== -1); - setSearchTerm(searchTerm); - - await nextTick(); - expect(findEnvsDropdownItems()).toHaveLength(resultEnvs.length); - }); - - it('does not filter dropdown items if search term is empty string', async () => { - const searchTerm = ''; - setSearchTerm(searchTerm); - - await nextTick(); - expect(findEnvsDropdownItems()).toHaveLength(environmentData.length); - }); - - it("shows error message if search term doesn't match", async () => { - const searchTerm = 'does-not-exist'; - setSearchTerm(searchTerm); - - await nextTick(); - expect(findEnvsDropdownSearchMsg().isVisible()).toBe(true); - }); - - it('shows loading element when environments fetch is still loading', async () => { - store.commit(`monitoringDashboard/${types.REQUEST_ENVIRONMENTS_DATA}`); - - await nextTick(); - expect(findEnvsDropdownLoadingIcon().exists()).toBe(true); - await store.commit( - `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, - environmentData, - ); - expect(findEnvsDropdownLoadingIcon().exists()).toBe(false); - }); - }); - }); - - describe('date time picker', () => { - beforeEach(() => { - createShallowWrapper(); - }); - - it('is rendered', () => { - expect(findDateTimePicker().exists()).toBe(true); - }); - - describe('timezone setting', () => { - const setupWithTimezone = (value) => { - store = createStore({ dashboardTimezone: value }); - createShallowWrapper(); - }; - - describe('local timezone is enabled by default', () => { - it('shows the data time picker in local timezone', () => { - expect(findDateTimePicker().props('utc')).toBe(false); - }); - }); - - describe('when LOCAL timezone is enabled', () => { - beforeEach(() => { - setupWithTimezone('LOCAL'); - }); - - it('shows the data time picker in local timezone', () => { - expect(findDateTimePicker().props('utc')).toBe(false); - }); - }); - - describe('when UTC timezone is enabled', () => { - beforeEach(() => { - setupWithTimezone('UTC'); - }); - - it('shows the data time picker in UTC format', () => { - expect(findDateTimePicker().props('utc')).toBe(true); - }); - }); - }); - }); - - describe('refresh button', () => { - beforeEach(() => { - createShallowWrapper(); - }); - - it('is rendered', () => { - expect(findRefreshButton().exists()).toBe(true); - }); - }); - - describe('external dashboard link', () => { - beforeEach(async () => { - store.state.monitoringDashboard.externalDashboardUrl = '/mockUrl'; - createShallowWrapper(); - - await nextTick(); - }); - - it('shows the link', () => { - const externalDashboardButton = wrapper.find('.js-external-dashboard-link'); - - expect(externalDashboardButton.exists()).toBe(true); - expect(externalDashboardButton.is(GlButton)).toBe(true); - expect(externalDashboardButton.text()).toContain('View full dashboard'); - }); - }); - - describe('actions menu', () => { - const ootbDashboards = [dashboardGitResponse[0].path]; - const customDashboards = [dashboardGitResponse[1].path]; - - it('is rendered', () => { - createShallowWrapper(); - - expect(findActionsMenu().exists()).toBe(true); - }); - - describe('adding metrics prop', () => { - it.each(ootbDashboards)( - 'gets passed true if current dashboard is OOTB', - async (dashboardPath) => { - createShallowWrapper({ customMetricsAvailable: true }); - - store.state.monitoringDashboard.emptyState = false; - setupAllDashboards(store, dashboardPath); - - await nextTick(); - expect(findActionsMenu().props('addingMetricsAvailable')).toBe(true); - }, - ); - - it.each(customDashboards)( - 'gets passed false if current dashboard is custom', - async (dashboardPath) => { - createShallowWrapper({ customMetricsAvailable: true }); - - store.state.monitoringDashboard.emptyState = false; - setupAllDashboards(store, dashboardPath); - - await nextTick(); - expect(findActionsMenu().props('addingMetricsAvailable')).toBe(false); - }, - ); - - it('gets passed false if empty state is shown', async () => { - createShallowWrapper({ customMetricsAvailable: true }); - - store.state.monitoringDashboard.emptyState = true; - setupAllDashboards(store, ootbDashboards[0]); - - await nextTick(); - expect(findActionsMenu().props('addingMetricsAvailable')).toBe(false); - }); - - it('gets passed false if custom metrics are not available', async () => { - createShallowWrapper({ customMetricsAvailable: false }); - - store.state.monitoringDashboard.emptyState = false; - setupAllDashboards(store, ootbDashboards[0]); - - await nextTick(); - expect(findActionsMenu().props('addingMetricsAvailable')).toBe(false); - }); - }); - - it('custom metrics path gets passed', async () => { - const path = 'https://path/to/customMetrics'; - - createShallowWrapper({ customMetricsPath: path }); - - await nextTick(); - expect(findActionsMenu().props('customMetricsPath')).toBe(path); - }); - - it('validate query path gets passed', async () => { - const path = 'https://path/to/validateQuery'; - - createShallowWrapper({ validateQueryPath: path }); - - await nextTick(); - expect(findActionsMenu().props('validateQueryPath')).toBe(path); - }); - - it('default branch gets passed', async () => { - const branch = 'branchName'; - - createShallowWrapper({ defaultBranch: branch }); - - await nextTick(); - expect(findActionsMenu().props('defaultBranch')).toBe(branch); - }); - }); - - describe('metrics settings button', () => { - const findSettingsButton = () => wrapper.find('[data-testid="metrics-settings-button"]'); - const url = 'https://path/to/project/settings'; - - beforeEach(() => { - createShallowWrapper(); - - store.state.monitoringDashboard.canAccessOperationsSettings = false; - store.state.monitoringDashboard.operationsSettingsPath = ''; - }); - - it('is rendered when the user can access the project settings and path to settings is available', async () => { - store.state.monitoringDashboard.canAccessOperationsSettings = true; - store.state.monitoringDashboard.operationsSettingsPath = url; - - await nextTick(); - expect(findSettingsButton().exists()).toBe(true); - }); - - it('is not rendered when the user can not access the project settings', async () => { - store.state.monitoringDashboard.canAccessOperationsSettings = false; - store.state.monitoringDashboard.operationsSettingsPath = url; - - await nextTick(); - expect(findSettingsButton().exists()).toBe(false); - }); - - it('is not rendered when the path to settings is unavailable', async () => { - store.state.monitoringDashboard.canAccessOperationsSettings = false; - store.state.monitoringDashboard.operationsSettingsPath = ''; - - await nextTick(); - expect(findSettingsButton().exists()).toBe(false); - }); - - it('leads to the project settings page', async () => { - store.state.monitoringDashboard.canAccessOperationsSettings = true; - store.state.monitoringDashboard.operationsSettingsPath = url; - - await nextTick(); - expect(findSettingsButton().attributes('href')).toBe(url); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js deleted file mode 100644 index 1cfd132b123..00000000000 --- a/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js +++ /dev/null @@ -1,226 +0,0 @@ -import { GlCard, GlForm, GlFormTextarea, GlAlert } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; -import DashboardPanelBuilder from '~/monitoring/components/dashboard_panel_builder.vue'; -import { createStore } from '~/monitoring/stores'; -import * as types from '~/monitoring/stores/mutation_types'; -import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; -import { metricsDashboardResponse } from '../fixture_data'; -import { mockTimeRange } from '../mock_data'; - -const mockPanel = metricsDashboardResponse.dashboard.panel_groups[0].panels[0]; - -describe('dashboard invalid url parameters', () => { - let store; - let wrapper; - let mockShowToast; - - const createComponent = (props = {}, options = {}) => { - wrapper = shallowMount(DashboardPanelBuilder, { - propsData: { ...props }, - store, - stubs: { - GlCard, - }, - mocks: { - $toast: { - show: mockShowToast, - }, - }, - options, - }); - }; - - const findForm = () => wrapper.findComponent(GlForm); - const findTxtArea = () => findForm().findComponent(GlFormTextarea); - const findSubmitBtn = () => findForm().find('[type="submit"]'); - const findClipboardCopyBtn = () => wrapper.findComponent({ ref: 'clipboardCopyBtn' }); - const findViewDocumentationBtn = () => wrapper.findComponent({ ref: 'viewDocumentationBtn' }); - const findOpenRepositoryBtn = () => wrapper.findComponent({ ref: 'openRepositoryBtn' }); - const findPanel = () => wrapper.findComponent(DashboardPanel); - const findTimeRangePicker = () => wrapper.findComponent(DateTimePicker); - const findRefreshButton = () => wrapper.find('[data-testid="previewRefreshButton"]'); - - beforeEach(() => { - mockShowToast = jest.fn(); - store = createStore(); - createComponent(); - jest.spyOn(store, 'dispatch').mockResolvedValue(); - }); - - it('is mounted', () => { - expect(wrapper.exists()).toBe(true); - }); - - it('displays an empty dashboard panel', () => { - expect(findPanel().exists()).toBe(true); - expect(findPanel().props('graphData')).toBe(null); - }); - - it('does not fetch initial data by default', () => { - expect(store.dispatch).not.toHaveBeenCalled(); - }); - - describe('yml form', () => { - it('form exists and can be submitted', () => { - expect(findForm().exists()).toBe(true); - expect(findSubmitBtn().exists()).toBe(true); - expect(findSubmitBtn().props('disabled')).toBe(false); - }); - - it('form has a text area with a default value', () => { - expect(findTxtArea().exists()).toBe(true); - - const value = findTxtArea().attributes('value'); - - // Panel definition should contain a title and a type - expect(value).toContain('title:'); - expect(value).toContain('type:'); - }); - - it('"copy to clipboard" button works', () => { - findClipboardCopyBtn().vm.$emit('click'); - const clipboardText = findClipboardCopyBtn().attributes('data-clipboard-text'); - - expect(clipboardText).toContain('title:'); - expect(clipboardText).toContain('type:'); - - expect(mockShowToast).toHaveBeenCalledTimes(1); - }); - - it('on submit fetches a panel preview', async () => { - findForm().vm.$emit('submit', new Event('submit')); - - await nextTick(); - expect(store.dispatch).toHaveBeenCalledWith( - 'monitoringDashboard/fetchPanelPreview', - expect.stringContaining('title:'), - ); - }); - - describe('when form is submitted', () => { - beforeEach(async () => { - store.commit(`monitoringDashboard/${types.REQUEST_PANEL_PREVIEW}`, 'mock yml content'); - await nextTick(); - }); - - it('submit button is disabled', () => { - expect(findSubmitBtn().props('disabled')).toBe(true); - }); - }); - }); - - describe('time range picker', () => { - it('is visible by default', () => { - expect(findTimeRangePicker().exists()).toBe(true); - }); - - it('when changed does not trigger data fetch unless preview panel button is clicked', async () => { - // mimic initial state where SET_PANEL_PREVIEW_IS_SHOWN is set to false - store.commit(`monitoringDashboard/${types.SET_PANEL_PREVIEW_IS_SHOWN}`, false); - - await nextTick(); - expect(store.dispatch).not.toHaveBeenCalled(); - }); - - it('when changed triggers data fetch if preview panel button is clicked', async () => { - findForm().vm.$emit('submit', new Event('submit')); - - store.commit(`monitoringDashboard/${types.SET_PANEL_PREVIEW_TIME_RANGE}`, mockTimeRange); - - await nextTick(); - expect(store.dispatch).toHaveBeenCalled(); - }); - }); - - describe('refresh', () => { - it('is visible by default', () => { - expect(findRefreshButton().exists()).toBe(true); - }); - - it('when clicked does not trigger data fetch unless preview panel button is clicked', async () => { - // mimic initial state where SET_PANEL_PREVIEW_IS_SHOWN is set to false - store.commit(`monitoringDashboard/${types.SET_PANEL_PREVIEW_IS_SHOWN}`, false); - - await nextTick(); - expect(store.dispatch).not.toHaveBeenCalled(); - }); - - it('when clicked triggers data fetch if preview panel button is clicked', async () => { - // mimic state where preview is visible. SET_PANEL_PREVIEW_IS_SHOWN is set to true - store.commit(`monitoringDashboard/${types.SET_PANEL_PREVIEW_IS_SHOWN}`, true); - - findRefreshButton().vm.$emit('click'); - - await nextTick(); - expect(store.dispatch).toHaveBeenCalledWith( - 'monitoringDashboard/fetchPanelPreviewMetrics', - undefined, - ); - }); - }); - - describe('instructions card', () => { - const mockDocsPath = '/docs-path'; - const mockProjectPath = '/project-path'; - - beforeEach(() => { - store.state.monitoringDashboard.addDashboardDocumentationPath = mockDocsPath; - store.state.monitoringDashboard.projectPath = mockProjectPath; - - createComponent(); - }); - - it('displays next actions for the user', () => { - expect(findViewDocumentationBtn().exists()).toBe(true); - expect(findViewDocumentationBtn().attributes('href')).toBe(mockDocsPath); - - expect(findOpenRepositoryBtn().exists()).toBe(true); - expect(findOpenRepositoryBtn().attributes('href')).toBe(mockProjectPath); - }); - }); - - describe('when there is an error', () => { - const mockError = 'an error occurred!'; - - beforeEach(async () => { - store.commit(`monitoringDashboard/${types.RECEIVE_PANEL_PREVIEW_FAILURE}`, mockError); - await nextTick(); - }); - - it('displays an alert', () => { - expect(wrapper.findComponent(GlAlert).exists()).toBe(true); - expect(wrapper.findComponent(GlAlert).text()).toBe(mockError); - }); - - it('displays an empty dashboard panel', () => { - expect(findPanel().props('graphData')).toBe(null); - }); - - it('changing time range should not refetch data', async () => { - store.commit(`monitoringDashboard/${types.SET_PANEL_PREVIEW_TIME_RANGE}`, mockTimeRange); - - await nextTick(); - expect(store.dispatch).not.toHaveBeenCalled(); - }); - }); - - describe('when panel data is available', () => { - beforeEach(async () => { - store.commit(`monitoringDashboard/${types.RECEIVE_PANEL_PREVIEW_SUCCESS}`, mockPanel); - await nextTick(); - }); - - it('displays no alert', () => { - expect(wrapper.findComponent(GlAlert).exists()).toBe(false); - }); - - it('displays panel with data', () => { - const { title, type } = wrapper.findComponent(DashboardPanel).props('graphData'); - - expect(title).toBe(mockPanel.title); - expect(type).toBe(mockPanel.type); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js deleted file mode 100644 index 491649e5b96..00000000000 --- a/spec/frontend/monitoring/components/dashboard_panel_spec.js +++ /dev/null @@ -1,582 +0,0 @@ -import { GlDropdownItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import AxiosMockAdapter from 'axios-mock-adapter'; -import Vuex from 'vuex'; -import { nextTick } from 'vue'; -import axios from '~/lib/utils/axios_utils'; - -import MonitorAnomalyChart from '~/monitoring/components/charts/anomaly.vue'; -import MonitorBarChart from '~/monitoring/components/charts/bar.vue'; -import MonitorColumnChart from '~/monitoring/components/charts/column.vue'; -import MonitorEmptyChart from '~/monitoring/components/charts/empty_chart.vue'; -import MonitorHeatmapChart from '~/monitoring/components/charts/heatmap.vue'; -import MonitorSingleStatChart from '~/monitoring/components/charts/single_stat.vue'; -import MonitorStackedColumnChart from '~/monitoring/components/charts/stacked_column.vue'; -import MonitorTimeSeriesChart from '~/monitoring/components/charts/time_series.vue'; -import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; -import { panelTypes } from '~/monitoring/constants'; - -import { createStore, monitoringDashboard } from '~/monitoring/stores'; -import { createStore as createEmbedGroupStore } from '~/monitoring/stores/embed_group'; -import { dashboardProps, graphData, graphDataEmpty } from '../fixture_data'; -import { - anomalyGraphData, - singleStatGraphData, - heatmapGraphData, - barGraphData, -} from '../graph_data'; -import { mockNamespace, mockNamespacedData, mockTimeRange } from '../mock_data'; - -const mocks = { - $toast: { - show: jest.fn(), - }, -}; - -describe('Dashboard Panel', () => { - let axiosMock; - let store; - let state; - let wrapper; - - const exampleText = 'example_text'; - - const findCopyLink = () => wrapper.findComponent({ ref: 'copyChartLink' }); - const findTimeChart = () => wrapper.findComponent({ ref: 'timeSeriesChart' }); - const findTitle = () => wrapper.findComponent({ ref: 'graphTitle' }); - const findCtxMenu = () => wrapper.findComponent({ ref: 'contextualMenu' }); - const findMenuItems = () => wrapper.findAllComponents(GlDropdownItem); - const findMenuItemByText = (text) => findMenuItems().filter((i) => i.text() === text); - - const createWrapper = (props, { mountFn = shallowMount, ...options } = {}) => { - wrapper = mountFn(DashboardPanel, { - propsData: { - graphData, - settingsPath: dashboardProps.settingsPath, - ...props, - }, - store, - mocks, - ...options, - }); - }; - - const mockGetterReturnValue = (getter, value) => { - jest.spyOn(monitoringDashboard.getters, getter).mockReturnValue(value); - store = new Vuex.Store({ - modules: { - monitoringDashboard, - }, - }); - }; - - beforeEach(() => { - store = createStore(); - state = store.state.monitoringDashboard; - - axiosMock = new AxiosMockAdapter(axios); - - jest.spyOn(URL, 'createObjectURL'); - }); - - afterEach(() => { - axiosMock.reset(); - }); - - describe('Renders slots', () => { - it('renders "topLeft" slot', () => { - createWrapper( - {}, - { - slots: { - 'top-left': `<div class="top-left-content">OK</div>`, - }, - }, - ); - - expect(wrapper.find('.top-left-content').exists()).toBe(true); - expect(wrapper.find('.top-left-content').text()).toBe('OK'); - }); - }); - - describe('When no graphData is available', () => { - beforeEach(() => { - createWrapper({ - graphData: graphDataEmpty, - }); - }); - - it('renders the chart title', () => { - expect(findTitle().text()).toBe(graphDataEmpty.title); - }); - - it('renders no download csv link', () => { - expect(wrapper.findComponent({ ref: 'downloadCsvLink' }).exists()).toBe(false); - }); - - it('does not contain graph widgets', () => { - expect(findCtxMenu().exists()).toBe(false); - }); - - it('The Empty Chart component is rendered and is a Vue instance', () => { - expect(wrapper.findComponent(MonitorEmptyChart).exists()).toBe(true); - }); - }); - - describe('When graphData is null', () => { - beforeEach(() => { - createWrapper({ - graphData: null, - }); - }); - - it('renders no chart title', () => { - expect(findTitle().text()).toBe(''); - }); - - it('renders no download csv link', () => { - expect(wrapper.findComponent({ ref: 'downloadCsvLink' }).exists()).toBe(false); - }); - - it('does not contain graph widgets', () => { - expect(findCtxMenu().exists()).toBe(false); - }); - - it('The Empty Chart component is rendered and is a Vue instance', () => { - expect(wrapper.findComponent(MonitorEmptyChart).exists()).toBe(true); - }); - }); - - describe('When graphData is available', () => { - beforeEach(() => { - createWrapper(); - }); - - it('renders the chart title', () => { - expect(findTitle().text()).toBe(graphData.title); - }); - - it('contains graph widgets', () => { - expect(findCtxMenu().exists()).toBe(true); - expect(wrapper.findComponent({ ref: 'downloadCsvLink' }).exists()).toBe(true); - }); - - it('sets no clipboard copy link on dropdown by default', () => { - expect(findCopyLink().exists()).toBe(false); - }); - - it('should emit `timerange` event when a zooming in/out in a chart occcurs', async () => { - const timeRange = { - start: '2020-01-01T00:00:00.000Z', - end: '2020-01-01T01:00:00.000Z', - }; - - jest.spyOn(wrapper.vm, '$emit'); - - findTimeChart().vm.$emit('datazoom', timeRange); - - await nextTick(); - expect(wrapper.vm.$emit).toHaveBeenCalledWith('timerangezoom', timeRange); - }); - - it('includes a default group id', () => { - expect(wrapper.vm.groupId).toBe('dashboard-panel'); - }); - - describe('Supports different panel types', () => { - const dataWithType = (type) => { - return { - ...graphData, - type, - }; - }; - - it('empty chart is rendered for empty results', () => { - createWrapper({ graphData: graphDataEmpty }); - expect(wrapper.findComponent(MonitorEmptyChart).exists()).toBe(true); - }); - - it('area chart is rendered by default', () => { - createWrapper(); - expect(wrapper.findComponent(MonitorTimeSeriesChart).exists()).toBe(true); - }); - - describe.each` - data | component | hasCtxMenu - ${dataWithType(panelTypes.AREA_CHART)} | ${MonitorTimeSeriesChart} | ${true} - ${dataWithType(panelTypes.LINE_CHART)} | ${MonitorTimeSeriesChart} | ${true} - ${singleStatGraphData()} | ${MonitorSingleStatChart} | ${true} - ${anomalyGraphData()} | ${MonitorAnomalyChart} | ${false} - ${dataWithType(panelTypes.COLUMN)} | ${MonitorColumnChart} | ${false} - ${dataWithType(panelTypes.STACKED_COLUMN)} | ${MonitorStackedColumnChart} | ${false} - ${heatmapGraphData()} | ${MonitorHeatmapChart} | ${false} - ${barGraphData()} | ${MonitorBarChart} | ${false} - `('when $data.type data is provided', ({ data, component, hasCtxMenu }) => { - const attrs = { attr1: 'attr1Value', attr2: 'attr2Value' }; - - beforeEach(() => { - createWrapper({ graphData: data }, { attrs }); - }); - - it(`renders the chart component and binds attributes`, () => { - expect(wrapper.findComponent(component).exists()).toBe(true); - expect(wrapper.findComponent(component).attributes()).toMatchObject(attrs); - }); - - it(`contextual menu is ${hasCtxMenu ? '' : 'not '}shown`, () => { - expect(findCtxMenu().exists()).toBe(hasCtxMenu); - }); - }); - }); - - describe('computed', () => { - describe('fixedCurrentTimeRange', () => { - it('returns fixed time for valid time range', async () => { - state.timeRange = mockTimeRange; - await nextTick(); - expect(findTimeChart().props('timeRange')).toEqual( - expect.objectContaining({ - start: expect.any(String), - end: expect.any(String), - }), - ); - }); - - it.each` - input | output - ${''} | ${{}} - ${undefined} | ${{}} - ${null} | ${{}} - ${'2020-12-03'} | ${{}} - `('returns $output for invalid input like $input', async ({ input, output }) => { - state.timeRange = input; - await nextTick(); - expect(findTimeChart().props('timeRange')).toEqual(output); - }); - }); - }); - }); - - describe('Edit custom metric dropdown item', () => { - const findEditCustomMetricLink = () => wrapper.findComponent({ ref: 'editMetricLink' }); - const mockEditPath = '/root/kubernetes-gke-project/prometheus/metrics/23/edit'; - - beforeEach(async () => { - createWrapper(); - await nextTick(); - }); - - it('is not present if the panel is not a custom metric', () => { - expect(findEditCustomMetricLink().exists()).toBe(false); - }); - - it('is present when the panel contains an edit_path property', async () => { - wrapper.setProps({ - graphData: { - ...graphData, - metrics: [ - { - ...graphData.metrics[0], - edit_path: mockEditPath, - }, - ], - }, - }); - - await nextTick(); - expect(findEditCustomMetricLink().exists()).toBe(true); - expect(findEditCustomMetricLink().text()).toBe('Edit metric'); - expect(findEditCustomMetricLink().attributes('href')).toBe(mockEditPath); - }); - - it('shows an "Edit metrics" link pointing to settingsPath for a panel with multiple metrics', async () => { - wrapper.setProps({ - graphData: { - ...graphData, - metrics: [ - { - ...graphData.metrics[0], - edit_path: '/root/kubernetes-gke-project/prometheus/metrics/23/edit', - }, - { - ...graphData.metrics[0], - edit_path: '/root/kubernetes-gke-project/prometheus/metrics/23/edit', - }, - ], - }, - }); - - await nextTick(); - expect(findEditCustomMetricLink().text()).toBe('Edit metrics'); - expect(findEditCustomMetricLink().attributes('href')).toBe(dashboardProps.settingsPath); - }); - }); - - describe('when clipboard data is available', () => { - const clipboardText = 'A value to copy.'; - - beforeEach(() => { - createWrapper({ - clipboardText, - }); - }); - - it('sets clipboard text on the dropdown', () => { - expect(findCopyLink().exists()).toBe(true); - expect(findCopyLink().element.dataset.clipboardText).toBe(clipboardText); - }); - - it('adds a copy button to the dropdown', () => { - expect(findCopyLink().text()).toContain('Copy link to chart'); - }); - - it('opens a toast on click', () => { - findCopyLink().vm.$emit('click'); - - expect(wrapper.vm.$toast.show).toHaveBeenCalled(); - }); - }); - - describe('when clipboard data is not available', () => { - it('there is no "copy to clipboard" link for a null value', () => { - createWrapper({ clipboardText: null }); - expect(findCopyLink().exists()).toBe(false); - }); - - it('there is no "copy to clipboard" link for an empty value', () => { - createWrapper({ clipboardText: '' }); - expect(findCopyLink().exists()).toBe(false); - }); - }); - - describe('when downloading metrics data as CSV', () => { - beforeEach(async () => { - wrapper = shallowMount(DashboardPanel, { - propsData: { - clipboardText: exampleText, - settingsPath: dashboardProps.settingsPath, - graphData: { - y_label: 'metric', - ...graphData, - }, - }, - store, - }); - await nextTick(); - }); - - describe('csvText', () => { - it('converts metrics data from json to csv', () => { - const header = `timestamp,"${graphData.y_label} > ${graphData.metrics[0].label}"`; - const data = graphData.metrics[0].result[0].values; - const firstRow = `${data[0][0]},${data[0][1]}`; - const secondRow = `${data[1][0]},${data[1][1]}`; - - expect(wrapper.vm.csvText).toMatch(`${header}\r\n${firstRow}\r\n${secondRow}\r\n`); - }); - }); - - describe('downloadCsv', () => { - it('produces a link with a Blob', () => { - expect(global.URL.createObjectURL).toHaveBeenLastCalledWith(expect.any(Blob)); - expect(global.URL.createObjectURL).toHaveBeenLastCalledWith( - expect.objectContaining({ - size: wrapper.vm.csvText.length, - type: 'text/plain', - }), - ); - }); - }); - }); - - describe('when using dynamic modules', () => { - const { mockDeploymentData, mockProjectPath } = mockNamespacedData; - - beforeEach(() => { - store = createEmbedGroupStore(); - store.registerModule(mockNamespace, monitoringDashboard); - store.state.embedGroup.modules.push(mockNamespace); - - createWrapper({ namespace: mockNamespace }); - }); - - it('handles namespaced deployment data state', async () => { - store.state[mockNamespace].deploymentData = mockDeploymentData; - - await nextTick(); - expect(findTimeChart().props().deploymentData).toEqual(mockDeploymentData); - }); - - it('handles namespaced project path state', async () => { - store.state[mockNamespace].projectPath = mockProjectPath; - - await nextTick(); - expect(findTimeChart().props().projectPath).toBe(mockProjectPath); - }); - - it('renders a time series chart with no errors', () => { - expect(wrapper.findComponent(MonitorTimeSeriesChart).exists()).toBe(true); - }); - }); - - describe('panel timezone', () => { - it('displays a time chart in local timezone', () => { - createWrapper(); - expect(findTimeChart().props('timezone')).toBe('LOCAL'); - }); - - it('displays a heatmap in local timezone', () => { - createWrapper({ graphData: heatmapGraphData() }); - expect(wrapper.findComponent(MonitorHeatmapChart).props('timezone')).toBe('LOCAL'); - }); - - describe('when timezone is set to UTC', () => { - beforeEach(() => { - store = createStore({ dashboardTimezone: 'UTC' }); - }); - - it('displays a time chart with UTC', () => { - createWrapper(); - expect(findTimeChart().props('timezone')).toBe('UTC'); - }); - - it('displays a heatmap with UTC', () => { - createWrapper({ graphData: heatmapGraphData() }); - expect(wrapper.findComponent(MonitorHeatmapChart).props('timezone')).toBe('UTC'); - }); - }); - }); - - describe('Expand to full screen', () => { - const findExpandBtn = () => wrapper.findComponent({ ref: 'expandBtn' }); - - describe('when there is no @expand listener', () => { - it('does not show `View full screen` option', () => { - createWrapper(); - expect(findExpandBtn().exists()).toBe(false); - }); - }); - - describe('when there is an @expand listener', () => { - beforeEach(() => { - createWrapper({}, { listeners: { expand: () => {} } }); - }); - - it('shows the `expand` option', () => { - expect(findExpandBtn().exists()).toBe(true); - }); - - it('emits the `expand` event', () => { - const preventDefault = jest.fn(); - findExpandBtn().vm.$emit('click', { preventDefault }); - expect(wrapper.emitted('expand')).toHaveLength(1); - expect(preventDefault).toHaveBeenCalled(); - }); - }); - }); - - describe('When graphData contains links', () => { - const findManageLinksItem = () => wrapper.findComponent({ ref: 'manageLinksItem' }); - const mockLinks = [ - { - url: 'https://example.com', - title: 'Example 1', - }, - { - url: 'https://gitlab.com', - title: 'Example 2', - }, - ]; - const createWrapperWithLinks = (links = mockLinks) => { - createWrapper({ - graphData: { - ...graphData, - links, - }, - }); - }; - - it('custom links are shown', () => { - createWrapperWithLinks(); - - mockLinks.forEach(({ url, title }) => { - const link = findMenuItemByText(title).at(0); - - expect(link.exists()).toBe(true); - expect(link.attributes('href')).toBe(url); - }); - }); - - it("custom links don't show unsecure content", () => { - createWrapperWithLinks([ - { - title: '<script>alert("XSS")</script>', - url: 'http://example.com', - }, - ]); - - expect(findMenuItems().at(1).element.innerHTML).toBe( - '<script>alert("XSS")</script>', - ); - }); - - it("custom links don't show unsecure href attributes", () => { - const title = 'Owned!'; - - createWrapperWithLinks([ - { - title, - // eslint-disable-next-line no-script-url - url: 'javascript:alert("Evil")', - }, - ]); - - const link = findMenuItemByText(title).at(0); - expect(link.attributes('href')).toBe('#'); - }); - - it('when an editable dashboard is selected, shows `Manage chart links` link to the blob path', () => { - const editUrl = '/edit'; - mockGetterReturnValue('selectedDashboard', { - can_edit: true, - project_blob_path: editUrl, - }); - createWrapperWithLinks(); - - expect(findManageLinksItem().exists()).toBe(true); - expect(findManageLinksItem().attributes('href')).toBe(editUrl); - }); - - it('when no dashboard is selected, does not show `Manage chart links`', () => { - mockGetterReturnValue('selectedDashboard', null); - createWrapperWithLinks(); - - expect(findManageLinksItem().exists()).toBe(false); - }); - - it('when non-editable dashboard is selected, does not show `Manage chart links`', () => { - const editUrl = '/edit'; - mockGetterReturnValue('selectedDashboard', { - can_edit: false, - project_blob_path: editUrl, - }); - createWrapperWithLinks(); - - expect(findManageLinksItem().exists()).toBe(false); - }); - }); - - describe('Runbook url', () => { - const findRunbookLinks = () => wrapper.findAll('[data-testid="runbookLink"]'); - - beforeEach(() => { - mockGetterReturnValue('metricsSavedToDb', []); - }); - - it('does not show a runbook link when alerts are not present', () => { - createWrapper(); - - expect(findRunbookLinks().length).toBe(0); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js deleted file mode 100644 index d7f1d4873bb..00000000000 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ /dev/null @@ -1,784 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import VueDraggable from 'vuedraggable'; -import { nextTick } from 'vue'; -import setWindowLocation from 'helpers/set_window_location_helper'; -import { TEST_HOST } from 'helpers/test_constants'; -import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import { objectToQuery } from '~/lib/utils/url_utility'; -import Dashboard from '~/monitoring/components/dashboard.vue'; -import DashboardHeader from '~/monitoring/components/dashboard_header.vue'; -import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; -import EmptyState from '~/monitoring/components/empty_state.vue'; -import GraphGroup from '~/monitoring/components/graph_group.vue'; -import GroupEmptyState from '~/monitoring/components/group_empty_state.vue'; -import LinksSection from '~/monitoring/components/links_section.vue'; -import { dashboardEmptyStates, metricStates } from '~/monitoring/constants'; -import { createStore } from '~/monitoring/stores'; -import * as types from '~/monitoring/stores/mutation_types'; -import { - metricsDashboardViewModel, - metricsDashboardPanelCount, - dashboardProps, -} from '../fixture_data'; -import { dashboardGitResponse, storeVariables } from '../mock_data'; -import { - setupAllDashboards, - setupStoreWithDashboard, - setMetricResult, - setupStoreWithData, - setupStoreWithDataForPanelCount, - setupStoreWithLinks, -} from '../store_utils'; - -jest.mock('~/alert'); - -describe('Dashboard', () => { - let store; - let wrapper; - let mock; - - const createShallowWrapper = (props = {}, options = {}) => { - wrapper = shallowMountExtended(Dashboard, { - propsData: { ...dashboardProps, ...props }, - store, - stubs: { - DashboardHeader, - }, - ...options, - }); - }; - - const createMountedWrapper = (props = {}, options = {}) => { - wrapper = mountExtended(Dashboard, { - propsData: { ...dashboardProps, ...props }, - store, - stubs: { - 'graph-group': true, - 'dashboard-panel': true, - 'dashboard-header': DashboardHeader, - }, - ...options, - }); - }; - - beforeEach(() => { - store = createStore(); - mock = new MockAdapter(axios); - jest.spyOn(store, 'dispatch').mockResolvedValue(); - }); - - afterEach(() => { - mock.restore(); - if (store.dispatch.mockReset) { - store.dispatch.mockReset(); - } - }); - - describe('request information to the server', () => { - it('calls to set time range and fetch data', async () => { - createShallowWrapper({ hasMetrics: true }); - - await nextTick(); - expect(store.dispatch).toHaveBeenCalledWith( - 'monitoringDashboard/setTimeRange', - expect.any(Object), - ); - - expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/fetchData', undefined); - }); - - it('shows up a loading state', async () => { - store.state.monitoringDashboard.emptyState = dashboardEmptyStates.LOADING; - - createShallowWrapper({ hasMetrics: true }); - - await nextTick(); - expect(wrapper.findComponent(EmptyState).exists()).toBe(true); - expect(wrapper.findComponent(EmptyState).props('selectedState')).toBe( - dashboardEmptyStates.LOADING, - ); - }); - - it('hides the group panels when showPanels is false', async () => { - createMountedWrapper({ hasMetrics: true, showPanels: false }); - - setupStoreWithData(store); - - await nextTick(); - expect(wrapper.vm.emptyState).toBeNull(); - expect(wrapper.findAll('.prometheus-panel')).toHaveLength(0); - }); - - it('fetches the metrics data with proper time window', async () => { - createMountedWrapper({ hasMetrics: true }); - - await nextTick(); - expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/fetchData', undefined); - expect(store.dispatch).toHaveBeenCalledWith( - 'monitoringDashboard/setTimeRange', - expect.objectContaining({ duration: { seconds: 28800 } }), - ); - }); - }); - - describe('panel containers layout', () => { - const findPanelLayoutWrapperAt = (index) => { - return wrapper - .findComponent(GraphGroup) - .findAll('[data-testid="dashboard-panel-layout-wrapper"]') - .at(index); - }; - - beforeEach(async () => { - createMountedWrapper({ hasMetrics: true }); - await nextTick(); - }); - - describe('when the graph group has an even number of panels', () => { - it('2 panels - all panel wrappers take half width of their parent', async () => { - setupStoreWithDataForPanelCount(store, 2); - - await nextTick(); - expect(findPanelLayoutWrapperAt(0).classes('col-lg-6')).toBe(true); - expect(findPanelLayoutWrapperAt(1).classes('col-lg-6')).toBe(true); - }); - - it('4 panels - all panel wrappers take half width of their parent', async () => { - setupStoreWithDataForPanelCount(store, 4); - - await nextTick(); - expect(findPanelLayoutWrapperAt(0).classes('col-lg-6')).toBe(true); - expect(findPanelLayoutWrapperAt(1).classes('col-lg-6')).toBe(true); - expect(findPanelLayoutWrapperAt(2).classes('col-lg-6')).toBe(true); - expect(findPanelLayoutWrapperAt(3).classes('col-lg-6')).toBe(true); - }); - }); - - describe('when the graph group has an odd number of panels', () => { - it('1 panel - panel wrapper does not take half width of its parent', async () => { - setupStoreWithDataForPanelCount(store, 1); - - await nextTick(); - expect(findPanelLayoutWrapperAt(0).classes('col-lg-6')).toBe(false); - }); - - it('3 panels - all panels but last take half width of their parents', async () => { - setupStoreWithDataForPanelCount(store, 3); - - await nextTick(); - expect(findPanelLayoutWrapperAt(0).classes('col-lg-6')).toBe(true); - expect(findPanelLayoutWrapperAt(1).classes('col-lg-6')).toBe(true); - expect(findPanelLayoutWrapperAt(2).classes('col-lg-6')).toBe(false); - }); - - it('5 panels - all panels but last take half width of their parents', async () => { - setupStoreWithDataForPanelCount(store, 5); - - await nextTick(); - expect(findPanelLayoutWrapperAt(0).classes('col-lg-6')).toBe(true); - expect(findPanelLayoutWrapperAt(1).classes('col-lg-6')).toBe(true); - expect(findPanelLayoutWrapperAt(2).classes('col-lg-6')).toBe(true); - expect(findPanelLayoutWrapperAt(3).classes('col-lg-6')).toBe(true); - expect(findPanelLayoutWrapperAt(4).classes('col-lg-6')).toBe(false); - }); - }); - }); - - describe('dashboard validation warning', () => { - it('displays a warning if there are validation warnings', async () => { - createMountedWrapper({ hasMetrics: true }); - - store.commit( - `monitoringDashboard/${types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS}`, - true, - ); - - await nextTick(); - expect(createAlert).toHaveBeenCalled(); - }); - - it('does not display a warning if there are no validation warnings', async () => { - createMountedWrapper({ hasMetrics: true }); - - store.commit( - `monitoringDashboard/${types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS}`, - false, - ); - - await nextTick(); - expect(createAlert).not.toHaveBeenCalled(); - }); - }); - - describe('when the URL contains a reference to a panel', () => { - const location = window.location.href; - - const setSearch = (searchParams) => { - setWindowLocation(`?${objectToQuery(searchParams)}`); - }; - - afterEach(() => { - setWindowLocation(location); - }); - - it('when the URL points to a panel it expands', async () => { - const panelGroup = metricsDashboardViewModel.panelGroups[0]; - const panel = panelGroup.panels[0]; - - setSearch({ - group: panelGroup.group, - title: panel.title, - y_label: panel.y_label, - }); - - createMountedWrapper({ hasMetrics: true }); - setupStoreWithData(store); - - await nextTick(); - expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setExpandedPanel', { - group: panelGroup.group, - panel: expect.objectContaining({ - title: panel.title, - y_label: panel.y_label, - }), - }); - }); - - it('when the URL does not link to any panel, no panel is expanded', async () => { - setSearch(); - - createMountedWrapper({ hasMetrics: true }); - setupStoreWithData(store); - - await nextTick(); - expect(store.dispatch).not.toHaveBeenCalledWith( - 'monitoringDashboard/setExpandedPanel', - expect.anything(), - ); - }); - - it('when the URL points to an incorrect panel it shows an error', async () => { - const panelGroup = metricsDashboardViewModel.panelGroups[0]; - const panel = panelGroup.panels[0]; - - setSearch({ - group: panelGroup.group, - title: 'incorrect', - y_label: panel.y_label, - }); - - createMountedWrapper({ hasMetrics: true }); - setupStoreWithData(store); - - await nextTick(); - expect(createAlert).toHaveBeenCalled(); - expect(store.dispatch).not.toHaveBeenCalledWith( - 'monitoringDashboard/setExpandedPanel', - expect.anything(), - ); - }); - }); - - describe('when the panel is expanded', () => { - let group; - let panel; - - const expandPanel = (mockGroup, mockPanel) => { - store.commit(`monitoringDashboard/${types.SET_EXPANDED_PANEL}`, { - group: mockGroup, - panel: mockPanel, - }); - }; - - beforeEach(() => { - setupStoreWithData(store); - - const { panelGroups } = store.state.monitoringDashboard.dashboard; - group = panelGroups[0].group; - [panel] = panelGroups[0].panels; - - jest.spyOn(window.history, 'pushState').mockImplementation(); - }); - - afterEach(() => { - window.history.pushState.mockRestore(); - }); - - it('URL is updated with panel parameters', async () => { - createMountedWrapper({ hasMetrics: true }); - expandPanel(group, panel); - - const expectedSearch = objectToQuery({ - group, - title: panel.title, - y_label: panel.y_label, - }); - - await nextTick(); - expect(window.history.pushState).toHaveBeenCalledTimes(1); - expect(window.history.pushState).toHaveBeenCalledWith( - expect.anything(), // state - expect.any(String), // document title - expect.stringContaining(`${expectedSearch}`), - ); - }); - - it('URL is updated with panel parameters and custom dashboard', async () => { - const dashboard = 'dashboard.yml'; - - store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { - currentDashboard: dashboard, - }); - createMountedWrapper({ hasMetrics: true }); - expandPanel(group, panel); - - const expectedSearch = objectToQuery({ - dashboard, - group, - title: panel.title, - y_label: panel.y_label, - }); - - await nextTick(); - expect(window.history.pushState).toHaveBeenCalledTimes(1); - expect(window.history.pushState).toHaveBeenCalledWith( - expect.anything(), // state - expect.any(String), // document title - expect.stringContaining(`${expectedSearch}`), - ); - }); - - it('URL is updated with no parameters', async () => { - expandPanel(group, panel); - createMountedWrapper({ hasMetrics: true }); - expandPanel(null, null); - - await nextTick(); - expect(window.history.pushState).toHaveBeenCalledTimes(1); - expect(window.history.pushState).toHaveBeenCalledWith( - expect.anything(), // state - expect.any(String), // document title - expect.not.stringMatching(/group|title|y_label/), // no panel params - ); - }); - }); - - describe('when all panels in the first group are loading', () => { - const findGroupAt = (i) => wrapper.findAllComponents(GraphGroup).at(i); - - beforeEach(async () => { - setupStoreWithDashboard(store); - - const { panels } = store.state.monitoringDashboard.dashboard.panelGroups[0]; - panels.forEach(({ metrics }) => { - store.commit(`monitoringDashboard/${types.REQUEST_METRIC_RESULT}`, { - metricId: metrics[0].metricId, - }); - }); - - createShallowWrapper(); - - await nextTick(); - }); - - it('a loading icon appears in the first group', () => { - expect(findGroupAt(0).props('isLoading')).toBe(true); - }); - - it('a loading icon does not appear in the second group', () => { - expect(findGroupAt(1).props('isLoading')).toBe(false); - }); - }); - - describe('when all requests have been committed by the store', () => { - beforeEach(async () => { - store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { - currentEnvironmentName: 'production', - currentDashboard: dashboardGitResponse[0].path, - projectPath: TEST_HOST, - }); - createMountedWrapper({ hasMetrics: true }); - setupStoreWithData(store); - - await nextTick(); - }); - - it('does not show loading icons in any group', async () => { - setupStoreWithData(store); - - await nextTick(); - wrapper.findAllComponents(GraphGroup).wrappers.forEach((groupWrapper) => { - expect(groupWrapper.props('isLoading')).toBe(false); - }); - }); - }); - - describe('variables section', () => { - beforeEach(async () => { - createShallowWrapper({ hasMetrics: true }); - setupStoreWithData(store); - store.state.monitoringDashboard.variables = storeVariables; - await nextTick(); - }); - - it('shows the variables section', () => { - expect(wrapper.vm.shouldShowVariablesSection).toBe(true); - }); - }); - - describe('links section', () => { - beforeEach(async () => { - createShallowWrapper({ hasMetrics: true }); - setupStoreWithData(store); - setupStoreWithLinks(store); - await nextTick(); - }); - - it('shows the links section', () => { - expect(wrapper.vm.shouldShowLinksSection).toBe(true); - expect(wrapper.findComponent(LinksSection).exists()).toBe(true); - }); - }); - - describe('single panel expands to "full screen" mode', () => { - const findExpandedPanel = () => wrapper.findComponent({ ref: 'expandedPanel' }); - - describe('when the panel is not expanded', () => { - beforeEach(async () => { - createShallowWrapper({ hasMetrics: true }); - setupStoreWithData(store); - await nextTick(); - }); - - it('expanded panel is not visible', () => { - expect(findExpandedPanel().isVisible()).toBe(false); - }); - - it('can set a panel as expanded', () => { - const panel = wrapper.findAllComponents(DashboardPanel).at(1); - - jest.spyOn(store, 'dispatch'); - - panel.vm.$emit('expand'); - - const groupData = metricsDashboardViewModel.panelGroups[0]; - - expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setExpandedPanel', { - group: groupData.group, - panel: expect.objectContaining({ - id: groupData.panels[0].id, - }), - }); - }); - }); - - describe('when the panel is expanded', () => { - let group; - let panel; - - const MockPanel = { - template: `<div><slot name="top-left"/></div>`, - }; - - beforeEach(async () => { - createShallowWrapper({ hasMetrics: true }, { stubs: { DashboardPanel: MockPanel } }); - setupStoreWithData(store); - - const { panelGroups } = store.state.monitoringDashboard.dashboard; - - group = panelGroups[0].group; - [panel] = panelGroups[0].panels; - - store.commit(`monitoringDashboard/${types.SET_EXPANDED_PANEL}`, { - group, - panel, - }); - - jest.spyOn(store, 'dispatch'); - await nextTick(); - }); - - it('displays a single panel and others are hidden', () => { - const panels = wrapper.findAllComponents(MockPanel); - const visiblePanels = panels.filter((w) => w.isVisible()); - - expect(findExpandedPanel().isVisible()).toBe(true); - // v-show for hiding panels is more performant than v-if - // check for panels to be hidden. - expect(panels.length).toBe(metricsDashboardPanelCount + 1); - expect(visiblePanels.length).toBe(1); - }); - - it('sets a link to the expanded panel', () => { - const searchQuery = - '?dashboard=config%2Fprometheus%2Fcommon_metrics.yml&group=System%20metrics%20(Kubernetes)&title=Memory%20Usage%20(Total)&y_label=Total%20Memory%20Used%20(GB)'; - - expect(findExpandedPanel().attributes('clipboard-text')).toEqual( - expect.stringContaining(searchQuery), - ); - }); - - it('restores full dashboard by clicking `back`', () => { - wrapper.findComponent({ ref: 'goBackBtn' }).vm.$emit('click'); - - expect(store.dispatch).toHaveBeenCalledWith( - 'monitoringDashboard/clearExpandedPanel', - undefined, - ); - }); - }); - }); - - describe('when one of the metrics is missing', () => { - beforeEach(async () => { - createShallowWrapper({ hasMetrics: true }); - - setupStoreWithDashboard(store); - setMetricResult({ store, result: [], panel: 2 }); - await nextTick(); - }); - - it('shows a group empty area', () => { - const emptyGroup = wrapper.findAllComponents({ ref: 'empty-group' }); - - expect(emptyGroup).toHaveLength(1); - expect(emptyGroup.is(GroupEmptyState)).toBe(true); - }); - - it('group empty area displays a NO_DATA state', () => { - expect( - wrapper.findAllComponents({ ref: 'empty-group' }).at(0).props('selectedState'), - ).toEqual(metricStates.NO_DATA); - }); - }); - - describe('drag and drop function', () => { - const findDraggables = () => wrapper.findAllComponents(VueDraggable); - const findEnabledDraggables = () => findDraggables().filter((f) => !f.attributes('disabled')); - const findDraggablePanels = () => wrapper.findAll('.js-draggable-panel'); - const findRearrangeButton = () => wrapper.find('.js-rearrange-button'); - - const setup = async () => { - // call original dispatch - store.dispatch.mockRestore(); - - createShallowWrapper({ hasMetrics: true }); - setupStoreWithData(store); - await nextTick(); - }; - - it('wraps vuedraggable', async () => { - await setup(); - - expect(findDraggablePanels().exists()).toBe(true); - expect(findDraggablePanels().length).toEqual(metricsDashboardPanelCount); - }); - - it('is disabled by default', async () => { - await setup(); - - expect(findRearrangeButton().exists()).toBe(false); - expect(findEnabledDraggables().length).toBe(0); - }); - - describe('when rearrange is enabled', () => { - beforeEach(async () => { - // call original dispatch - store.dispatch.mockRestore(); - - createShallowWrapper({ hasMetrics: true, rearrangePanelsAvailable: true }); - setupStoreWithData(store); - - await nextTick(); - }); - - it('displays rearrange button', () => { - expect(findRearrangeButton().exists()).toBe(true); - }); - - describe('when rearrange button is clicked', () => { - const findFirstDraggableRemoveButton = () => - findDraggablePanels().at(0).find('.js-draggable-remove'); - - it('enables draggables', async () => { - findRearrangeButton().vm.$emit('click'); - await nextTick(); - - expect(findRearrangeButton().attributes('pressed')).toBe('true'); - expect(findEnabledDraggables().wrappers).toEqual(findDraggables().wrappers); - }); - - it('metrics can be swapped', async () => { - findRearrangeButton().vm.$emit('click'); - await nextTick(); - - const firstDraggable = findDraggables().at(0); - const mockMetrics = [...metricsDashboardViewModel.panelGroups[0].panels]; - - const firstTitle = mockMetrics[0].title; - const secondTitle = mockMetrics[1].title; - - // swap two elements and `input` them - [mockMetrics[0], mockMetrics[1]] = [mockMetrics[1], mockMetrics[0]]; - firstDraggable.vm.$emit('input', mockMetrics); - - await nextTick(); - - const { panels } = wrapper.vm.dashboard.panelGroups[0]; - - expect(panels[1].title).toEqual(firstTitle); - expect(panels[0].title).toEqual(secondTitle); - }); - - it('shows a remove button, which removes a panel', async () => { - findRearrangeButton().vm.$emit('click'); - await nextTick(); - - expect(findFirstDraggableRemoveButton().find('a').exists()).toBe(true); - - expect(findDraggablePanels().length).toEqual(metricsDashboardPanelCount); - await findFirstDraggableRemoveButton().trigger('click'); - - expect(findDraggablePanels().length).toEqual(metricsDashboardPanelCount - 1); - }); - - it('disables draggables when clicked again', async () => { - findRearrangeButton().vm.$emit('click'); - await nextTick(); - - findRearrangeButton().vm.$emit('click'); - await nextTick(); - expect(findRearrangeButton().attributes('pressed')).toBeUndefined(); - expect(findEnabledDraggables().length).toBe(0); - }); - }); - }); - }); - - describe('cluster health', () => { - beforeEach(async () => { - createShallowWrapper({ hasMetrics: true, showHeader: false }); - - // all_dashboards is not defined in health dashboards - store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, undefined); - await nextTick(); - }); - - it('hides dashboard header by default', () => { - expect(wrapper.findComponent({ ref: 'prometheusGraphsHeader' }).exists()).toEqual(false); - }); - - it('renders correctly', () => { - expect(wrapper.html()).not.toBe(''); - }); - }); - - describe('document title', () => { - const originalTitle = 'Original Title'; - const overviewDashboardName = dashboardGitResponse[0].display_name; - - beforeEach(() => { - document.title = originalTitle; - createShallowWrapper({ hasMetrics: true }); - }); - - afterAll(() => { - document.title = ''; - }); - - it('is prepended with the overview dashboard name by default', async () => { - setupAllDashboards(store); - - await nextTick(); - expect(document.title.startsWith(`${overviewDashboardName} · `)).toBe(true); - }); - - it('is prepended with dashboard name if path is known', async () => { - const dashboard = dashboardGitResponse[1]; - const currentDashboard = dashboard.path; - - setupAllDashboards(store, currentDashboard); - - await nextTick(); - expect(document.title.startsWith(`${dashboard.display_name} · `)).toBe(true); - }); - - it('is prepended with the overview dashboard name if path is not known', async () => { - setupAllDashboards(store, 'unknown/path'); - - await nextTick(); - expect(document.title.startsWith(`${overviewDashboardName} · `)).toBe(true); - }); - - it('is not modified when dashboard name is not provided', async () => { - const dashboard = { ...dashboardGitResponse[1], display_name: null }; - const currentDashboard = dashboard.path; - - store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, [dashboard]); - - store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { - currentDashboard, - }); - - await nextTick(); - expect(document.title).toBe(originalTitle); - }); - }); - - describe('Clipboard text in panels', () => { - const currentDashboard = dashboardGitResponse[1].path; - const panelIndex = 1; // skip expanded panel - - const getClipboardTextFirstPanel = () => - wrapper.findAllComponents(DashboardPanel).at(panelIndex).props('clipboardText'); - - beforeEach(async () => { - setupStoreWithData(store); - store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { - currentDashboard, - }); - createShallowWrapper({ hasMetrics: true }); - await nextTick(); - }); - - it('contains a link to the dashboard', () => { - const dashboardParam = `dashboard=${encodeURIComponent(currentDashboard)}`; - - expect(getClipboardTextFirstPanel()).toContain(dashboardParam); - expect(getClipboardTextFirstPanel()).toContain(`group=`); - expect(getClipboardTextFirstPanel()).toContain(`title=`); - expect(getClipboardTextFirstPanel()).toContain(`y_label=`); - }); - }); - - describe('keyboard shortcuts', () => { - const currentDashboard = dashboardGitResponse[1].path; - const panelRef = 'dashboard-panel-response-metrics-aws-elb-4-1'; // skip expanded panel - - // While the recommendation in the documentation is to test - // with a data-testid attribute, I want to make sure that - // the dashboard panels have a ref attribute set. - const getDashboardPanel = () => wrapper.findComponent({ ref: panelRef }); - - beforeEach(async () => { - setupStoreWithData(store); - store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { - currentDashboard, - }); - createShallowWrapper({ hasMetrics: true }); - - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ hoveredPanel: panelRef }); - await nextTick(); - }); - - it('contains a ref attribute inside a DashboardPanel component', () => { - const dashboardPanel = getDashboardPanel(); - - expect(dashboardPanel.exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/dashboard_template_spec.js b/spec/frontend/monitoring/components/dashboard_template_spec.js deleted file mode 100644 index 4e220d724f4..00000000000 --- a/spec/frontend/monitoring/components/dashboard_template_spec.js +++ /dev/null @@ -1,41 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; -import Dashboard from '~/monitoring/components/dashboard.vue'; -import DashboardHeader from '~/monitoring/components/dashboard_header.vue'; -import { createStore } from '~/monitoring/stores'; -import { dashboardProps } from '../fixture_data'; -import { setupAllDashboards } from '../store_utils'; - -jest.mock('~/lib/utils/url_utility'); - -describe('Dashboard template', () => { - let wrapper; - let store; - let mock; - - beforeEach(() => { - store = createStore({ - currentEnvironmentName: 'production', - }); - mock = new MockAdapter(axios); - - setupAllDashboards(store); - }); - - afterEach(() => { - mock.restore(); - }); - - it('matches the default snapshot', () => { - wrapper = shallowMount(Dashboard, { - propsData: { ...dashboardProps }, - store, - stubs: { - DashboardHeader, - }, - }); - - expect(wrapper.element).toMatchSnapshot(); - }); -}); diff --git a/spec/frontend/monitoring/components/dashboard_url_time_spec.js b/spec/frontend/monitoring/components/dashboard_url_time_spec.js deleted file mode 100644 index b123d1e7d79..00000000000 --- a/spec/frontend/monitoring/components/dashboard_url_time_spec.js +++ /dev/null @@ -1,159 +0,0 @@ -import { mount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; -import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import { - queryToObject, - redirectTo, // eslint-disable-line import/no-deprecated - removeParams, - mergeUrlParams, - updateHistory, -} from '~/lib/utils/url_utility'; - -import Dashboard from '~/monitoring/components/dashboard.vue'; -import DashboardHeader from '~/monitoring/components/dashboard_header.vue'; -import { createStore } from '~/monitoring/stores'; -import { defaultTimeRange } from '~/vue_shared/constants'; -import { dashboardProps } from '../fixture_data'; -import { mockProjectDir } from '../mock_data'; - -jest.mock('~/alert'); -jest.mock('~/lib/utils/url_utility'); - -describe('dashboard invalid url parameters', () => { - let store; - let wrapper; - let mock; - - const createMountedWrapper = (props = { hasMetrics: true }, options = {}) => { - wrapper = mount(Dashboard, { - propsData: { ...dashboardProps, ...props }, - store, - stubs: { 'graph-group': true, 'dashboard-panel': true, 'dashboard-header': DashboardHeader }, - ...options, - }); - }; - - const findDateTimePicker = () => - wrapper.findComponent(DashboardHeader).findComponent({ ref: 'dateTimePicker' }); - - beforeEach(() => { - store = createStore(); - jest.spyOn(store, 'dispatch'); - - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - queryToObject.mockReset(); - }); - - it('passes default url parameters to the time range picker', async () => { - queryToObject.mockReturnValue({}); - - createMountedWrapper(); - - await nextTick(); - expect(findDateTimePicker().props('value')).toEqual(defaultTimeRange); - - expect(store.dispatch).toHaveBeenCalledWith( - 'monitoringDashboard/setTimeRange', - expect.any(Object), - ); - expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/fetchData', undefined); - }); - - it('passes a fixed time range in the URL to the time range picker', async () => { - const params = { - start: '2019-01-01T00:00:00.000Z', - end: '2019-01-10T00:00:00.000Z', - }; - - queryToObject.mockReturnValue(params); - - createMountedWrapper(); - - await nextTick(); - expect(findDateTimePicker().props('value')).toEqual(params); - - expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setTimeRange', params); - expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/fetchData', undefined); - }); - - it('passes a rolling time range in the URL to the time range picker', async () => { - queryToObject.mockReturnValue({ - duration_seconds: '120', - }); - - createMountedWrapper(); - - await nextTick(); - const expectedTimeRange = { - duration: { seconds: 60 * 2 }, - }; - - expect(findDateTimePicker().props('value')).toMatchObject(expectedTimeRange); - - expect(store.dispatch).toHaveBeenCalledWith( - 'monitoringDashboard/setTimeRange', - expectedTimeRange, - ); - expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/fetchData', undefined); - }); - - it('shows an error message and loads a default time range if invalid url parameters are passed', async () => { - queryToObject.mockReturnValue({ - start: '<script>alert("XSS")</script>', - end: '<script>alert("XSS")</script>', - }); - - createMountedWrapper(); - - await nextTick(); - expect(createAlert).toHaveBeenCalled(); - - expect(findDateTimePicker().props('value')).toEqual(defaultTimeRange); - - expect(store.dispatch).toHaveBeenCalledWith( - 'monitoringDashboard/setTimeRange', - defaultTimeRange, - ); - expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/fetchData', undefined); - }); - - it('redirects to different time range', async () => { - const toUrl = `${mockProjectDir}/-/metrics?environment=1`; - removeParams.mockReturnValueOnce(toUrl); - - createMountedWrapper(); - - await nextTick(); - findDateTimePicker().vm.$emit('input', { - duration: { seconds: 120 }, - }); - - // redirect to with new parameters - expect(mergeUrlParams).toHaveBeenCalledWith({ duration_seconds: '120' }, toUrl); - expect(redirectTo).toHaveBeenCalledTimes(1); // eslint-disable-line import/no-deprecated - }); - - it('changes the url when a panel moves the time slider', async () => { - const timeRange = { - start: '2020-01-01T00:00:00.000Z', - end: '2020-01-01T01:00:00.000Z', - }; - - queryToObject.mockReturnValue(timeRange); - - createMountedWrapper(); - - await nextTick(); - wrapper.vm.onTimeRangeZoom(timeRange); - - expect(updateHistory).toHaveBeenCalled(); - expect(wrapper.vm.selectedTimeRange.start.toString()).toBe(timeRange.start); - expect(wrapper.vm.selectedTimeRange.end.toString()).toBe(timeRange.end); - }); -}); diff --git a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js deleted file mode 100644 index 3ccaa2d28ac..00000000000 --- a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js +++ /dev/null @@ -1,170 +0,0 @@ -import { GlDropdownItem, GlIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; - -import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue'; - -import { dashboardGitResponse } from '../mock_data'; - -const defaultBranch = 'main'; -const starredDashboards = dashboardGitResponse.filter(({ starred }) => starred); -const notStarredDashboards = dashboardGitResponse.filter(({ starred }) => !starred); - -describe('DashboardsDropdown', () => { - let wrapper; - let mockDashboards; - let mockSelectedDashboard; - - function createComponent(props, opts = {}) { - const storeOpts = { - computed: { - allDashboards: () => mockDashboards, - selectedDashboard: () => mockSelectedDashboard, - }, - }; - - wrapper = shallowMount(DashboardsDropdown, { - propsData: { - ...props, - defaultBranch, - }, - ...storeOpts, - ...opts, - }); - } - - const findItems = () => wrapper.findAllComponents(GlDropdownItem); - const findItemAt = (i) => wrapper.findAllComponents(GlDropdownItem).at(i); - const findSearchInput = () => wrapper.findComponent({ ref: 'monitorDashboardsDropdownSearch' }); - const findNoItemsMsg = () => wrapper.findComponent({ ref: 'monitorDashboardsDropdownMsg' }); - const findStarredListDivider = () => wrapper.findComponent({ ref: 'starredListDivider' }); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - const setSearchTerm = (searchTerm) => wrapper.setData({ searchTerm }); - - beforeEach(() => { - mockDashboards = dashboardGitResponse; - mockSelectedDashboard = null; - }); - - describe('when it receives dashboards data', () => { - beforeEach(() => { - createComponent(); - }); - - it('displays an item for each dashboard', () => { - expect(findItems().length).toEqual(dashboardGitResponse.length); - }); - - it('displays items with the dashboard display name, with starred dashboards first', () => { - expect(findItemAt(0).text()).toBe(starredDashboards[0].display_name); - expect(findItemAt(1).text()).toBe(notStarredDashboards[0].display_name); - expect(findItemAt(2).text()).toBe(notStarredDashboards[1].display_name); - }); - - it('displays separator between starred and not starred dashboards', () => { - expect(findStarredListDivider().exists()).toBe(true); - }); - - it('displays a search input', () => { - expect(findSearchInput().isVisible()).toBe(true); - }); - - it('hides no message text by default', () => { - expect(findNoItemsMsg().isVisible()).toBe(false); - }); - - it('filters dropdown items when searched for item exists in the list', async () => { - const searchTerm = 'Overview'; - setSearchTerm(searchTerm); - await nextTick(); - - expect(findItems()).toHaveLength(1); - }); - - it('shows no items found message when searched for item does not exists in the list', async () => { - const searchTerm = 'does-not-exist'; - setSearchTerm(searchTerm); - await nextTick(); - - expect(findNoItemsMsg().isVisible()).toBe(true); - }); - }); - - describe('when a dashboard is selected', () => { - beforeEach(() => { - [mockSelectedDashboard] = starredDashboards; - createComponent(); - }); - - it('dashboard item is selected', () => { - expect(findItemAt(0).props('isChecked')).toBe(true); - expect(findItemAt(1).props('isChecked')).toBe(false); - }); - }); - - describe('when the dashboard is missing a display name', () => { - beforeEach(() => { - mockDashboards = dashboardGitResponse.map((d) => ({ ...d, display_name: undefined })); - createComponent(); - }); - - it('displays items with the dashboard path, with starred dashboards first', () => { - expect(findItemAt(0).text()).toBe(starredDashboards[0].path); - expect(findItemAt(1).text()).toBe(notStarredDashboards[0].path); - expect(findItemAt(2).text()).toBe(notStarredDashboards[1].path); - }); - }); - - describe('when it receives starred dashboards', () => { - beforeEach(() => { - mockDashboards = starredDashboards; - createComponent(); - }); - - it('displays an item for each dashboard', () => { - expect(findItems().length).toEqual(starredDashboards.length); - }); - - it('displays a star icon', () => { - const star = findItemAt(0).findComponent(GlIcon); - expect(star.exists()).toBe(true); - expect(star.attributes('name')).toBe('star'); - }); - - it('displays no separator between starred and not starred dashboards', () => { - expect(findStarredListDivider().exists()).toBe(false); - }); - }); - - describe('when it receives only not-starred dashboards', () => { - beforeEach(() => { - mockDashboards = notStarredDashboards; - createComponent(); - }); - - it('displays an item for each dashboard', () => { - expect(findItems().length).toEqual(notStarredDashboards.length); - }); - - it('displays no star icon', () => { - const star = findItemAt(0).findComponent(GlIcon); - expect(star.exists()).toBe(false); - }); - - it('displays no separator between starred and not starred dashboards', () => { - expect(findStarredListDivider().exists()).toBe(false); - }); - }); - - describe('when a dashboard gets selected by the user', () => { - beforeEach(() => { - createComponent(); - findItemAt(1).vm.$emit('click'); - }); - - it('emits a "selectDashboard" event with dashboard information', () => { - expect(wrapper.emitted().selectDashboard[0]).toEqual([dashboardGitResponse[0]]); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js deleted file mode 100644 index b54ca926dae..00000000000 --- a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js +++ /dev/null @@ -1,166 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import DuplicateDashboardForm from '~/monitoring/components/duplicate_dashboard_form.vue'; - -import { dashboardGitResponse } from '../mock_data'; - -let wrapper; - -const createMountedWrapper = (props = {}) => { - // Use `mount` to render native input elements - wrapper = mount(DuplicateDashboardForm, { - propsData: { ...props }, - // We need to attach to document, so that `document.activeElement` is properly set in jsdom - attachTo: document.body, - }); -}; - -describe('DuplicateDashboardForm', () => { - const defaultBranch = 'main'; - - const findByRef = (ref) => wrapper.findComponent({ ref }); - const setValue = (ref, val) => { - findByRef(ref).setValue(val); - }; - const setChecked = (value) => { - const input = wrapper.find(`.custom-control-input[value="${value}"]`); - input.element.checked = true; - input.trigger('click'); - input.trigger('change'); - }; - - beforeEach(() => { - createMountedWrapper({ dashboard: dashboardGitResponse[0], defaultBranch }); - }); - - it('renders correctly', () => { - expect(wrapper.exists()).toEqual(true); - }); - - it('renders form elements', () => { - expect(findByRef('fileName').exists()).toEqual(true); - expect(findByRef('branchName').exists()).toEqual(true); - expect(findByRef('branchOption').exists()).toEqual(true); - expect(findByRef('commitMessage').exists()).toEqual(true); - }); - - describe('validates the file name', () => { - const findInvalidFeedback = () => findByRef('fileNameFormGroup').find('.invalid-feedback'); - - it('when is empty', async () => { - setValue('fileName', ''); - await nextTick(); - - expect(findByRef('fileNameFormGroup').classes()).toContain('is-valid'); - expect(findInvalidFeedback().exists()).toBe(false); - }); - - it('when is valid', async () => { - setValue('fileName', 'my_dashboard.yml'); - await nextTick(); - - expect(findByRef('fileNameFormGroup').classes()).toContain('is-valid'); - expect(findInvalidFeedback().exists()).toBe(false); - }); - - it('when is not valid', async () => { - setValue('fileName', 'my_dashboard.exe'); - await nextTick(); - - expect(findByRef('fileNameFormGroup').classes()).toContain('is-invalid'); - expect(findInvalidFeedback().text()).toBe('The file name should have a .yml extension'); - }); - }); - - describe('emits `change` event', () => { - const lastChange = () => - nextTick().then(() => { - wrapper.find('form').trigger('change'); - - // Resolves to the last emitted change - const changes = wrapper.emitted().change; - return changes[changes.length - 1][0]; - }); - - it('with the inital form values', () => { - expect(wrapper.emitted().change).toHaveLength(1); - - return expect(lastChange()).resolves.toEqual({ - branch: '', - commitMessage: expect.any(String), - dashboard: dashboardGitResponse[0].path, - fileName: 'common_metrics.yml', - }); - }); - - it('containing an inputted file name', () => { - setValue('fileName', 'my_dashboard.yml'); - - return expect(lastChange()).resolves.toMatchObject({ - fileName: 'my_dashboard.yml', - }); - }); - - it('containing a default commit message when no message is set', () => { - setValue('commitMessage', ''); - - return expect(lastChange()).resolves.toMatchObject({ - commitMessage: expect.stringContaining('Create custom dashboard'), - }); - }); - - it('containing an inputted commit message', () => { - setValue('commitMessage', 'My commit message'); - - return expect(lastChange()).resolves.toMatchObject({ - commitMessage: expect.stringContaining('My commit message'), - }); - }); - - it('containing an inputted branch name', () => { - setValue('branchName', 'a-new-branch'); - - return expect(lastChange()).resolves.toMatchObject({ - branch: 'a-new-branch', - }); - }); - - it('when a `default` branch option is set, branch input is invisible and ignored', () => { - setChecked(wrapper.vm.$options.radioVals.DEFAULT); - setValue('branchName', 'a-new-branch'); - - return Promise.all([ - expect(lastChange()).resolves.toMatchObject({ - branch: defaultBranch, - }), - nextTick(() => { - expect(findByRef('branchName').isVisible()).toBe(false); - }), - ]); - }); - - it('when `new` branch option is chosen, focuses on the branch name input', async () => { - setChecked(wrapper.vm.$options.radioVals.NEW); - - await nextTick(); - - wrapper.find('form').trigger('change'); - expect(document.activeElement).toBe(findByRef('branchName').element); - }); - }); -}); - -describe('DuplicateDashboardForm escapes elements', () => { - const branchToEscape = "<img/src='x'onerror=alert(document.domain)>"; - - beforeEach(() => { - createMountedWrapper({ dashboard: dashboardGitResponse[0], defaultBranch: branchToEscape }); - }); - - it('should escape branch name data', () => { - const branchOptionHtml = wrapper.vm.branchOptions[0].html; - const escapedBranch = '<img/src='x'onerror=alert(document.domain)>'; - - expect(branchOptionHtml).toEqual(expect.stringContaining(escapedBranch)); - }); -}); diff --git a/spec/frontend/monitoring/components/duplicate_dashboard_modal_spec.js b/spec/frontend/monitoring/components/duplicate_dashboard_modal_spec.js deleted file mode 100644 index d83a9192876..00000000000 --- a/spec/frontend/monitoring/components/duplicate_dashboard_modal_spec.js +++ /dev/null @@ -1,110 +0,0 @@ -import { GlAlert, GlLoadingIcon, GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; - -import waitForPromises from 'helpers/wait_for_promises'; - -import DuplicateDashboardForm from '~/monitoring/components/duplicate_dashboard_form.vue'; -import DuplicateDashboardModal from '~/monitoring/components/duplicate_dashboard_modal.vue'; - -import { dashboardGitResponse } from '../mock_data'; - -Vue.use(Vuex); - -describe('duplicate dashboard modal', () => { - let wrapper; - let mockDashboards; - let mockSelectedDashboard; - let duplicateDashboardAction; - let okEvent; - - function createComponent() { - const store = new Vuex.Store({ - modules: { - monitoringDashboard: { - namespaced: true, - actions: { - duplicateSystemDashboard: duplicateDashboardAction, - }, - getters: { - allDashboards: () => mockDashboards, - selectedDashboard: () => mockSelectedDashboard, - }, - }, - }, - }); - - return shallowMount(DuplicateDashboardModal, { - propsData: { - defaultBranch: 'main', - modalId: 'id', - }, - store, - }); - } - - const findAlert = () => wrapper.findComponent(GlAlert); - const findModal = () => wrapper.findComponent(GlModal); - const findDuplicateDashboardForm = () => wrapper.findComponent(DuplicateDashboardForm); - - beforeEach(() => { - mockDashboards = dashboardGitResponse; - [mockSelectedDashboard] = dashboardGitResponse; - - duplicateDashboardAction = jest.fn().mockResolvedValue(); - - okEvent = { - preventDefault: jest.fn(), - }; - - wrapper = createComponent(); - - wrapper.vm.$refs.duplicateDashboardModal.hide = jest.fn(); - }); - - it('contains a form to duplicate a dashboard', () => { - expect(findDuplicateDashboardForm().exists()).toBe(true); - }); - - it('saves a new dashboard', async () => { - findModal().vm.$emit('ok', okEvent); - - await waitForPromises(); - expect(okEvent.preventDefault).toHaveBeenCalled(); - expect(wrapper.emitted('dashboardDuplicated')).toHaveLength(1); - expect(wrapper.emitted().dashboardDuplicated[0]).toEqual([dashboardGitResponse[0]]); - expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); - expect(wrapper.vm.$refs.duplicateDashboardModal.hide).toHaveBeenCalled(); - expect(findAlert().exists()).toBe(false); - }); - - it('handles error when a new dashboard is not saved', async () => { - const errMsg = 'An error occurred'; - - duplicateDashboardAction.mockRejectedValueOnce(errMsg); - findModal().vm.$emit('ok', okEvent); - - await waitForPromises(); - - expect(okEvent.preventDefault).toHaveBeenCalled(); - - expect(findAlert().exists()).toBe(true); - expect(findAlert().text()).toBe(errMsg); - - expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); - expect(wrapper.vm.$refs.duplicateDashboardModal.hide).not.toHaveBeenCalled(); - }); - - it('updates the form on changes', () => { - const formVals = { - dashboard: 'common_metrics.yml', - commitMessage: 'A commit message', - }; - - findModal().findComponent(DuplicateDashboardForm).vm.$emit('change', formVals); - - // Binding's second argument contains the modal id - expect(wrapper.vm.form).toEqual(formVals); - }); -}); diff --git a/spec/frontend/monitoring/components/embeds/embed_group_spec.js b/spec/frontend/monitoring/components/embeds/embed_group_spec.js deleted file mode 100644 index beb698c838f..00000000000 --- a/spec/frontend/monitoring/components/embeds/embed_group_spec.js +++ /dev/null @@ -1,157 +0,0 @@ -import { GlButton, GlCard } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import Vuex from 'vuex'; -import { TEST_HOST } from 'helpers/test_constants'; -import EmbedGroup from '~/monitoring/components/embeds/embed_group.vue'; -import MetricEmbed from '~/monitoring/components/embeds/metric_embed.vue'; -import { - addModuleAction, - initialEmbedGroupState, - singleEmbedProps, - dashboardEmbedProps, - multipleEmbedProps, -} from './mock_data'; - -Vue.use(Vuex); - -describe('Embed Group', () => { - let wrapper; - let store; - const metricsWithDataGetter = jest.fn(); - - function mountComponent({ urls = [TEST_HOST], shallow = true, stubs } = {}) { - const mountMethod = shallow ? shallowMount : mount; - wrapper = mountMethod(EmbedGroup, { - store, - propsData: { - urls, - }, - stubs, - }); - } - - beforeEach(() => { - store = new Vuex.Store({ - modules: { - embedGroup: { - namespaced: true, - actions: { addModule: jest.fn() }, - getters: { metricsWithData: metricsWithDataGetter }, - state: initialEmbedGroupState, - }, - }, - }); - store.registerModule = jest.fn(); - jest.spyOn(store, 'dispatch'); - }); - - afterEach(() => { - metricsWithDataGetter.mockReset(); - }); - - describe('interactivity', () => { - it('hides the component when no chart data is loaded', () => { - metricsWithDataGetter.mockReturnValue([]); - mountComponent(); - - expect(wrapper.findComponent(GlCard).isVisible()).toBe(false); - }); - - it('shows the component when chart data is loaded', () => { - metricsWithDataGetter.mockReturnValue([1]); - mountComponent(); - - expect(wrapper.findComponent(GlCard).isVisible()).toBe(true); - }); - - it('is expanded by default', () => { - metricsWithDataGetter.mockReturnValue([1]); - mountComponent({ shallow: false, stubs: { MetricEmbed: true } }); - - expect(wrapper.find('.gl-card-body').classes()).not.toContain('d-none'); - }); - - it('collapses when clicked', async () => { - metricsWithDataGetter.mockReturnValue([1]); - mountComponent({ shallow: false, stubs: { MetricEmbed: true } }); - - wrapper.findComponent(GlButton).trigger('click'); - - await nextTick(); - expect(wrapper.find('.gl-card-body').classes()).toContain('d-none'); - }); - }); - - describe('single metrics', () => { - beforeEach(() => { - metricsWithDataGetter.mockReturnValue([1]); - mountComponent(); - }); - - it('renders an Embed component', () => { - expect(wrapper.findComponent(MetricEmbed).exists()).toBe(true); - }); - - it('passes the correct props to the Embed component', () => { - expect(wrapper.findComponent(MetricEmbed).props()).toEqual(singleEmbedProps()); - }); - - it('adds the monitoring dashboard module', () => { - expect(store.dispatch).toHaveBeenCalledWith(addModuleAction, 'monitoringDashboard/0'); - }); - }); - - describe('dashboard metrics', () => { - beforeEach(() => { - metricsWithDataGetter.mockReturnValue([2]); - mountComponent(); - }); - - it('passes the correct props to the dashboard Embed component', () => { - expect(wrapper.findComponent(MetricEmbed).props()).toEqual(dashboardEmbedProps()); - }); - - it('adds the monitoring dashboard module', () => { - expect(store.dispatch).toHaveBeenCalledWith(addModuleAction, 'monitoringDashboard/0'); - }); - }); - - describe('multiple metrics', () => { - beforeEach(() => { - metricsWithDataGetter.mockReturnValue([1, 1]); - mountComponent({ urls: [TEST_HOST, TEST_HOST] }); - }); - - it('creates Embed components', () => { - expect(wrapper.findAllComponents(MetricEmbed)).toHaveLength(2); - }); - - it('passes the correct props to the Embed components', () => { - expect(wrapper.findAllComponents(MetricEmbed).wrappers.map((item) => item.props())).toEqual( - multipleEmbedProps(), - ); - }); - - it('adds multiple monitoring dashboard modules', () => { - expect(store.dispatch).toHaveBeenCalledWith(addModuleAction, 'monitoringDashboard/0'); - expect(store.dispatch).toHaveBeenCalledWith(addModuleAction, 'monitoringDashboard/1'); - }); - }); - - describe('button text', () => { - it('has a singular label when there is one embed', () => { - metricsWithDataGetter.mockReturnValue([1]); - mountComponent({ shallow: false, stubs: { MetricEmbed: true } }); - - expect(wrapper.findComponent(GlButton).text()).toBe('Hide chart'); - }); - - it('has a plural label when there are multiple embeds', () => { - metricsWithDataGetter.mockReturnValue([2]); - mountComponent({ shallow: false, stubs: { MetricEmbed: true } }); - - expect(wrapper.findComponent(GlButton).text()).toBe('Hide charts'); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/embeds/metric_embed_spec.js b/spec/frontend/monitoring/components/embeds/metric_embed_spec.js deleted file mode 100644 index db25d524592..00000000000 --- a/spec/frontend/monitoring/components/embeds/metric_embed_spec.js +++ /dev/null @@ -1,100 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; -import { setHTMLFixture } from 'helpers/fixtures'; -import { TEST_HOST } from 'helpers/test_constants'; -import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; -import MetricEmbed from '~/monitoring/components/embeds/metric_embed.vue'; -import { groups, initialState, metricsData, metricsWithData } from './mock_data'; - -Vue.use(Vuex); - -describe('MetricEmbed', () => { - let wrapper; - let store; - let actions; - let metricsWithDataGetter; - - function mountComponent() { - wrapper = shallowMount(MetricEmbed, { - store, - propsData: { - dashboardUrl: TEST_HOST, - }, - }); - } - - beforeEach(() => { - setHTMLFixture('<div class="layout-page"></div>'); - - actions = { - setInitialState: jest.fn(), - setShowErrorBanner: jest.fn(), - setTimeRange: jest.fn(), - fetchDashboard: jest.fn(), - }; - - metricsWithDataGetter = jest.fn(); - - store = new Vuex.Store({ - modules: { - monitoringDashboard: { - namespaced: true, - actions, - getters: { - metricsWithData: () => metricsWithDataGetter, - }, - state: initialState, - }, - }, - }); - }); - - afterEach(() => { - metricsWithDataGetter.mockClear(); - }); - - describe('no metrics are available yet', () => { - beforeEach(() => { - mountComponent(); - }); - - it('shows an empty state when no metrics are present', () => { - expect(wrapper.find('.metrics-embed').exists()).toBe(true); - expect(wrapper.findComponent(DashboardPanel).exists()).toBe(false); - }); - }); - - describe('metrics are available', () => { - beforeEach(() => { - store.state.monitoringDashboard.dashboard.panelGroups = groups; - store.state.monitoringDashboard.dashboard.panelGroups[0].panels = metricsData; - - metricsWithDataGetter.mockReturnValue(metricsWithData); - - mountComponent(); - }); - - it('calls actions to fetch data', () => { - const expectedTimeRangePayload = expect.objectContaining({ - start: expect.any(String), - end: expect.any(String), - }); - - expect(actions.setTimeRange).toHaveBeenCalledTimes(1); - expect(actions.setTimeRange.mock.calls[0][1]).toEqual(expectedTimeRangePayload); - - expect(actions.fetchDashboard).toHaveBeenCalled(); - }); - - it('shows a chart when metrics are present', () => { - expect(wrapper.find('.metrics-embed').exists()).toBe(true); - expect(wrapper.findComponent(DashboardPanel).exists()).toBe(true); - expect(wrapper.findAllComponents(DashboardPanel).length).toBe(2); - }); - - it('includes groupId with dashboardUrl', () => { - expect(wrapper.findComponent(DashboardPanel).props('groupId')).toBe(TEST_HOST); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/embeds/mock_data.js b/spec/frontend/monitoring/components/embeds/mock_data.js deleted file mode 100644 index e32e1a08cdb..00000000000 --- a/spec/frontend/monitoring/components/embeds/mock_data.js +++ /dev/null @@ -1,86 +0,0 @@ -import { TEST_HOST } from 'helpers/test_constants'; - -export const metricsWithData = ['15_metric_a', '16_metric_b']; - -export const groups = [ - { - panels: [ - { - title: 'Memory Usage (Total)', - type: 'area-chart', - y_label: 'Total Memory Used', - metrics: null, - }, - ], - }, -]; - -const result = [ - { - values: [ - ['Mon', 1220], - ['Tue', 932], - ['Wed', 901], - ['Thu', 934], - ['Fri', 1290], - ['Sat', 1330], - ['Sun', 1320], - ], - }, -]; - -export const metricsData = [ - { - metrics: [ - { - metricId: '15_metric_a', - result, - }, - ], - }, - { - metrics: [ - { - metricId: '16_metric_b', - result, - }, - ], - }, -]; - -export const initialState = () => ({ - dashboard: { - panel_groups: [], - }, -}); - -export const initialEmbedGroupState = () => ({ - modules: [], -}); - -export const singleEmbedProps = () => ({ - dashboardUrl: TEST_HOST, - containerClass: 'col-lg-12', - namespace: 'monitoringDashboard/0', -}); - -export const dashboardEmbedProps = () => ({ - dashboardUrl: TEST_HOST, - containerClass: 'col-lg-6', - namespace: 'monitoringDashboard/0', -}); - -export const multipleEmbedProps = () => [ - { - dashboardUrl: TEST_HOST, - containerClass: 'col-lg-6', - namespace: 'monitoringDashboard/0', - }, - { - dashboardUrl: TEST_HOST, - containerClass: 'col-lg-6', - namespace: 'monitoringDashboard/1', - }, -]; - -export const addModuleAction = 'embedGroup/addModule'; diff --git a/spec/frontend/monitoring/components/empty_state_spec.js b/spec/frontend/monitoring/components/empty_state_spec.js deleted file mode 100644 index ddefa8c5cd0..00000000000 --- a/spec/frontend/monitoring/components/empty_state_spec.js +++ /dev/null @@ -1,55 +0,0 @@ -import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import EmptyState from '~/monitoring/components/empty_state.vue'; -import { dashboardEmptyStates } from '~/monitoring/constants'; - -function createComponent(props) { - return shallowMount(EmptyState, { - propsData: { - settingsPath: '/settingsPath', - clustersPath: '/clustersPath', - documentationPath: '/documentationPath', - emptyGettingStartedSvgPath: '/path/to/getting-started.svg', - emptyLoadingSvgPath: '/path/to/loading.svg', - emptyNoDataSvgPath: '/path/to/no-data.svg', - emptyNoDataSmallSvgPath: '/path/to/no-data-small.svg', - emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg', - ...props, - }, - }); -} - -describe('EmptyState', () => { - it('shows loading state with a loading icon', () => { - const wrapper = createComponent({ - selectedState: dashboardEmptyStates.LOADING, - }); - - expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); - expect(wrapper.findComponent(GlEmptyState).exists()).toBe(false); - }); - - it('shows gettingStarted state', () => { - const wrapper = createComponent({ - selectedState: dashboardEmptyStates.GETTING_STARTED, - }); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('shows unableToConnect state', () => { - const wrapper = createComponent({ - selectedState: dashboardEmptyStates.UNABLE_TO_CONNECT, - }); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('shows noData state', () => { - const wrapper = createComponent({ - selectedState: dashboardEmptyStates.NO_DATA, - }); - - expect(wrapper.element).toMatchSnapshot(); - }); -}); diff --git a/spec/frontend/monitoring/components/graph_group_spec.js b/spec/frontend/monitoring/components/graph_group_spec.js deleted file mode 100644 index 593d832f297..00000000000 --- a/spec/frontend/monitoring/components/graph_group_spec.js +++ /dev/null @@ -1,144 +0,0 @@ -import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import GraphGroup from '~/monitoring/components/graph_group.vue'; - -describe('Graph group component', () => { - let wrapper; - - const findGroup = () => wrapper.findComponent({ ref: 'graph-group' }); - const findContent = () => wrapper.findComponent({ ref: 'graph-group-content' }); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findCaretIcon = () => wrapper.findComponent(GlIcon); - const findToggleButton = () => wrapper.find('[data-testid="group-toggle-button"]'); - - const createComponent = (propsData) => { - wrapper = shallowMount(GraphGroup, { - propsData, - }); - }; - - describe('When group is not collapsed', () => { - beforeEach(() => { - createComponent({ - name: 'panel', - collapseGroup: false, - }); - }); - - it('should not show a loading icon', () => { - expect(findLoadingIcon().exists()).toBe(false); - }); - - it('should show the chevron-lg-down caret icon', () => { - expect(findContent().isVisible()).toBe(true); - expect(findCaretIcon().props('name')).toBe('chevron-lg-down'); - }); - - it('should show the chevron-lg-right caret icon when the user collapses the group', async () => { - findToggleButton().trigger('click'); - - await nextTick(); - expect(findContent().isVisible()).toBe(false); - expect(findCaretIcon().props('name')).toBe('chevron-lg-right'); - }); - - it('should contain a tab index for the collapse button', () => { - const groupToggle = findToggleButton(); - - expect(groupToggle.attributes('tabindex')).toBeDefined(); - }); - - it('should show the open the group when collapseGroup is set to true', async () => { - wrapper.setProps({ - collapseGroup: true, - }); - - await nextTick(); - expect(findContent().isVisible()).toBe(true); - expect(findCaretIcon().props('name')).toBe('chevron-lg-down'); - }); - }); - - describe('When group is collapsed', () => { - beforeEach(() => { - createComponent({ - name: 'panel', - collapseGroup: true, - }); - }); - - it('should show the chevron-lg-down caret icon when collapseGroup is true', () => { - expect(findCaretIcon().props('name')).toBe('chevron-lg-right'); - }); - - it('should show the chevron-lg-right caret icon when collapseGroup is false', async () => { - findToggleButton().trigger('click'); - - await nextTick(); - expect(findCaretIcon().props('name')).toBe('chevron-lg-down'); - }); - - it('should call collapse the graph group content when enter is pressed on the caret icon', () => { - const graphGroupContent = findContent(); - const button = findToggleButton(); - - button.trigger('keyup.enter'); - - expect(graphGroupContent.isVisible()).toBe(false); - }); - }); - - describe('When groups can not be collapsed', () => { - beforeEach(() => { - createComponent({ - name: 'panel', - showPanels: false, - collapseGroup: false, - }); - }); - - it('should not have a container when showPanels is false', () => { - expect(findGroup().exists()).toBe(false); - expect(findContent().exists()).toBe(true); - }); - }); - - describe('When group is loading', () => { - beforeEach(() => { - createComponent({ - name: 'panel', - isLoading: true, - }); - }); - - it('should show a loading icon', () => { - expect(findLoadingIcon().exists()).toBe(true); - }); - }); - - describe('When group does not show a panel heading', () => { - beforeEach(() => { - createComponent({ - name: 'panel', - showPanels: false, - collapseGroup: false, - }); - }); - - it('should collapse the panel content', () => { - expect(findContent().isVisible()).toBe(true); - expect(findCaretIcon().exists()).toBe(false); - }); - - it('should show the panel content when collapse is set to false', async () => { - wrapper.setProps({ - collapseGroup: false, - }); - - await nextTick(); - expect(findContent().isVisible()).toBe(true); - expect(findCaretIcon().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/group_empty_state_spec.js b/spec/frontend/monitoring/components/group_empty_state_spec.js deleted file mode 100644 index d3a48be7939..00000000000 --- a/spec/frontend/monitoring/components/group_empty_state_spec.js +++ /dev/null @@ -1,47 +0,0 @@ -import { GlEmptyState } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { stubComponent } from 'helpers/stub_component'; -import GroupEmptyState from '~/monitoring/components/group_empty_state.vue'; -import { metricStates } from '~/monitoring/constants'; - -function createComponent(props) { - return shallowMount(GroupEmptyState, { - propsData: { - ...props, - documentationPath: '/path/to/docs', - settingsPath: '/path/to/settings', - svgPath: '/path/to/empty-group-illustration.svg', - }, - stubs: { - GlEmptyState: stubComponent(GlEmptyState, { - template: '<div><slot name="description"></slot></div>', - }), - }, - }); -} - -describe('GroupEmptyState', () => { - let wrapper; - - describe.each([ - metricStates.NO_DATA, - metricStates.TIMEOUT, - metricStates.CONNECTION_FAILED, - metricStates.BAD_QUERY, - metricStates.LOADING, - metricStates.UNKNOWN_ERROR, - 'FOO STATE', // does not fail with unknown states - ])('given state %s', (selectedState) => { - beforeEach(() => { - wrapper = createComponent({ selectedState }); - }); - - it('renders the slotted content', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - - it('passes the expected props to GlEmptyState', () => { - expect(wrapper.findComponent(GlEmptyState).props()).toMatchSnapshot(); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/links_section_spec.js b/spec/frontend/monitoring/components/links_section_spec.js deleted file mode 100644 index 94938e7f459..00000000000 --- a/spec/frontend/monitoring/components/links_section_spec.js +++ /dev/null @@ -1,64 +0,0 @@ -import { GlLink } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; - -import LinksSection from '~/monitoring/components/links_section.vue'; -import { createStore } from '~/monitoring/stores'; - -describe('Links Section component', () => { - let store; - let wrapper; - - const createShallowWrapper = () => { - wrapper = shallowMount(LinksSection, { - store, - }); - }; - const setState = (links) => { - store.state.monitoringDashboard = { - ...store.state.monitoringDashboard, - emptyState: null, - links, - }; - }; - const findLinks = () => wrapper.findAllComponents(GlLink); - - beforeEach(() => { - store = createStore(); - createShallowWrapper(); - }); - - it('does not render a section if no links are present', async () => { - setState(); - - await nextTick(); - - expect(findLinks().length).toBe(0); - }); - - it('renders a link inside a section', async () => { - setState([ - { - title: 'GitLab Website', - url: 'https://gitlab.com', - }, - ]); - - await nextTick(); - expect(findLinks()).toHaveLength(1); - const firstLink = findLinks().at(0); - - expect(firstLink.attributes('href')).toBe('https://gitlab.com'); - expect(firstLink.text()).toBe('GitLab Website'); - }); - - it('renders multiple links inside a section', async () => { - const links = new Array(10) - .fill(null) - .map((_, i) => ({ title: `Title ${i}`, url: `https://gitlab.com/projects/${i}` })); - setState(links); - - await nextTick(); - expect(findLinks()).toHaveLength(10); - }); -}); diff --git a/spec/frontend/monitoring/components/refresh_button_spec.js b/spec/frontend/monitoring/components/refresh_button_spec.js deleted file mode 100644 index f6cc6789b1f..00000000000 --- a/spec/frontend/monitoring/components/refresh_button_spec.js +++ /dev/null @@ -1,139 +0,0 @@ -import { GlDropdown, GlDropdownItem, GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Visibility from 'visibilityjs'; -import { nextTick } from 'vue'; -import RefreshButton from '~/monitoring/components/refresh_button.vue'; -import { createStore } from '~/monitoring/stores'; - -describe('RefreshButton', () => { - let wrapper; - let store; - let dispatch; - let documentHidden; - - const createWrapper = (options = {}) => { - wrapper = shallowMount(RefreshButton, { store, ...options }); - }; - - const findRefreshBtn = () => wrapper.findComponent(GlButton); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findOptions = () => findDropdown().findAllComponents(GlDropdownItem); - const findOptionAt = (index) => findOptions().at(index); - - const expectFetchDataToHaveBeenCalledTimes = (times) => { - const refreshCalls = dispatch.mock.calls.filter(([action, payload]) => { - return action === 'monitoringDashboard/fetchDashboardData' && payload === undefined; - }); - expect(refreshCalls).toHaveLength(times); - }; - - beforeEach(() => { - store = createStore(); - jest.spyOn(store, 'dispatch').mockResolvedValue(); - dispatch = store.dispatch; - - documentHidden = false; - jest.spyOn(Visibility, 'hidden').mockImplementation(() => documentHidden); - - createWrapper(); - }); - - afterEach(() => { - dispatch.mockReset(); - // eslint-disable-next-line @gitlab/vtu-no-explicit-wrapper-destroy - wrapper.destroy(); - }); - - it('refreshes data when "refresh" is clicked', () => { - findRefreshBtn().vm.$emit('click'); - expectFetchDataToHaveBeenCalledTimes(1); - }); - - it('refresh rate is "Off" in the dropdown', () => { - expect(findDropdown().props('text')).toBe('Off'); - }); - - describe('refresh rate options', () => { - it('presents multiple options', () => { - expect(findOptions().length).toBeGreaterThan(1); - }); - - it('presents an "Off" option as the default option', () => { - expect(findOptionAt(0).text()).toBe('Off'); - expect(findOptionAt(0).props('isChecked')).toBe(true); - }); - }); - - describe('when a refresh rate is chosen', () => { - const optIndex = 2; // Other option than "Off" - - beforeEach(async () => { - findOptionAt(optIndex).vm.$emit('click'); - await nextTick(); - }); - - it('refresh rate appears in the dropdown', () => { - expect(findDropdown().props('text')).toBe('10s'); - }); - - it('refresh rate option is checked', () => { - expect(findOptionAt(0).props('isChecked')).toBe(false); - expect(findOptionAt(optIndex).props('isChecked')).toBe(true); - }); - - it('refreshes data when a new refresh rate is chosen', () => { - expectFetchDataToHaveBeenCalledTimes(1); - }); - - it('refreshes data after two intervals of time have passed', async () => { - jest.runOnlyPendingTimers(); - expectFetchDataToHaveBeenCalledTimes(2); - - await nextTick(); - - jest.runOnlyPendingTimers(); - expectFetchDataToHaveBeenCalledTimes(3); - }); - - it('does not refresh data if the document is hidden', async () => { - documentHidden = true; - - jest.runOnlyPendingTimers(); - expectFetchDataToHaveBeenCalledTimes(1); - - await nextTick(); - - jest.runOnlyPendingTimers(); - expectFetchDataToHaveBeenCalledTimes(1); - }); - - it('data is not refreshed anymore after component is destroyed', () => { - expect(jest.getTimerCount()).toBe(1); - - wrapper.destroy(); - - expect(jest.getTimerCount()).toBe(0); - }); - - describe('when "Off" refresh rate is chosen', () => { - beforeEach(async () => { - findOptionAt(0).vm.$emit('click'); - await nextTick(); - }); - - it('refresh rate is "Off" in the dropdown', () => { - expect(findDropdown().props('text')).toBe('Off'); - }); - - it('refresh rate option is appears selected', () => { - expect(findOptionAt(0).props('isChecked')).toBe(true); - expect(findOptionAt(optIndex).props('isChecked')).toBe(false); - }); - - it('stops refreshing data', () => { - jest.runOnlyPendingTimers(); - expectFetchDataToHaveBeenCalledTimes(1); - }); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/variables/dropdown_field_spec.js b/spec/frontend/monitoring/components/variables/dropdown_field_spec.js deleted file mode 100644 index e6c5569fa19..00000000000 --- a/spec/frontend/monitoring/components/variables/dropdown_field_spec.js +++ /dev/null @@ -1,62 +0,0 @@ -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import DropdownField from '~/monitoring/components/variables/dropdown_field.vue'; - -describe('Custom variable component', () => { - let wrapper; - - const defaultProps = { - name: 'env', - label: 'Select environment', - value: 'Production', - options: { - values: [ - { text: 'Production', value: 'prod' }, - { text: 'Canary', value: 'canary' }, - ], - }, - }; - - const createShallowWrapper = (props) => { - wrapper = shallowMount(DropdownField, { - propsData: { - ...defaultProps, - ...props, - }, - }); - }; - - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - - it('renders dropdown element when all necessary props are passed', () => { - createShallowWrapper(); - - expect(findDropdown().exists()).toBe(true); - }); - - it('renders dropdown element with a text', () => { - createShallowWrapper(); - - expect(findDropdown().attributes('text')).toBe(defaultProps.value); - }); - - it('renders all the dropdown items', () => { - createShallowWrapper(); - - expect(findDropdownItems()).toHaveLength(defaultProps.options.values.length); - }); - - it('renders dropdown when values are missing', () => { - createShallowWrapper({ options: {} }); - - expect(findDropdown().exists()).toBe(true); - }); - - it('changing dropdown items triggers update', () => { - createShallowWrapper(); - findDropdownItems().at(1).vm.$emit('click'); - - expect(wrapper.emitted('input')).toEqual([['canary']]); - }); -}); diff --git a/spec/frontend/monitoring/components/variables/text_field_spec.js b/spec/frontend/monitoring/components/variables/text_field_spec.js deleted file mode 100644 index 20e1937c5ac..00000000000 --- a/spec/frontend/monitoring/components/variables/text_field_spec.js +++ /dev/null @@ -1,55 +0,0 @@ -import { GlFormInput } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import TextField from '~/monitoring/components/variables/text_field.vue'; - -describe('Text variable component', () => { - let wrapper; - const propsData = { - name: 'pod', - label: 'Select pod', - value: 'test-pod', - }; - const createShallowWrapper = () => { - wrapper = shallowMount(TextField, { - propsData, - }); - }; - - const findInput = () => wrapper.findComponent(GlFormInput); - - it('renders a text input when all props are passed', () => { - createShallowWrapper(); - - expect(findInput().exists()).toBe(true); - }); - - it('always has a default value', async () => { - createShallowWrapper(); - - await nextTick(); - expect(findInput().attributes('value')).toBe(propsData.value); - }); - - it('triggers keyup enter', async () => { - createShallowWrapper(); - - findInput().element.value = 'prod-pod'; - findInput().trigger('input'); - findInput().trigger('keyup.enter'); - - await nextTick(); - expect(wrapper.emitted('input')).toEqual([['prod-pod']]); - }); - - it('triggers blur enter', async () => { - createShallowWrapper(); - - findInput().element.value = 'canary-pod'; - findInput().trigger('input'); - findInput().trigger('blur'); - - await nextTick(); - expect(wrapper.emitted('input')).toEqual([['canary-pod']]); - }); -}); diff --git a/spec/frontend/monitoring/components/variables_section_spec.js b/spec/frontend/monitoring/components/variables_section_spec.js deleted file mode 100644 index d6f8aac99aa..00000000000 --- a/spec/frontend/monitoring/components/variables_section_spec.js +++ /dev/null @@ -1,125 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Vuex from 'vuex'; -import { nextTick } from 'vue'; -import { updateHistory, mergeUrlParams } from '~/lib/utils/url_utility'; -import DropdownField from '~/monitoring/components/variables/dropdown_field.vue'; -import TextField from '~/monitoring/components/variables/text_field.vue'; -import VariablesSection from '~/monitoring/components/variables_section.vue'; -import { createStore } from '~/monitoring/stores'; -import { convertVariablesForURL } from '~/monitoring/utils'; -import { storeVariables } from '../mock_data'; - -jest.mock('~/lib/utils/url_utility', () => ({ - updateHistory: jest.fn(), - mergeUrlParams: jest.fn(), -})); - -describe('Metrics dashboard/variables section component', () => { - let store; - let wrapper; - - const createShallowWrapper = () => { - wrapper = shallowMount(VariablesSection, { - store, - }); - }; - - const findTextInputs = () => wrapper.findAllComponents(TextField); - const findCustomInputs = () => wrapper.findAllComponents(DropdownField); - - beforeEach(() => { - store = createStore(); - - store.state.monitoringDashboard.emptyState = null; - }); - - it('does not show the variables section', () => { - createShallowWrapper(); - const allInputs = findTextInputs().length + findCustomInputs().length; - - expect(allInputs).toBe(0); - }); - - describe('when variables are set', () => { - beforeEach(async () => { - store.state.monitoringDashboard.variables = storeVariables; - createShallowWrapper(); - - await nextTick(); - }); - - it('shows the variables section', () => { - const allInputs = findTextInputs().length + findCustomInputs().length; - - expect(allInputs).toBe(storeVariables.length); - }); - - it('shows the right custom variable inputs', () => { - const customInputs = findCustomInputs(); - - expect(customInputs.at(0).props('name')).toBe('customSimple'); - expect(customInputs.at(1).props('name')).toBe('customAdvanced'); - }); - }); - - describe('when changing the variable inputs', () => { - const updateVariablesAndFetchData = jest.fn(); - - beforeEach(() => { - store = new Vuex.Store({ - modules: { - monitoringDashboard: { - namespaced: true, - state: { - emptyState: null, - variables: storeVariables, - }, - actions: { - updateVariablesAndFetchData, - }, - }, - }, - }); - - createShallowWrapper(); - }); - - it('merges the url params and refreshes the dashboard when a text-based variables inputs are updated', async () => { - const firstInput = findTextInputs().at(0); - - firstInput.vm.$emit('input', 'test'); - - await nextTick(); - expect(updateVariablesAndFetchData).toHaveBeenCalled(); - expect(mergeUrlParams).toHaveBeenCalledWith( - convertVariablesForURL(storeVariables), - window.location.href, - ); - expect(updateHistory).toHaveBeenCalled(); - }); - - it('merges the url params and refreshes the dashboard when a custom-based variables inputs are updated', async () => { - const firstInput = findCustomInputs().at(0); - - firstInput.vm.$emit('input', 'test'); - - await nextTick(); - expect(updateVariablesAndFetchData).toHaveBeenCalled(); - expect(mergeUrlParams).toHaveBeenCalledWith( - convertVariablesForURL(storeVariables), - window.location.href, - ); - expect(updateHistory).toHaveBeenCalled(); - }); - - it('does not merge the url params and refreshes the dashboard if the value entered is not different that is what currently stored', () => { - const firstInput = findTextInputs().at(0); - - firstInput.vm.$emit('input', 'My default value'); - - expect(updateVariablesAndFetchData).not.toHaveBeenCalled(); - expect(mergeUrlParams).not.toHaveBeenCalled(); - expect(updateHistory).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/monitoring/csv_export_spec.js b/spec/frontend/monitoring/csv_export_spec.js deleted file mode 100644 index 42d19c21a7b..00000000000 --- a/spec/frontend/monitoring/csv_export_spec.js +++ /dev/null @@ -1,126 +0,0 @@ -import { graphDataToCsv } from '~/monitoring/csv_export'; -import { timeSeriesGraphData } from './graph_data'; - -describe('monitoring export_csv', () => { - describe('graphDataToCsv', () => { - const expectCsvToMatchLines = (csv, lines) => expect(`${lines.join('\r\n')}\r\n`).toEqual(csv); - - it('should return a csv with 0 metrics', () => { - const data = timeSeriesGraphData({}, { metricCount: 0 }); - - expect(graphDataToCsv(data)).toEqual(''); - }); - - it('should return a csv with 1 metric with no data', () => { - const data = timeSeriesGraphData({}, { metricCount: 1 }); - - // When state is NO_DATA, result is null - data.metrics[0].result = null; - - expect(graphDataToCsv(data)).toEqual(''); - }); - - it('should return a csv with 1 metric', () => { - const data = timeSeriesGraphData({}, { metricCount: 1 }); - - expectCsvToMatchLines(graphDataToCsv(data), [ - `timestamp,"Y Axis > Metric 1"`, - '2015-07-01T20:10:50.000Z,1', - '2015-07-01T20:12:50.000Z,2', - '2015-07-01T20:14:50.000Z,3', - ]); - }); - - it('should return a csv with multiple metrics and one with no data', () => { - const data = timeSeriesGraphData({}, { metricCount: 2 }); - - // When state is NO_DATA, result is null - data.metrics[0].result = null; - - expectCsvToMatchLines(graphDataToCsv(data), [ - `timestamp,"Y Axis > Metric 2"`, - '2015-07-01T20:10:50.000Z,1', - '2015-07-01T20:12:50.000Z,2', - '2015-07-01T20:14:50.000Z,3', - ]); - }); - - it('should return a csv when not all metrics have the same timestamps', () => { - const data = timeSeriesGraphData({}, { metricCount: 3 }); - - // Add an "odd" timestamp that is not in the dataset - Object.assign(data.metrics[2].result[0], { - value: ['2016-01-01T00:00:00.000Z', 9], - values: [['2016-01-01T00:00:00.000Z', 9]], - }); - - expectCsvToMatchLines(graphDataToCsv(data), [ - `timestamp,"Y Axis > Metric 1","Y Axis > Metric 2","Y Axis > Metric 3"`, - '2015-07-01T20:10:50.000Z,1,1,', - '2015-07-01T20:12:50.000Z,2,2,', - '2015-07-01T20:14:50.000Z,3,3,', - '2016-01-01T00:00:00.000Z,,,9', - ]); - }); - - it('should escape double quotes in metric labels with two double quotes ("")', () => { - const data = timeSeriesGraphData({}, { metricCount: 1 }); - - data.metrics[0].label = 'My "quoted" metric'; - - expectCsvToMatchLines(graphDataToCsv(data), [ - `timestamp,"Y Axis > My ""quoted"" metric"`, - '2015-07-01T20:10:50.000Z,1', - '2015-07-01T20:12:50.000Z,2', - '2015-07-01T20:14:50.000Z,3', - ]); - }); - - it('should return a csv with multiple metrics', () => { - const data = timeSeriesGraphData({}, { metricCount: 3 }); - - expectCsvToMatchLines(graphDataToCsv(data), [ - `timestamp,"Y Axis > Metric 1","Y Axis > Metric 2","Y Axis > Metric 3"`, - '2015-07-01T20:10:50.000Z,1,1,1', - '2015-07-01T20:12:50.000Z,2,2,2', - '2015-07-01T20:14:50.000Z,3,3,3', - ]); - }); - - it('should return a csv with 1 metric and multiple series with labels', () => { - const data = timeSeriesGraphData({}, { isMultiSeries: true }); - - expectCsvToMatchLines(graphDataToCsv(data), [ - `timestamp,"Y Axis > Metric 1","Y Axis > Metric 1"`, - '2015-07-01T20:10:50.000Z,1,4', - '2015-07-01T20:12:50.000Z,2,5', - '2015-07-01T20:14:50.000Z,3,6', - ]); - }); - - it('should return a csv with 1 metric and multiple series', () => { - const data = timeSeriesGraphData({}, { isMultiSeries: true, withLabels: false }); - - expectCsvToMatchLines(graphDataToCsv(data), [ - `timestamp,"Y Axis > __name__: up, job: prometheus, instance: localhost:9090","Y Axis > __name__: up, job: node, instance: localhost:9091"`, - '2015-07-01T20:10:50.000Z,1,4', - '2015-07-01T20:12:50.000Z,2,5', - '2015-07-01T20:14:50.000Z,3,6', - ]); - }); - - it('should return a csv with multiple metrics and multiple series', () => { - const data = timeSeriesGraphData( - {}, - { metricCount: 3, isMultiSeries: true, withLabels: false }, - ); - - expectCsvToMatchLines(graphDataToCsv(data), [ - `timestamp,"Y Axis > __name__: up, job: prometheus, instance: localhost:9090","Y Axis > __name__: up, job: node, instance: localhost:9091","Y Axis > __name__: up, job: prometheus, instance: localhost:9090","Y Axis > __name__: up, job: node, instance: localhost:9091","Y Axis > __name__: up, job: prometheus, instance: localhost:9090","Y Axis > __name__: up, job: node, instance: localhost:9091"`, - '2015-07-01T20:10:50.000Z,1,4,1,4,1,4', - '2015-07-01T20:12:50.000Z,2,5,2,5,2,5', - '2015-07-01T20:14:50.000Z,3,6,3,6,3,6', - ]); - }); - }); -}); diff --git a/spec/frontend/monitoring/fixture_data.js b/spec/frontend/monitoring/fixture_data.js deleted file mode 100644 index f4062adea81..00000000000 --- a/spec/frontend/monitoring/fixture_data.js +++ /dev/null @@ -1,49 +0,0 @@ -import fixture from 'test_fixtures/metrics_dashboard/environment_metrics_dashboard.json'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { metricStates } from '~/monitoring/constants'; -import { mapToDashboardViewModel } from '~/monitoring/stores/utils'; -import { stateAndPropsFromDataset } from '~/monitoring/utils'; - -import { metricsResult } from './mock_data'; - -export const metricsDashboardResponse = fixture; - -export const metricsDashboardPayload = metricsDashboardResponse.dashboard; - -const datasetState = stateAndPropsFromDataset( - convertObjectPropsToCamelCase(metricsDashboardResponse.metrics_data), -); - -// new properties like addDashboardDocumentationPath prop -// was recently added to dashboard.vue component this needs to be -// added to fixtures data -// https://gitlab.com/gitlab-org/gitlab/-/issues/229256 -export const dashboardProps = { - ...datasetState.dataProps, -}; - -export const metricsDashboardViewModel = mapToDashboardViewModel(metricsDashboardPayload); - -export const metricsDashboardPanelCount = 22; - -// Graph data - -const firstPanel = metricsDashboardViewModel.panelGroups[0].panels[0]; - -export const graphData = { - ...firstPanel, - metrics: firstPanel.metrics.map((metric) => ({ - ...metric, - result: metricsResult, - state: metricStates.OK, - })), -}; - -export const graphDataEmpty = { - ...firstPanel, - metrics: firstPanel.metrics.map((metric) => ({ - ...metric, - result: [], - state: metricStates.NO_DATA, - })), -}; diff --git a/spec/frontend/monitoring/graph_data.js b/spec/frontend/monitoring/graph_data.js deleted file mode 100644 index 981955efebb..00000000000 --- a/spec/frontend/monitoring/graph_data.js +++ /dev/null @@ -1,274 +0,0 @@ -import { panelTypes, metricStates } from '~/monitoring/constants'; -import { mapPanelToViewModel, normalizeQueryResponseData } from '~/monitoring/stores/utils'; - -const initTime = 1435781450; // "Wed, 01 Jul 2015 20:10:50 GMT" -const intervalSeconds = 120; - -const makeValue = (val) => [initTime, val]; -const makeValues = (vals) => vals.map((val, i) => [initTime + intervalSeconds * i, val]); - -// Raw Promethues Responses - -export const prometheusMatrixMultiResult = ({ - values1 = ['1', '2', '3'], - values2 = ['4', '5', '6'], -} = {}) => ({ - resultType: 'matrix', - result: [ - { - metric: { - __name__: 'up', - job: 'prometheus', - instance: 'localhost:9090', - }, - values: makeValues(values1), - }, - { - metric: { - __name__: 'up', - job: 'node', - instance: 'localhost:9091', - }, - values: makeValues(values2), - }, - ], -}); - -// Normalized Prometheus Responses - -const scalarResult = ({ value = '1' } = {}) => - normalizeQueryResponseData({ - resultType: 'scalar', - result: makeValue(value), - }); - -const vectorResult = ({ value1 = '1', value2 = '2' } = {}) => - normalizeQueryResponseData({ - resultType: 'vector', - result: [ - { - metric: { - __name__: 'up', - job: 'prometheus', - instance: 'localhost:9090', - }, - value: makeValue(value1), - }, - { - metric: { - __name__: 'up', - job: 'node', - instance: 'localhost:9100', - }, - value: makeValue(value2), - }, - ], - }); - -const matrixSingleResult = ({ values = ['1', '2', '3'] } = {}) => - normalizeQueryResponseData({ - resultType: 'matrix', - result: [ - { - metric: {}, - values: makeValues(values), - }, - ], - }); - -const matrixMultiResult = ({ values1 = ['1', '2', '3'], values2 = ['4', '5', '6'] } = {}) => - normalizeQueryResponseData({ - resultType: 'matrix', - result: [ - { - metric: { - __name__: 'up', - job: 'prometheus', - instance: 'localhost:9090', - }, - values: makeValues(values1), - }, - { - metric: { - __name__: 'up', - job: 'node', - instance: 'localhost:9091', - }, - values: makeValues(values2), - }, - ], - }); - -// GraphData factory - -/** - * Generate mock graph data according to options - * - * @param {Object} panelOptions - Panel options as in YML. - * @param {Object} dataOptions - * @param {Object} dataOptions.metricCount - * @param {Object} dataOptions.isMultiSeries - */ -export const timeSeriesGraphData = (panelOptions = {}, dataOptions = {}) => { - const { metricCount = 1, isMultiSeries = false, withLabels = true } = dataOptions; - - return mapPanelToViewModel({ - title: 'Time Series Panel', - type: panelTypes.LINE_CHART, - x_label: 'X Axis', - y_label: 'Y Axis', - metrics: Array.from(Array(metricCount), (_, i) => ({ - label: withLabels ? `Metric ${i + 1}` : undefined, - state: metricStates.OK, - result: isMultiSeries ? matrixMultiResult() : matrixSingleResult(), - })), - ...panelOptions, - }); -}; - -/** - * Generate mock graph data according to options - * - * @param {Object} panelOptions - Panel options as in YML. - * @param {Object} dataOptions - * @param {Object} dataOptions.unit - * @param {Object} dataOptions.value - * @param {Object} dataOptions.isVector - */ -export const singleStatGraphData = (panelOptions = {}, dataOptions = {}) => { - const { unit, value = '1', isVector = false } = dataOptions; - - return mapPanelToViewModel({ - title: 'Single Stat Panel', - type: panelTypes.SINGLE_STAT, - metrics: [ - { - label: 'Metric Label', - state: metricStates.OK, - result: isVector ? vectorResult({ value }) : scalarResult({ value }), - unit, - }, - ], - ...panelOptions, - }); -}; - -/** - * Generate mock graph data according to options - * - * @param {Object} panelOptions - Panel options as in YML. - * @param {Object} dataOptions - * @param {Array} dataOptions.values - Metric values - * @param {Array} dataOptions.upper - Upper boundary values - * @param {Array} dataOptions.lower - Lower boundary values - */ -export const anomalyGraphData = (panelOptions = {}, dataOptions = {}) => { - const { values, upper, lower } = dataOptions; - - return mapPanelToViewModel({ - title: 'Anomaly Panel', - type: panelTypes.ANOMALY_CHART, - x_label: 'X Axis', - y_label: 'Y Axis', - metrics: [ - { - label: `Metric`, - state: metricStates.OK, - result: matrixSingleResult({ values }), - }, - { - label: `Upper boundary`, - state: metricStates.OK, - result: matrixSingleResult({ values: upper }), - }, - { - label: `Lower boundary`, - state: metricStates.OK, - result: matrixSingleResult({ values: lower }), - }, - ], - ...panelOptions, - }); -}; - -/** - * Generate mock graph data for heatmaps according to options - */ -export const heatmapGraphData = (panelOptions = {}, dataOptions = {}) => { - const { metricCount = 1 } = dataOptions; - - return mapPanelToViewModel({ - title: 'Heatmap Panel', - type: panelTypes.HEATMAP, - x_label: 'X Axis', - y_label: 'Y Axis', - metrics: Array.from(Array(metricCount), (_, i) => ({ - label: `Metric ${i + 1}`, - state: metricStates.OK, - result: matrixMultiResult(), - })), - ...panelOptions, - }); -}; - -/** - * Generate gauge chart mock graph data according to options - * - * @param {Object} panelOptions - Panel options as in YML. - * - */ -export const gaugeChartGraphData = (panelOptions = {}) => { - const { - minValue = 100, - maxValue = 1000, - split = 20, - thresholds = { - mode: 'absolute', - values: [500, 800], - }, - format = 'kilobytes', - } = panelOptions; - - return mapPanelToViewModel({ - title: 'Gauge Chart Panel', - type: panelTypes.GAUGE_CHART, - min_value: minValue, - max_value: maxValue, - split, - thresholds, - format, - metrics: [ - { - label: `Metric`, - state: metricStates.OK, - result: matrixSingleResult(), - }, - ], - }); -}; - -/** - * Generates stacked mock graph data according to options - * - * @param {Object} panelOptions - Panel options as in YML. - * @param {Object} dataOptions - */ -export const stackedColumnGraphData = (panelOptions = {}, dataOptions = {}) => { - return { - ...timeSeriesGraphData(panelOptions, dataOptions), - type: panelTypes.STACKED_COLUMN, - }; -}; - -/** - * Generates bar mock graph data according to options - * - * @param {Object} panelOptions - Panel options as in YML. - * @param {Object} dataOptions - */ -export const barGraphData = (panelOptions = {}, dataOptions = {}) => { - return { - ...timeSeriesGraphData(panelOptions, dataOptions), - type: panelTypes.BAR, - }; -}; diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js deleted file mode 100644 index 1d23190e586..00000000000 --- a/spec/frontend/monitoring/mock_data.js +++ /dev/null @@ -1,574 +0,0 @@ -// The path below needs to be relative because we import the mock-data to karma -import invalidUrl from '~/lib/utils/invalid_url'; -import { TEST_HOST } from '../__helpers__/test_constants'; -// This import path needs to be relative for now because this mock data is used in -// Karma specs too, where the helpers/test_constants alias can not be resolved - -export const mockProjectDir = '/frontend-fixtures/environments-project'; -export const mockApiEndpoint = `${TEST_HOST}/monitoring/mock`; - -export const customDashboardBasePath = '.gitlab/dashboards'; - -const customDashboardsData = new Array(30).fill(null).map((_, idx) => ({ - default: false, - display_name: `Custom Dashboard ${idx}`, - can_edit: true, - system_dashboard: false, - out_of_the_box_dashboard: false, - project_blob_path: `${mockProjectDir}/blob/main/dashboards/.gitlab/dashboards/dashboard_${idx}.yml`, - path: `.gitlab/dashboards/dashboard_${idx}.yml`, - starred: false, -})); - -export const mockDashboardsErrorResponse = { - all_dashboards: customDashboardsData, - message: "Each 'panel_group' must define an array :panels", - status: 'error', -}; - -export const anomalyDeploymentData = [ - { - id: 111, - iid: 3, - sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', - ref: { - name: 'main', - }, - created_at: '2019-08-19T22:00:00.000Z', - deployed_at: '2019-08-19T22:01:00.000Z', - tag: false, - 'last?': true, - }, - { - id: 110, - iid: 2, - sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', - ref: { - name: 'main', - }, - created_at: '2019-08-19T23:00:00.000Z', - deployed_at: '2019-08-19T23:00:00.000Z', - tag: false, - 'last?': false, - }, -]; - -export const deploymentData = [ - { - id: 111, - iid: 3, - sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', - commitUrl: - 'http://test.host/frontend-fixtures/environments-project/-/commit/f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', - ref: { - name: 'main', - }, - created_at: '2019-07-16T10:14:25.589Z', - tag: false, - tagUrl: 'http://test.host/frontend-fixtures/environments-project/tags/false', - 'last?': true, - }, - { - id: 110, - iid: 2, - sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', - commitUrl: - 'http://test.host/frontend-fixtures/environments-project/-/commit/f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', - ref: { - name: 'main', - }, - created_at: '2019-07-16T11:14:25.589Z', - tag: false, - tagUrl: 'http://test.host/frontend-fixtures/environments-project/tags/false', - 'last?': false, - }, - { - id: 109, - iid: 1, - sha: '6511e58faafaa7ad2228990ec57f19d66f7db7c2', - commitUrl: - 'http://test.host/frontend-fixtures/environments-project/-/commit/6511e58faafaa7ad2228990ec57f19d66f7db7c2', - ref: { - name: 'update2-readme', - }, - created_at: '2019-07-16T12:14:25.589Z', - tag: false, - tagUrl: 'http://test.host/frontend-fixtures/environments-project/tags/false', - 'last?': false, - }, -]; - -export const annotationsData = [ - { - id: 'gid://gitlab/Metrics::Dashboard::Annotation/1', - startingAt: '2020-04-12 12:51:53 UTC', - endingAt: null, - panelId: null, - description: 'This is a test annotation', - }, - { - id: 'gid://gitlab/Metrics::Dashboard::Annotation/2', - description: 'test annotation 2', - startingAt: '2020-04-13 12:51:53 UTC', - endingAt: null, - panelId: null, - }, - { - id: 'gid://gitlab/Metrics::Dashboard::Annotation/3', - description: 'test annotation 3', - startingAt: '2020-04-16 12:51:53 UTC', - endingAt: null, - panelId: null, - }, -]; - -const extraEnvironmentData = new Array(15).fill(null).map((_, idx) => ({ - id: `gid://gitlab/Environments/${150 + idx}`, - name: `no-deployment/noop-branch-${idx}`, - state: 'available', - created_at: '2018-07-04T18:39:41.702Z', - updated_at: '2018-07-04T18:44:54.010Z', -})); - -export const environmentData = [ - { - id: 'gid://gitlab/Environments/34', - name: 'production', - state: 'available', - external_url: 'http://root-autodevops-deploy.my-fake-domain.com', - environment_type: null, - stop_action: false, - metrics_path: '/root/hello-prometheus/environments/34/metrics', - environment_path: '/root/hello-prometheus/environments/34', - stop_path: '/root/hello-prometheus/environments/34/stop', - terminal_path: '/root/hello-prometheus/environments/34/terminal', - folder_path: '/root/hello-prometheus/environments/folders/production', - created_at: '2018-06-29T16:53:38.301Z', - updated_at: '2018-06-29T16:57:09.825Z', - last_deployment: { - id: 127, - }, - }, - { - id: 'gid://gitlab/Environments/35', - name: 'review/noop-branch', - state: 'available', - external_url: 'http://root-autodevops-deploy-review-noop-branc-die93w.my-fake-domain.com', - environment_type: 'review', - stop_action: true, - metrics_path: '/root/hello-prometheus/environments/35/metrics', - environment_path: '/root/hello-prometheus/environments/35', - stop_path: '/root/hello-prometheus/environments/35/stop', - terminal_path: '/root/hello-prometheus/environments/35/terminal', - folder_path: '/root/hello-prometheus/environments/folders/review', - created_at: '2018-07-03T18:39:41.702Z', - updated_at: '2018-07-03T18:44:54.010Z', - last_deployment: { - id: 128, - }, - }, -].concat(extraEnvironmentData); - -export const dashboardGitResponse = [ - { - default: true, - display_name: 'Overview', - can_edit: false, - system_dashboard: true, - out_of_the_box_dashboard: true, - project_blob_path: null, - path: 'config/prometheus/common_metrics.yml', - starred: false, - user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=config/prometheus/common_metrics.yml`, - }, - { - default: false, - display_name: 'dashboard.yml', - can_edit: true, - system_dashboard: false, - out_of_the_box_dashboard: false, - project_blob_path: `${mockProjectDir}/-/blob/main/.gitlab/dashboards/dashboard.yml`, - path: '.gitlab/dashboards/dashboard.yml', - starred: true, - user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=.gitlab/dashboards/dashboard.yml`, - }, - { - default: false, - display_name: 'Pod Health', - can_edit: false, - system_dashboard: false, - out_of_the_box_dashboard: true, - project_blob_path: null, - path: 'config/prometheus/pod_metrics.yml', - starred: false, - user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=config/prometheus/pod_metrics.yml`, - }, - ...customDashboardsData, -]; - -// Metrics mocks - -export const metricsResult = [ - { - metric: {}, - values: [ - [1563272065.589, '10.396484375'], - [1563272125.589, '10.333984375'], - [1563272185.589, '10.333984375'], - [1563272245.589, '10.333984375'], - ], - }, -]; - -export const barMockData = { - title: 'SLA Trends - Primary Services', - type: 'bar', - xLabel: 'service', - y_label: 'percentile', - metrics: [ - { - id: 'sla_trends_primary_services', - series_name: 'group 1', - metricId: 'NO_DB_sla_trends_primary_services', - query_range: - 'avg(avg_over_time(slo_observation_status{environment="gprd", stage=~"main|", type=~"api|web|git|registry|sidekiq|ci-runners"}[1d])) by (type)', - unit: 'Percentile', - label: 'SLA', - prometheus_endpoint_path: - '/gitlab-com/metrics-dogfooding/-/environments/266/prometheus/api/v1/query_range?query=clamp_min%28clamp_max%28avg%28avg_over_time%28slo_observation_status%7Benvironment%3D%22gprd%22%2C+stage%3D~%22main%7C%22%2C+type%3D~%22api%7Cweb%7Cgit%7Cregistry%7Csidekiq%7Cci-runners%22%7D%5B1d%5D%29%29+by+%28type%29%2C1%29%2C0%29', - result: [ - { - metric: { type: 'api' }, - values: [[1583995208, '0.9935198135198128']], - }, - { - metric: { type: 'git' }, - values: [[1583995208, '0.9975296513504401']], - }, - { - metric: { type: 'registry' }, - values: [[1583995208, '0.9994716394716395']], - }, - { - metric: { type: 'sidekiq' }, - values: [[1583995208, '0.9948251748251747']], - }, - { - metric: { type: 'web' }, - values: [[1583995208, '0.9535664335664336']], - }, - { - metric: { type: 'postgresql_database' }, - values: [[1583995208, '0.9335664335664336']], - }, - ], - }, - ], -}; - -export const baseNamespace = 'monitoringDashboard'; - -export const mockNamespace = `${baseNamespace}/1`; - -export const mockNamespaces = [`${baseNamespace}/1`, `${baseNamespace}/2`]; - -export const mockTimeRange = { duration: { seconds: 120 } }; - -export const mockFixedTimeRange = { - start: '2020-06-17T19:59:08.659Z', - end: '2020-07-17T19:59:08.659Z', -}; - -export const mockNamespacedData = { - mockDeploymentData: ['mockDeploymentData'], - mockProjectPath: '/mockProjectPath', -}; - -export const mockLogsPath = '/mockLogsPath'; - -export const mockLogsHref = `${mockLogsPath}?duration_seconds=${mockTimeRange.duration.seconds}`; - -export const mockLinks = [ - { - title: 'Job', - url: 'http://intel.com/bibendum/felis/sed/interdum/venenatis.png', - }, - { - title: 'Solarbreeze', - url: 'http://ebay.co.uk/primis/in/faucibus.jsp', - }, - { - title: 'Bentosanzap', - url: 'http://cargocollective.com/sociis/natoque/penatibus/et/magnis/dis.js', - }, - { - title: 'Wrapsafe', - url: 'https://bloomberg.com/tempus/vel/pede/morbi.aspx', - }, - { - title: 'Stronghold', - url: 'https://networkadvertising.org/primis/in/faucibus/orci/luctus/et/ultrices.html', - }, - { - title: 'Lotstring', - url: - 'https://huffingtonpost.com/sapien/a/libero.aspx?et=lacus&ultrices=at&posuere=velit&cubilia=vivamus&curae=vel&duis=nulla&faucibus=eget&accumsan=eros&odio=elementum&curabitur=pellentesque&convallis=quisque&duis=porta&consequat=volutpat&dui=erat&nec=quisque&nisi=erat&volutpat=eros&eleifend=viverra&donec=eget&ut=congue&dolor=eget&morbi=semper&vel=rutrum&lectus=nulla&in=nunc&quam=purus&fringilla=phasellus&rhoncus=in&mauris=felis&enim=donec&leo=semper&rhoncus=sapien&sed=a&vestibulum=libero&sit=nam&amet=dui&cursus=proin&id=leo&turpis=odio&integer=porttitor&aliquet=id&massa=consequat&id=in&lobortis=consequat&convallis=ut&tortor=nulla&risus=sed&dapibus=accumsan&augue=felis&vel=ut&accumsan=at&tellus=dolor&nisi=quis&eu=odio', - }, - { - title: 'Cardify', - url: - 'http://nature.com/imperdiet/et/commodo/vulputate/justo/in/blandit.json?tempus=posuere&semper=felis&est=sed&quam=lacus&pharetra=morbi&magna=sem&ac=mauris&consequat=laoreet&metus=ut&sapien=rhoncus&ut=aliquet&nunc=pulvinar&vestibulum=sed&ante=nisl&ipsum=nunc&primis=rhoncus&in=dui&faucibus=vel&orci=sem&luctus=sed&et=sagittis&ultrices=nam&posuere=congue&cubilia=risus&curae=semper&mauris=porta&viverra=volutpat&diam=quam&vitae=pede&quam=lobortis&suspendisse=ligula&potenti=sit&nullam=amet&porttitor=eleifend&lacus=pede&at=libero&turpis=quis', - }, - { - title: 'Ventosanzap', - url: - 'http://stanford.edu/augue/vestibulum/ante/ipsum/primis/in/faucibus.xml?metus=morbi&sapien=quis&ut=tortor&nunc=id&vestibulum=nulla&ante=ultrices&ipsum=aliquet&primis=maecenas&in=leo&faucibus=odio&orci=condimentum&luctus=id&et=luctus&ultrices=nec&posuere=molestie&cubilia=sed&curae=justo&mauris=pellentesque&viverra=viverra&diam=pede&vitae=ac&quam=diam&suspendisse=cras&potenti=pellentesque&nullam=volutpat&porttitor=dui&lacus=maecenas&at=tristique&turpis=est&donec=et&posuere=tempus&metus=semper&vitae=est&ipsum=quam&aliquam=pharetra&non=magna&mauris=ac&morbi=consequat&non=metus', - }, - { - title: 'Cardguard', - url: - 'https://google.com.hk/lacinia/eget/tincidunt/eget/tempus/vel.js?at=eget&turpis=nunc&a=donec', - }, - { - title: 'Namfix', - url: - 'https://fotki.com/eget/rutrum/at/lorem.jsp?at=id&vulputate=nulla&vitae=ultrices&nisl=aliquet&aenean=maecenas&lectus=leo&pellentesque=odio&eget=condimentum&nunc=id&donec=luctus&quis=nec&orci=molestie&eget=sed&orci=justo&vehicula=pellentesque&condimentum=viverra&curabitur=pede&in=ac&libero=diam&ut=cras&massa=pellentesque&volutpat=volutpat&convallis=dui&morbi=maecenas&odio=tristique&odio=est&elementum=et&eu=tempus&interdum=semper&eu=est&tincidunt=quam&in=pharetra&leo=magna&maecenas=ac&pulvinar=consequat&lobortis=metus&est=sapien&phasellus=ut&sit=nunc&amet=vestibulum&erat=ante&nulla=ipsum&tempus=primis&vivamus=in&in=faucibus&felis=orci&eu=luctus&sapien=et&cursus=ultrices&vestibulum=posuere&proin=cubilia&eu=curae&mi=mauris&nulla=viverra&ac=diam&enim=vitae&in=quam&tempor=suspendisse&turpis=potenti&nec=nullam&euismod=porttitor&scelerisque=lacus&quam=at&turpis=turpis&adipiscing=donec&lorem=posuere&vitae=metus&mattis=vitae&nibh=ipsum&ligula=aliquam&nec=non&sem=mauris&duis=morbi&aliquam=non&convallis=lectus&nunc=aliquam&proin=sit&at=amet', - }, - { - title: 'Alpha', - url: - 'http://bravesites.com/tempus/vel.jpg?risus=est&auctor=phasellus&sed=sit&tristique=amet&in=erat&tempus=nulla&sit=tempus&amet=vivamus&sem=in&fusce=felis&consequat=eu&nulla=sapien&nisl=cursus&nunc=vestibulum&nisl=proin&duis=eu&bibendum=mi&felis=nulla&sed=ac&interdum=enim&venenatis=in&turpis=tempor&enim=turpis&blandit=nec&mi=euismod&in=scelerisque&porttitor=quam&pede=turpis&justo=adipiscing&eu=lorem&massa=vitae&donec=mattis&dapibus=nibh&duis=ligula', - }, - { - title: 'Sonsing', - url: - 'http://microsoft.com/blandit.js?quis=ante&lectus=vestibulum&suspendisse=ante&potenti=ipsum&in=primis&eleifend=in&quam=faucibus&a=orci&odio=luctus&in=et&hac=ultrices&habitasse=posuere&platea=cubilia&dictumst=curae&maecenas=duis&ut=faucibus&massa=accumsan&quis=odio&augue=curabitur&luctus=convallis&tincidunt=duis&nulla=consequat&mollis=dui&molestie=nec&lorem=nisi&quisque=volutpat&ut=eleifend&erat=donec&curabitur=ut&gravida=dolor&nisi=morbi&at=vel&nibh=lectus&in=in&hac=quam&habitasse=fringilla&platea=rhoncus&dictumst=mauris&aliquam=enim&augue=leo&quam=rhoncus&sollicitudin=sed&vitae=vestibulum&consectetuer=sit&eget=amet&rutrum=cursus&at=id&lorem=turpis&integer=integer&tincidunt=aliquet&ante=massa&vel=id&ipsum=lobortis&praesent=convallis&blandit=tortor&lacinia=risus&erat=dapibus&vestibulum=augue&sed=vel&magna=accumsan&at=tellus&nunc=nisi&commodo=eu&placerat=orci&praesent=mauris&blandit=lacinia&nam=sapien&nulla=quis&integer=libero', - }, - { - title: 'Fintone', - url: - 'https://linkedin.com/duis/bibendum/felis/sed/interdum/venenatis.json?ut=justo&suscipit=sollicitudin&a=ut&feugiat=suscipit&et=a&eros=feugiat&vestibulum=et&ac=eros&est=vestibulum&lacinia=ac&nisi=est&venenatis=lacinia&tristique=nisi&fusce=venenatis&congue=tristique&diam=fusce&id=congue&ornare=diam&imperdiet=id&sapien=ornare&urna=imperdiet&pretium=sapien&nisl=urna&ut=pretium&volutpat=nisl&sapien=ut&arcu=volutpat&sed=sapien&augue=arcu&aliquam=sed&erat=augue&volutpat=aliquam&in=erat&congue=volutpat&etiam=in&justo=congue&etiam=etiam&pretium=justo&iaculis=etiam&justo=pretium&in=iaculis&hac=justo&habitasse=in&platea=hac&dictumst=habitasse&etiam=platea&faucibus=dictumst&cursus=etiam&urna=faucibus&ut=cursus&tellus=urna&nulla=ut&ut=tellus&erat=nulla&id=ut&mauris=erat&vulputate=id&elementum=mauris&nullam=vulputate&varius=elementum&nulla=nullam&facilisi=varius&cras=nulla&non=facilisi&velit=cras&nec=non&nisi=velit&vulputate=nec&nonummy=nisi&maecenas=vulputate&tincidunt=nonummy&lacus=maecenas&at=tincidunt&velit=lacus&vivamus=at&vel=velit&nulla=vivamus&eget=vel&eros=nulla&elementum=eget', - }, - { - title: 'Fix San', - url: - 'http://pinterest.com/mi/in/porttitor/pede.png?varius=nibh&integer=quisque&ac=id&leo=justo&pellentesque=sit&ultrices=amet&mattis=sapien&odio=dignissim&donec=vestibulum&vitae=vestibulum&nisi=ante&nam=ipsum&ultrices=primis&libero=in&non=faucibus&mattis=orci&pulvinar=luctus&nulla=et&pede=ultrices&ullamcorper=posuere&augue=cubilia&a=curae&suscipit=nulla&nulla=dapibus&elit=dolor&ac=vel&nulla=est&sed=donec&vel=odio&enim=justo&sit=sollicitudin&amet=ut&nunc=suscipit&viverra=a&dapibus=feugiat&nulla=et&suscipit=eros&ligula=vestibulum&in=ac&lacus=est&curabitur=lacinia&at=nisi&ipsum=venenatis&ac=tristique&tellus=fusce&semper=congue&interdum=diam&mauris=id&ullamcorper=ornare&purus=imperdiet&sit=sapien&amet=urna&nulla=pretium&quisque=nisl&arcu=ut&libero=volutpat&rutrum=sapien&ac=arcu&lobortis=sed&vel=augue&dapibus=aliquam&at=erat&diam=volutpat&nam=in&tristique=congue&tortor=etiam', - }, - { - title: 'Ronstring', - url: - 'https://ebay.com/ut/erat.aspx?nulla=sed&eget=nisl&eros=nunc&elementum=rhoncus&pellentesque=dui&quisque=vel&porta=sem&volutpat=sed&erat=sagittis&quisque=nam&erat=congue&eros=risus&viverra=semper&eget=porta&congue=volutpat&eget=quam&semper=pede&rutrum=lobortis&nulla=ligula', - }, - { - title: 'It', - url: - 'http://symantec.com/tortor/sollicitudin/mi/sit/amet.json?in=nullam&libero=varius&ut=nulla&massa=facilisi&volutpat=cras&convallis=non&morbi=velit&odio=nec&odio=nisi&elementum=vulputate&eu=nonummy&interdum=maecenas&eu=tincidunt&tincidunt=lacus&in=at&leo=velit&maecenas=vivamus&pulvinar=vel&lobortis=nulla&est=eget&phasellus=eros&sit=elementum&amet=pellentesque&erat=quisque&nulla=porta&tempus=volutpat&vivamus=erat&in=quisque&felis=erat&eu=eros&sapien=viverra&cursus=eget&vestibulum=congue&proin=eget&eu=semper', - }, - { - title: 'Andalax', - url: - 'https://acquirethisname.com/tortor/eu.js?volutpat=mauris&dui=laoreet&maecenas=ut&tristique=rhoncus&est=aliquet&et=pulvinar&tempus=sed&semper=nisl&est=nunc&quam=rhoncus&pharetra=dui&magna=vel&ac=sem&consequat=sed&metus=sagittis&sapien=nam&ut=congue&nunc=risus&vestibulum=semper&ante=porta&ipsum=volutpat&primis=quam&in=pede&faucibus=lobortis&orci=ligula&luctus=sit&et=amet&ultrices=eleifend&posuere=pede&cubilia=libero&curae=quis&mauris=orci&viverra=nullam&diam=molestie&vitae=nibh&quam=in&suspendisse=lectus&potenti=pellentesque&nullam=at&porttitor=nulla&lacus=suspendisse&at=potenti&turpis=cras&donec=in&posuere=purus&metus=eu&vitae=magna&ipsum=vulputate&aliquam=luctus&non=cum&mauris=sociis&morbi=natoque&non=penatibus&lectus=et&aliquam=magnis&sit=dis&amet=parturient&diam=montes&in=nascetur&magna=ridiculus&bibendum=mus', - }, -]; - -export const templatingVariablesExamples = { - text: { - textSimple: 'My default value', - textAdvanced: { - label: 'Advanced text variable', - type: 'text', - options: { - default_value: 'A default value', - }, - }, - }, - custom: { - customSimple: ['value1', 'value2', 'value3'], - customAdvanced: { - label: 'Advanced Var', - type: 'custom', - options: { - values: [ - { value: 'value1', text: 'Var 1 Option 1' }, - { - value: 'value2', - text: 'Var 1 Option 2', - default: true, - }, - ], - }, - }, - customAdvancedWithoutOpts: { - type: 'custom', - options: {}, - }, - customAdvancedWithoutLabel: { - type: 'custom', - options: { - values: [ - { value: 'value1', text: 'Var 1 Option 1' }, - { - value: 'value2', - text: 'Var 1 Option 2', - default: true, - }, - ], - }, - }, - customAdvancedWithoutType: { - label: 'Variable 2', - options: { - values: [ - { value: 'value1', text: 'Var 1 Option 1' }, - { - value: 'value2', - text: 'Var 1 Option 2', - default: true, - }, - ], - }, - }, - customAdvancedWithoutOptText: { - label: 'Options without text', - type: 'custom', - options: { - values: [ - { value: 'value1' }, - { - value: 'value2', - default: true, - }, - ], - }, - }, - }, - metricLabelValues: { - metricLabelValuesSimple: { - label: 'Metric Label Values', - type: 'metric_label_values', - options: { - prometheus_endpoint_path: '/series', - series_selector: 'backend:haproxy_backend_availability:ratio{env="{{env}}"}', - label: 'backend', - }, - }, - }, -}; - -export const storeTextVariables = [ - { - type: 'text', - name: 'textSimple', - label: 'textSimple', - value: 'My default value', - }, - { - type: 'text', - name: 'textAdvanced', - label: 'Advanced text variable', - value: 'A default value', - }, -]; - -export const storeCustomVariables = [ - { - type: 'custom', - name: 'customSimple', - label: 'customSimple', - options: { - values: [ - { default: false, text: 'value1', value: 'value1' }, - { default: false, text: 'value2', value: 'value2' }, - { default: false, text: 'value3', value: 'value3' }, - ], - }, - value: 'value1', - }, - { - type: 'custom', - name: 'customAdvanced', - label: 'Advanced Var', - options: { - values: [ - { default: false, text: 'Var 1 Option 1', value: 'value1' }, - { default: true, text: 'Var 1 Option 2', value: 'value2' }, - ], - }, - value: 'value2', - }, - { - type: 'custom', - name: 'customAdvancedWithoutOpts', - label: 'customAdvancedWithoutOpts', - options: { values: [] }, - value: null, - }, - { - type: 'custom', - name: 'customAdvancedWithoutLabel', - label: 'customAdvancedWithoutLabel', - value: 'value2', - options: { - values: [ - { default: false, text: 'Var 1 Option 1', value: 'value1' }, - { default: true, text: 'Var 1 Option 2', value: 'value2' }, - ], - }, - }, - { - type: 'custom', - name: 'customAdvancedWithoutOptText', - label: 'Options without text', - options: { - values: [ - { default: false, text: 'value1', value: 'value1' }, - { default: true, text: 'value2', value: 'value2' }, - ], - }, - value: 'value2', - }, -]; - -export const storeMetricLabelValuesVariables = [ - { - type: 'metric_label_values', - name: 'metricLabelValuesSimple', - label: 'Metric Label Values', - options: { prometheusEndpointPath: '/series', label: 'backend', values: [] }, - value: null, - }, -]; - -export const storeVariables = [ - ...storeTextVariables, - ...storeCustomVariables, - ...storeMetricLabelValuesVariables, -]; - -export const dashboardHeaderProps = { - defaultBranch: 'main', - isRearrangingPanels: false, - selectedTimeRange: { - start: '2020-01-01T00:00:00.000Z', - end: '2020-01-01T01:00:00.000Z', - }, -}; - -export const dashboardActionsMenuProps = { - defaultBranch: 'main', - addingMetricsAvailable: true, - customMetricsPath: 'https://path/to/customMetrics', - validateQueryPath: 'https://path/to/validateQuery', - isOotbDashboard: true, -}; - -export const mockAlert = { - alert_path: 'alert_path', - id: 8, - metricId: 'mock_metric_id', - operator: '>', - query: 'testQuery', - runbookUrl: invalidUrl, - threshold: 5, - title: 'alert title', -}; diff --git a/spec/frontend/monitoring/pages/dashboard_page_spec.js b/spec/frontend/monitoring/pages/dashboard_page_spec.js deleted file mode 100644 index 7fcb7607772..00000000000 --- a/spec/frontend/monitoring/pages/dashboard_page_spec.js +++ /dev/null @@ -1,60 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Dashboard from '~/monitoring/components/dashboard.vue'; -import DashboardPage from '~/monitoring/pages/dashboard_page.vue'; -import { createStore } from '~/monitoring/stores'; -import { assertProps } from 'helpers/assert_props'; -import { dashboardProps } from '../fixture_data'; - -describe('monitoring/pages/dashboard_page', () => { - let wrapper; - let store; - let $route; - - const buildRouter = () => { - const dashboard = {}; - $route = { - params: { dashboard }, - query: { dashboard }, - }; - }; - - const buildWrapper = (props = {}) => { - wrapper = shallowMount(DashboardPage, { - store, - propsData: { - ...props, - }, - mocks: { - $route, - }, - }); - }; - - const findDashboardComponent = () => wrapper.findComponent(Dashboard); - - beforeEach(() => { - buildRouter(); - store = createStore(); - jest.spyOn(store, 'dispatch').mockResolvedValue(); - }); - - it('throws errors if dashboard props are not passed', () => { - expect(() => assertProps(DashboardPage, {})).toThrow('Missing required prop: "dashboardProps"'); - }); - - it('renders the dashboard page with dashboard component', () => { - buildWrapper({ dashboardProps }); - - const allProps = { - ...dashboardProps, - // default props values - rearrangePanelsAvailable: false, - showHeader: true, - showPanels: true, - smallEmptyState: false, - }; - - expect(findDashboardComponent().exists()).toBe(true); - expect(allProps).toMatchObject(findDashboardComponent().props()); - }); -}); diff --git a/spec/frontend/monitoring/pages/panel_new_page_spec.js b/spec/frontend/monitoring/pages/panel_new_page_spec.js deleted file mode 100644 index 98ee6c1cb29..00000000000 --- a/spec/frontend/monitoring/pages/panel_new_page_spec.js +++ /dev/null @@ -1,93 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import DashboardPanelBuilder from '~/monitoring/components/dashboard_panel_builder.vue'; -import PanelNewPage from '~/monitoring/pages/panel_new_page.vue'; -import { DASHBOARD_PAGE, PANEL_NEW_PAGE } from '~/monitoring/router/constants'; -import { createStore } from '~/monitoring/stores'; - -const dashboard = 'dashboard.yml'; - -// Button stub that can accept `to` as router links do -// https://bootstrap-vue.org/docs/components/button#comp-ref-b-button-props -const GlButtonStub = { - extends: GlButton, - props: { - to: [String, Object], - }, -}; - -describe('monitoring/pages/panel_new_page', () => { - let store; - let wrapper; - let $route; - let $router; - - const mountComponent = (propsData = {}, route) => { - $route = route ?? { name: PANEL_NEW_PAGE, params: { dashboard } }; - $router = { - push: jest.fn(), - }; - - wrapper = shallowMount(PanelNewPage, { - propsData, - store, - stubs: { - GlButton: GlButtonStub, - }, - mocks: { - $router, - $route, - }, - }); - }; - - const findBackButton = () => wrapper.findComponent(GlButtonStub); - const findPanelBuilder = () => wrapper.findComponent(DashboardPanelBuilder); - - beforeEach(() => { - store = createStore(); - mountComponent(); - }); - - describe('back to dashboard button', () => { - it('is rendered', () => { - expect(findBackButton().exists()).toBe(true); - expect(findBackButton().props('icon')).toBe('go-back'); - }); - - it('links back to the dashboard', () => { - expect(findBackButton().props('to')).toEqual({ - name: DASHBOARD_PAGE, - params: { dashboard }, - }); - }); - - it('links back to the dashboard while preserving query params', () => { - $route = { - name: PANEL_NEW_PAGE, - params: { dashboard }, - query: { another: 'param' }, - }; - - mountComponent({}, $route); - - expect(findBackButton().props('to')).toEqual({ - name: DASHBOARD_PAGE, - params: { dashboard }, - query: { another: 'param' }, - }); - }); - }); - - describe('dashboard panel builder', () => { - it('is rendered', () => { - expect(findPanelBuilder().exists()).toBe(true); - }); - }); - - describe('page routing', () => { - it('route is not updated by default', () => { - expect($router.push).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/monitoring/requests/index_spec.js b/spec/frontend/monitoring/requests/index_spec.js deleted file mode 100644 index 308895768a4..00000000000 --- a/spec/frontend/monitoring/requests/index_spec.js +++ /dev/null @@ -1,157 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import { backoffMockImplementation } from 'helpers/backoff_helper'; -import axios from '~/lib/utils/axios_utils'; -import * as commonUtils from '~/lib/utils/common_utils'; -import { - HTTP_STATUS_BAD_REQUEST, - HTTP_STATUS_INTERNAL_SERVER_ERROR, - HTTP_STATUS_NO_CONTENT, - HTTP_STATUS_OK, - HTTP_STATUS_SERVICE_UNAVAILABLE, - HTTP_STATUS_UNAUTHORIZED, - HTTP_STATUS_UNPROCESSABLE_ENTITY, -} from '~/lib/utils/http_status'; -import { getDashboard, getPrometheusQueryData } from '~/monitoring/requests'; -import { metricsDashboardResponse } from '../fixture_data'; - -describe('monitoring metrics_requests', () => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - jest.spyOn(commonUtils, 'backOff').mockImplementation(backoffMockImplementation); - }); - - afterEach(() => { - mock.reset(); - - commonUtils.backOff.mockReset(); - }); - - describe('getDashboard', () => { - const response = metricsDashboardResponse; - const dashboardEndpoint = '/dashboard'; - const params = { - start_time: 'start_time', - end_time: 'end_time', - }; - - it('returns a dashboard response', () => { - mock.onGet(dashboardEndpoint).reply(HTTP_STATUS_OK, response); - - return getDashboard(dashboardEndpoint, params).then((data) => { - expect(data).toEqual(metricsDashboardResponse); - }); - }); - - it('returns a dashboard response after retrying twice', () => { - mock.onGet(dashboardEndpoint).replyOnce(HTTP_STATUS_NO_CONTENT); - mock.onGet(dashboardEndpoint).replyOnce(HTTP_STATUS_NO_CONTENT); - mock.onGet(dashboardEndpoint).reply(HTTP_STATUS_OK, response); - - return getDashboard(dashboardEndpoint, params).then((data) => { - expect(data).toEqual(metricsDashboardResponse); - expect(mock.history.get).toHaveLength(3); - }); - }); - - it('rejects after getting an error', () => { - mock.onGet(dashboardEndpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - - return getDashboard(dashboardEndpoint, params).catch((error) => { - expect(error).toEqual(expect.any(Error)); - expect(mock.history.get).toHaveLength(1); - }); - }); - }); - - describe('getPrometheusQueryData', () => { - const response = { - status: 'success', - data: { - resultType: 'matrix', - result: [], - }, - }; - const prometheusEndpoint = '/query_range'; - const params = { - start_time: 'start_time', - end_time: 'end_time', - }; - - it('returns a dashboard response', () => { - mock.onGet(prometheusEndpoint).reply(HTTP_STATUS_OK, response); - - return getPrometheusQueryData(prometheusEndpoint, params).then((data) => { - expect(data).toEqual(response.data); - }); - }); - - it('returns a dashboard response after retrying twice', () => { - // Mock multiple attempts while the cache is filling up - mock.onGet(prometheusEndpoint).replyOnce(HTTP_STATUS_NO_CONTENT); - mock.onGet(prometheusEndpoint).replyOnce(HTTP_STATUS_NO_CONTENT); - mock.onGet(prometheusEndpoint).reply(HTTP_STATUS_OK, response); // 3rd attempt - - return getPrometheusQueryData(prometheusEndpoint, params).then((data) => { - expect(data).toEqual(response.data); - expect(mock.history.get).toHaveLength(3); - }); - }); - - it('rejects after getting an HTTP 500 error', () => { - mock.onGet(prometheusEndpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, { - status: 'error', - error: 'An error occurred', - }); - - return getPrometheusQueryData(prometheusEndpoint, params).catch((error) => { - expect(error).toEqual(new Error('Request failed with status code 500')); - }); - }); - - it('rejects after retrying twice and getting an HTTP 401 error', () => { - // Mock multiple attempts while the cache is filling up and fails - mock.onGet(prometheusEndpoint).reply(HTTP_STATUS_UNAUTHORIZED, { - status: 'error', - error: 'An error occurred', - }); - - return getPrometheusQueryData(prometheusEndpoint, params).catch((error) => { - expect(error).toEqual(new Error('Request failed with status code 401')); - }); - }); - - it('rejects after retrying twice and getting an HTTP 500 error', () => { - // Mock multiple attempts while the cache is filling up and fails - mock.onGet(prometheusEndpoint).replyOnce(HTTP_STATUS_NO_CONTENT); - mock.onGet(prometheusEndpoint).replyOnce(HTTP_STATUS_NO_CONTENT); - mock.onGet(prometheusEndpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, { - status: 'error', - error: 'An error occurred', - }); // 3rd attempt - - return getPrometheusQueryData(prometheusEndpoint, params).catch((error) => { - expect(error).toEqual(new Error('Request failed with status code 500')); - expect(mock.history.get).toHaveLength(3); - }); - }); - - it.each` - code | reason - ${HTTP_STATUS_BAD_REQUEST} | ${'Parameters are missing or incorrect'} - ${HTTP_STATUS_UNPROCESSABLE_ENTITY} | ${"Expression can't be executed"} - ${HTTP_STATUS_SERVICE_UNAVAILABLE} | ${'Query timed out or aborted'} - `('rejects with details: "$reason" after getting an HTTP $code error', ({ code, reason }) => { - mock.onGet(prometheusEndpoint).reply(code, { - status: 'error', - error: reason, - }); - - return getPrometheusQueryData(prometheusEndpoint, params).catch((error) => { - expect(error).toEqual(new Error(reason)); - expect(mock.history.get).toHaveLength(1); - }); - }); - }); -}); diff --git a/spec/frontend/monitoring/router_spec.js b/spec/frontend/monitoring/router_spec.js deleted file mode 100644 index 368bd955fb3..00000000000 --- a/spec/frontend/monitoring/router_spec.js +++ /dev/null @@ -1,106 +0,0 @@ -import { mount } from '@vue/test-utils'; -import Vue from 'vue'; -import VueRouter from 'vue-router'; -import Dashboard from '~/monitoring/components/dashboard.vue'; -import DashboardPage from '~/monitoring/pages/dashboard_page.vue'; -import PanelNewPage from '~/monitoring/pages/panel_new_page.vue'; -import createRouter from '~/monitoring/router'; -import { createStore } from '~/monitoring/stores'; -import { dashboardProps } from './fixture_data'; -import { dashboardHeaderProps } from './mock_data'; - -const LEGACY_BASE_PATH = '/project/my-group/test-project/-/environments/71146/metrics'; -const BASE_PATH = '/project/my-group/test-project/-/metrics'; - -const MockApp = { - data() { - return { - dashboardProps: { ...dashboardProps, ...dashboardHeaderProps }, - }; - }, - template: `<router-view :dashboard-props="dashboardProps"/>`, -}; - -describe('Monitoring router', () => { - let router; - let store; - - const createWrapper = (basePath, routeArg) => { - Vue.use(VueRouter); - - router = createRouter(basePath); - if (routeArg !== undefined) { - router.push(routeArg); - } - - return mount(MockApp, { - store, - router, - }); - }; - - beforeEach(() => { - store = createStore(); - jest.spyOn(store, 'dispatch').mockResolvedValue(); - }); - - afterEach(() => { - window.location.hash = ''; - }); - - describe('support legacy URLs with full dashboard path to visit dashboard page', () => { - it.each` - path | currentDashboard - ${'/dashboard.yml'} | ${'dashboard.yml'} - ${'/folder1/dashboard.yml'} | ${'folder1/dashboard.yml'} - ${'/?dashboard=dashboard.yml'} | ${'dashboard.yml'} - `('"$path" renders page with dashboard "$currentDashboard"', ({ path, currentDashboard }) => { - const wrapper = createWrapper(LEGACY_BASE_PATH, path); - - expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setCurrentDashboard', { - currentDashboard, - }); - - expect(wrapper.findComponent(DashboardPage).exists()).toBe(true); - expect(wrapper.findComponent(DashboardPage).findComponent(Dashboard).exists()).toBe(true); - }); - }); - - describe('supports URLs to visit dashboard page', () => { - it.each` - path | currentDashboard - ${'/'} | ${null} - ${'/dashboard.yml'} | ${'dashboard.yml'} - ${'/folder1/dashboard.yml'} | ${'folder1/dashboard.yml'} - ${'/folder1%2Fdashboard.yml'} | ${'folder1/dashboard.yml'} - ${'/dashboard.yml'} | ${'dashboard.yml'} - ${'/config/prometheus/common_metrics.yml'} | ${'config/prometheus/common_metrics.yml'} - ${'/config/prometheus/pod_metrics.yml'} | ${'config/prometheus/pod_metrics.yml'} - ${'/config%2Fprometheus%2Fpod_metrics.yml'} | ${'config/prometheus/pod_metrics.yml'} - `('"$path" renders page with dashboard "$currentDashboard"', ({ path, currentDashboard }) => { - const wrapper = createWrapper(BASE_PATH, path); - - expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setCurrentDashboard', { - currentDashboard, - }); - - expect(wrapper.findComponent(DashboardPage).exists()).toBe(true); - expect(wrapper.findComponent(DashboardPage).findComponent(Dashboard).exists()).toBe(true); - }); - }); - - describe('supports URLs to visit new panel page', () => { - it.each` - path | currentDashboard - ${'/panel/new'} | ${undefined} - ${'/dashboard.yml/panel/new'} | ${'dashboard.yml'} - ${'/config/prometheus/common_metrics.yml/panel/new'} | ${'config/prometheus/common_metrics.yml'} - ${'/config%2Fprometheus%2Fcommon_metrics.yml/panel/new'} | ${'config/prometheus/common_metrics.yml'} - `('"$path" renders page with dashboard "$currentDashboard"', ({ path, currentDashboard }) => { - const wrapper = createWrapper(BASE_PATH, path); - - expect(wrapper.vm.$route.params.dashboard).toBe(currentDashboard); - expect(wrapper.findComponent(PanelNewPage).exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js deleted file mode 100644 index b3b198d6b51..00000000000 --- a/spec/frontend/monitoring/store/actions_spec.js +++ /dev/null @@ -1,1167 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import { backoffMockImplementation } from 'helpers/backoff_helper'; -import testAction from 'helpers/vuex_action_helper'; -import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import * as commonUtils from '~/lib/utils/common_utils'; -import { - HTTP_STATUS_BAD_REQUEST, - HTTP_STATUS_CREATED, - HTTP_STATUS_INTERNAL_SERVER_ERROR, - HTTP_STATUS_OK, - HTTP_STATUS_UNPROCESSABLE_ENTITY, -} from '~/lib/utils/http_status'; -import { ENVIRONMENT_AVAILABLE_STATE } from '~/monitoring/constants'; - -import getAnnotations from '~/monitoring/queries/get_annotations.query.graphql'; -import getDashboardValidationWarnings from '~/monitoring/queries/get_dashboard_validation_warnings.query.graphql'; -import getEnvironments from '~/monitoring/queries/get_environments.query.graphql'; -import { createStore } from '~/monitoring/stores'; -import { - setGettingStartedEmptyState, - setInitialState, - setExpandedPanel, - clearExpandedPanel, - filterEnvironments, - fetchData, - fetchDashboard, - receiveMetricsDashboardSuccess, - fetchDashboardData, - fetchPrometheusMetric, - fetchDeploymentsData, - fetchEnvironmentsData, - fetchAnnotations, - fetchDashboardValidationWarnings, - toggleStarredValue, - duplicateSystemDashboard, - updateVariablesAndFetchData, - fetchVariableMetricLabelValues, - fetchPanelPreview, -} from '~/monitoring/stores/actions'; -import * as getters from '~/monitoring/stores/getters'; -import * as types from '~/monitoring/stores/mutation_types'; -import storeState from '~/monitoring/stores/state'; -import { - gqClient, - parseEnvironmentsResponse, - parseAnnotationsResponse, -} from '~/monitoring/stores/utils'; -import Tracking from '~/tracking'; -import { defaultTimeRange } from '~/vue_shared/constants'; -import { - metricsDashboardResponse, - metricsDashboardViewModel, - metricsDashboardPanelCount, -} from '../fixture_data'; -import { - deploymentData, - environmentData, - annotationsData, - dashboardGitResponse, - mockDashboardsErrorResponse, -} from '../mock_data'; - -jest.mock('~/alert'); - -describe('Monitoring store actions', () => { - const { convertObjectPropsToCamelCase } = commonUtils; - - let mock; - let store; - let state; - - let dispatch; - let commit; - - beforeEach(() => { - store = createStore({ getters }); - state = store.state.monitoringDashboard; - mock = new MockAdapter(axios); - - commit = jest.fn(); - dispatch = jest.fn(); - - jest.spyOn(commonUtils, 'backOff').mockImplementation(backoffMockImplementation); - }); - - afterEach(() => { - mock.reset(); - - commonUtils.backOff.mockReset(); - createAlert.mockReset(); - }); - - // Setup - - describe('setGettingStartedEmptyState', () => { - it('should commit SET_GETTING_STARTED_EMPTY_STATE mutation', () => { - return testAction( - setGettingStartedEmptyState, - null, - state, - [ - { - type: types.SET_GETTING_STARTED_EMPTY_STATE, - }, - ], - [], - ); - }); - }); - - describe('setInitialState', () => { - it('should commit SET_INITIAL_STATE mutation', () => { - return testAction( - setInitialState, - { - currentDashboard: '.gitlab/dashboards/dashboard.yml', - deploymentsEndpoint: 'deployments.json', - }, - state, - [ - { - type: types.SET_INITIAL_STATE, - payload: { - currentDashboard: '.gitlab/dashboards/dashboard.yml', - deploymentsEndpoint: 'deployments.json', - }, - }, - ], - [], - ); - }); - }); - - describe('setExpandedPanel', () => { - it('Sets a panel as expanded', () => { - const group = 'group_1'; - const panel = { title: 'A Panel' }; - - return testAction( - setExpandedPanel, - { group, panel }, - state, - [{ type: types.SET_EXPANDED_PANEL, payload: { group, panel } }], - [], - ); - }); - }); - - describe('clearExpandedPanel', () => { - it('Clears a panel as expanded', () => { - return testAction( - clearExpandedPanel, - undefined, - state, - [{ type: types.SET_EXPANDED_PANEL, payload: { group: null, panel: null } }], - [], - ); - }); - }); - - // All Data - - describe('fetchData', () => { - it('dispatches fetchEnvironmentsData and fetchEnvironmentsData', () => { - return testAction( - fetchData, - null, - state, - [], - [ - { type: 'fetchEnvironmentsData' }, - { type: 'fetchDashboard' }, - { type: 'fetchAnnotations' }, - ], - ); - }); - - it('dispatches when feature metricsDashboardAnnotations is on', () => { - window.gon = { features: { metricsDashboardAnnotations: true } }; - - return testAction( - fetchData, - null, - state, - [], - [ - { type: 'fetchEnvironmentsData' }, - { type: 'fetchDashboard' }, - { type: 'fetchAnnotations' }, - ], - ); - }); - }); - - // Metrics dashboard - - describe('fetchDashboard', () => { - const response = metricsDashboardResponse; - beforeEach(() => { - state.dashboardEndpoint = '/dashboard'; - }); - - it('on success, dispatches receive and success actions, then fetches dashboard warnings', () => { - document.body.dataset.page = 'projects:environments:metrics'; - mock.onGet(state.dashboardEndpoint).reply(HTTP_STATUS_OK, response); - - return testAction( - fetchDashboard, - null, - state, - [], - [ - { type: 'requestMetricsDashboard' }, - { - type: 'receiveMetricsDashboardSuccess', - payload: { response }, - }, - { type: 'fetchDashboardValidationWarnings' }, - ], - ); - }); - - describe('on failure', () => { - let result; - beforeEach(() => { - const params = {}; - const localGetters = { - fullDashboardPath: store.getters['monitoringDashboard/fullDashboardPath'], - }; - result = () => { - mock - .onGet(state.dashboardEndpoint) - .replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR, mockDashboardsErrorResponse); - return fetchDashboard({ state, commit, dispatch, getters: localGetters }, params); - }; - }); - - it('dispatches a failure', async () => { - await result(); - expect(commit).toHaveBeenCalledWith( - types.SET_ALL_DASHBOARDS, - mockDashboardsErrorResponse.all_dashboards, - ); - expect(dispatch).toHaveBeenCalledWith( - 'receiveMetricsDashboardFailure', - new Error('Request failed with status code 500'), - ); - expect(createAlert).toHaveBeenCalled(); - }); - - it('dispatches a failure action when a message is returned', async () => { - await result(); - expect(dispatch).toHaveBeenCalledWith( - 'receiveMetricsDashboardFailure', - new Error('Request failed with status code 500'), - ); - expect(createAlert).toHaveBeenCalledWith({ - message: expect.stringContaining(mockDashboardsErrorResponse.message), - }); - }); - - it('does not show an alert when showErrorBanner is disabled', async () => { - state.showErrorBanner = false; - - await result(); - expect(dispatch).toHaveBeenCalledWith( - 'receiveMetricsDashboardFailure', - new Error('Request failed with status code 500'), - ); - expect(createAlert).not.toHaveBeenCalled(); - }); - }); - }); - - describe('receiveMetricsDashboardSuccess', () => { - it('stores groups', () => { - const response = metricsDashboardResponse; - receiveMetricsDashboardSuccess({ state, commit, dispatch }, { response }); - expect(commit).toHaveBeenCalledWith( - types.RECEIVE_METRICS_DASHBOARD_SUCCESS, - - metricsDashboardResponse.dashboard, - ); - expect(dispatch).toHaveBeenCalledWith('fetchDashboardData'); - }); - - it('sets the dashboards loaded from the repository', () => { - const params = {}; - const response = metricsDashboardResponse; - response.all_dashboards = dashboardGitResponse; - receiveMetricsDashboardSuccess( - { - state, - commit, - dispatch, - }, - { - response, - params, - }, - ); - expect(commit).toHaveBeenCalledWith(types.SET_ALL_DASHBOARDS, dashboardGitResponse); - }); - }); - - // Metrics - - describe('fetchDashboardData', () => { - beforeEach(() => { - jest.spyOn(Tracking, 'event'); - - state.timeRange = defaultTimeRange; - }); - - it('commits empty state when state.groups is empty', async () => { - const localGetters = { - metricsWithData: () => [], - }; - await fetchDashboardData({ state, commit, dispatch, getters: localGetters }); - expect(Tracking.event).toHaveBeenCalledWith(document.body.dataset.page, 'dashboard_fetch', { - label: 'custom_metrics_dashboard', - property: 'count', - value: 0, - }); - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData'); - expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', { - defaultQueryParams: { - start_time: expect.any(String), - end_time: expect.any(String), - step: expect.any(Number), - }, - }); - - expect(createAlert).not.toHaveBeenCalled(); - }); - - it('dispatches fetchPrometheusMetric for each panel query', async () => { - state.dashboard.panelGroups = convertObjectPropsToCamelCase( - metricsDashboardResponse.dashboard.panel_groups, - ); - - const [metric] = state.dashboard.panelGroups[0].panels[0].metrics; - const localGetters = { - metricsWithData: () => [metric.id], - }; - - await fetchDashboardData({ state, commit, dispatch, getters: localGetters }); - expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { - metric, - defaultQueryParams: { - start_time: expect.any(String), - end_time: expect.any(String), - step: expect.any(Number), - }, - }); - - expect(Tracking.event).toHaveBeenCalledWith(document.body.dataset.page, 'dashboard_fetch', { - label: 'custom_metrics_dashboard', - property: 'count', - value: 1, - }); - }); - - it('dispatches fetchPrometheusMetric for each panel query, handles an error', async () => { - state.dashboard.panelGroups = metricsDashboardViewModel.panelGroups; - const metric = state.dashboard.panelGroups[0].panels[0].metrics[0]; - - dispatch.mockResolvedValueOnce(); // fetchDeploymentsData - dispatch.mockResolvedValueOnce(); // fetchVariableMetricLabelValues - // Mock having one out of four metrics failing - dispatch.mockRejectedValueOnce(new Error('Error fetching this metric')); - dispatch.mockResolvedValue(); - - await fetchDashboardData({ state, commit, dispatch }); - const defaultQueryParams = { - start_time: expect.any(String), - end_time: expect.any(String), - step: expect.any(Number), - }; - - expect(dispatch).toHaveBeenCalledTimes(metricsDashboardPanelCount + 2); // plus 1 for deployments - expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData'); - expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', { - defaultQueryParams, - }); - expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { - metric, - defaultQueryParams, - }); - - expect(createAlert).toHaveBeenCalledTimes(1); - }); - }); - - describe('fetchPrometheusMetric', () => { - const defaultQueryParams = { - start_time: '2019-08-06T12:40:02.184Z', - end_time: '2019-08-06T20:40:02.184Z', - step: 60, - }; - let metric; - let data; - let prometheusEndpointPath; - - beforeEach(() => { - state = storeState(); - [metric] = metricsDashboardViewModel.panelGroups[0].panels[0].metrics; - - prometheusEndpointPath = metric.prometheusEndpointPath; - - data = { - metricId: metric.metricId, - result: [1582065167.353, 5, 1582065599.353], - }; - }); - - it('commits result', () => { - mock.onGet(prometheusEndpointPath).reply(HTTP_STATUS_OK, { data }); // One attempt - - return testAction( - fetchPrometheusMetric, - { metric, defaultQueryParams }, - state, - [ - { - type: types.REQUEST_METRIC_RESULT, - payload: { - metricId: metric.metricId, - }, - }, - { - type: types.RECEIVE_METRIC_RESULT_SUCCESS, - payload: { - metricId: metric.metricId, - data, - }, - }, - ], - [], - ); - }); - - describe('without metric defined step', () => { - const expectedParams = { - start_time: '2019-08-06T12:40:02.184Z', - end_time: '2019-08-06T20:40:02.184Z', - step: 60, - }; - - it('uses calculated step', async () => { - mock.onGet(prometheusEndpointPath).reply(HTTP_STATUS_OK, { data }); // One attempt - - await testAction( - fetchPrometheusMetric, - { metric, defaultQueryParams }, - state, - [ - { - type: types.REQUEST_METRIC_RESULT, - payload: { - metricId: metric.metricId, - }, - }, - { - type: types.RECEIVE_METRIC_RESULT_SUCCESS, - payload: { - metricId: metric.metricId, - data, - }, - }, - ], - [], - ); - expect(mock.history.get[0].params).toEqual(expectedParams); - }); - }); - - describe('with metric defined step', () => { - beforeEach(() => { - metric.step = 7; - }); - - const expectedParams = { - start_time: '2019-08-06T12:40:02.184Z', - end_time: '2019-08-06T20:40:02.184Z', - step: 7, - }; - - it('uses metric step', async () => { - mock.onGet(prometheusEndpointPath).reply(HTTP_STATUS_OK, { data }); // One attempt - - await testAction( - fetchPrometheusMetric, - { metric, defaultQueryParams }, - state, - [ - { - type: types.REQUEST_METRIC_RESULT, - payload: { - metricId: metric.metricId, - }, - }, - { - type: types.RECEIVE_METRIC_RESULT_SUCCESS, - payload: { - metricId: metric.metricId, - data, - }, - }, - ], - [], - ); - expect(mock.history.get[0].params).toEqual(expectedParams); - }); - }); - - it('commits failure, when waiting for results and getting a server error', async () => { - mock.onGet(prometheusEndpointPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - - const error = new Error('Request failed with status code 500'); - - await expect( - testAction( - fetchPrometheusMetric, - { metric, defaultQueryParams }, - state, - [ - { - type: types.REQUEST_METRIC_RESULT, - payload: { - metricId: metric.metricId, - }, - }, - { - type: types.RECEIVE_METRIC_RESULT_FAILURE, - payload: { - metricId: metric.metricId, - error, - }, - }, - ], - [], - ), - ).rejects.toEqual(error); - }); - }); - - // Deployments - - describe('fetchDeploymentsData', () => { - it('dispatches receiveDeploymentsDataSuccess on success', () => { - state.deploymentsEndpoint = '/success'; - mock.onGet(state.deploymentsEndpoint).reply(HTTP_STATUS_OK, { - deployments: deploymentData, - }); - - return testAction( - fetchDeploymentsData, - null, - state, - [], - [{ type: 'receiveDeploymentsDataSuccess', payload: deploymentData }], - ); - }); - it('dispatches receiveDeploymentsDataFailure on error', () => { - state.deploymentsEndpoint = '/error'; - mock.onGet(state.deploymentsEndpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - - return testAction( - fetchDeploymentsData, - null, - state, - [], - [{ type: 'receiveDeploymentsDataFailure' }], - () => { - expect(createAlert).toHaveBeenCalled(); - }, - ); - }); - }); - - // Environments - - describe('fetchEnvironmentsData', () => { - beforeEach(() => { - state.projectPath = 'gitlab-org/gitlab-test'; - }); - - it('setting SET_ENVIRONMENTS_FILTER should dispatch fetchEnvironmentsData', () => { - jest.spyOn(gqClient, 'mutate').mockReturnValue({ - data: { - project: { - data: { - environments: [], - }, - }, - }, - }); - - return testAction( - filterEnvironments, - {}, - state, - [ - { - type: 'SET_ENVIRONMENTS_FILTER', - payload: {}, - }, - ], - [ - { - type: 'fetchEnvironmentsData', - }, - ], - ); - }); - - it('fetch environments data call takes in search param', () => { - const mockMutate = jest.spyOn(gqClient, 'mutate'); - const searchTerm = 'Something'; - const mutationVariables = { - mutation: getEnvironments, - variables: { - projectPath: state.projectPath, - search: searchTerm, - states: [ENVIRONMENT_AVAILABLE_STATE], - }, - }; - state.environmentsSearchTerm = searchTerm; - mockMutate.mockResolvedValue({}); - - return testAction( - fetchEnvironmentsData, - null, - state, - [], - [ - { type: 'requestEnvironmentsData' }, - { type: 'receiveEnvironmentsDataSuccess', payload: [] }, - ], - () => { - expect(mockMutate).toHaveBeenCalledWith(mutationVariables); - }, - ); - }); - - it('dispatches receiveEnvironmentsDataSuccess on success', () => { - jest.spyOn(gqClient, 'mutate').mockResolvedValue({ - data: { - project: { - data: { - environments: environmentData, - }, - }, - }, - }); - - return testAction( - fetchEnvironmentsData, - null, - state, - [], - [ - { type: 'requestEnvironmentsData' }, - { - type: 'receiveEnvironmentsDataSuccess', - payload: parseEnvironmentsResponse(environmentData, state.projectPath), - }, - ], - ); - }); - - it('dispatches receiveEnvironmentsDataFailure on error', () => { - jest.spyOn(gqClient, 'mutate').mockRejectedValue({}); - - return testAction( - fetchEnvironmentsData, - null, - state, - [], - [{ type: 'requestEnvironmentsData' }, { type: 'receiveEnvironmentsDataFailure' }], - ); - }); - }); - - describe('fetchAnnotations', () => { - beforeEach(() => { - state.timeRange = { - start: '2020-04-15T12:54:32.137Z', - end: '2020-08-15T12:54:32.137Z', - }; - state.projectPath = 'gitlab-org/gitlab-test'; - state.currentEnvironmentName = 'production'; - state.currentDashboard = '.gitlab/dashboards/custom_dashboard.yml'; - // testAction doesn't have access to getters. The state is passed in as getters - // instead of the actual getters inside the testAction method implementation. - // All methods downstream that needs access to getters will throw and error. - // For that reason, the result of the getter is set as a state variable. - state.fullDashboardPath = store.getters['monitoringDashboard/fullDashboardPath']; - }); - - it('fetches annotations data and dispatches receiveAnnotationsSuccess', () => { - const mockMutate = jest.spyOn(gqClient, 'mutate'); - const mutationVariables = { - mutation: getAnnotations, - variables: { - projectPath: state.projectPath, - environmentName: state.currentEnvironmentName, - dashboardPath: state.currentDashboard, - startingFrom: state.timeRange.start, - }, - }; - const parsedResponse = parseAnnotationsResponse(annotationsData); - - mockMutate.mockResolvedValue({ - data: { - project: { - environments: { - nodes: [ - { - metricsDashboard: { - annotations: { - nodes: parsedResponse, - }, - }, - }, - ], - }, - }, - }, - }); - - return testAction( - fetchAnnotations, - null, - state, - [], - [{ type: 'receiveAnnotationsSuccess', payload: parsedResponse }], - () => { - expect(mockMutate).toHaveBeenCalledWith(mutationVariables); - }, - ); - }); - - it('dispatches receiveAnnotationsFailure if the annotations API call fails', () => { - const mockMutate = jest.spyOn(gqClient, 'mutate'); - const mutationVariables = { - mutation: getAnnotations, - variables: { - projectPath: state.projectPath, - environmentName: state.currentEnvironmentName, - dashboardPath: state.currentDashboard, - startingFrom: state.timeRange.start, - }, - }; - - mockMutate.mockRejectedValue({}); - - return testAction( - fetchAnnotations, - null, - state, - [], - [{ type: 'receiveAnnotationsFailure' }], - () => { - expect(mockMutate).toHaveBeenCalledWith(mutationVariables); - }, - ); - }); - }); - - describe('fetchDashboardValidationWarnings', () => { - let mockMutate; - let mutationVariables; - - beforeEach(() => { - state.projectPath = 'gitlab-org/gitlab-test'; - state.currentEnvironmentName = 'production'; - state.currentDashboard = '.gitlab/dashboards/dashboard_with_warnings.yml'; - // testAction doesn't have access to getters. The state is passed in as getters - // instead of the actual getters inside the testAction method implementation. - // All methods downstream that needs access to getters will throw and error. - // For that reason, the result of the getter is set as a state variable. - state.fullDashboardPath = store.getters['monitoringDashboard/fullDashboardPath']; - - mockMutate = jest.spyOn(gqClient, 'mutate'); - mutationVariables = { - mutation: getDashboardValidationWarnings, - variables: { - projectPath: state.projectPath, - environmentName: state.currentEnvironmentName, - dashboardPath: state.fullDashboardPath, - }, - }; - }); - - it('dispatches receiveDashboardValidationWarningsSuccess with true payload when there are warnings', () => { - mockMutate.mockResolvedValue({ - data: { - project: { - id: 'gid://gitlab/Project/29', - environments: { - nodes: [ - { - name: 'production', - metricsDashboard: { - path: '.gitlab/dashboards/dashboard_errors_test.yml', - schemaValidationWarnings: ["unit: can't be blank"], - }, - }, - ], - }, - }, - }, - }); - - return testAction( - fetchDashboardValidationWarnings, - null, - state, - [], - [{ type: 'receiveDashboardValidationWarningsSuccess', payload: true }], - () => { - expect(mockMutate).toHaveBeenCalledWith(mutationVariables); - }, - ); - }); - - it('dispatches receiveDashboardValidationWarningsSuccess with false payload when there are no warnings', () => { - mockMutate.mockResolvedValue({ - data: { - project: { - id: 'gid://gitlab/Project/29', - environments: { - nodes: [ - { - name: 'production', - metricsDashboard: { - path: '.gitlab/dashboards/dashboard_errors_test.yml', - schemaValidationWarnings: [], - }, - }, - ], - }, - }, - }, - }); - - return testAction( - fetchDashboardValidationWarnings, - null, - state, - [], - [{ type: 'receiveDashboardValidationWarningsSuccess', payload: false }], - () => { - expect(mockMutate).toHaveBeenCalledWith(mutationVariables); - }, - ); - }); - - it('dispatches receiveDashboardValidationWarningsSuccess with false payload when the response is empty', () => { - mockMutate.mockResolvedValue({ - data: { - project: null, - }, - }); - - return testAction( - fetchDashboardValidationWarnings, - null, - state, - [], - [{ type: 'receiveDashboardValidationWarningsSuccess', payload: false }], - () => { - expect(mockMutate).toHaveBeenCalledWith(mutationVariables); - }, - ); - }); - - it('dispatches receiveDashboardValidationWarningsFailure if the warnings API call fails', () => { - mockMutate.mockRejectedValue({}); - - return testAction( - fetchDashboardValidationWarnings, - null, - state, - [], - [{ type: 'receiveDashboardValidationWarningsFailure' }], - () => { - expect(mockMutate).toHaveBeenCalledWith(mutationVariables); - }, - ); - }); - }); - - // Dashboard manipulation - - describe('toggleStarredValue', () => { - let unstarredDashboard; - let starredDashboard; - - beforeEach(() => { - state.isUpdatingStarredValue = false; - [unstarredDashboard, starredDashboard] = dashboardGitResponse; - }); - - it('performs no changes if no dashboard is selected', () => { - return testAction(toggleStarredValue, null, state, [], []); - }); - - it('performs no changes if already changing starred value', () => { - state.selectedDashboard = unstarredDashboard; - state.isUpdatingStarredValue = true; - return testAction(toggleStarredValue, null, state, [], []); - }); - - it('stars dashboard if it is not starred', () => { - state.selectedDashboard = unstarredDashboard; - mock.onPost(unstarredDashboard.user_starred_path).reply(HTTP_STATUS_OK); - - return testAction(toggleStarredValue, null, state, [ - { type: types.REQUEST_DASHBOARD_STARRING }, - { - type: types.RECEIVE_DASHBOARD_STARRING_SUCCESS, - payload: { - newStarredValue: true, - selectedDashboard: unstarredDashboard, - }, - }, - ]); - }); - - it('unstars dashboard if it is starred', () => { - state.selectedDashboard = starredDashboard; - mock.onPost(starredDashboard.user_starred_path).reply(HTTP_STATUS_OK); - - return testAction(toggleStarredValue, null, state, [ - { type: types.REQUEST_DASHBOARD_STARRING }, - { type: types.RECEIVE_DASHBOARD_STARRING_FAILURE }, - ]); - }); - }); - - describe('duplicateSystemDashboard', () => { - beforeEach(() => { - state.dashboardsEndpoint = '/dashboards.json'; - }); - - it('Succesful POST request resolves', async () => { - mock.onPost(state.dashboardsEndpoint).reply(HTTP_STATUS_CREATED, { - dashboard: dashboardGitResponse[1], - }); - - await testAction(duplicateSystemDashboard, {}, state, [], []); - expect(mock.history.post).toHaveLength(1); - }); - - it('Succesful POST request resolves to a dashboard', async () => { - const mockCreatedDashboard = dashboardGitResponse[1]; - - const params = { - dashboard: 'my-dashboard', - fileName: 'file-name.yml', - branch: 'my-new-branch', - commitMessage: 'A new commit message', - }; - - const expectedPayload = JSON.stringify({ - dashboard: 'my-dashboard', - file_name: 'file-name.yml', - branch: 'my-new-branch', - commit_message: 'A new commit message', - }); - - mock.onPost(state.dashboardsEndpoint).reply(HTTP_STATUS_CREATED, { - dashboard: mockCreatedDashboard, - }); - - const result = await testAction(duplicateSystemDashboard, params, state, [], []); - expect(mock.history.post).toHaveLength(1); - expect(mock.history.post[0].data).toEqual(expectedPayload); - expect(result).toEqual(mockCreatedDashboard); - }); - - it('Failed POST request throws an error', async () => { - mock.onPost(state.dashboardsEndpoint).reply(HTTP_STATUS_BAD_REQUEST); - - await expect(testAction(duplicateSystemDashboard, {}, state, [], [])).rejects.toEqual( - 'There was an error creating the dashboard.', - ); - expect(mock.history.post).toHaveLength(1); - }); - - it('Failed POST request throws an error with a description', async () => { - const backendErrorMsg = 'This file already exists!'; - - mock.onPost(state.dashboardsEndpoint).reply(HTTP_STATUS_BAD_REQUEST, { - error: backendErrorMsg, - }); - - await expect(testAction(duplicateSystemDashboard, {}, state, [], [])).rejects.toEqual( - `There was an error creating the dashboard. ${backendErrorMsg}`, - ); - expect(mock.history.post).toHaveLength(1); - }); - }); - - // Variables manipulation - - describe('updateVariablesAndFetchData', () => { - it('should commit UPDATE_VARIABLE_VALUE mutation and fetch data', () => { - return testAction( - updateVariablesAndFetchData, - { pod: 'POD' }, - state, - [ - { - type: types.UPDATE_VARIABLE_VALUE, - payload: { pod: 'POD' }, - }, - ], - [ - { - type: 'fetchDashboardData', - }, - ], - ); - }); - }); - - describe('fetchVariableMetricLabelValues', () => { - const variable = { - type: 'metric_label_values', - name: 'label1', - options: { - prometheusEndpointPath: '/series?match[]=metric_name', - label: 'job', - }, - }; - - const defaultQueryParams = { - start_time: '2019-08-06T12:40:02.184Z', - end_time: '2019-08-06T20:40:02.184Z', - }; - - beforeEach(() => { - state = { - ...state, - timeRange: defaultTimeRange, - variables: [variable], - }; - }); - - it('should commit UPDATE_VARIABLE_METRIC_LABEL_VALUES mutation and fetch data', () => { - const data = [ - { - __name__: 'up', - job: 'prometheus', - }, - { - __name__: 'up', - job: 'POD', - }, - ]; - - mock.onGet('/series?match[]=metric_name').reply(HTTP_STATUS_OK, { - status: 'success', - data, - }); - - return testAction( - fetchVariableMetricLabelValues, - { defaultQueryParams }, - state, - [ - { - type: types.UPDATE_VARIABLE_METRIC_LABEL_VALUES, - payload: { variable, label: 'job', data }, - }, - ], - [], - ); - }); - - it('should notify the user that dynamic options were not loaded', () => { - mock.onGet('/series?match[]=metric_name').reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - - return testAction(fetchVariableMetricLabelValues, { defaultQueryParams }, state, [], []).then( - () => { - expect(createAlert).toHaveBeenCalledTimes(1); - expect(createAlert).toHaveBeenCalledWith({ - message: expect.stringContaining('error getting options for variable "label1"'), - }); - }, - ); - }); - }); - - describe('fetchPanelPreview', () => { - const panelPreviewEndpoint = '/builder.json'; - const mockYmlContent = 'mock yml content'; - - beforeEach(() => { - state.panelPreviewEndpoint = panelPreviewEndpoint; - }); - - it('should not commit or dispatch if payload is empty', () => { - testAction(fetchPanelPreview, '', state, [], []); - }); - - it('should store the panel and fetch metric results', () => { - const mockPanel = { - title: 'Go heap size', - type: 'area-chart', - }; - - mock - .onPost(panelPreviewEndpoint, { panel_yaml: mockYmlContent }) - .reply(HTTP_STATUS_OK, mockPanel); - - testAction( - fetchPanelPreview, - mockYmlContent, - state, - [ - { type: types.SET_PANEL_PREVIEW_IS_SHOWN, payload: true }, - { type: types.REQUEST_PANEL_PREVIEW, payload: mockYmlContent }, - { type: types.RECEIVE_PANEL_PREVIEW_SUCCESS, payload: mockPanel }, - ], - [{ type: 'fetchPanelPreviewMetrics' }], - ); - }); - - it('should display a validation error when the backend cannot process the yml', () => { - const mockErrorMsg = 'Each "metric" must define one of :query or :query_range'; - - mock - .onPost(panelPreviewEndpoint, { panel_yaml: mockYmlContent }) - .reply(HTTP_STATUS_UNPROCESSABLE_ENTITY, { - message: mockErrorMsg, - }); - - testAction(fetchPanelPreview, mockYmlContent, state, [ - { type: types.SET_PANEL_PREVIEW_IS_SHOWN, payload: true }, - { type: types.REQUEST_PANEL_PREVIEW, payload: mockYmlContent }, - { type: types.RECEIVE_PANEL_PREVIEW_FAILURE, payload: mockErrorMsg }, - ]); - }); - - it('should display a generic error when the backend fails', () => { - mock - .onPost(panelPreviewEndpoint, { panel_yaml: mockYmlContent }) - .reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - - testAction(fetchPanelPreview, mockYmlContent, state, [ - { type: types.SET_PANEL_PREVIEW_IS_SHOWN, payload: true }, - { type: types.REQUEST_PANEL_PREVIEW, payload: mockYmlContent }, - { - type: types.RECEIVE_PANEL_PREVIEW_FAILURE, - payload: 'Request failed with status code 500', - }, - ]); - }); - }); -}); diff --git a/spec/frontend/monitoring/store/embed_group/actions_spec.js b/spec/frontend/monitoring/store/embed_group/actions_spec.js deleted file mode 100644 index 5bdfc506cff..00000000000 --- a/spec/frontend/monitoring/store/embed_group/actions_spec.js +++ /dev/null @@ -1,16 +0,0 @@ -// import store from '~/monitoring/stores/embed_group'; -import * as actions from '~/monitoring/stores/embed_group/actions'; -import * as types from '~/monitoring/stores/embed_group/mutation_types'; -import { mockNamespace } from '../../mock_data'; - -describe('Embed group actions', () => { - describe('addModule', () => { - it('adds a module to the store', () => { - const commit = jest.fn(); - - actions.addModule({ commit }, mockNamespace); - - expect(commit).toHaveBeenCalledWith(types.ADD_MODULE, mockNamespace); - }); - }); -}); diff --git a/spec/frontend/monitoring/store/embed_group/getters_spec.js b/spec/frontend/monitoring/store/embed_group/getters_spec.js deleted file mode 100644 index e3241e41f5e..00000000000 --- a/spec/frontend/monitoring/store/embed_group/getters_spec.js +++ /dev/null @@ -1,19 +0,0 @@ -import { metricsWithData } from '~/monitoring/stores/embed_group/getters'; -import { mockNamespaces } from '../../mock_data'; - -describe('Embed group getters', () => { - describe('metricsWithData', () => { - it('correctly sums the number of metrics with data', () => { - const mockMetric = {}; - const state = { - modules: mockNamespaces, - }; - const rootGetters = { - [`${mockNamespaces[0]}/metricsWithData`]: () => [mockMetric], - [`${mockNamespaces[1]}/metricsWithData`]: () => [mockMetric, mockMetric], - }; - - expect(metricsWithData(state, null, null, rootGetters)).toEqual([1, 2]); - }); - }); -}); diff --git a/spec/frontend/monitoring/store/embed_group/mutations_spec.js b/spec/frontend/monitoring/store/embed_group/mutations_spec.js deleted file mode 100644 index 2f8d7687aad..00000000000 --- a/spec/frontend/monitoring/store/embed_group/mutations_spec.js +++ /dev/null @@ -1,16 +0,0 @@ -import * as types from '~/monitoring/stores/embed_group/mutation_types'; -import mutations from '~/monitoring/stores/embed_group/mutations'; -import state from '~/monitoring/stores/embed_group/state'; -import { mockNamespace } from '../../mock_data'; - -describe('Embed group mutations', () => { - describe('ADD_MODULE', () => { - it('should add a module', () => { - const stateCopy = state(); - - mutations[types.ADD_MODULE](stateCopy, mockNamespace); - - expect(stateCopy.modules).toEqual([mockNamespace]); - }); - }); -}); diff --git a/spec/frontend/monitoring/store/getters_spec.js b/spec/frontend/monitoring/store/getters_spec.js deleted file mode 100644 index c7f3bdbf1f8..00000000000 --- a/spec/frontend/monitoring/store/getters_spec.js +++ /dev/null @@ -1,457 +0,0 @@ -import _ from 'lodash'; -import { metricStates } from '~/monitoring/constants'; -import * as getters from '~/monitoring/stores/getters'; -import * as types from '~/monitoring/stores/mutation_types'; -import mutations from '~/monitoring/stores/mutations'; -import { metricsDashboardPayload } from '../fixture_data'; -import { - customDashboardBasePath, - environmentData, - metricsResult, - dashboardGitResponse, - storeVariables, - mockLinks, -} from '../mock_data'; - -describe('Monitoring store Getters', () => { - let state; - - const getMetric = ({ group = 0, panel = 0, metric = 0 } = {}) => - state.dashboard.panelGroups[group].panels[panel].metrics[metric]; - - const setMetricSuccess = ({ group, panel, metric, result = metricsResult } = {}) => { - const { metricId } = getMetric({ group, panel, metric }); - mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, { - metricId, - data: { - resultType: 'matrix', - result, - }, - }); - }; - - const setMetricFailure = ({ group, panel, metric } = {}) => { - const { metricId } = getMetric({ group, panel, metric }); - mutations[types.RECEIVE_METRIC_RESULT_FAILURE](state, { - metricId, - }); - }; - - describe('getMetricStates', () => { - let setupState; - let getMetricStates; - - beforeEach(() => { - setupState = (initState = {}) => { - state = initState; - getMetricStates = getters.getMetricStates(state); - }; - }); - - it('has method-style access', () => { - setupState(); - - expect(getMetricStates).toEqual(expect.any(Function)); - }); - - it('when dashboard has no panel groups, returns empty', () => { - setupState({ - dashboard: { - panelGroups: [], - }, - }); - - expect(getMetricStates()).toEqual([]); - }); - - describe('when the dashboard is set', () => { - let groups; - beforeEach(() => { - setupState({ - dashboard: { panelGroups: [] }, - }); - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - groups = state.dashboard.panelGroups; - }); - - it('no loaded metric returns empty', () => { - expect(getMetricStates()).toEqual([]); - }); - - it('on an empty metric with no result, returns NO_DATA', () => { - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - setMetricSuccess({ group: 2, result: [] }); - - expect(getMetricStates()).toEqual([metricStates.NO_DATA]); - }); - - it('on a metric with a result, returns OK', () => { - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - setMetricSuccess({ group: 1 }); - - expect(getMetricStates()).toEqual([metricStates.OK]); - }); - - it('on a metric with an error, returns an error', () => { - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - setMetricFailure({}); - - expect(getMetricStates()).toEqual([metricStates.UNKNOWN_ERROR]); - }); - - it('on multiple metrics with results, returns OK', () => { - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - - setMetricSuccess({ group: 1 }); - setMetricSuccess({ group: 1, panel: 1 }); - - expect(getMetricStates()).toEqual([metricStates.OK]); - - // Filtered by groups - expect(getMetricStates(state.dashboard.panelGroups[1].key)).toEqual([metricStates.OK]); - expect(getMetricStates(state.dashboard.panelGroups[2].key)).toEqual([]); - }); - it('on multiple metrics errors', () => { - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - - setMetricFailure({}); - setMetricFailure({ group: 1 }); - - // Entire dashboard fails - expect(getMetricStates()).toEqual([metricStates.UNKNOWN_ERROR]); - expect(getMetricStates(groups[0].key)).toEqual([metricStates.UNKNOWN_ERROR]); - expect(getMetricStates(groups[1].key)).toEqual([metricStates.UNKNOWN_ERROR]); - }); - - it('on multiple metrics with errors', () => { - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - - // An success in 1 group - setMetricSuccess({ group: 1 }); - - // An error in 2 groups - setMetricFailure({ group: 1, panel: 1 }); - setMetricFailure({ group: 2, panel: 0 }); - - expect(getMetricStates()).toEqual([metricStates.OK, metricStates.UNKNOWN_ERROR]); - expect(getMetricStates(groups[1].key)).toEqual([ - metricStates.OK, - metricStates.UNKNOWN_ERROR, - ]); - expect(getMetricStates(groups[2].key)).toEqual([metricStates.UNKNOWN_ERROR]); - }); - }); - }); - - describe('metricsWithData', () => { - let metricsWithData; - let setupState; - - beforeEach(() => { - setupState = (initState = {}) => { - state = initState; - metricsWithData = getters.metricsWithData(state); - }; - }); - - afterEach(() => { - state = null; - }); - - it('has method-style access', () => { - setupState(); - - expect(metricsWithData).toEqual(expect.any(Function)); - }); - - it('when dashboard has no panel groups, returns empty', () => { - setupState({ - dashboard: { - panelGroups: [], - }, - }); - - expect(metricsWithData()).toEqual([]); - }); - - describe('when the dashboard is set', () => { - beforeEach(() => { - setupState({ - dashboard: { panelGroups: [] }, - }); - }); - - it('no loaded metric returns empty', () => { - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - - expect(metricsWithData()).toEqual([]); - }); - - it('an empty metric, returns empty', () => { - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - setMetricSuccess({ result: [] }); - - expect(metricsWithData()).toEqual([]); - }); - - it('a metric with results, it returns a metric', () => { - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - setMetricSuccess(); - - expect(metricsWithData()).toEqual([getMetric().metricId]); - }); - - it('multiple metrics with results, it return multiple metrics', () => { - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - setMetricSuccess({ panel: 0 }); - setMetricSuccess({ panel: 1 }); - - expect(metricsWithData()).toEqual([ - getMetric({ panel: 0 }).metricId, - getMetric({ panel: 1 }).metricId, - ]); - }); - - it('multiple metrics with results, it returns metrics filtered by group', () => { - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - - setMetricSuccess({ group: 1 }); - setMetricSuccess({ group: 1, panel: 1 }); - - // First group has metrics - expect(metricsWithData(state.dashboard.panelGroups[1].key)).toEqual([ - getMetric({ group: 1 }).metricId, - getMetric({ group: 1, panel: 1 }).metricId, - ]); - - // Second group has no metrics - expect(metricsWithData(state.dashboard.panelGroups[2].key)).toEqual([]); - }); - }); - }); - - describe('filteredEnvironments', () => { - const setupState = (initState = {}) => { - state = { - ...state, - ...initState, - }; - }; - - beforeAll(() => { - setupState({ - environments: environmentData, - }); - }); - - afterAll(() => { - state = null; - }); - - [ - { - input: '', - output: 17, - }, - { - input: ' ', - output: 17, - }, - { - input: null, - output: 17, - }, - { - input: 'does-not-exist', - output: 0, - }, - { - input: 'noop-branch-', - output: 15, - }, - { - input: 'noop-branch-9', - output: 1, - }, - ].forEach(({ input, output }) => { - it(`filteredEnvironments returns ${output} items for ${input}`, () => { - setupState({ - environmentsSearchTerm: input, - }); - expect(getters.filteredEnvironments(state).length).toBe(output); - }); - }); - }); - - describe('metricsSavedToDb', () => { - let metricsSavedToDb; - let mockData; - - beforeEach(() => { - mockData = _.cloneDeep(metricsDashboardPayload); - state = { - dashboard: { - panelGroups: [], - }, - }; - }); - - it('return no metrics when dashboard is not persisted', () => { - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, mockData); - metricsSavedToDb = getters.metricsSavedToDb(state); - - expect(metricsSavedToDb).toEqual([]); - }); - - it('return a metric id when one metric is persisted', () => { - const id = 99; - - const [metric] = mockData.panel_groups[0].panels[0].metrics; - - metric.metric_id = id; - - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, mockData); - metricsSavedToDb = getters.metricsSavedToDb(state); - - expect(metricsSavedToDb).toEqual([`${id}_${metric.id}`]); - }); - - it('return a metric id when two metrics are persisted', () => { - const id1 = 101; - const id2 = 102; - - const [metric1] = mockData.panel_groups[0].panels[0].metrics; - const [metric2] = mockData.panel_groups[0].panels[1].metrics; - - // database persisted 2 metrics - metric1.metric_id = id1; - metric2.metric_id = id2; - - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, mockData); - metricsSavedToDb = getters.metricsSavedToDb(state); - - expect(metricsSavedToDb).toEqual([`${id1}_${metric1.id}`, `${id2}_${metric2.id}`]); - }); - }); - - describe('getCustomVariablesParams', () => { - beforeEach(() => { - state = { - variables: {}, - }; - }); - - it('transforms the variables object to an array in the [variable, variable_value] format for all variable types', () => { - state.variables = storeVariables; - const variablesArray = getters.getCustomVariablesParams(state); - - expect(variablesArray).toEqual({ - 'variables[textSimple]': 'My default value', - 'variables[textAdvanced]': 'A default value', - 'variables[customSimple]': 'value1', - 'variables[customAdvanced]': 'value2', - 'variables[customAdvancedWithoutLabel]': 'value2', - 'variables[customAdvancedWithoutOptText]': 'value2', - }); - }); - - it('transforms the variables object to an empty array when no keys are present', () => { - state.variables = []; - const variablesArray = getters.getCustomVariablesParams(state); - - expect(variablesArray).toEqual({}); - }); - }); - - describe('selectedDashboard', () => { - const { selectedDashboard } = getters; - const localGetters = (localState) => ({ - fullDashboardPath: getters.fullDashboardPath(localState), - }); - - it('returns a dashboard', () => { - const localState = { - allDashboards: dashboardGitResponse, - currentDashboard: dashboardGitResponse[0].path, - customDashboardBasePath, - }; - expect(selectedDashboard(localState, localGetters(localState))).toEqual( - dashboardGitResponse[0], - ); - }); - - it('returns a dashboard different from the overview dashboard', () => { - const localState = { - allDashboards: dashboardGitResponse, - currentDashboard: dashboardGitResponse[1].path, - customDashboardBasePath, - }; - expect(selectedDashboard(localState, localGetters(localState))).toEqual( - dashboardGitResponse[1], - ); - }); - - it('returns the overview dashboard when no dashboard is selected', () => { - const localState = { - allDashboards: dashboardGitResponse, - currentDashboard: null, - customDashboardBasePath, - }; - expect(selectedDashboard(localState, localGetters(localState))).toEqual( - dashboardGitResponse[0], - ); - }); - - it('returns the overview dashboard when dashboard cannot be found', () => { - const localState = { - allDashboards: dashboardGitResponse, - currentDashboard: 'wrong_path', - customDashboardBasePath, - }; - expect(selectedDashboard(localState, localGetters(localState))).toEqual( - dashboardGitResponse[0], - ); - }); - - it('returns null when no dashboards are present', () => { - const localState = { - allDashboards: [], - currentDashboard: dashboardGitResponse[0].path, - customDashboardBasePath, - }; - expect(selectedDashboard(localState, localGetters(localState))).toEqual(null); - }); - }); - - describe('linksWithMetadata', () => { - const setupState = (initState = {}) => { - state = { - ...state, - ...initState, - }; - }; - - beforeAll(() => { - setupState({ - links: mockLinks, - }); - }); - - afterAll(() => { - state = null; - }); - - it.each` - timeRange | output - ${{}} | ${''} - ${{ start: '2020-01-01T00:00:00.000Z', end: '2020-01-31T23:59:00.000Z' }} | ${'start=2020-01-01T00%3A00%3A00.000Z&end=2020-01-31T23%3A59%3A00.000Z'} - ${{ duration: { seconds: 86400 } }} | ${'duration_seconds=86400'} - `('linksWithMetadata returns URLs with time range', ({ timeRange, output }) => { - setupState({ timeRange }); - const links = getters.linksWithMetadata(state); - links.forEach(({ url }) => { - expect(url).toMatch(output); - }); - }); - }); -}); diff --git a/spec/frontend/monitoring/store/index_spec.js b/spec/frontend/monitoring/store/index_spec.js deleted file mode 100644 index 4184687eec8..00000000000 --- a/spec/frontend/monitoring/store/index_spec.js +++ /dev/null @@ -1,23 +0,0 @@ -import { createStore } from '~/monitoring/stores'; - -describe('Monitoring Store Index', () => { - it('creates store with a `monitoringDashboard` namespace', () => { - expect(createStore().state).toEqual({ - monitoringDashboard: expect.any(Object), - }); - }); - - it('creates store with initial values', () => { - const defaults = { - deploymentsEndpoint: '/mock/deployments', - dashboardEndpoint: '/mock/dashboard', - dashboardsEndpoint: '/mock/dashboards', - }; - - const { state } = createStore(defaults); - - expect(state).toEqual({ - monitoringDashboard: expect.objectContaining(defaults), - }); - }); -}); diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js deleted file mode 100644 index 3baef743f42..00000000000 --- a/spec/frontend/monitoring/store/mutations_spec.js +++ /dev/null @@ -1,586 +0,0 @@ -import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_SERVICE_UNAVAILABLE } from '~/lib/utils/http_status'; -import { dashboardEmptyStates, metricStates } from '~/monitoring/constants'; -import * as types from '~/monitoring/stores/mutation_types'; -import mutations from '~/monitoring/stores/mutations'; -import state from '~/monitoring/stores/state'; -import { metricsDashboardPayload } from '../fixture_data'; -import { prometheusMatrixMultiResult } from '../graph_data'; -import { deploymentData, dashboardGitResponse, storeTextVariables } from '../mock_data'; - -describe('Monitoring mutations', () => { - let stateCopy; - - beforeEach(() => { - stateCopy = state(); - }); - - describe('REQUEST_METRICS_DASHBOARD', () => { - it('sets an empty loading state', () => { - mutations[types.REQUEST_METRICS_DASHBOARD](stateCopy); - - expect(stateCopy.emptyState).toBe(dashboardEmptyStates.LOADING); - }); - }); - - describe('RECEIVE_METRICS_DASHBOARD_SUCCESS', () => { - let payload; - const getGroups = () => stateCopy.dashboard.panelGroups; - - beforeEach(() => { - stateCopy.dashboard.panelGroups = []; - payload = metricsDashboardPayload; - }); - it('sets an empty noData state when the dashboard is empty', () => { - const emptyDashboardPayload = { - ...payload, - panel_groups: [], - }; - - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](stateCopy, emptyDashboardPayload); - const groups = getGroups(); - - expect(groups).toEqual([]); - expect(stateCopy.emptyState).toBe(dashboardEmptyStates.NO_DATA); - }); - it('adds a key to the group', () => { - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](stateCopy, payload); - const groups = getGroups(); - - expect(groups[0].key).toBe('system-metrics-kubernetes-0'); - expect(groups[1].key).toBe('response-metrics-nginx-ingress-vts-1'); - expect(groups[2].key).toBe('response-metrics-nginx-ingress-2'); - }); - it('normalizes values', () => { - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](stateCopy, payload); - const expectedLabel = 'Pod average (MB)'; - - const { label, queryRange } = getGroups()[0].panels[2].metrics[0]; - expect(label).toEqual(expectedLabel); - expect(queryRange.length).toBeGreaterThan(0); - }); - it('contains six groups, with panels with a metric each', () => { - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](stateCopy, payload); - - const groups = getGroups(); - - expect(groups).toBeDefined(); - expect(groups).toHaveLength(6); - - expect(groups[0].panels).toHaveLength(7); - expect(groups[0].panels[0].metrics).toHaveLength(1); - expect(groups[0].panels[1].metrics).toHaveLength(1); - expect(groups[0].panels[2].metrics).toHaveLength(1); - - expect(groups[1].panels).toHaveLength(3); - expect(groups[1].panels[0].metrics).toHaveLength(1); - }); - it('assigns metrics a metric id', () => { - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](stateCopy, payload); - - const groups = getGroups(); - - expect(groups[0].panels[0].metrics[0].metricId).toEqual( - 'NO_DB_system_metrics_kubernetes_container_memory_total', - ); - expect(groups[1].panels[0].metrics[0].metricId).toEqual( - 'NO_DB_response_metrics_nginx_ingress_throughput_status_code', - ); - expect(groups[2].panels[0].metrics[0].metricId).toEqual( - 'NO_DB_response_metrics_nginx_ingress_16_throughput_status_code', - ); - }); - }); - - describe('RECEIVE_METRICS_DASHBOARD_FAILURE', () => { - it('sets an empty noData state when an empty error occurs', () => { - mutations[types.RECEIVE_METRICS_DASHBOARD_FAILURE](stateCopy); - - expect(stateCopy.emptyState).toBe(dashboardEmptyStates.NO_DATA); - }); - - it('sets an empty unableToConnect state when an error occurs', () => { - mutations[types.RECEIVE_METRICS_DASHBOARD_FAILURE](stateCopy, 'myerror'); - - expect(stateCopy.emptyState).toBe(dashboardEmptyStates.UNABLE_TO_CONNECT); - }); - }); - - describe('Dashboard starring mutations', () => { - it('REQUEST_DASHBOARD_STARRING', () => { - stateCopy = { isUpdatingStarredValue: false }; - mutations[types.REQUEST_DASHBOARD_STARRING](stateCopy); - - expect(stateCopy.isUpdatingStarredValue).toBe(true); - }); - - describe('RECEIVE_DASHBOARD_STARRING_SUCCESS', () => { - let allDashboards; - - beforeEach(() => { - allDashboards = [...dashboardGitResponse]; - stateCopy = { - allDashboards, - currentDashboard: allDashboards[1].path, - isUpdatingStarredValue: true, - }; - }); - - it('sets a dashboard as starred', () => { - mutations[types.RECEIVE_DASHBOARD_STARRING_SUCCESS](stateCopy, { - selectedDashboard: stateCopy.allDashboards[1], - newStarredValue: true, - }); - - expect(stateCopy.isUpdatingStarredValue).toBe(false); - expect(stateCopy.allDashboards[1].starred).toBe(true); - }); - - it('sets a dashboard as unstarred', () => { - mutations[types.RECEIVE_DASHBOARD_STARRING_SUCCESS](stateCopy, { - selectedDashboard: stateCopy.allDashboards[1], - newStarredValue: false, - }); - - expect(stateCopy.isUpdatingStarredValue).toBe(false); - expect(stateCopy.allDashboards[1].starred).toBe(false); - }); - }); - - it('RECEIVE_DASHBOARD_STARRING_FAILURE', () => { - stateCopy = { isUpdatingStarredValue: true }; - mutations[types.RECEIVE_DASHBOARD_STARRING_FAILURE](stateCopy); - - expect(stateCopy.isUpdatingStarredValue).toBe(false); - }); - }); - - describe('RECEIVE_DEPLOYMENTS_DATA_SUCCESS', () => { - it('stores the deployment data', () => { - stateCopy.deploymentData = []; - mutations[types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS](stateCopy, deploymentData); - expect(stateCopy.deploymentData).toBeDefined(); - expect(stateCopy.deploymentData).toHaveLength(3); - expect(typeof stateCopy.deploymentData[0]).toEqual('object'); - }); - }); - - describe('SET_INITIAL_STATE', () => { - it('should set all the endpoints', () => { - mutations[types.SET_INITIAL_STATE](stateCopy, { - deploymentsEndpoint: 'deployments.json', - dashboardEndpoint: 'dashboard.json', - projectPath: '/gitlab-org/gitlab-foss', - currentEnvironmentName: 'production', - }); - expect(stateCopy.deploymentsEndpoint).toEqual('deployments.json'); - expect(stateCopy.dashboardEndpoint).toEqual('dashboard.json'); - expect(stateCopy.projectPath).toEqual('/gitlab-org/gitlab-foss'); - expect(stateCopy.currentEnvironmentName).toEqual('production'); - }); - - it('should not remove previously set properties', () => { - mutations[types.SET_INITIAL_STATE](stateCopy, { - dashboardEndpoint: 'dashboard.json', - }); - mutations[types.SET_INITIAL_STATE](stateCopy, { - projectPath: '/gitlab-org/gitlab-foss', - }); - mutations[types.SET_INITIAL_STATE](stateCopy, { - currentEnvironmentName: 'canary', - }); - - expect(stateCopy).toMatchObject({ - dashboardEndpoint: 'dashboard.json', - projectPath: '/gitlab-org/gitlab-foss', - currentEnvironmentName: 'canary', - }); - }); - - it('should not update unknown properties', () => { - mutations[types.SET_INITIAL_STATE](stateCopy, { - dashboardEndpoint: 'dashboard.json', - someOtherProperty: 'some invalid value', // someOtherProperty is not allowed - }); - - expect(stateCopy.dashboardEndpoint).toBe('dashboard.json'); - expect(stateCopy.someOtherProperty).toBeUndefined(); - }); - }); - - describe('SET_ENDPOINTS', () => { - it('should set all the endpoints', () => { - mutations[types.SET_ENDPOINTS](stateCopy, { - deploymentsEndpoint: 'deployments.json', - dashboardEndpoint: 'dashboard.json', - projectPath: '/gitlab-org/gitlab-foss', - }); - expect(stateCopy.deploymentsEndpoint).toEqual('deployments.json'); - expect(stateCopy.dashboardEndpoint).toEqual('dashboard.json'); - expect(stateCopy.projectPath).toEqual('/gitlab-org/gitlab-foss'); - }); - - it('should not remove previously set properties', () => { - mutations[types.SET_ENDPOINTS](stateCopy, { - dashboardEndpoint: 'dashboard.json', - }); - mutations[types.SET_ENDPOINTS](stateCopy, { - projectPath: '/gitlab-org/gitlab-foss', - }); - - expect(stateCopy).toMatchObject({ - dashboardEndpoint: 'dashboard.json', - projectPath: '/gitlab-org/gitlab-foss', - }); - }); - - it('should not update unknown properties', () => { - mutations[types.SET_ENDPOINTS](stateCopy, { - dashboardEndpoint: 'dashboard.json', - someOtherProperty: 'some invalid value', // someOtherProperty is not allowed - }); - - expect(stateCopy.dashboardEndpoint).toBe('dashboard.json'); - expect(stateCopy.someOtherProperty).toBeUndefined(); - }); - }); - - describe('Individual panel/metric results', () => { - const metricId = 'NO_DB_response_metrics_nginx_ingress_throughput_status_code'; - - const dashboard = metricsDashboardPayload; - const getMetric = () => stateCopy.dashboard.panelGroups[1].panels[0].metrics[0]; - - describe('REQUEST_METRIC_RESULT', () => { - beforeEach(() => { - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](stateCopy, dashboard); - }); - it('stores a loading state on a metric', () => { - mutations[types.REQUEST_METRIC_RESULT](stateCopy, { - metricId, - }); - - expect(getMetric()).toEqual( - expect.objectContaining({ - loading: true, - }), - ); - }); - }); - - describe('RECEIVE_METRIC_RESULT_SUCCESS', () => { - beforeEach(() => { - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](stateCopy, dashboard); - }); - - it('adds results to the store', () => { - const data = prometheusMatrixMultiResult(); - - expect(getMetric().result).toBe(null); - - mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](stateCopy, { - metricId, - data, - }); - - expect(getMetric().result).toHaveLength(data.result.length); - expect(getMetric()).toEqual( - expect.objectContaining({ - loading: false, - state: metricStates.OK, - }), - ); - }); - }); - - describe('RECEIVE_METRIC_RESULT_FAILURE', () => { - beforeEach(() => { - mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](stateCopy, dashboard); - }); - - it('stores a timeout error in a metric', () => { - mutations[types.RECEIVE_METRIC_RESULT_FAILURE](stateCopy, { - metricId, - error: { message: 'BACKOFF_TIMEOUT' }, - }); - - expect(getMetric()).toEqual( - expect.objectContaining({ - loading: false, - result: null, - state: metricStates.TIMEOUT, - }), - ); - }); - - it('stores a connection failed error in a metric', () => { - mutations[types.RECEIVE_METRIC_RESULT_FAILURE](stateCopy, { - metricId, - error: { - response: { - status: HTTP_STATUS_SERVICE_UNAVAILABLE, - }, - }, - }); - expect(getMetric()).toEqual( - expect.objectContaining({ - loading: false, - result: null, - state: metricStates.CONNECTION_FAILED, - }), - ); - }); - - it('stores a bad data error in a metric', () => { - mutations[types.RECEIVE_METRIC_RESULT_FAILURE](stateCopy, { - metricId, - error: { - response: { - status: HTTP_STATUS_BAD_REQUEST, - }, - }, - }); - - expect(getMetric()).toEqual( - expect.objectContaining({ - loading: false, - result: null, - state: metricStates.BAD_QUERY, - }), - ); - }); - - it('stores an unknown error in a metric', () => { - mutations[types.RECEIVE_METRIC_RESULT_FAILURE](stateCopy, { - metricId, - error: null, // no reason in response - }); - - expect(getMetric()).toEqual( - expect.objectContaining({ - loading: false, - result: null, - state: metricStates.UNKNOWN_ERROR, - }), - ); - }); - }); - }); - - describe('SET_ALL_DASHBOARDS', () => { - it('stores `undefined` dashboards as an empty array', () => { - mutations[types.SET_ALL_DASHBOARDS](stateCopy, undefined); - - expect(stateCopy.allDashboards).toEqual([]); - }); - - it('stores `null` dashboards as an empty array', () => { - mutations[types.SET_ALL_DASHBOARDS](stateCopy, null); - - expect(stateCopy.allDashboards).toEqual([]); - }); - - it('stores dashboards loaded from the git repository', () => { - mutations[types.SET_ALL_DASHBOARDS](stateCopy, dashboardGitResponse); - expect(stateCopy.allDashboards).toEqual(dashboardGitResponse); - }); - }); - - describe('SET_EXPANDED_PANEL', () => { - it('no expanded panel is set initally', () => { - expect(stateCopy.expandedPanel.panel).toEqual(null); - expect(stateCopy.expandedPanel.group).toEqual(null); - }); - - it('sets a panel id as the expanded panel', () => { - const group = 'group_1'; - const panel = { title: 'A Panel' }; - mutations[types.SET_EXPANDED_PANEL](stateCopy, { group, panel }); - - expect(stateCopy.expandedPanel).toEqual({ group, panel }); - }); - - it('clears panel as the expanded panel', () => { - mutations[types.SET_EXPANDED_PANEL](stateCopy, { group: null, panel: null }); - - expect(stateCopy.expandedPanel.group).toEqual(null); - expect(stateCopy.expandedPanel.panel).toEqual(null); - }); - }); - - describe('UPDATE_VARIABLE_VALUE', () => { - it('updates only the value of the variable in variables', () => { - stateCopy.variables = storeTextVariables; - mutations[types.UPDATE_VARIABLE_VALUE](stateCopy, { name: 'textSimple', value: 'New Value' }); - - expect(stateCopy.variables[0].value).toEqual('New Value'); - }); - }); - - describe('UPDATE_VARIABLE_METRIC_LABEL_VALUES', () => { - it('updates options in a variable', () => { - const data = [ - { - __name__: 'up', - job: 'prometheus', - env: 'prd', - }, - { - __name__: 'up', - job: 'prometheus', - env: 'stg', - }, - { - __name__: 'up', - job: 'node', - env: 'prod', - }, - { - __name__: 'up', - job: 'node', - env: 'stg', - }, - ]; - - const variable = { - options: {}, - }; - - mutations[types.UPDATE_VARIABLE_METRIC_LABEL_VALUES](stateCopy, { - variable, - label: 'job', - data, - }); - - expect(variable.options).toEqual({ - values: [ - { text: 'prometheus', value: 'prometheus' }, - { text: 'node', value: 'node' }, - ], - }); - }); - }); - - describe('REQUEST_PANEL_PREVIEW', () => { - it('saves yml content and resets other preview data', () => { - const mockYmlContent = 'mock yml content'; - mutations[types.REQUEST_PANEL_PREVIEW](stateCopy, mockYmlContent); - - expect(stateCopy.panelPreviewIsLoading).toBe(true); - expect(stateCopy.panelPreviewYml).toBe(mockYmlContent); - expect(stateCopy.panelPreviewGraphData).toBe(null); - expect(stateCopy.panelPreviewError).toBe(null); - }); - }); - - describe('RECEIVE_PANEL_PREVIEW_SUCCESS', () => { - it('saves graph data', () => { - mutations[types.RECEIVE_PANEL_PREVIEW_SUCCESS](stateCopy, { - title: 'My Title', - type: 'area-chart', - }); - - expect(stateCopy.panelPreviewIsLoading).toBe(false); - expect(stateCopy.panelPreviewGraphData).toMatchObject({ - title: 'My Title', - type: 'area-chart', - }); - expect(stateCopy.panelPreviewError).toBe(null); - }); - }); - - describe('RECEIVE_PANEL_PREVIEW_FAILURE', () => { - it('saves graph data', () => { - mutations[types.RECEIVE_PANEL_PREVIEW_FAILURE](stateCopy, 'Error!'); - - expect(stateCopy.panelPreviewIsLoading).toBe(false); - expect(stateCopy.panelPreviewGraphData).toBe(null); - expect(stateCopy.panelPreviewError).toBe('Error!'); - }); - }); - - describe('panel preview metric', () => { - const getPreviewMetricAt = (i) => stateCopy.panelPreviewGraphData.metrics[i]; - - beforeEach(() => { - stateCopy.panelPreviewGraphData = { - title: 'Preview panel title', - metrics: [ - { - query: 'query', - }, - ], - }; - }); - - describe('REQUEST_PANEL_PREVIEW_METRIC_RESULT', () => { - it('sets the metric to loading for the first time', () => { - mutations[types.REQUEST_PANEL_PREVIEW_METRIC_RESULT](stateCopy, { index: 0 }); - - expect(getPreviewMetricAt(0).loading).toBe(true); - expect(getPreviewMetricAt(0).state).toBe(metricStates.LOADING); - }); - - it('sets the metric to loading and keeps the result', () => { - getPreviewMetricAt(0).result = [[0, 1]]; - getPreviewMetricAt(0).state = metricStates.OK; - - mutations[types.REQUEST_PANEL_PREVIEW_METRIC_RESULT](stateCopy, { index: 0 }); - - expect(getPreviewMetricAt(0)).toMatchObject({ - loading: true, - result: [[0, 1]], - state: metricStates.OK, - }); - }); - }); - - describe('RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS', () => { - it('saves the result in the metric', () => { - const data = prometheusMatrixMultiResult(); - - mutations[types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS](stateCopy, { - index: 0, - data, - }); - - expect(getPreviewMetricAt(0)).toMatchObject({ - loading: false, - state: metricStates.OK, - result: expect.any(Array), - }); - expect(getPreviewMetricAt(0).result).toHaveLength(data.result.length); - }); - }); - - describe('RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE', () => { - it('stores an error in the metric', () => { - mutations[types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE](stateCopy, { - index: 0, - }); - - expect(getPreviewMetricAt(0).loading).toBe(false); - expect(getPreviewMetricAt(0).state).toBe(metricStates.UNKNOWN_ERROR); - expect(getPreviewMetricAt(0).result).toBe(null); - - expect(getPreviewMetricAt(0)).toMatchObject({ - loading: false, - result: null, - state: metricStates.UNKNOWN_ERROR, - }); - }); - - it('stores a timeout error in a metric', () => { - mutations[types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE](stateCopy, { - index: 0, - error: { message: 'BACKOFF_TIMEOUT' }, - }); - - expect(getPreviewMetricAt(0)).toMatchObject({ - loading: false, - result: null, - state: metricStates.TIMEOUT, - }); - }); - }); - }); -}); diff --git a/spec/frontend/monitoring/store/utils_spec.js b/spec/frontend/monitoring/store/utils_spec.js deleted file mode 100644 index 54f9c59308e..00000000000 --- a/spec/frontend/monitoring/store/utils_spec.js +++ /dev/null @@ -1,893 +0,0 @@ -import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; -import * as urlUtils from '~/lib/utils/url_utility'; -import { NOT_IN_DB_PREFIX } from '~/monitoring/constants'; -import { - uniqMetricsId, - parseEnvironmentsResponse, - parseAnnotationsResponse, - removeLeadingSlash, - mapToDashboardViewModel, - normalizeQueryResponseData, - convertToGrafanaTimeRange, - addDashboardMetaDataToLink, - normalizeCustomDashboardPath, -} from '~/monitoring/stores/utils'; -import { annotationsData } from '../mock_data'; - -const projectPath = 'gitlab-org/gitlab-test'; - -describe('mapToDashboardViewModel', () => { - it('maps an empty dashboard', () => { - expect(mapToDashboardViewModel({})).toEqual({ - dashboard: '', - panelGroups: [], - links: [], - variables: [], - }); - }); - - it('maps a simple dashboard', () => { - const response = { - dashboard: 'Dashboard Name', - panel_groups: [ - { - group: 'Group 1', - panels: [ - { - id: 'ID_ABC', - title: 'Title A', - xLabel: '', - xAxis: { - name: '', - }, - type: 'chart-type', - y_label: 'Y Label A', - metrics: [], - }, - ], - }, - ], - }; - - expect(mapToDashboardViewModel(response)).toEqual({ - dashboard: 'Dashboard Name', - links: [], - variables: [], - panelGroups: [ - { - group: 'Group 1', - key: 'group-1-0', - panels: [ - { - id: 'ID_ABC', - title: 'Title A', - type: 'chart-type', - xLabel: '', - xAxis: { - name: '', - }, - y_label: 'Y Label A', - yAxis: { - name: 'Y Label A', - format: 'engineering', - precision: 2, - }, - links: [], - metrics: [], - }, - ], - }, - ], - }); - }); - - describe('panel groups mapping', () => { - it('key', () => { - const response = { - dashboard: 'Dashboard Name', - links: [], - variables: {}, - panel_groups: [ - { - group: 'Group A', - }, - { - group: 'Group B', - }, - { - group: '', - unsupported_property: 'This should be removed', - }, - ], - }; - - expect(mapToDashboardViewModel(response).panelGroups).toEqual([ - { - group: 'Group A', - key: 'group-a-0', - panels: [], - }, - { - group: 'Group B', - key: 'group-b-1', - panels: [], - }, - { - group: '', - key: 'default-2', - panels: [], - }, - ]); - }); - }); - - describe('panel mapping', () => { - const panelTitle = 'Panel Title'; - const yAxisName = 'Y Axis Name'; - - let dashboard; - - const setupWithPanel = (panel) => { - dashboard = { - panel_groups: [ - { - panels: [panel], - }, - ], - }; - }; - - const getMappedPanel = () => mapToDashboardViewModel(dashboard).panelGroups[0].panels[0]; - - it('panel with x_label', () => { - setupWithPanel({ - id: 'ID_123', - title: panelTitle, - x_label: 'x label', - }); - - expect(getMappedPanel()).toEqual({ - id: 'ID_123', - title: panelTitle, - xLabel: 'x label', - xAxis: { - name: 'x label', - }, - y_label: '', - yAxis: { - name: '', - format: SUPPORTED_FORMATS.engineering, - precision: 2, - }, - links: [], - metrics: [], - }); - }); - - it('group y_axis defaults', () => { - setupWithPanel({ - id: 'ID_456', - title: panelTitle, - }); - - expect(getMappedPanel()).toEqual({ - id: 'ID_456', - title: panelTitle, - xLabel: '', - y_label: '', - xAxis: { - name: '', - }, - yAxis: { - name: '', - format: SUPPORTED_FORMATS.engineering, - precision: 2, - }, - links: [], - metrics: [], - }); - }); - - it('panel with y_axis.name', () => { - setupWithPanel({ - y_axis: { - name: yAxisName, - }, - }); - - expect(getMappedPanel().y_label).toBe(yAxisName); - expect(getMappedPanel().yAxis.name).toBe(yAxisName); - }); - - it('panel with y_axis.name and y_label, displays y_axis.name', () => { - setupWithPanel({ - y_label: 'Ignored Y Label', - y_axis: { - name: yAxisName, - }, - }); - - expect(getMappedPanel().y_label).toBe(yAxisName); - expect(getMappedPanel().yAxis.name).toBe(yAxisName); - }); - - it('group y_label', () => { - setupWithPanel({ - y_label: yAxisName, - }); - - expect(getMappedPanel().y_label).toBe(yAxisName); - expect(getMappedPanel().yAxis.name).toBe(yAxisName); - }); - - it('group y_axis format and precision', () => { - setupWithPanel({ - title: panelTitle, - y_axis: { - precision: 0, - format: SUPPORTED_FORMATS.bytes, - }, - }); - - expect(getMappedPanel().yAxis.format).toBe(SUPPORTED_FORMATS.bytes); - expect(getMappedPanel().yAxis.precision).toBe(0); - }); - - it('group y_axis unsupported format defaults to number', () => { - setupWithPanel({ - title: panelTitle, - y_axis: { - format: 'invalid_format', - }, - }); - - expect(getMappedPanel().yAxis.format).toBe(SUPPORTED_FORMATS.engineering); - }); - - // This property allows single_stat panels to render percentile values - it('group maxValue', () => { - setupWithPanel({ - max_value: 100, - }); - - expect(getMappedPanel().maxValue).toBe(100); - }); - - describe('panel with links', () => { - const title = 'Example'; - const url = 'https://example.com'; - - it('maps an empty link collection', () => { - setupWithPanel({ - links: undefined, - }); - - expect(getMappedPanel().links).toEqual([]); - }); - - it('maps a link', () => { - setupWithPanel({ links: [{ title, url }] }); - - expect(getMappedPanel().links).toEqual([{ title, url }]); - }); - - it('maps a link without a title', () => { - setupWithPanel({ - links: [{ url }], - }); - - expect(getMappedPanel().links).toEqual([{ title: url, url }]); - }); - - it('maps a link without a url', () => { - setupWithPanel({ - links: [{ title }], - }); - - expect(getMappedPanel().links).toEqual([{ title, url: '#' }]); - }); - - it('maps a link without a url or title', () => { - setupWithPanel({ - links: [{}], - }); - - expect(getMappedPanel().links).toEqual([{ title: 'null', url: '#' }]); - }); - - it('maps a link with an unsafe url safely', () => { - // eslint-disable-next-line no-script-url - const unsafeUrl = 'javascript:alert("XSS")'; - - setupWithPanel({ - links: [ - { - title, - url: unsafeUrl, - }, - ], - }); - - expect(getMappedPanel().links).toEqual([{ title, url: '#' }]); - }); - - it('maps multple links', () => { - setupWithPanel({ - links: [{ title, url }, { url }, { title }], - }); - - expect(getMappedPanel().links).toEqual([ - { title, url }, - { title: url, url }, - { title, url: '#' }, - ]); - }); - }); - }); - - describe('metrics mapping', () => { - const defaultLabel = 'Panel Label'; - const dashboardWithMetric = (metric, label = defaultLabel) => ({ - panel_groups: [ - { - panels: [ - { - y_label: label, - metrics: [metric], - }, - ], - }, - ], - }); - - const getMappedMetric = (dashboard) => { - return mapToDashboardViewModel(dashboard).panelGroups[0].panels[0].metrics[0]; - }; - - it('creates a metric', () => { - const dashboard = dashboardWithMetric({ label: 'Panel Label' }); - - expect(getMappedMetric(dashboard)).toEqual({ - label: expect.any(String), - metricId: expect.any(String), - loading: false, - result: null, - state: null, - }); - }); - - it('creates a metric with a correct id', () => { - const dashboard = dashboardWithMetric({ - id: 'http_responses', - metric_id: 1, - }); - - expect(getMappedMetric(dashboard).metricId).toEqual('1_http_responses'); - }); - - it('creates a metric without a default label', () => { - const dashboard = dashboardWithMetric({}); - - expect(getMappedMetric(dashboard)).toMatchObject({ - label: undefined, - }); - }); - - it('creates a metric with an endpoint and query', () => { - const dashboard = dashboardWithMetric({ - prometheus_endpoint_path: 'http://test', - query_range: 'http_responses', - }); - - expect(getMappedMetric(dashboard)).toMatchObject({ - prometheusEndpointPath: 'http://test', - queryRange: 'http_responses', - }); - }); - - it('creates a metric with an ad-hoc property', () => { - // This behavior is deprecated and should be removed - // https://gitlab.com/gitlab-org/gitlab/issues/207198 - - const dashboard = dashboardWithMetric({ - x_label: 'Another label', - unkown_option: 'unkown_data', - }); - - expect(getMappedMetric(dashboard)).toMatchObject({ - x_label: 'Another label', - unkown_option: 'unkown_data', - }); - }); - }); - - describe('templating variables mapping', () => { - beforeEach(() => { - jest.spyOn(urlUtils, 'queryToObject'); - }); - - afterEach(() => { - urlUtils.queryToObject.mockRestore(); - }); - - it('sets variables as-is from yml file if URL has no variables', () => { - const response = { - dashboard: 'Dashboard Name', - links: [], - templating: { - variables: { - pod: 'kubernetes', - pod_2: 'kubernetes-2', - }, - }, - }; - - urlUtils.queryToObject.mockReturnValueOnce(); - - expect(mapToDashboardViewModel(response).variables).toEqual([ - { - name: 'pod', - label: 'pod', - type: 'text', - value: 'kubernetes', - }, - { - name: 'pod_2', - label: 'pod_2', - type: 'text', - value: 'kubernetes-2', - }, - ]); - }); - - it('sets variables as-is from yml file if URL has no matching variables', () => { - const response = { - dashboard: 'Dashboard Name', - links: [], - templating: { - variables: { - pod: 'kubernetes', - pod_2: 'kubernetes-2', - }, - }, - }; - - urlUtils.queryToObject.mockReturnValueOnce({ - 'var-environment': 'POD', - }); - - expect(mapToDashboardViewModel(response).variables).toEqual([ - { - label: 'pod', - name: 'pod', - type: 'text', - value: 'kubernetes', - }, - { - label: 'pod_2', - name: 'pod_2', - type: 'text', - value: 'kubernetes-2', - }, - ]); - }); - - it('merges variables from URL with the ones from yml file', () => { - const response = { - dashboard: 'Dashboard Name', - links: [], - templating: { - variables: { - pod: 'kubernetes', - pod_2: 'kubernetes-2', - }, - }, - }; - - urlUtils.queryToObject.mockReturnValueOnce({ - 'var-environment': 'POD', - 'var-pod': 'POD1', - 'var-pod_2': 'POD2', - }); - - expect(mapToDashboardViewModel(response).variables).toEqual([ - { - label: 'pod', - name: 'pod', - type: 'text', - value: 'POD1', - }, - { - label: 'pod_2', - name: 'pod_2', - type: 'text', - value: 'POD2', - }, - ]); - }); - }); -}); - -describe('uniqMetricsId', () => { - [ - { input: { id: 1 }, expected: `${NOT_IN_DB_PREFIX}_1` }, - { input: { metricId: 2 }, expected: '2_undefined' }, - { input: { metricId: 2, id: 21 }, expected: '2_21' }, - { input: { metricId: 22, id: 1 }, expected: '22_1' }, - { input: { metricId: 'aaa', id: '_a' }, expected: 'aaa__a' }, - ].forEach(({ input, expected }) => { - it(`creates unique metric ID with ${JSON.stringify(input)}`, () => { - expect(uniqMetricsId(input)).toEqual(expected); - }); - }); -}); - -describe('parseEnvironmentsResponse', () => { - [ - { - input: null, - output: [], - }, - { - input: undefined, - output: [], - }, - { - input: [], - output: [], - }, - { - input: [ - { - id: '1', - name: 'env-1', - }, - ], - output: [ - { - id: 1, - name: 'env-1', - metrics_path: `${projectPath}/-/metrics?environment=1`, - }, - ], - }, - { - input: [ - { - id: 'gid://gitlab/Environment/12', - name: 'env-12', - }, - ], - output: [ - { - id: 12, - name: 'env-12', - metrics_path: `${projectPath}/-/metrics?environment=12`, - }, - ], - }, - ].forEach(({ input, output }) => { - it(`parseEnvironmentsResponse returns ${JSON.stringify(output)} with input ${JSON.stringify( - input, - )}`, () => { - expect(parseEnvironmentsResponse(input, projectPath)).toEqual(output); - }); - }); -}); - -describe('parseAnnotationsResponse', () => { - const parsedAnnotationResponse = [ - { - description: 'This is a test annotation', - endingAt: null, - id: 'gid://gitlab/Metrics::Dashboard::Annotation/1', - panelId: null, - startingAt: new Date('2020-04-12T12:51:53.000Z'), - }, - ]; - it.each` - case | input | expected - ${'Returns empty array for null input'} | ${null} | ${[]} - ${'Returns empty array for undefined input'} | ${undefined} | ${[]} - ${'Returns empty array for empty input'} | ${[]} | ${[]} - ${'Returns parsed responses for annotations data'} | ${[annotationsData[0]]} | ${parsedAnnotationResponse} - `('$case', ({ input, expected }) => { - expect(parseAnnotationsResponse(input)).toEqual(expected); - }); -}); - -describe('removeLeadingSlash', () => { - [ - { input: null, output: '' }, - { input: '', output: '' }, - { input: 'gitlab-org', output: 'gitlab-org' }, - { input: 'gitlab-org/gitlab', output: 'gitlab-org/gitlab' }, - { input: '/gitlab-org/gitlab', output: 'gitlab-org/gitlab' }, - { input: '////gitlab-org/gitlab', output: 'gitlab-org/gitlab' }, - ].forEach(({ input, output }) => { - it(`removeLeadingSlash returns ${output} with input ${input}`, () => { - expect(removeLeadingSlash(input)).toEqual(output); - }); - }); -}); - -describe('user-defined links utils', () => { - const mockRelativeTimeRange = { - metricsDashboard: { - duration: { - seconds: 86400, - }, - }, - grafana: { - from: 'now-86400s', - to: 'now', - }, - }; - const mockAbsoluteTimeRange = { - metricsDashboard: { - start: '2020-06-08T16:13:01.995Z', - end: '2020-06-08T21:12:32.243Z', - }, - grafana: { - from: 1591632781995, - to: 1591650752243, - }, - }; - describe('convertToGrafanaTimeRange', () => { - it('converts relative timezone to grafana timezone', () => { - expect(convertToGrafanaTimeRange(mockRelativeTimeRange.metricsDashboard)).toEqual( - mockRelativeTimeRange.grafana, - ); - }); - - it('converts absolute timezone to grafana timezone', () => { - expect(convertToGrafanaTimeRange(mockAbsoluteTimeRange.metricsDashboard)).toEqual( - mockAbsoluteTimeRange.grafana, - ); - }); - }); - - describe('addDashboardMetaDataToLink', () => { - const link = { title: 'title', url: 'https://gitlab.com' }; - const grafanaLink = { ...link, type: 'grafana' }; - - it('adds relative time range to link w/o type for metrics dashboards', () => { - const adder = addDashboardMetaDataToLink({ - timeRange: mockRelativeTimeRange.metricsDashboard, - }); - expect(adder(link)).toMatchObject({ - title: 'title', - url: 'https://gitlab.com?duration_seconds=86400', - }); - }); - - it('adds relative time range to Grafana type links', () => { - const adder = addDashboardMetaDataToLink({ - timeRange: mockRelativeTimeRange.metricsDashboard, - }); - expect(adder(grafanaLink)).toMatchObject({ - title: 'title', - url: 'https://gitlab.com?from=now-86400s&to=now', - }); - }); - - it('adds absolute time range to link w/o type for metrics dashboard', () => { - const adder = addDashboardMetaDataToLink({ - timeRange: mockAbsoluteTimeRange.metricsDashboard, - }); - expect(adder(link)).toMatchObject({ - title: 'title', - url: - 'https://gitlab.com?start=2020-06-08T16%3A13%3A01.995Z&end=2020-06-08T21%3A12%3A32.243Z', - }); - }); - - it('adds absolute time range to Grafana type links', () => { - const adder = addDashboardMetaDataToLink({ - timeRange: mockAbsoluteTimeRange.metricsDashboard, - }); - expect(adder(grafanaLink)).toMatchObject({ - title: 'title', - url: 'https://gitlab.com?from=1591632781995&to=1591650752243', - }); - }); - }); -}); - -describe('normalizeQueryResponseData', () => { - // Data examples from - // https://prometheus.io/docs/prometheus/latest/querying/api/#expression-queries - - it('processes a string result', () => { - const mockScalar = { - resultType: 'string', - result: [1435781451.781, '1'], - }; - - expect(normalizeQueryResponseData(mockScalar)).toEqual([ - { - metric: {}, - value: ['2015-07-01T20:10:51.781Z', '1'], - values: [['2015-07-01T20:10:51.781Z', '1']], - }, - ]); - }); - - it('processes a scalar result', () => { - const mockScalar = { - resultType: 'scalar', - result: [1435781451.781, '1'], - }; - - expect(normalizeQueryResponseData(mockScalar)).toEqual([ - { - metric: {}, - value: ['2015-07-01T20:10:51.781Z', 1], - values: [['2015-07-01T20:10:51.781Z', 1]], - }, - ]); - }); - - it('processes a vector result', () => { - const mockVector = { - resultType: 'vector', - result: [ - { - metric: { - __name__: 'up', - job: 'prometheus', - instance: 'localhost:9090', - }, - value: [1435781451.781, '1'], - }, - { - metric: { - __name__: 'up', - job: 'node', - instance: 'localhost:9100', - }, - value: [1435781451.781, '0'], - }, - ], - }; - - expect(normalizeQueryResponseData(mockVector)).toEqual([ - { - metric: { __name__: 'up', job: 'prometheus', instance: 'localhost:9090' }, - value: ['2015-07-01T20:10:51.781Z', 1], - values: [['2015-07-01T20:10:51.781Z', 1]], - }, - { - metric: { __name__: 'up', job: 'node', instance: 'localhost:9100' }, - value: ['2015-07-01T20:10:51.781Z', 0], - values: [['2015-07-01T20:10:51.781Z', 0]], - }, - ]); - }); - - it('processes a matrix result', () => { - const mockMatrix = { - resultType: 'matrix', - result: [ - { - metric: { - __name__: 'up', - job: 'prometheus', - instance: 'localhost:9090', - }, - values: [ - [1435781430.781, '1'], - [1435781445.781, '2'], - [1435781460.781, '3'], - ], - }, - { - metric: { - __name__: 'up', - job: 'node', - instance: 'localhost:9091', - }, - values: [ - [1435781430.781, '4'], - [1435781445.781, '5'], - [1435781460.781, '6'], - ], - }, - ], - }; - - expect(normalizeQueryResponseData(mockMatrix)).toEqual([ - { - metric: { __name__: 'up', instance: 'localhost:9090', job: 'prometheus' }, - value: ['2015-07-01T20:11:00.781Z', 3], - values: [ - ['2015-07-01T20:10:30.781Z', 1], - ['2015-07-01T20:10:45.781Z', 2], - ['2015-07-01T20:11:00.781Z', 3], - ], - }, - { - metric: { __name__: 'up', instance: 'localhost:9091', job: 'node' }, - value: ['2015-07-01T20:11:00.781Z', 6], - values: [ - ['2015-07-01T20:10:30.781Z', 4], - ['2015-07-01T20:10:45.781Z', 5], - ['2015-07-01T20:11:00.781Z', 6], - ], - }, - ]); - }); - - it('processes a scalar result with a NaN result', () => { - // Queries may return "NaN" string values. - // e.g. when Prometheus cannot find a metric the query - // `scalar(does_not_exist)` will return a "NaN" value. - - const mockScalar = { - resultType: 'scalar', - result: [1435781451.781, 'NaN'], - }; - - expect(normalizeQueryResponseData(mockScalar)).toEqual([ - { - metric: {}, - value: ['2015-07-01T20:10:51.781Z', NaN], - values: [['2015-07-01T20:10:51.781Z', NaN]], - }, - ]); - }); - - it('processes a matrix result with a "NaN" value', () => { - // Queries may return "NaN" string values. - const mockMatrix = { - resultType: 'matrix', - result: [ - { - metric: { - __name__: 'up', - job: 'prometheus', - instance: 'localhost:9090', - }, - values: [ - [1435781430.781, '1'], - [1435781460.781, 'NaN'], - ], - }, - ], - }; - - expect(normalizeQueryResponseData(mockMatrix)).toEqual([ - { - metric: { __name__: 'up', instance: 'localhost:9090', job: 'prometheus' }, - value: ['2015-07-01T20:11:00.781Z', NaN], - values: [ - ['2015-07-01T20:10:30.781Z', 1], - ['2015-07-01T20:11:00.781Z', NaN], - ], - }, - ]); - }); -}); - -describe('normalizeCustomDashboardPath', () => { - it.each` - input | expected - ${[undefined]} | ${''} - ${[null]} | ${''} - ${[]} | ${''} - ${['links.yml']} | ${'links.yml'} - ${['links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/links.yml'} - ${['config/prometheus/common_metrics.yml']} | ${'config/prometheus/common_metrics.yml'} - ${['config/prometheus/common_metrics.yml', '.gitlab/dashboards']} | ${'config/prometheus/common_metrics.yml'} - ${['dir1/links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/dir1/links.yml'} - ${['dir1/dir2/links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/dir1/dir2/links.yml'} - ${['.gitlab/dashboards/links.yml']} | ${'.gitlab/dashboards/links.yml'} - ${['.gitlab/dashboards/links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/links.yml'} - ${['.gitlab/dashboards/dir1/links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/dir1/links.yml'} - ${['.gitlab/dashboards/dir1/dir2/links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/dir1/dir2/links.yml'} - ${['config/prometheus/pod_metrics.yml', '.gitlab/dashboards']} | ${'config/prometheus/pod_metrics.yml'} - ${['config/prometheus/pod_metrics.yml']} | ${'config/prometheus/pod_metrics.yml'} - `(`normalizeCustomDashboardPath returns $expected for $input`, ({ input, expected }) => { - expect(normalizeCustomDashboardPath(...input)).toEqual(expected); - }); -}); diff --git a/spec/frontend/monitoring/store/variable_mapping_spec.js b/spec/frontend/monitoring/store/variable_mapping_spec.js deleted file mode 100644 index 58e7175c04c..00000000000 --- a/spec/frontend/monitoring/store/variable_mapping_spec.js +++ /dev/null @@ -1,209 +0,0 @@ -import * as urlUtils from '~/lib/utils/url_utility'; -import { - parseTemplatingVariables, - mergeURLVariables, - optionsFromSeriesData, -} from '~/monitoring/stores/variable_mapping'; -import { - templatingVariablesExamples, - storeTextVariables, - storeCustomVariables, - storeMetricLabelValuesVariables, -} from '../mock_data'; - -describe('Monitoring variable mapping', () => { - describe('parseTemplatingVariables', () => { - it.each` - case | input - ${'For undefined templating object'} | ${undefined} - ${'For empty templating object'} | ${{}} - `('$case, returns an empty array', ({ input }) => { - expect(parseTemplatingVariables(input)).toEqual([]); - }); - - it.each` - case | input | output - ${'Returns parsed object for text variables'} | ${templatingVariablesExamples.text} | ${storeTextVariables} - ${'Returns parsed object for custom variables'} | ${templatingVariablesExamples.custom} | ${storeCustomVariables} - ${'Returns parsed object for metric label value variables'} | ${templatingVariablesExamples.metricLabelValues} | ${storeMetricLabelValuesVariables} - `('$case, returns an empty array', ({ input, output }) => { - expect(parseTemplatingVariables(input)).toEqual(output); - }); - }); - - describe('mergeURLVariables', () => { - beforeEach(() => { - jest.spyOn(urlUtils, 'queryToObject'); - }); - - afterEach(() => { - urlUtils.queryToObject.mockRestore(); - }); - - it('returns empty object if variables are not defined in yml or URL', () => { - urlUtils.queryToObject.mockReturnValueOnce({}); - - expect(mergeURLVariables([])).toEqual([]); - }); - - it('returns empty object if variables are defined in URL but not in yml', () => { - urlUtils.queryToObject.mockReturnValueOnce({ - 'var-env': 'one', - 'var-instance': 'localhost', - }); - - expect(mergeURLVariables([])).toEqual([]); - }); - - it('returns yml variables if variables defined in yml but not in the URL', () => { - urlUtils.queryToObject.mockReturnValueOnce({}); - - const variables = [ - { - name: 'env', - value: 'one', - }, - { - name: 'instance', - value: 'localhost', - }, - ]; - - expect(mergeURLVariables(variables)).toEqual(variables); - }); - - it('returns yml variables if variables defined in URL do not match with yml variables', () => { - const urlParams = { - 'var-env': 'one', - 'var-instance': 'localhost', - }; - const variables = [ - { - name: 'env', - value: 'one', - }, - { - name: 'service', - value: 'database', - }, - ]; - urlUtils.queryToObject.mockReturnValueOnce(urlParams); - - expect(mergeURLVariables(variables)).toEqual(variables); - }); - - it('returns merged yml and URL variables if there is some match', () => { - const urlParams = { - 'var-env': 'one', - 'var-instance': 'localhost:8080', - }; - const variables = [ - { - name: 'instance', - value: 'localhost', - }, - { - name: 'service', - value: 'database', - }, - ]; - - urlUtils.queryToObject.mockReturnValueOnce(urlParams); - - expect(mergeURLVariables(variables)).toEqual([ - { - name: 'instance', - value: 'localhost:8080', - }, - { - name: 'service', - value: 'database', - }, - ]); - }); - }); - - describe('optionsFromSeriesData', () => { - it('fetches the label values from missing data', () => { - expect(optionsFromSeriesData({ label: 'job' })).toEqual([]); - }); - - it('fetches the label values from a simple series', () => { - const data = [ - { - __name__: 'up', - job: 'job1', - }, - { - __name__: 'up', - job: 'job2', - }, - ]; - - expect(optionsFromSeriesData({ label: 'job', data })).toEqual([ - { text: 'job1', value: 'job1' }, - { text: 'job2', value: 'job2' }, - ]); - }); - - it('fetches the label values from multiple series', () => { - const data = [ - { - __name__: 'up', - job: 'job1', - instance: 'host1', - }, - { - __name__: 'up', - job: 'job2', - instance: 'host1', - }, - { - __name__: 'up', - job: 'job1', - instance: 'host2', - }, - { - __name__: 'up', - job: 'job2', - instance: 'host2', - }, - ]; - - expect(optionsFromSeriesData({ label: '__name__', data })).toEqual([ - { text: 'up', value: 'up' }, - ]); - - expect(optionsFromSeriesData({ label: 'job', data })).toEqual([ - { text: 'job1', value: 'job1' }, - { text: 'job2', value: 'job2' }, - ]); - - expect(optionsFromSeriesData({ label: 'instance', data })).toEqual([ - { text: 'host1', value: 'host1' }, - { text: 'host2', value: 'host2' }, - ]); - }); - - it('fetches the label values from a series with missing values', () => { - const data = [ - { - __name__: 'up', - job: 'job1', - }, - { - __name__: 'up', - job: 'job2', - }, - { - __name__: 'up', - }, - ]; - - expect(optionsFromSeriesData({ label: 'job', data })).toEqual([ - { text: 'job1', value: 'job1' }, - { text: 'job2', value: 'job2' }, - ]); - }); - }); -}); diff --git a/spec/frontend/monitoring/store_utils.js b/spec/frontend/monitoring/store_utils.js deleted file mode 100644 index 96219661b9b..00000000000 --- a/spec/frontend/monitoring/store_utils.js +++ /dev/null @@ -1,80 +0,0 @@ -import * as types from '~/monitoring/stores/mutation_types'; -import { metricsDashboardPayload } from './fixture_data'; -import { metricsResult, environmentData, dashboardGitResponse } from './mock_data'; - -export const setMetricResult = ({ store, result, group = 0, panel = 0, metric = 0 }) => { - const { dashboard } = store.state.monitoringDashboard; - const { metricId } = dashboard.panelGroups[group].panels[panel].metrics[metric]; - - store.commit(`monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, { - metricId, - data: { - resultType: 'matrix', - result, - }, - }); -}; - -const setEnvironmentData = (store) => { - store.commit(`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, environmentData); -}; - -export const setupAllDashboards = (store, path) => { - store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, dashboardGitResponse); - if (path) { - store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { - currentDashboard: path, - }); - } -}; - -export const setupStoreWithDashboard = (store) => { - store.commit( - `monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`, - metricsDashboardPayload, - ); -}; - -export const setupStoreWithLinks = (store) => { - store.commit(`monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`, { - ...metricsDashboardPayload, - links: [ - { - title: 'GitLab Website', - url: `https://gitlab.com/website`, - }, - ], - }); -}; - -export const setupStoreWithData = (store) => { - setupAllDashboards(store); - setupStoreWithDashboard(store); - - setMetricResult({ store, result: [], panel: 0 }); - setMetricResult({ store, result: metricsResult, panel: 1 }); - setMetricResult({ store, result: metricsResult, panel: 2 }); - - setEnvironmentData(store); -}; - -export const setupStoreWithDataForPanelCount = (store, panelCount) => { - const payloadPanelGroup = metricsDashboardPayload.panel_groups[0]; - - const panelGroupCustom = { - ...payloadPanelGroup, - panels: payloadPanelGroup.panels.slice(0, panelCount), - }; - - const metricsDashboardPayloadCustom = { - ...metricsDashboardPayload, - panel_groups: [panelGroupCustom], - }; - - store.commit( - `monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`, - metricsDashboardPayloadCustom, - ); - - setMetricResult({ store, result: metricsResult, panel: 0 }); -}; diff --git a/spec/frontend/monitoring/stubs/modal_stub.js b/spec/frontend/monitoring/stubs/modal_stub.js deleted file mode 100644 index 4cd0362096e..00000000000 --- a/spec/frontend/monitoring/stubs/modal_stub.js +++ /dev/null @@ -1,11 +0,0 @@ -const ModalStub = { - name: 'glmodal-stub', - template: ` - <div> - <slot></slot> - <slot name="modal-ok"></slot> - </div> - `, -}; - -export default ModalStub; diff --git a/spec/frontend/monitoring/utils_spec.js b/spec/frontend/monitoring/utils_spec.js deleted file mode 100644 index 348825c334a..00000000000 --- a/spec/frontend/monitoring/utils_spec.js +++ /dev/null @@ -1,464 +0,0 @@ -import { TEST_HOST } from 'helpers/test_constants'; -import * as urlUtils from '~/lib/utils/url_utility'; -import * as monitoringUtils from '~/monitoring/utils'; -import { metricsDashboardViewModel, graphData } from './fixture_data'; -import { singleStatGraphData, anomalyGraphData } from './graph_data'; -import { mockProjectDir, barMockData } from './mock_data'; - -const mockPath = `${TEST_HOST}${mockProjectDir}/-/environments/29/metrics`; - -const generatedLink = 'http://chart.link.com'; - -const chartTitle = 'Some metric chart'; - -const range = { - start: '2019-01-01T00:00:00.000Z', - end: '2019-01-10T00:00:00.000Z', -}; - -const rollingRange = { - duration: { seconds: 120 }, -}; - -describe('monitoring/utils', () => { - describe('trackGenerateLinkToChartEventOptions', () => { - it('should return Cluster Monitoring options if located on Cluster Health Dashboard', () => { - document.body.dataset.page = 'groups:clusters:show'; - - expect(monitoringUtils.generateLinkToChartOptions(generatedLink)).toEqual({ - category: 'Cluster Monitoring', - action: 'generate_link_to_cluster_metric_chart', - label: 'Chart link', - property: generatedLink, - }); - }); - - it('should return Incident Management event options if located on Metrics Dashboard', () => { - document.body.dataset.page = 'metrics:show'; - - expect(monitoringUtils.generateLinkToChartOptions(generatedLink)).toEqual({ - category: 'Incident Management::Embedded metrics', - action: 'generate_link_to_metrics_chart', - label: 'Chart link', - property: generatedLink, - }); - }); - }); - - describe('trackDownloadCSVEvent', () => { - it('should return Cluster Monitoring options if located on Cluster Health Dashboard', () => { - document.body.dataset.page = 'groups:clusters:show'; - - expect(monitoringUtils.downloadCSVOptions(chartTitle)).toEqual({ - category: 'Cluster Monitoring', - action: 'download_csv_of_cluster_metric_chart', - label: 'Chart title', - property: chartTitle, - }); - }); - - it('should return Incident Management event options if located on Metrics Dashboard', () => { - document.body.dataset.page = 'metriss:show'; - - expect(monitoringUtils.downloadCSVOptions(chartTitle)).toEqual({ - category: 'Incident Management::Embedded metrics', - action: 'download_csv_of_metrics_dashboard_chart', - label: 'Chart title', - property: chartTitle, - }); - }); - }); - - describe('graphDataValidatorForValues', () => { - /* - * When dealing with a metric using the query format, e.g. - * query: 'max(go_memstats_alloc_bytes{job="prometheus"}) by (job) /1024/1024' - * the validator will look for the `value` key instead of `values` - */ - it('validates data with the query format', () => { - const validGraphData = monitoringUtils.graphDataValidatorForValues( - true, - singleStatGraphData(), - ); - - expect(validGraphData).toBe(true); - }); - - /* - * When dealing with a metric using the query?range format, e.g. - * query_range: 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) /1024/1024/1024', - * the validator will look for the `values` key instead of `value` - */ - it('validates data with the query_range format', () => { - const validGraphData = monitoringUtils.graphDataValidatorForValues(false, graphData); - - expect(validGraphData).toBe(true); - }); - }); - - describe('graphDataValidatorForAnomalyValues', () => { - let oneMetric; - let threeMetrics; - let fourMetrics; - beforeEach(() => { - oneMetric = singleStatGraphData(); - threeMetrics = anomalyGraphData(); - - const metrics = [...threeMetrics.metrics]; - metrics.push(threeMetrics.metrics[0]); - fourMetrics = { - ...anomalyGraphData(), - metrics, - }; - }); - /* - * Anomaly charts can accept results for exactly 3 metrics, - */ - it('validates passes with the right query format', () => { - expect(monitoringUtils.graphDataValidatorForAnomalyValues(threeMetrics)).toBe(true); - }); - - it('validation fails for wrong format, 1 metric', () => { - expect(monitoringUtils.graphDataValidatorForAnomalyValues(oneMetric)).toBe(false); - }); - - it('validation fails for wrong format, more than 3 metrics', () => { - expect(monitoringUtils.graphDataValidatorForAnomalyValues(fourMetrics)).toBe(false); - }); - }); - - describe('timeRangeFromUrl', () => { - beforeEach(() => { - jest.spyOn(urlUtils, 'queryToObject'); - }); - - afterEach(() => { - urlUtils.queryToObject.mockRestore(); - }); - - const { timeRangeFromUrl } = monitoringUtils; - - it('returns a fixed range when query contains `start` and `end` parameters are given', () => { - urlUtils.queryToObject.mockReturnValueOnce(range); - expect(timeRangeFromUrl()).toEqual(range); - }); - - it('returns a rolling range when query contains `duration_seconds` parameters are given', () => { - const { seconds } = rollingRange.duration; - - urlUtils.queryToObject.mockReturnValueOnce({ - dashboard: '.gitlab/dashboard/my_dashboard.yml', - duration_seconds: `${seconds}`, - }); - - expect(timeRangeFromUrl()).toEqual(rollingRange); - }); - - it('returns null when no time range parameters are given', () => { - urlUtils.queryToObject.mockReturnValueOnce({ - dashboard: '.gitlab/dashboards/custom_dashboard.yml', - param1: 'value1', - param2: 'value2', - }); - - expect(timeRangeFromUrl()).toBe(null); - }); - }); - - describe('templatingVariablesFromUrl', () => { - const { templatingVariablesFromUrl } = monitoringUtils; - - beforeEach(() => { - jest.spyOn(urlUtils, 'queryToObject'); - }); - - afterEach(() => { - urlUtils.queryToObject.mockRestore(); - }); - - it('returns an object with only the custom variables', () => { - urlUtils.queryToObject.mockReturnValueOnce({ - dashboard: '.gitlab/dashboards/custom_dashboard.yml', - y_label: 'memory usage', - group: 'kubernetes', - title: 'Kubernetes memory total', - start: '2020-05-06', - end: '2020-05-07', - duration_seconds: '86400', - direction: 'left', - anchor: 'top', - pod: 'POD', - 'var-pod': 'POD', - }); - - expect(templatingVariablesFromUrl()).toEqual(expect.objectContaining({ pod: 'POD' })); - }); - - it('returns an empty object when no custom variables are present', () => { - urlUtils.queryToObject.mockReturnValueOnce({ - dashboard: '.gitlab/dashboards/custom_dashboard.yml', - }); - - expect(templatingVariablesFromUrl()).toStrictEqual({}); - }); - }); - - describe('removeTimeRangeParams', () => { - const { removeTimeRangeParams } = monitoringUtils; - - it('returns when query contains `start` and `end` parameters are given', () => { - expect(removeTimeRangeParams(`${mockPath}?start=${range.start}&end=${range.end}`)).toEqual( - mockPath, - ); - }); - }); - - describe('timeRangeToUrl', () => { - const { timeRangeToUrl } = monitoringUtils; - - beforeEach(() => { - jest.spyOn(urlUtils, 'mergeUrlParams'); - jest.spyOn(urlUtils, 'removeParams'); - }); - - afterEach(() => { - urlUtils.mergeUrlParams.mockRestore(); - urlUtils.removeParams.mockRestore(); - }); - - it('returns a fixed range when query contains `start` and `end` parameters are given', () => { - const toUrl = `${mockPath}?start=${range.start}&end=${range.end}`; - const fromUrl = mockPath; - - urlUtils.removeParams.mockReturnValueOnce(fromUrl); - urlUtils.mergeUrlParams.mockReturnValueOnce(toUrl); - - expect(timeRangeToUrl(range)).toEqual(toUrl); - expect(urlUtils.mergeUrlParams).toHaveBeenCalledWith(range, fromUrl); - }); - - it('returns a rolling range when query contains `duration_seconds` parameters are given', () => { - const { seconds } = rollingRange.duration; - - const toUrl = `${mockPath}?duration_seconds=${seconds}`; - const fromUrl = mockPath; - - urlUtils.removeParams.mockReturnValueOnce(fromUrl); - urlUtils.mergeUrlParams.mockReturnValueOnce(toUrl); - - expect(timeRangeToUrl(rollingRange)).toEqual(toUrl); - expect(urlUtils.mergeUrlParams).toHaveBeenCalledWith( - { duration_seconds: `${seconds}` }, - fromUrl, - ); - }); - }); - - describe('expandedPanelPayloadFromUrl', () => { - const { expandedPanelPayloadFromUrl } = monitoringUtils; - const [panelGroup] = metricsDashboardViewModel.panelGroups; - const [panel] = panelGroup.panels; - - const { group } = panelGroup; - const { title, y_label: yLabel } = panel; - - it('returns payload for a panel when query parameters are given', () => { - const search = `?group=${group}&title=${title}&y_label=${yLabel}`; - - expect(expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toEqual({ - group: panelGroup.group, - panel, - }); - }); - - it('returns null when no parameters are given', () => { - expect(expandedPanelPayloadFromUrl(metricsDashboardViewModel, '')).toBe(null); - }); - - it('throws an error when no group is provided', () => { - const search = `?title=${panel.title}&y_label=${yLabel}`; - expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow(); - }); - - it('throws an error when no title is provided', () => { - const search = `?title=${title}&y_label=${yLabel}`; - expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow(); - }); - - it('throws an error when no y_label group is provided', () => { - const search = `?group=${group}&title=${title}`; - expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow(); - }); - - it.each` - group | title | yLabel | missingField - ${'NOT_A_GROUP'} | ${title} | ${yLabel} | ${'group'} - ${group} | ${'NOT_A_TITLE'} | ${yLabel} | ${'title'} - ${group} | ${title} | ${'NOT_A_Y_LABEL'} | ${'y_label'} - `('throws an error when $missingField is incorrect', (params) => { - const search = `?group=${params.group}&title=${params.title}&y_label=${params.yLabel}`; - expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow(); - }); - }); - - describe('panelToUrl', () => { - const { panelToUrl } = monitoringUtils; - - const dashboard = 'metrics.yml'; - const [panelGroup] = metricsDashboardViewModel.panelGroups; - const [panel] = panelGroup.panels; - - const getUrlParams = (url) => urlUtils.queryToObject(url.split('?')[1]); - - it('returns URL for a panel when query parameters are given', () => { - const params = getUrlParams(panelToUrl(dashboard, {}, panelGroup.group, panel)); - - expect(params).toEqual( - expect.objectContaining({ - dashboard, - group: panelGroup.group, - title: panel.title, - y_label: panel.y_label, - }), - ); - }); - - it('returns a dashboard only URL if group is missing', () => { - const params = getUrlParams(panelToUrl(dashboard, {}, null, panel)); - expect(params).toEqual(expect.objectContaining({ dashboard: 'metrics.yml' })); - }); - - it('returns a dashboard only URL if panel is missing', () => { - const params = getUrlParams(panelToUrl(dashboard, {}, panelGroup.group, null)); - expect(params).toEqual(expect.objectContaining({ dashboard: 'metrics.yml' })); - }); - - it('returns URL for a panel when query paramters are given including custom variables', () => { - const params = getUrlParams(panelToUrl(dashboard, { pod: 'pod' }, panelGroup.group, null)); - expect(params).toEqual(expect.objectContaining({ dashboard: 'metrics.yml', pod: 'pod' })); - }); - }); - - describe('barChartsDataParser', () => { - const singleMetricExpected = { - SLA: [ - ['0.9935198135198128', 'api'], - ['0.9975296513504401', 'git'], - ['0.9994716394716395', 'registry'], - ['0.9948251748251747', 'sidekiq'], - ['0.9535664335664336', 'web'], - ['0.9335664335664336', 'postgresql_database'], - ], - }; - - const multipleMetricExpected = { - ...singleMetricExpected, - SLA_2: Object.values(singleMetricExpected)[0], - }; - - const barMockDataWithMultipleMetrics = { - ...barMockData, - metrics: [ - barMockData.metrics[0], - { - ...barMockData.metrics[0], - label: 'SLA_2', - }, - ], - }; - - it.each([ - { - input: { metrics: undefined }, - output: {}, - testCase: 'barChartsDataParser returns {} with undefined', - }, - { - input: { metrics: null }, - output: {}, - testCase: 'barChartsDataParser returns {} with null', - }, - { - input: { metrics: [] }, - output: {}, - testCase: 'barChartsDataParser returns {} with []', - }, - { - input: barMockData, - output: singleMetricExpected, - testCase: 'barChartsDataParser returns single series object with single metrics', - }, - { - input: barMockDataWithMultipleMetrics, - output: multipleMetricExpected, - testCase: 'barChartsDataParser returns multiple series object with multiple metrics', - }, - ])('$testCase', ({ input, output }) => { - expect(monitoringUtils.barChartsDataParser(input.metrics)).toEqual( - expect.objectContaining(output), - ); - }); - }); - - describe('removePrefixFromLabel', () => { - it.each` - input | expected - ${undefined} | ${''} - ${null} | ${''} - ${''} | ${''} - ${' '} | ${' '} - ${'pod-1'} | ${'pod-1'} - ${'pod-var-1'} | ${'pod-var-1'} - ${'pod-1-var'} | ${'pod-1-var'} - ${'podvar--1'} | ${'podvar--1'} - ${'povar-d-1'} | ${'povar-d-1'} - ${'var-pod-1'} | ${'pod-1'} - ${'var-var-pod-1'} | ${'var-pod-1'} - ${'varvar-pod-1'} | ${'varvar-pod-1'} - ${'var-pod-1-var-'} | ${'pod-1-var-'} - `('removePrefixFromLabel returns $expected with input $input', ({ input, expected }) => { - expect(monitoringUtils.removePrefixFromLabel(input)).toEqual(expected); - }); - }); - - describe('convertVariablesForURL', () => { - it.each` - input | expected - ${[]} | ${{}} - ${[{ name: 'env', value: 'prod' }]} | ${{ 'var-env': 'prod' }} - ${[{ name: 'env1', value: 'prod' }, { name: 'env2', value: null }]} | ${{ 'var-env1': 'prod' }} - ${[{ name: 'var-env', value: 'prod' }]} | ${{ 'var-var-env': 'prod' }} - `('convertVariablesForURL returns $expected with input $input', ({ input, expected }) => { - expect(monitoringUtils.convertVariablesForURL(input)).toEqual(expected); - }); - }); - - describe('setCustomVariablesFromUrl', () => { - beforeEach(() => { - window.history.pushState = jest.fn(); - jest.spyOn(urlUtils, 'updateHistory'); - }); - - afterEach(() => { - urlUtils.updateHistory.mockRestore(); - }); - - it.each` - input | urlParams - ${[]} | ${''} - ${[{ name: 'env', value: 'prod' }]} | ${'?var-env=prod'} - ${[{ name: 'env1', value: 'prod' }, { name: 'env2', value: null }]} | ${'?var-env1=prod'} - `( - 'setCustomVariablesFromUrl updates history with query "$urlParams" with input $input', - ({ input, urlParams }) => { - monitoringUtils.setCustomVariablesFromUrl(input); - - expect(urlUtils.updateHistory).toHaveBeenCalledTimes(1); - expect(urlUtils.updateHistory).toHaveBeenCalledWith({ - url: `${TEST_HOST}/${urlParams}`, - title: '', - }); - }, - ); - }); -}); diff --git a/spec/frontend/monitoring/validators_spec.js b/spec/frontend/monitoring/validators_spec.js deleted file mode 100644 index 0c3d77a7d98..00000000000 --- a/spec/frontend/monitoring/validators_spec.js +++ /dev/null @@ -1,80 +0,0 @@ -import { alertsValidator, queriesValidator } from '~/monitoring/validators'; - -describe('alertsValidator', () => { - const validAlert = { - alert_path: 'my/alert.json', - operator: '<', - threshold: 5, - metricId: '8', - }; - it('requires all alerts to have an alert path', () => { - const { operator, threshold, metricId } = validAlert; - const input = { - [validAlert.alert_path]: { - operator, - threshold, - metricId, - }, - }; - expect(alertsValidator(input)).toEqual(false); - }); - it('requires that the object key matches the alert path', () => { - const input = { - undefined: validAlert, - }; - expect(alertsValidator(input)).toEqual(false); - }); - it('requires all alerts to have a metric id', () => { - const input = { - [validAlert.alert_path]: { ...validAlert, metricId: undefined }, - }; - expect(alertsValidator(input)).toEqual(false); - }); - it('requires the metricId to be a string', () => { - const input = { - [validAlert.alert_path]: { ...validAlert, metricId: 8 }, - }; - expect(alertsValidator(input)).toEqual(false); - }); - it('requires all alerts to have an operator', () => { - const input = { - [validAlert.alert_path]: { ...validAlert, operator: '' }, - }; - expect(alertsValidator(input)).toEqual(false); - }); - it('requires all alerts to have an numeric threshold', () => { - const input = { - [validAlert.alert_path]: { ...validAlert, threshold: '60' }, - }; - expect(alertsValidator(input)).toEqual(false); - }); - it('correctly identifies a valid alerts object', () => { - const input = { - [validAlert.alert_path]: validAlert, - }; - expect(alertsValidator(input)).toEqual(true); - }); -}); -describe('queriesValidator', () => { - const validQuery = { - metricId: '8', - alert_path: 'alert', - label: 'alert-label', - }; - it('requires all alerts to have a metric id', () => { - const input = [{ ...validQuery, metricId: undefined }]; - expect(queriesValidator(input)).toEqual(false); - }); - it('requires the metricId to be a string', () => { - const input = [{ ...validQuery, metricId: 8 }]; - expect(queriesValidator(input)).toEqual(false); - }); - it('requires all queries to have a label', () => { - const input = [{ ...validQuery, label: undefined }]; - expect(queriesValidator(input)).toEqual(false); - }); - it('correctly identifies a valid queries array', () => { - const input = [validQuery]; - expect(queriesValidator(input)).toEqual(true); - }); -}); diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 6c774a1ecd0..a6d88bdd310 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -20,6 +20,7 @@ import eventHub from '~/notes/event_hub'; import { COMMENT_FORM } from '~/notes/i18n'; import notesModule from '~/notes/stores/modules'; import { sprintf } from '~/locale'; +import { mockTracking } from 'helpers/tracking_helper'; import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data'; jest.mock('autosize'); @@ -31,6 +32,7 @@ Vue.use(Vuex); describe('issue_comment_form component', () => { useLocalStorageSpy(); + let trackingSpy; let store; let wrapper; let axiosMock; @@ -121,6 +123,15 @@ describe('issue_comment_form component', () => { provide: { glFeatures: features, }, + mocks: { + $apollo: { + queries: { + currentUser: { + loading: false, + }, + }, + }, + }, }), ); }; @@ -128,6 +139,7 @@ describe('issue_comment_form component', () => { beforeEach(() => { axiosMock = new MockAdapter(axios); store = createStore(); + trackingSpy = mockTracking(undefined, null, jest.spyOn); }); afterEach(() => { @@ -150,6 +162,21 @@ describe('issue_comment_form component', () => { expect(wrapper.vm.stopPolling).toHaveBeenCalled(); }); + it('tracks event', () => { + mountComponent({ mountFunction: mount, initialData: { note: 'hello world' } }); + + jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); + jest.spyOn(wrapper.vm, 'stopPolling'); + + findCloseReopenButton().trigger('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'editor_type_used', { + context: 'Issue_comment', + editorType: 'editor_type_plain_text_editor', + label: 'editor_tracking', + }); + }); + it('does not report errors in the UI when the save succeeds', async () => { mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' } }); @@ -294,13 +321,13 @@ describe('issue_comment_form component', () => { it('hides content editor switcher if feature flag content_editor_on_issues is off', () => { mountComponent({ mountFunction: mount, features: { contentEditorOnIssues: false } }); - expect(wrapper.text()).not.toContain('Switch to rich text'); + expect(wrapper.text()).not.toContain('Switch to rich text editing'); }); it('shows content editor switcher if feature flag content_editor_on_issues is on', () => { mountComponent({ mountFunction: mount, features: { contentEditorOnIssues: true } }); - expect(wrapper.text()).toContain('Switch to rich text'); + expect(wrapper.text()).toContain('Switch to rich text editing'); }); describe('textarea', () => { @@ -327,9 +354,8 @@ describe('issue_comment_form component', () => { jest.spyOn(wrapper.vm, 'stopPolling'); jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - await wrapper.setData({ note: 'hello world' }); + findMarkdownEditor().vm.$emit('input', 'hello world'); + await nextTick(); await findCommentButton().trigger('click'); @@ -347,15 +373,7 @@ describe('issue_comment_form component', () => { const { markdownDocsPath } = notesDataMock; - expect(wrapper.find(`a[href="${markdownDocsPath}"]`).text()).toBe('Markdown'); - }); - - it('should link to quick actions docs', () => { - mountComponent({ mountFunction: mount }); - - const { quickActionsDocsPath } = notesDataMock; - - expect(wrapper.find(`a[href="${quickActionsDocsPath}"]`).text()).toBe('quick actions'); + expect(wrapper.find(`[href="${markdownDocsPath}"]`).exists()).toBe(true); }); it('should resize textarea after note discarded', async () => { @@ -459,9 +477,8 @@ describe('issue_comment_form component', () => { it('should enable comment button if it has note', async () => { mountComponent(); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - await wrapper.setData({ note: 'Foo' }); + findMarkdownEditor().vm.$emit('input', 'Foo'); + await nextTick(); expect(findCommentTypeDropdown().props('disabled')).toBe(false); }); diff --git a/spec/frontend/notes/components/comment_type_dropdown_spec.js b/spec/frontend/notes/components/comment_type_dropdown_spec.js index b891c1f553d..053542a421c 100644 --- a/spec/frontend/notes/components/comment_type_dropdown_spec.js +++ b/spec/frontend/notes/components/comment_type_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlButton, GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import CommentTypeDropdown from '~/notes/components/comment_type_dropdown.vue'; @@ -8,9 +8,9 @@ import { COMMENT_FORM } from '~/notes/i18n'; describe('CommentTypeDropdown component', () => { let wrapper; - const findCommentGlDropdown = () => wrapper.findComponent(GlDropdown); - const findCommentDropdownOption = () => wrapper.findAllComponents(GlDropdownItem).at(0); - const findDiscussionDropdownOption = () => wrapper.findAllComponents(GlDropdownItem).at(1); + const findCommentButton = () => wrapper.findComponent(GlButton); + const findCommentListboxOption = () => wrapper.findAllComponents(GlListboxItem).at(0); + const findDiscussionListboxOption = () => wrapper.findAllComponents(GlListboxItem).at(1); const mountComponent = ({ props = {} } = {}) => { wrapper = extendedWrapper( @@ -20,6 +20,10 @@ describe('CommentTypeDropdown component', () => { noteType: constants.COMMENT, ...props, }, + stubs: { + GlCollapsibleListbox, + GlListboxItem, + }, }), ); }; @@ -33,15 +37,15 @@ describe('CommentTypeDropdown component', () => { ({ isInternalNote, buttonText }) => { mountComponent({ props: { noteType: constants.COMMENT, isInternalNote } }); - expect(findCommentGlDropdown().props()).toMatchObject({ text: buttonText }); + expect(findCommentButton().text()).toBe(buttonText); }, ); it('Should set correct dropdown item checked when comment is selected', () => { mountComponent({ props: { noteType: constants.COMMENT } }); - expect(findCommentDropdownOption().props()).toMatchObject({ isChecked: true }); - expect(findDiscussionDropdownOption().props()).toMatchObject({ isChecked: false }); + expect(findCommentListboxOption().props('isSelected')).toBe(true); + expect(findDiscussionListboxOption().props('isSelected')).toBe(false); }); it.each` @@ -53,32 +57,22 @@ describe('CommentTypeDropdown component', () => { ({ isInternalNote, buttonText }) => { mountComponent({ props: { noteType: constants.DISCUSSION, isInternalNote } }); - expect(findCommentGlDropdown().props()).toMatchObject({ text: buttonText }); + expect(findCommentButton().text()).toBe(buttonText); }, ); it('Should set correct dropdown item option checked when discussion is selected', () => { mountComponent({ props: { noteType: constants.DISCUSSION } }); - expect(findCommentDropdownOption().props()).toMatchObject({ isChecked: false }); - expect(findDiscussionDropdownOption().props()).toMatchObject({ isChecked: true }); + expect(findCommentListboxOption().props('isSelected')).toBe(false); + expect(findDiscussionListboxOption().props('isSelected')).toBe(true); }); it('Should emit `change` event when clicking on an alternate dropdown option', () => { mountComponent({ props: { noteType: constants.DISCUSSION } }); - const event = { - type: 'click', - stopPropagation: jest.fn(), - preventDefault: jest.fn(), - }; - - findCommentDropdownOption().vm.$emit('click', event); - findDiscussionDropdownOption().vm.$emit('click', event); - - // ensure the native events don't trigger anything - expect(event.stopPropagation).toHaveBeenCalledTimes(2); - expect(event.preventDefault).toHaveBeenCalledTimes(2); + findCommentListboxOption().trigger('click'); + findDiscussionListboxOption().trigger('click'); expect(wrapper.emitted('change')[0]).toEqual([constants.COMMENT]); expect(wrapper.emitted('change').length).toEqual(1); @@ -87,7 +81,7 @@ describe('CommentTypeDropdown component', () => { it('Should emit `click` event when clicking on the action button', () => { mountComponent({ props: { noteType: constants.DISCUSSION } }); - findCommentGlDropdown().vm.$emit('click'); + findCommentButton().vm.$emit('click'); expect(wrapper.emitted('click').length > 0).toBe(true); }); diff --git a/spec/frontend/notes/components/diff_discussion_header_spec.js b/spec/frontend/notes/components/diff_discussion_header_spec.js index 66b86ed3ce0..123d53de3f3 100644 --- a/spec/frontend/notes/components/diff_discussion_header_spec.js +++ b/spec/frontend/notes/components/diff_discussion_header_spec.js @@ -12,14 +12,21 @@ describe('diff_discussion_header component', () => { let store; let wrapper; + const createComponent = ({ propsData = {} } = {}) => { + wrapper = shallowMount(diffDiscussionHeader, { + store, + propsData: { + discussion: discussionMock, + ...propsData, + }, + }); + }; + beforeEach(() => { window.mrTabs = {}; store = createStore(); - wrapper = shallowMount(diffDiscussionHeader, { - store, - propsData: { discussion: discussionMock }, - }); + createComponent({ propsData: { discussion: discussionMock } }); }); describe('Avatar', () => { @@ -27,19 +34,23 @@ describe('diff_discussion_header component', () => { const findAvatarLink = () => wrapper.findComponent(GlAvatarLink); const findAvatar = () => wrapper.findComponent(GlAvatar); - it('should render user avatar and user avatar link', () => { + it('should render user avatar and user avatar link with popover support', () => { expect(findAvatar().exists()).toBe(true); - expect(findAvatarLink().exists()).toBe(true); + + const avatarLink = findAvatarLink(); + expect(avatarLink.exists()).toBe(true); + expect(avatarLink.classes()).toContain('js-user-link'); + expect(avatarLink.attributes()).toMatchObject({ + href: firstNoteAuthor.path, + 'data-user-id': `${firstNoteAuthor.id}`, + 'data-username': `${firstNoteAuthor.username}`, + }); }); it('renders avatar of the first note author', () => { - const props = findAvatar().props(); - - expect(props).toMatchObject({ - src: firstNoteAuthor.avatar_url, - alt: firstNoteAuthor.name, - size: 32, - }); + expect(findAvatar().props('src')).toBe(firstNoteAuthor.avatar_url); + expect(findAvatar().props('alt')).toBe(firstNoteAuthor.name); + expect(findAvatar().props('size')).toBe(32); }); }); @@ -53,14 +64,16 @@ describe('diff_discussion_header component', () => { projectPath: 'something', }; - wrapper.setProps({ - discussion: { - ...discussionMock, - for_commit: true, - commit_id: commitId, - diff_discussion: true, - diff_file: { - ...mockDiffFile, + createComponent({ + propsData: { + discussion: { + ...discussionMock, + for_commit: true, + commit_id: commitId, + diff_discussion: true, + diff_file: { + ...mockDiffFile, + }, }, }, }); @@ -71,9 +84,15 @@ describe('diff_discussion_header component', () => { describe('for diff threads without a commit id', () => { it('should show started a thread on the diff text', async () => { - Object.assign(wrapper.vm.discussion, { - for_commit: false, - commit_id: null, + createComponent({ + propsData: { + discussion: { + ...discussionMock, + diff_discussion: true, + for_commit: false, + commit_id: null, + }, + }, }); await nextTick(); @@ -81,10 +100,16 @@ describe('diff_discussion_header component', () => { }); it('should show thread on older version text', async () => { - Object.assign(wrapper.vm.discussion, { - for_commit: false, - commit_id: null, - active: false, + createComponent({ + propsData: { + discussion: { + ...discussionMock, + diff_discussion: true, + for_commit: false, + commit_id: null, + active: false, + }, + }, }); await nextTick(); @@ -102,7 +127,16 @@ describe('diff_discussion_header component', () => { describe('for diff thread with a commit id', () => { it('should display started thread on commit header', async () => { - wrapper.vm.discussion.for_commit = false; + createComponent({ + propsData: { + discussion: { + ...discussionMock, + diff_discussion: true, + for_commit: false, + commit_id: commitId, + }, + }, + }); await nextTick(); expect(wrapper.text()).toContain(`started a thread on commit ${truncatedCommitId}`); @@ -111,8 +145,17 @@ describe('diff_discussion_header component', () => { }); it('should display outdated change on commit header', async () => { - wrapper.vm.discussion.for_commit = false; - wrapper.vm.discussion.active = false; + createComponent({ + propsData: { + discussion: { + ...discussionMock, + diff_discussion: true, + for_commit: false, + commit_id: commitId, + active: false, + }, + }, + }); await nextTick(); expect(wrapper.text()).toContain( diff --git a/spec/frontend/notes/components/discussion_counter_spec.js b/spec/frontend/notes/components/discussion_counter_spec.js index ac677841ee1..e52dd87f784 100644 --- a/spec/frontend/notes/components/discussion_counter_spec.js +++ b/spec/frontend/notes/components/discussion_counter_spec.js @@ -1,11 +1,11 @@ -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import DiscussionCounter from '~/notes/components/discussion_counter.vue'; import notesModule from '~/notes/stores/modules'; import * as types from '~/notes/stores/mutation_types'; -import { noteableDataMock, discussionMock, notesDataMock, userDataMock } from '../mock_data'; +import { discussionMock, noteableDataMock, notesDataMock, userDataMock } from '../mock_data'; describe('DiscussionCounter component', () => { let store; @@ -101,9 +101,24 @@ describe('DiscussionCounter component', () => { `('renders correctly if $title', async ({ resolved, groupLength }) => { updateStore({ resolvable: true, resolved }); wrapper = mount(DiscussionCounter, { store, propsData: { blocksMerge: true } }); - await wrapper.find('.dropdown-toggle').trigger('click'); + await wrapper.findComponent(GlDisclosureDropdown).trigger('click'); - expect(wrapper.findAllComponents(GlDropdownItem)).toHaveLength(groupLength); + expect(wrapper.findAllComponents(GlDisclosureDropdownItem)).toHaveLength(groupLength); + }); + + describe('resolve all with new issue link', () => { + it('has correct href prop', async () => { + updateStore({ resolvable: true }); + wrapper = mount(DiscussionCounter, { store, propsData: { blocksMerge: true } }); + + const resolveDiscussionsPath = + store.getters.getNoteableData.create_issue_to_resolve_discussions_path; + + await wrapper.findComponent(GlDisclosureDropdown).trigger('click'); + const resolveAllLink = wrapper.find('[data-testid="resolve-all-with-issue-link"]'); + + expect(resolveAllLink.attributes('href')).toBe(resolveDiscussionsPath); + }); }); }); @@ -114,7 +129,7 @@ describe('DiscussionCounter component', () => { store.commit(types.ADD_OR_UPDATE_DISCUSSIONS, [discussion]); store.dispatch('updateResolvableDiscussionsCounts'); wrapper = mount(DiscussionCounter, { store, propsData: { blocksMerge: true } }); - await wrapper.find('.dropdown-toggle').trigger('click'); + await wrapper.findComponent(GlDisclosureDropdown).trigger('click'); toggleAllButton = wrapper.find('[data-testid="toggle-all-discussions-btn"]'); }; diff --git a/spec/frontend/notes/components/mr_discussion_filter_spec.js b/spec/frontend/notes/components/mr_discussion_filter_spec.js index beb25c30af6..2bb47fd3c9e 100644 --- a/spec/frontend/notes/components/mr_discussion_filter_spec.js +++ b/spec/frontend/notes/components/mr_discussion_filter_spec.js @@ -67,7 +67,7 @@ describe('Merge request discussion filter component', () => { it('lists current filters', () => { createComponent(); - expect(wrapper.findAllComponents(GlListboxItem).length).toBe(MR_FILTER_OPTIONS.length); + expect(wrapper.findAllComponents(GlListboxItem)).toHaveLength(MR_FILTER_OPTIONS.length); }); it('updates store when selecting filter', async () => { @@ -107,4 +107,30 @@ describe('Merge request discussion filter component', () => { expect(wrapper.findComponent(GlButton).text()).toBe(expectedText); }); + + it('when clicking de-select it de-selects all options', async () => { + createComponent(); + + wrapper.find('[data-testid="listbox-reset-button"]').vm.$emit('click'); + + await nextTick(); + + expect(wrapper.findAll('[aria-selected="true"]')).toHaveLength(0); + }); + + it('when clicking select all it selects all options', async () => { + createComponent(); + + wrapper.find('[data-testid="listbox-item-approval"]').vm.$emit('select', false); + + await nextTick(); + + expect(wrapper.findAll('[aria-selected="true"]')).toHaveLength(9); + + wrapper.find('[data-testid="listbox-select-all-button"]').vm.$emit('click'); + + await nextTick(); + + expect(wrapper.findAll('[aria-selected="true"]')).toHaveLength(10); + }); }); diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index b5b33607282..645aef21e38 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -7,6 +7,7 @@ import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { AT_WHO_ACTIVE_CLASS } from '~/gfm_auto_complete'; import eventHub from '~/environments/event_hub'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking } from 'helpers/tracking_helper'; import { noteableDataMock, notesDataMock, discussionMock, note } from '../mock_data'; jest.mock('~/lib/utils/autosave'); @@ -15,6 +16,7 @@ describe('issue_note_form component', () => { let store; let wrapper; let props; + let trackingSpy; const createComponentWrapper = (propsData = {}, provide = {}) => { wrapper = mountExtended(NoteForm, { @@ -26,6 +28,15 @@ describe('issue_note_form component', () => { provide: { glFeatures: provide, }, + mocks: { + $apollo: { + queries: { + currentUser: { + loading: false, + }, + }, + }, + }, }); }; @@ -43,6 +54,7 @@ describe('issue_note_form component', () => { noteBody: 'Magni suscipit eius consectetur enim et ex et commodi.', noteId: '545', }; + trackingSpy = mockTracking(undefined, null, jest.spyOn); }); describe('noteHash', () => { @@ -66,13 +78,13 @@ describe('issue_note_form component', () => { it('hides content editor switcher if feature flag content_editor_on_issues is off', () => { createComponentWrapper({}, { contentEditorOnIssues: false }); - expect(wrapper.text()).not.toContain('Switch to rich text'); + expect(wrapper.text()).not.toContain('Switch to rich text editing'); }); it('shows content editor switcher if feature flag content_editor_on_issues is on', () => { createComponentWrapper({}, { contentEditorOnIssues: true }); - expect(wrapper.text()).toContain('Switch to rich text'); + expect(wrapper.text()).toContain('Switch to rich text editing'); }); describe('conflicts editing', () => { @@ -213,6 +225,21 @@ describe('issue_note_form component', () => { expect(wrapper.emitted('handleFormUpdate')).toHaveLength(1); }); + + it('tracks event when save button is clicked', () => { + createComponentWrapper(); + + const textarea = wrapper.find('textarea'); + textarea.setValue('Foo'); + const saveButton = wrapper.find('.js-vue-issue-save'); + saveButton.vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'editor_type_used', { + context: 'Issue_note', + editorType: 'editor_type_plain_text_editor', + label: 'editor_tracking', + }); + }); }); }); @@ -271,7 +298,9 @@ describe('issue_note_form component', () => { await nextTick(); - expect(wrapper.emitted('handleFormUpdateAddToReview')).toEqual([['Foo', false]]); + expect(wrapper.emitted('handleFormUpdateAddToReview')).toStrictEqual([ + ['Foo', false, wrapper.vm.$refs.editNoteForm, expect.any(Function)], + ]); }); }); }); diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js index d50fb130a69..059972df56b 100644 --- a/spec/frontend/notes/components/noteable_note_spec.js +++ b/spec/frontend/notes/components/noteable_note_spec.js @@ -1,6 +1,6 @@ import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; -import { GlAvatar } from '@gitlab/ui'; +import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; import { clone } from 'lodash'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -218,6 +218,18 @@ describe('issue_note', () => { }); }); + it('should render user avatar link with popover support', () => { + const { author } = note; + const avatarLink = wrapper.findComponent(GlAvatarLink); + + expect(avatarLink.classes()).toContain('js-user-link'); + expect(avatarLink.attributes()).toMatchObject({ + href: author.path, + 'data-user-id': `${author.id}`, + 'data-username': `${author.username}`, + }); + }); + it('should render user avatar', () => { const { author } = note; const avatar = wrapper.findComponent(GlAvatar); @@ -373,10 +385,24 @@ describe('issue_note', () => { afterEach(() => updateNote.mockReset()); - it('responds to handleFormUpdate', () => { + it('emits handleUpdateNote', () => { + const updatedNote = { ...note, note_html: `<p dir="auto">${params.noteText}</p>\n` }; + findNoteBody().vm.$emit('handleFormUpdate', params); expect(wrapper.emitted('handleUpdateNote')).toHaveLength(1); + + expect(wrapper.emitted('handleUpdateNote')[0]).toEqual([ + { + note: updatedNote, + noteText: params.noteText, + resolveDiscussion: params.resolveDiscussion, + position: {}, + flashContainer: wrapper.vm.$el, + callback: expect.any(Function), + errorCallback: expect.any(Function), + }, + ]); }); it('updates note content', async () => { diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js index 0f70b264326..caf47febedd 100644 --- a/spec/frontend/notes/components/notes_app_spec.js +++ b/spec/frontend/notes/components/notes_app_spec.js @@ -122,7 +122,7 @@ describe('note_app', () => { ); }); - // https://gitlab.com/gitlab-org/gitlab/-/issues/410409 + // quarantine: https://gitlab.com/gitlab-org/gitlab/-/issues/410409 // eslint-disable-next-line jest/no-disabled-tests it.skip('should render form comment button as disabled', () => { expect(findCommentButton().props('disabled')).toEqual(true); @@ -250,15 +250,7 @@ describe('note_app', () => { it('should render markdown docs url', () => { const { markdownDocsPath } = mockData.notesDataMock; - expect(wrapper.find(`a[href="${markdownDocsPath}"]`).text().trim()).toEqual('Markdown'); - }); - - it('should render quick action docs url', () => { - const { quickActionsDocsPath } = mockData.notesDataMock; - - expect(wrapper.find(`a[href="${quickActionsDocsPath}"]`).text().trim()).toEqual( - 'quick actions', - ); + expect(wrapper.find(`a[href="${markdownDocsPath}"]`).exists()).toBe(true); }); }); @@ -274,19 +266,7 @@ describe('note_app', () => { const { markdownDocsPath } = mockData.notesDataMock; await nextTick(); - expect(wrapper.find(`.edit-note a[href="${markdownDocsPath}"]`).text().trim()).toEqual( - 'Markdown', - ); - }); - - it('should render quick actions docs url', async () => { - wrapper.find('.js-note-edit').trigger('click'); - const { quickActionsDocsPath } = mockData.notesDataMock; - - await nextTick(); - expect(wrapper.find(`.edit-note a[href="${quickActionsDocsPath}"]`).text().trim()).toEqual( - 'quick actions', - ); + expect(wrapper.find(`.edit-note a[href="${markdownDocsPath}"]`).exists()).toBe(true); }); }); diff --git a/spec/frontend/notes/deprecated_notes_spec.js b/spec/frontend/notes/deprecated_notes_spec.js index 355ecb78187..0e0af3f0480 100644 --- a/spec/frontend/notes/deprecated_notes_spec.js +++ b/spec/frontend/notes/deprecated_notes_spec.js @@ -32,8 +32,7 @@ function wrappedDiscussionNote(note) { return `<table><tbody>${note}</tbody></table>`; } -// the following test is unreliable and failing in main 2-3 times a day -// see https://gitlab.com/gitlab-org/gitlab/issues/206906#note_290602581 +// quarantine: https://gitlab.com/gitlab-org/gitlab/-/issues/208441 // eslint-disable-next-line jest/no-disabled-tests describe.skip('Old Notes (~/deprecated_notes.js)', () => { beforeEach(() => { diff --git a/spec/frontend/notes/mixins/discussion_navigation_spec.js b/spec/frontend/notes/mixins/discussion_navigation_spec.js index b6a2b318ec3..bef8ed8e659 100644 --- a/spec/frontend/notes/mixins/discussion_navigation_spec.js +++ b/spec/frontend/notes/mixins/discussion_navigation_spec.js @@ -74,7 +74,6 @@ describe('Discussion navigation mixin', () => { }); afterEach(() => { - jest.clearAllMocks(); resetHTMLFixture(); }); diff --git a/spec/frontend/notes/mock_data.js b/spec/frontend/notes/mock_data.js index d5b7ad73177..94549c4a73b 100644 --- a/spec/frontend/notes/mock_data.js +++ b/spec/frontend/notes/mock_data.js @@ -60,7 +60,7 @@ export const noteableDataMock = { updated_at: '2017-08-04T09:53:01.226Z', updated_by_id: 1, web_url: '/gitlab-org/gitlab-foss/issues/26', - noteableType: 'issue', + noteableType: 'Issue', blocked_by_issues: [], }; diff --git a/spec/frontend/notes/utils_spec.js b/spec/frontend/notes/utils_spec.js index 0882e0a5759..3607c3c546c 100644 --- a/spec/frontend/notes/utils_spec.js +++ b/spec/frontend/notes/utils_spec.js @@ -1,12 +1,12 @@ import { sprintf } from '~/locale'; -import { getErrorMessages } from '~/notes/utils'; +import { createNoteErrorMessages, updateNoteErrorMessage } from '~/notes/utils'; import { HTTP_STATUS_UNPROCESSABLE_ENTITY, HTTP_STATUS_BAD_REQUEST } from '~/lib/utils/http_status'; -import { COMMENT_FORM } from '~/notes/i18n'; +import { COMMENT_FORM, UPDATE_COMMENT_FORM } from '~/notes/i18n'; -describe('getErrorMessages', () => { +describe('createNoteErrorMessages', () => { describe('when http status is not HTTP_STATUS_UNPROCESSABLE_ENTITY', () => { it('returns generic error', () => { - const errorMessages = getErrorMessages( + const errorMessages = createNoteErrorMessages( { errors: ['unknown error'] }, HTTP_STATUS_BAD_REQUEST, ); @@ -17,7 +17,7 @@ describe('getErrorMessages', () => { describe('when http status is HTTP_STATUS_UNPROCESSABLE_ENTITY', () => { it('returns all errors', () => { - const errorMessages = getErrorMessages( + const errorMessages = createNoteErrorMessages( { errors: 'error 1 and error 2' }, HTTP_STATUS_UNPROCESSABLE_ENTITY, ); @@ -29,7 +29,7 @@ describe('getErrorMessages', () => { describe('when response contains commands_only errors', () => { it('only returns commands_only errors', () => { - const errorMessages = getErrorMessages( + const errorMessages = createNoteErrorMessages( { errors: { commands_only: ['commands_only error 1', 'commands_only error 2'], @@ -44,3 +44,22 @@ describe('getErrorMessages', () => { }); }); }); + +describe('updateNoteErrorMessage', () => { + describe('with server error', () => { + it('returns error message with server error', () => { + const error = 'error 1 and error 2'; + const errorMessage = updateNoteErrorMessage({ response: { data: { errors: error } } }); + + expect(errorMessage).toEqual(sprintf(UPDATE_COMMENT_FORM.error, { reason: error })); + }); + }); + + describe('without server error', () => { + it('returns generic error message', () => { + const errorMessage = updateNoteErrorMessage(null); + + expect(errorMessage).toEqual(UPDATE_COMMENT_FORM.defaultError); + }); + }); +}); diff --git a/spec/frontend/notifications/components/notification_email_listbox_input_spec.js b/spec/frontend/notifications/components/notification_email_listbox_input_spec.js index c490c737cf1..a3a847b9523 100644 --- a/spec/frontend/notifications/components/notification_email_listbox_input_spec.js +++ b/spec/frontend/notifications/components/notification_email_listbox_input_spec.js @@ -13,6 +13,7 @@ describe('NotificationEmailListboxInput', () => { const emptyValueText = 'emptyValueText'; const value = 'value'; const disabled = false; + const placement = 'right'; // Finders const findListboxInput = () => wrapper.findComponent(ListboxInput); @@ -26,6 +27,7 @@ describe('NotificationEmailListboxInput', () => { emptyValueText, value, disabled, + placement, }, attachTo, }); diff --git a/spec/frontend/observability/client_spec.js b/spec/frontend/observability/client_spec.js new file mode 100644 index 00000000000..239d7adf986 --- /dev/null +++ b/spec/frontend/observability/client_spec.js @@ -0,0 +1,66 @@ +import MockAdapter from 'axios-mock-adapter'; +import { buildClient } from '~/observability/client'; +import axios from '~/lib/utils/axios_utils'; + +jest.mock('~/lib/utils/axios_utils'); + +describe('buildClient', () => { + let client; + let axiosMock; + + const tracingUrl = 'https://example.com/tracing'; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + jest.spyOn(axios, 'get'); + + client = buildClient({ + tracingUrl, + provisioningUrl: 'https://example.com/provisioning', + }); + }); + + afterEach(() => { + axiosMock.restore(); + }); + + describe('fetchTraces', () => { + it('should fetch traces from the tracing URL', async () => { + const mockTraces = [ + { id: 1, spans: [{ duration_nano: 1000 }, { duration_nano: 2000 }] }, + { id: 2, spans: [{ duration_nano: 2000 }] }, + ]; + + axiosMock.onGet(tracingUrl).reply(200, { + traces: mockTraces, + }); + + const result = await client.fetchTraces(); + + expect(axios.get).toHaveBeenCalledTimes(1); + expect(axios.get).toHaveBeenCalledWith(tracingUrl, { + withCredentials: true, + }); + expect(result).toEqual([ + { id: 1, spans: [{ duration_nano: 1000 }, { duration_nano: 2000 }], duration: 3 }, + { id: 2, spans: [{ duration_nano: 2000 }], duration: 2 }, + ]); + }); + + it('rejects if traces are missing', () => { + axiosMock.onGet(tracingUrl).reply(200, {}); + + return expect(client.fetchTraces()).rejects.toThrow( + 'traces are missing/invalid in the response', + ); + }); + + it('rejects if traces are invalid', () => { + axiosMock.onGet(tracingUrl).reply(200, { traces: 'invalid' }); + + return expect(client.fetchTraces()).rejects.toThrow( + 'traces are missing/invalid in the response', + ); + }); + }); +}); diff --git a/spec/frontend/observability/observability_app_spec.js b/spec/frontend/observability/observability_app_spec.js index 4a9be71b880..392992a5962 100644 --- a/spec/frontend/observability/observability_app_spec.js +++ b/spec/frontend/observability/observability_app_spec.js @@ -1,4 +1,5 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; import ObservabilityApp from '~/observability/components/observability_app.vue'; import ObservabilitySkeleton from '~/observability/components/skeleton/index.vue'; import { @@ -21,7 +22,7 @@ describe('ObservabilityApp', () => { query: { otherQuery: 100 }, }; - const mockHandleSkeleton = jest.fn(); + const mockSkeletonOnContentLoaded = jest.fn(); const findIframe = () => wrapper.findByTestId('observability-ui-iframe'); @@ -36,7 +37,9 @@ describe('ObservabilityApp', () => { ...props, }, stubs: { - 'observability-skeleton': ObservabilitySkeleton, + ObservabilitySkeleton: stubComponent(ObservabilitySkeleton, { + methods: { onContentLoaded: mockSkeletonOnContentLoaded }, + }), }, mocks: { $route, @@ -155,14 +158,14 @@ describe('ObservabilityApp', () => { describe('on GOUI_LOADED', () => { beforeEach(() => { mountComponent(); - wrapper.vm.$refs.observabilitySkeleton.onContentLoaded = mockHandleSkeleton; }); + it('should call onContentLoaded method', () => { dispatchMessageEvent({ data: { type: MESSAGE_EVENT_TYPE.GOUI_LOADED }, origin: 'https://observe.gitlab.com', }); - expect(mockHandleSkeleton).toHaveBeenCalled(); + expect(mockSkeletonOnContentLoaded).toHaveBeenCalled(); }); it('should not call onContentLoaded method if origin is different', () => { @@ -170,7 +173,7 @@ describe('ObservabilityApp', () => { data: { type: MESSAGE_EVENT_TYPE.GOUI_LOADED }, origin: 'https://example.com', }); - expect(mockHandleSkeleton).not.toHaveBeenCalled(); + expect(mockSkeletonOnContentLoaded).not.toHaveBeenCalled(); }); it('should not call onContentLoaded method if event type is different', () => { @@ -178,7 +181,7 @@ describe('ObservabilityApp', () => { data: { type: 'UNKNOWN_EVENT' }, origin: 'https://observe.gitlab.com', }); - expect(mockHandleSkeleton).not.toHaveBeenCalled(); + expect(mockSkeletonOnContentLoaded).not.toHaveBeenCalled(); }); }); diff --git a/spec/frontend/observability/observability_container_spec.js b/spec/frontend/observability/observability_container_spec.js new file mode 100644 index 00000000000..1152df072d4 --- /dev/null +++ b/spec/frontend/observability/observability_container_spec.js @@ -0,0 +1,134 @@ +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; +import ObservabilityContainer from '~/observability/components/observability_container.vue'; +import ObservabilitySkeleton from '~/observability/components/skeleton/index.vue'; +import { buildClient } from '~/observability/client'; + +jest.mock('~/observability/client'); + +describe('ObservabilityContainer', () => { + let wrapper; + + const mockSkeletonOnContentLoaded = jest.fn(); + const mockSkeletonOnError = jest.fn(); + + const OAUTH_URL = 'https://example.com/oauth'; + const TRACING_URL = 'https://example.com/tracing'; + const PROVISIONING_URL = 'https://example.com/provisioning'; + + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(); + + buildClient.mockReturnValue({}); + + wrapper = shallowMountExtended(ObservabilityContainer, { + propsData: { + oauthUrl: OAUTH_URL, + tracingUrl: TRACING_URL, + provisioningUrl: PROVISIONING_URL, + }, + stubs: { + ObservabilitySkeleton: stubComponent(ObservabilitySkeleton, { + methods: { onContentLoaded: mockSkeletonOnContentLoaded, onError: mockSkeletonOnError }, + }), + }, + slots: { + default: { + render(h) { + h(`<div>mockedComponent</div>`); + }, + name: 'MockComponent', + props: { + observabilityClient: { + type: Object, + required: true, + }, + }, + }, + }, + }); + }); + + const dispatchMessageEvent = (status, origin) => + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'AUTH_COMPLETION', + status, + }, + origin: origin ?? new URL(OAUTH_URL).origin, + }), + ); + + const findIframe = () => wrapper.findByTestId('observability-oauth-iframe'); + const findSlotComponent = () => wrapper.findComponent({ name: 'MockComponent' }); + + it('should render the oauth iframe', () => { + const iframe = findIframe(); + expect(iframe.exists()).toBe(true); + expect(iframe.attributes('hidden')).toBe('hidden'); + expect(iframe.attributes('src')).toBe(OAUTH_URL); + expect(iframe.attributes('sandbox')).toBe('allow-same-origin allow-forms allow-scripts'); + }); + + it('should render the ObservabilitySkeleton', () => { + const skeleton = wrapper.findComponent(ObservabilitySkeleton); + expect(skeleton.exists()).toBe(true); + }); + + it('should not render the default slot', () => { + expect(findSlotComponent().exists()).toBe(false); + }); + + it('renders the slot content and removes the iframe on oauth success message', async () => { + dispatchMessageEvent('success'); + + await nextTick(); + + expect(mockSkeletonOnContentLoaded).toHaveBeenCalledTimes(1); + + const slotComponent = findSlotComponent(); + expect(slotComponent.exists()).toBe(true); + expect(buildClient).toHaveBeenCalledWith({ + provisioningUrl: PROVISIONING_URL, + tracingUrl: TRACING_URL, + }); + expect(findIframe().exists()).toBe(false); + }); + + it('does not render the slot content and removes the iframe on oauth error message', async () => { + dispatchMessageEvent('error'); + + await nextTick(); + + expect(mockSkeletonOnError).toHaveBeenCalledTimes(1); + + expect(findSlotComponent().exists()).toBe(false); + expect(findIframe().exists()).toBe(false); + expect(buildClient).not.toHaveBeenCalled(); + }); + + it('handles oauth message only once', () => { + dispatchMessageEvent('success'); + dispatchMessageEvent('success'); + + expect(mockSkeletonOnContentLoaded).toHaveBeenCalledTimes(1); + }); + + it('only handles messages from the oauth url', () => { + dispatchMessageEvent('success', 'www.fake-url.com'); + + expect(mockSkeletonOnContentLoaded).toHaveBeenCalledTimes(0); + expect(findSlotComponent().exists()).toBe(false); + expect(findIframe().exists()).toBe(true); + }); + + it('does not handle messages if the component has been destroyed', () => { + wrapper.destroy(); + + dispatchMessageEvent('success'); + + expect(mockSkeletonOnContentLoaded).toHaveBeenCalledTimes(0); + }); +}); diff --git a/spec/frontend/observability/skeleton_spec.js b/spec/frontend/observability/skeleton_spec.js index 65dbb003743..979070cfb12 100644 --- a/spec/frontend/observability/skeleton_spec.js +++ b/spec/frontend/observability/skeleton_spec.js @@ -1,5 +1,5 @@ import { nextTick } from 'vue'; -import { GlSkeletonLoader, GlAlert } from '@gitlab/ui'; +import { GlSkeletonLoader, GlAlert, GlLoadingIcon } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import Skeleton from '~/observability/components/skeleton/index.vue'; @@ -17,9 +17,9 @@ import { describe('Skeleton component', () => { let wrapper; - const SKELETON_VARIANTS = Object.values(SKELETON_VARIANTS_BY_ROUTE); + const SKELETON_VARIANTS = [...Object.values(SKELETON_VARIANTS_BY_ROUTE), 'spinner']; - const findContentWrapper = () => wrapper.findByTestId('observability-wrapper'); + const findContentWrapper = () => wrapper.findByTestId('content-wrapper'); const findExploreSkeleton = () => wrapper.findComponent(ExploreSkeleton); @@ -42,8 +42,8 @@ describe('Skeleton component', () => { mountComponent({ variant: 'explore' }); }); - describe('loading timers', () => { - it('show Skeleton if content is not loaded within CONTENT_WAIT_MS', async () => { + describe('showing content', () => { + it('shows the skeleton if content is not loaded within CONTENT_WAIT_MS', async () => { expect(findExploreSkeleton().exists()).toBe(false); expect(findContentWrapper().isVisible()).toBe(false); @@ -55,7 +55,7 @@ describe('Skeleton component', () => { expect(findContentWrapper().isVisible()).toBe(false); }); - it('does not show the skeleton if content has loaded within CONTENT_WAIT_MS', async () => { + it('does not show the skeleton if content loads within CONTENT_WAIT_MS', async () => { expect(findExploreSkeleton().exists()).toBe(false); expect(findContentWrapper().isVisible()).toBe(false); @@ -73,9 +73,25 @@ describe('Skeleton component', () => { expect(findContentWrapper().isVisible()).toBe(true); expect(findExploreSkeleton().exists()).toBe(false); }); + + it('hides the skeleton after content loads', async () => { + jest.advanceTimersByTime(DEFAULT_TIMERS.CONTENT_WAIT_MS); + + await nextTick(); + + expect(findExploreSkeleton().exists()).toBe(true); + expect(findContentWrapper().isVisible()).toBe(false); + + wrapper.vm.onContentLoaded(); + + await nextTick(); + + expect(findContentWrapper().isVisible()).toBe(true); + expect(findExploreSkeleton().exists()).toBe(false); + }); }); - describe('error timeout', () => { + describe('error handling', () => { it('shows the error dialog if content has not loaded within TIMEOUT_MS', async () => { expect(findAlert().exists()).toBe(false); jest.advanceTimersByTime(DEFAULT_TIMERS.TIMEOUT_MS); @@ -86,6 +102,17 @@ describe('Skeleton component', () => { expect(findContentWrapper().isVisible()).toBe(false); }); + it('shows the error dialog if content fails to load', async () => { + expect(findAlert().exists()).toBe(false); + + wrapper.vm.onError(); + + await nextTick(); + + expect(findAlert().exists()).toBe(true); + expect(findContentWrapper().isVisible()).toBe(false); + }); + it('does not show the error dialog if content has loaded within TIMEOUT_MS', async () => { wrapper.vm.onContentLoaded(); jest.advanceTimersByTime(DEFAULT_TIMERS.TIMEOUT_MS); @@ -105,6 +132,7 @@ describe('Skeleton component', () => { ${'explore'} | ${'variant is explore'} | ${SKELETON_VARIANTS[1]} ${'manage'} | ${'variant is manage'} | ${SKELETON_VARIANTS[2]} ${'embed'} | ${'variant is embed'} | ${SKELETON_VARIANT_EMBED} + ${'spinner'} | ${'variant is spinner'} | ${'spinner'} ${'default'} | ${'variant is not manage, dashboards or explore'} | ${'unknown'} `('should render $skeletonType skeleton if $condition', async ({ skeletonType, variant }) => { mountComponent({ variant }); @@ -120,6 +148,8 @@ describe('Skeleton component', () => { expect(findEmbedSkeleton().exists()).toBe(skeletonType === SKELETON_VARIANT_EMBED); expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(showsDefaultSkeleton); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(variant === 'spinner'); }); }); diff --git a/spec/frontend/organizations/groups_and_projects/components/app_spec.js b/spec/frontend/organizations/groups_and_projects/components/app_spec.js new file mode 100644 index 00000000000..24e1a26336c --- /dev/null +++ b/spec/frontend/organizations/groups_and_projects/components/app_spec.js @@ -0,0 +1,99 @@ +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import { GlLoadingIcon } from '@gitlab/ui'; +import App from '~/organizations/groups_and_projects/components/app.vue'; +import resolvers from '~/organizations/groups_and_projects/graphql/resolvers'; +import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { createAlert } from '~/alert'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { organizationProjects } from './mock_data'; + +jest.mock('~/alert'); + +Vue.use(VueApollo); +jest.useFakeTimers(); + +describe('GroupsAndProjectsApp', () => { + let wrapper; + let mockApollo; + + const createComponent = ({ mockResolvers = resolvers } = {}) => { + mockApollo = createMockApollo([], mockResolvers); + + wrapper = shallowMountExtended(App, { apolloProvider: mockApollo }); + }; + + afterEach(() => { + mockApollo = null; + }); + + describe('when API call is loading', () => { + beforeEach(() => { + const mockResolvers = { + Query: { + organization: jest.fn().mockReturnValueOnce(new Promise(() => {})), + }, + }; + + createComponent({ mockResolvers }); + }); + + it('renders loading icon', () => { + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + }); + + describe('when API call is successful', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders `ProjectsList` component and passes correct props', async () => { + jest.runAllTimers(); + await waitForPromises(); + + expect(wrapper.findComponent(ProjectsList).props()).toEqual({ + projects: organizationProjects.projects.nodes.map( + ({ id, nameWithNamespace, accessLevel, ...project }) => ({ + ...project, + id: getIdFromGraphQLId(id), + name: nameWithNamespace, + permissions: { + projectAccess: { + accessLevel: accessLevel.integerValue, + }, + }, + }), + ), + showProjectIcon: true, + }); + }); + }); + + describe('when API call is not successful', () => { + const error = new Error(); + + beforeEach(() => { + const mockResolvers = { + Query: { + organization: jest.fn().mockRejectedValueOnce(error), + }, + }; + + createComponent({ mockResolvers }); + }); + + it('displays error alert', async () => { + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: App.i18n.errorMessage, + error, + captureError: true, + }); + }); + }); +}); diff --git a/spec/frontend/organizations/groups_and_projects/components/mock_data.js b/spec/frontend/organizations/groups_and_projects/components/mock_data.js new file mode 100644 index 00000000000..c3276450745 --- /dev/null +++ b/spec/frontend/organizations/groups_and_projects/components/mock_data.js @@ -0,0 +1,98 @@ +export const organizationProjects = { + id: 'gid://gitlab/Organization/1', + __typename: 'Organization', + projects: { + nodes: [ + { + id: 'gid://gitlab/Project/8', + nameWithNamespace: 'Twitter / Typeahead.Js', + webUrl: 'http://127.0.0.1:3000/twitter/Typeahead.Js', + topics: ['JavaScript', 'Vue.js'], + forksCount: 4, + avatarUrl: null, + starCount: 0, + visibility: 'public', + openIssuesCount: 48, + descriptionHtml: + '<p data-sourcepos="1:1-1:59" dir="auto">Optio et reprehenderit enim doloremque deserunt et commodi.</p>', + issuesAccessLevel: 'enabled', + forkingAccessLevel: 'enabled', + accessLevel: { + integerValue: 30, + }, + }, + { + id: 'gid://gitlab/Project/7', + nameWithNamespace: 'Flightjs / Flight', + webUrl: 'http://127.0.0.1:3000/flightjs/Flight', + topics: [], + forksCount: 0, + avatarUrl: null, + starCount: 0, + visibility: 'private', + openIssuesCount: 37, + descriptionHtml: + '<p data-sourcepos="1:1-1:49" dir="auto">Dolor dicta rerum et ut eius voluptate earum qui.</p>', + issuesAccessLevel: 'enabled', + forkingAccessLevel: 'enabled', + accessLevel: { + integerValue: 20, + }, + }, + { + id: 'gid://gitlab/Project/6', + nameWithNamespace: 'Jashkenas / Underscore', + webUrl: 'http://127.0.0.1:3000/jashkenas/Underscore', + topics: [], + forksCount: 0, + avatarUrl: null, + starCount: 0, + visibility: 'private', + openIssuesCount: 34, + descriptionHtml: + '<p data-sourcepos="1:1-1:52" dir="auto">Incidunt est aliquam autem nihil eveniet quis autem.</p>', + issuesAccessLevel: 'enabled', + forkingAccessLevel: 'enabled', + accessLevel: { + integerValue: 40, + }, + }, + { + id: 'gid://gitlab/Project/5', + nameWithNamespace: 'Commit451 / Lab Coat', + webUrl: 'http://127.0.0.1:3000/Commit451/lab-coat', + topics: [], + forksCount: 0, + avatarUrl: null, + starCount: 0, + visibility: 'internal', + openIssuesCount: 49, + descriptionHtml: + '<p data-sourcepos="1:1-1:34" dir="auto">Sint eos dolorem impedit rerum et.</p>', + issuesAccessLevel: 'enabled', + forkingAccessLevel: 'enabled', + accessLevel: { + integerValue: 10, + }, + }, + { + id: 'gid://gitlab/Project/1', + nameWithNamespace: 'Toolbox / Gitlab Smoke Tests', + webUrl: 'http://127.0.0.1:3000/toolbox/gitlab-smoke-tests', + topics: [], + forksCount: 0, + avatarUrl: null, + starCount: 0, + visibility: 'internal', + openIssuesCount: 34, + descriptionHtml: + '<p data-sourcepos="1:1-1:40" dir="auto">Veritatis error laboriosam libero autem.</p>', + issuesAccessLevel: 'enabled', + forkingAccessLevel: 'enabled', + accessLevel: { + integerValue: 30, + }, + }, + ], + }, +}; diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js index f74dfcb029d..d4b69d3e8e8 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js @@ -1,5 +1,11 @@ -import { GlFormCheckbox, GlSprintf, GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { + GlFormCheckbox, + GlSprintf, + GlIcon, + GlDisclosureDropdown, + GlDisclosureDropdownItem, +} from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; @@ -37,16 +43,17 @@ describe('tags list row', () => { const findManifestDetail = () => wrapper.find('[data-testid="manifest-detail"]'); const findConfigurationDetail = () => wrapper.find('[data-testid="configuration-detail"]'); const findWarningIcon = () => wrapper.findComponent(GlIcon); - const findAdditionalActionsMenu = () => wrapper.findComponent(GlDropdown); - const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); + const findAdditionalActionsMenu = () => wrapper.findComponent(GlDisclosureDropdown); + const findDeleteButton = () => wrapper.findComponent(GlDisclosureDropdownItem); - const mountComponent = (propsData = defaultProps) => { - wrapper = shallowMount(component, { + const mountComponent = (propsData = defaultProps, mountFn = shallowMount) => { + wrapper = mountFn(component, { stubs: { GlSprintf, ListItem, DetailsRow, - GlDropdown, + GlDisclosureDropdown, + GlDisclosureDropdownItem, }, propsData, directives: { @@ -274,10 +281,10 @@ describe('tags list row', () => { expect(findAdditionalActionsMenu().props()).toMatchObject({ icon: 'ellipsis_v', - text: 'More actions', + toggleText: 'More actions', textSrOnly: true, category: 'tertiary', - right: true, + placement: 'right', disabled: false, }); }); @@ -308,16 +315,19 @@ describe('tags list row', () => { mountComponent(); expect(findDeleteButton().exists()).toBe(true); - expect(findDeleteButton().attributes()).toMatchObject({ - variant: 'danger', + expect(findDeleteButton().props('item').extraAttrs).toMatchObject({ + class: 'gl-text-red-500!', + 'data-testid': 'single-delete-button', + 'data-qa-selector': 'tag_delete_button', }); + expect(findDeleteButton().text()).toBe(REMOVE_TAG_BUTTON_TITLE); }); it('delete event emits delete', () => { - mountComponent(); + mountComponent(undefined, mount); - findDeleteButton().vm.$emit('click'); + wrapper.find('[data-testid="single-delete-button"]').trigger('click'); expect(wrapper.emitted('delete')).toEqual([[]]); }); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js index 1928dbf72b6..f590cff0312 100644 --- a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js +++ b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js @@ -6,7 +6,7 @@ import { GlFormGroup, GlModal, GlSprintf, - GlEmptyState, + GlSkeletonLoader, } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; @@ -78,7 +78,7 @@ describe('DependencyProxyApp', () => { const findFormInputGroup = () => wrapper.findComponent(GlFormInputGroup); const findProxyCountText = () => wrapper.findByTestId('proxy-count'); const findManifestList = () => wrapper.findComponent(ManifestsList); - const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findLoader = () => wrapper.findComponent(GlSkeletonLoader); const findClearCacheDropdownList = () => wrapper.findComponent(GlDropdown); const findClearCacheModal = () => wrapper.findComponent(GlModal); const findClearCacheAlert = () => wrapper.findComponent(GlAlert); @@ -102,9 +102,16 @@ describe('DependencyProxyApp', () => { describe('when the dependency proxy is available', () => { describe('when is loading', () => { - it('does not render a form group with label', () => { + beforeEach(() => { createComponent(); + }); + + it('renders loading component & sets loading prop', () => { + expect(findLoader().exists()).toBe(true); + expect(findManifestList().props('loading')).toBe(true); + }); + it('does not render a form group with label', () => { expect(findFormGroup().exists()).toBe(false); }); }); @@ -120,11 +127,15 @@ describe('DependencyProxyApp', () => { expect(findFormGroup().attributes('label')).toBe( DependencyProxyApp.i18n.proxyImagePrefix, ); + expect(findFormGroup().attributes('labelfor')).toBe('proxy-url'); }); it('renders a form input group', () => { expect(findFormInputGroup().exists()).toBe(true); + expect(findFormInputGroup().attributes('id')).toBe('proxy-url'); expect(findFormInputGroup().props('value')).toBe(proxyData().dependencyProxyImagePrefix); + expect(findFormInputGroup().attributes('readonly')).toBeDefined(); + expect(findFormInputGroup().props('selectOnClick')).toBe(true); }); it('form input group has a clipboard button', () => { @@ -175,23 +186,12 @@ describe('DependencyProxyApp', () => { return waitForPromises(); }); - it('shows the empty state message', () => { - expect(findEmptyState().props()).toMatchObject({ - svgPath: provideDefaults.noManifestsIllustration, - title: DependencyProxyApp.i18n.noManifestTitle, - }); - }); - - it('hides the list', () => { - expect(findManifestList().exists()).toBe(false); + it('renders the list', () => { + expect(findManifestList().exists()).toBe(true); }); }); describe('when there are manifests', () => { - it('hides the empty state message', () => { - expect(findEmptyState().exists()).toBe(false); - }); - it('shows list', () => { expect(findManifestList().props()).toMatchObject({ dependencyProxyImagePrefix: proxyData().dependencyProxyImagePrefix, @@ -200,26 +200,58 @@ describe('DependencyProxyApp', () => { }); }); - it('prev-page event on list fetches the previous page', async () => { - findManifestList().vm.$emit('prev-page'); - await waitForPromises(); + describe('prev-page event on list', () => { + beforeEach(() => { + findManifestList().vm.$emit('prev-page'); + }); + + describe('while loading', () => { + it('does not render loading component & sets loading prop', () => { + expect(findLoader().exists()).toBe(false); + expect(findManifestList().props('loading')).toBe(true); + }); - expect(resolver).toHaveBeenCalledWith({ - before: pagination().startCursor, - first: null, - fullPath: provideDefaults.groupPath, - last: GRAPHQL_PAGE_SIZE, + it('renders form group with label', () => { + expect(findFormGroup().exists()).toBe(true); + }); + }); + + it('list fetches the previous page', async () => { + await waitForPromises(); + + expect(resolver).toHaveBeenCalledWith({ + before: pagination().startCursor, + first: null, + fullPath: provideDefaults.groupPath, + last: GRAPHQL_PAGE_SIZE, + }); }); }); - it('next-page event on list fetches the next page', async () => { - findManifestList().vm.$emit('next-page'); - await waitForPromises(); + describe('next-page event on list', () => { + beforeEach(() => { + findManifestList().vm.$emit('next-page'); + }); - expect(resolver).toHaveBeenCalledWith({ - after: pagination().endCursor, - first: GRAPHQL_PAGE_SIZE, - fullPath: provideDefaults.groupPath, + describe('while loading', () => { + it('does not render loading component & sets loading prop', () => { + expect(findLoader().exists()).toBe(false); + expect(findManifestList().props('loading')).toBe(true); + }); + + it('renders form group with label', () => { + expect(findFormGroup().exists()).toBe(true); + }); + }); + + it('fetches the next page', async () => { + await waitForPromises(); + + expect(resolver).toHaveBeenCalledWith({ + after: pagination().endCursor, + first: GRAPHQL_PAGE_SIZE, + fullPath: provideDefaults.groupPath, + }); }); }); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js index 4149f728cd8..8f445843aa8 100644 --- a/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js +++ b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js @@ -1,6 +1,7 @@ import { GlKeysetPagination, GlSkeletonLoader } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ManifestRow from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue'; +import ManifestsEmptyState from '~/packages_and_registries/dependency_proxy/components/manifests_empty_state.vue'; import Component from '~/packages_and_registries/dependency_proxy/components/manifests_list.vue'; import { proxyData, @@ -24,6 +25,7 @@ describe('Manifests List', () => { }); }; + const findEmptyState = () => wrapper.findComponent(ManifestsEmptyState); const findRows = () => wrapper.findAllComponents(ManifestRow); const findPagination = () => wrapper.findComponent(GlKeysetPagination); const findMainArea = () => wrapper.findByTestId('main-area'); @@ -38,7 +40,13 @@ describe('Manifests List', () => { it('shows a row for every manifest', () => { createComponent(); - expect(findRows().length).toBe(defaultProps.manifests.length); + expect(findRows()).toHaveLength(defaultProps.manifests.length); + }); + + it('does not show the empty state component', () => { + createComponent(); + + expect(findEmptyState().exists()).toBe(false); }); it('binds a manifest to each row', () => { @@ -68,6 +76,20 @@ describe('Manifests List', () => { }); }); + describe('when there are no manifests', () => { + beforeEach(() => { + createComponent({ ...defaultProps, manifests: [], pagination: {} }); + }); + + it('shows the empty state component', () => { + expect(findEmptyState().exists()).toBe(true); + }); + + it('hides the list', () => { + expect(findRows()).toHaveLength(0); + }); + }); + describe('pagination', () => { it('is hidden when there is no next or prev pages', () => { createComponent({ ...defaultProps, pagination: {} }); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/components/manifests_empty_state_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/components/manifests_empty_state_spec.js new file mode 100644 index 00000000000..00c1469994b --- /dev/null +++ b/spec/frontend/packages_and_registries/dependency_proxy/components/manifests_empty_state_spec.js @@ -0,0 +1,81 @@ +import { GlEmptyState, GlFormGroup, GlFormInputGroup, GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ManifestsEmptyState from '~/packages_and_registries/dependency_proxy/components/manifests_empty_state.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +describe('manifests empty state', () => { + let wrapper; + + const provideDefaults = { + noManifestsIllustration: 'noManifestsIllustration', + }; + + const createComponent = ({ stubs = {} } = {}) => { + wrapper = shallowMountExtended(ManifestsEmptyState, { + provide: provideDefaults, + stubs: { + GlEmptyState, + GlFormInputGroup, + ...stubs, + }, + }); + }; + + const findDocsLink = () => wrapper.findComponent(GlLink); + const findEmptyTextDescription = () => wrapper.findAllComponents(GlSprintf).at(0); + const findDocumentationTextDescription = () => wrapper.findAllComponents(GlSprintf).at(1); + const findClipBoardButton = () => wrapper.findComponent(ClipboardButton); + const findFormGroup = () => wrapper.findComponent(GlFormGroup); + const findFormInputGroup = () => wrapper.findComponent(GlFormInputGroup); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + beforeEach(() => { + createComponent(); + }); + + it('shows the empty state message', () => { + expect(findEmptyState().props()).toMatchObject({ + svgPath: provideDefaults.noManifestsIllustration, + title: ManifestsEmptyState.i18n.noManifestTitle, + }); + }); + + it('renders correct description', () => { + expect(findEmptyTextDescription().attributes('message')).toBe( + ManifestsEmptyState.i18n.emptyText, + ); + expect(findDocumentationTextDescription().attributes('message')).toBe( + ManifestsEmptyState.i18n.documentationText, + ); + }); + + it('renders a form group with a label', () => { + expect(findFormGroup().attributes('label')).toBe(ManifestsEmptyState.i18n.codeExampleLabel); + expect(findFormGroup().attributes('label-sr-only')).toBeDefined(); + expect(findFormGroup().attributes('label-for')).toBe('code-example'); + }); + + it('renders a form input group', () => { + expect(findFormInputGroup().exists()).toBe(true); + expect(findFormInputGroup().attributes('id')).toBe('code-example'); + expect(findFormInputGroup().props('value')).toBe(ManifestsEmptyState.codeExample); + expect(findFormInputGroup().attributes('readonly')).toBeDefined(); + expect(findFormInputGroup().props('selectOnClick')).toBe(true); + }); + + it('form input group has a clipboard button', () => { + expect(findClipBoardButton().exists()).toBe(true); + expect(findClipBoardButton().props()).toMatchObject({ + text: ManifestsEmptyState.codeExample, + title: ManifestsEmptyState.i18n.copyExample, + }); + }); + + it('shows link to docs', () => { + createComponent({ stubs: { GlSprintf } }); + + expect(findDocsLink().attributes('href')).toBe( + ManifestsEmptyState.links.DEPENDENCY_PROXY_HELP_PAGE_PATH, + ); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js index 2b60684e60a..2c712feac86 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js @@ -1,4 +1,12 @@ -import { GlAlert, GlDropdown, GlButton, GlFormCheckbox, GlLoadingIcon, GlModal } from '@gitlab/ui'; +import { + GlAlert, + GlDisclosureDropdown, + GlFormCheckbox, + GlLoadingIcon, + GlModal, + GlKeysetPagination, +} from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { stubComponent } from 'helpers/stub_component'; @@ -13,6 +21,7 @@ import { packageFilesQuery, packageDestroyFilesMutation, packageDestroyFilesMutationError, + pagination, } from 'jest/packages_and_registries/package_registry/mock_data'; import { DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION, @@ -22,16 +31,22 @@ import { DELETE_PACKAGE_FILE_ERROR_MESSAGE, DELETE_PACKAGE_FILES_SUCCESS_MESSAGE, DELETE_PACKAGE_FILES_ERROR_MESSAGE, + GRAPHQL_PACKAGE_FILES_PAGE_SIZE, } from '~/packages_and_registries/package_registry/constants'; +import { NEXT, PREV } from '~/vue_shared/components/pagination/constants'; import PackageFiles from '~/packages_and_registries/package_registry/components/details/package_files.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; - +import { scrollToElement } from '~/lib/utils/common_utils'; import getPackageFiles from '~/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql'; import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql'; Vue.use(VueApollo); jest.mock('~/alert'); +jest.mock('~/lib/utils/common_utils', () => ({ + ...jest.requireActual('~/lib/utils/common_utils'), + scrollToElement: jest.fn(), +})); describe('Package Files', () => { let wrapper; @@ -43,13 +58,15 @@ describe('Package Files', () => { const findFirstRow = () => extendedWrapper(findAllRows().at(0)); const findSecondRow = () => extendedWrapper(findAllRows().at(1)); const findPackageFilesAlert = () => wrapper.findComponent(GlAlert); + const findPagination = () => wrapper.findComponent(GlKeysetPagination); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findFirstRowDownloadLink = () => findFirstRow().findByTestId('download-link'); const findFirstRowFileIcon = () => findFirstRow().findComponent(FileIcon); const findFirstRowCreatedAt = () => findFirstRow().findComponent(TimeAgoTooltip); - const findFirstActionMenu = () => extendedWrapper(findFirstRow().findComponent(GlDropdown)); + const findFirstActionMenu = () => + extendedWrapper(findFirstRow().findComponent(GlDisclosureDropdown)); const findActionMenuDelete = () => findFirstActionMenu().findByTestId('delete-file'); - const findFirstToggleDetailsButton = () => findFirstRow().findComponent(GlButton); + const findFirstToggleDetailsButton = () => findFirstRow().findByTestId('toggle-details-button'); const findFirstRowShaComponent = (id) => wrapper.findByTestId(id); const findCheckAllCheckbox = () => wrapper.findByTestId('package-files-checkbox-all'); const findAllRowCheckboxes = () => wrapper.findAllByTestId('package-files-checkbox'); @@ -68,6 +85,7 @@ describe('Package Files', () => { stubs, resolver = jest.fn().mockResolvedValue(packageFilesQuery({ files: [file] })), filesDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFilesMutation()), + options = {}, } = {}) => { const requestHandlers = [ [getPackageFiles, resolver], @@ -92,9 +110,14 @@ describe('Package Files', () => { }), ...stubs, }, + ...options, }); }; + beforeEach(() => { + jest.spyOn(Sentry, 'captureException').mockImplementation(); + }); + describe('rows', () => { it('do not get rendered when query is loading', () => { createComponent(); @@ -123,6 +146,7 @@ describe('Package Files', () => { await waitForPromises(); expect(findPackageFilesAlert().exists()).toBe(false); + expect(Sentry.captureException).not.toHaveBeenCalled(); }); it('renders gl-alert if load fails', async () => { @@ -133,6 +157,40 @@ describe('Package Files', () => { expect(findPackageFilesAlert().text()).toBe( s__('PackageRegistry|Something went wrong while fetching package assets.'), ); + expect(Sentry.captureException).toHaveBeenCalled(); + }); + + it('renders pagination', async () => { + createComponent({ resolver: jest.fn().mockResolvedValue(packageFilesQuery()) }); + await waitForPromises(); + + const { endCursor, startCursor, hasNextPage, hasPreviousPage } = pagination(); + + expect(findPagination().props()).toMatchObject({ + endCursor, + startCursor, + hasNextPage, + hasPreviousPage, + prevText: PREV, + nextText: NEXT, + disabled: false, + }); + }); + + it('hides pagination when only one page', async () => { + createComponent({ + resolver: jest.fn().mockResolvedValue( + packageFilesQuery({ + extendPagination: { + hasNextPage: false, + hasPreviousPage: false, + }, + }), + ), + }); + await waitForPromises(); + + expect(findPagination().exists()).toBe(false); }); }); @@ -204,7 +262,7 @@ describe('Package Files', () => { expect(findFirstActionMenu().exists()).toBe(true); expect(findFirstActionMenu().props('icon')).toBe('ellipsis_v'); expect(findFirstActionMenu().props('textSrOnly')).toBe(true); - expect(findFirstActionMenu().props('text')).toMatchInterpolatedText('More actions'); + expect(findFirstActionMenu().props('toggleText')).toMatchInterpolatedText('More actions'); }); describe('menu items', () => { @@ -214,7 +272,7 @@ describe('Package Files', () => { }); it('shows delete file confirmation modal', async () => { - await findActionMenuDelete().trigger('click'); + await findActionMenuDelete().vm.$emit('action'); expect(showMock).toHaveBeenCalledTimes(1); @@ -354,7 +412,7 @@ describe('Package Files', () => { resolver: jest.fn().mockResolvedValue( packageFilesQuery({ files: [file], - pageInfo: { + extendPagination: { hasNextPage: false, }, }), @@ -379,7 +437,7 @@ describe('Package Files', () => { createComponent({ resolver: jest.fn().mockResolvedValue( packageFilesQuery({ - pageInfo: { + extendPagination: { hasNextPage: false, }, }), @@ -421,6 +479,69 @@ describe('Package Files', () => { }); }); + describe('when user interacts with pagination', () => { + const resolver = jest.fn().mockResolvedValue(packageFilesQuery()); + + beforeEach(async () => { + createComponent({ resolver, options: { attachTo: document.body } }); + await waitForPromises(); + }); + + describe('when list emits next event', () => { + beforeEach(() => { + findPagination().vm.$emit('next'); + }); + + it('fetches the next set of files', () => { + expect(resolver).toHaveBeenLastCalledWith( + expect.objectContaining({ + after: pagination().endCursor, + first: GRAPHQL_PACKAGE_FILES_PAGE_SIZE, + }), + ); + }); + + it('scrolls to top of package files component', async () => { + await waitForPromises(); + + expect(scrollToElement).toHaveBeenCalledWith(wrapper.vm.$el); + }); + + it('first row is the active element', async () => { + await waitForPromises(); + + expect(findFirstRow().element).toBe(document.activeElement); + }); + }); + + describe('when list emits prev event', () => { + beforeEach(() => { + findPagination().vm.$emit('prev'); + }); + + it('fetches the previous set of files', () => { + expect(resolver).toHaveBeenLastCalledWith( + expect.objectContaining({ + before: pagination().startCursor, + last: GRAPHQL_PACKAGE_FILES_PAGE_SIZE, + }), + ); + }); + + it('scrolls to top of package files component', async () => { + await waitForPromises(); + + expect(scrollToElement).toHaveBeenCalledWith(wrapper.vm.$el); + }); + + it('first row is the active element', async () => { + await waitForPromises(); + + expect(findFirstRow().element).toBe(document.activeElement); + }); + }); + }); + describe('deleting a file', () => { const doDeleteFile = async () => { const first = findAllRowCheckboxes().at(0); @@ -442,6 +563,7 @@ describe('Package Files', () => { await nextTick(); expect(findLoadingIcon().exists()).toBe(true); + expect(findPagination().props('disabled')).toBe(true); }); it('confirming on the modal deletes the file and shows a success message', async () => { @@ -474,7 +596,7 @@ describe('Package Files', () => { expect(resolver).toHaveBeenCalledTimes(2); expect(resolver).toHaveBeenCalledWith({ id: '1', - first: 100, + first: GRAPHQL_PACKAGE_FILES_PAGE_SIZE, }); }); @@ -534,6 +656,7 @@ describe('Package Files', () => { await nextTick(); expect(findLoadingIcon().exists()).toBe(true); + expect(findPagination().props('disabled')).toBe(true); }); it('confirming on the modal deletes the file and shows a success message', async () => { @@ -566,7 +689,7 @@ describe('Package Files', () => { expect(resolver).toHaveBeenCalledTimes(2); expect(resolver).toHaveBeenCalledWith({ id: '1', - first: 100, + first: GRAPHQL_PACKAGE_FILES_PAGE_SIZE, }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js index f7c8e909ff6..bc7203f73c9 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js @@ -1,5 +1,13 @@ -import { GlDropdownItem, GlFormCheckbox, GlIcon, GlLink, GlSprintf, GlTruncate } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlFormCheckbox, + GlIcon, + GlLink, + GlSprintf, + GlTruncate, +} from '@gitlab/ui'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; @@ -24,10 +32,16 @@ describe('VersionRow', () => { const findPackageName = () => wrapper.findComponent(GlTruncate); const findWarningIcon = () => wrapper.findComponent(GlIcon); const findBulkDeleteAction = () => wrapper.findComponent(GlFormCheckbox); - const findDeleteDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findDeleteDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem); - function createComponent({ packageEntity = packageVersion, selected = false } = {}) { - wrapper = shallowMountExtended(VersionRow, { + function createComponent(options = {}) { + const { + mountFn = shallowMountExtended, + packageEntity = packageVersion, + selected = false, + } = options; + + wrapper = mountFn(VersionRow, { propsData: { packageEntity, selected, @@ -35,6 +49,7 @@ describe('VersionRow', () => { stubs: { GlSprintf, GlTruncate, + GlDisclosureDropdown, }, directives: { GlTooltip: createMockDirective('gl-tooltip'), @@ -100,9 +115,7 @@ describe('VersionRow', () => { }); it('renders checkbox in selected state if selected', () => { - createComponent({ - selected: true, - }); + createComponent({ selected: true }); expect(findBulkDeleteAction().attributes('checked')).toBe('true'); expect(findListItem().props('selected')).toBe(true); @@ -116,19 +129,16 @@ describe('VersionRow', () => { expect(findDeleteDropdownItem().exists()).toBe(false); }); - it('exists and has the correct props', () => { + it('exists', () => { createComponent(); expect(findDeleteDropdownItem().exists()).toBe(true); - expect(findDeleteDropdownItem().attributes()).toMatchObject({ - variant: 'danger', - }); }); - it('emits the delete event when the delete button is clicked', () => { - createComponent(); + it('emits the delete event when the delete button is clicked', async () => { + createComponent({ mountFn: mountExtended }); - findDeleteDropdownItem().vm.$emit('click'); + await findDeleteDropdownItem().find('button').trigger('click'); expect(wrapper.emitted('delete')).toHaveLength(1); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap index c647230bc5f..0443fb85dc9 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap @@ -64,16 +64,12 @@ exports[`packages_list_row renders 1`] = ` withtooltip="true" /> - <!----> - <span class="gl-ml-2" data-testid="package-type" > · npm </span> - - <!----> </div> </div> </div> @@ -91,15 +87,16 @@ exports[`packages_list_row renders 1`] = ` class="gl-display-flex gl-align-items-center gl-min-h-6" > <span - data-testid="created-date" + data-testid="right-secondary" > - Created - <timeago-tooltip-stub - cssclass="" - datetimeformat="DATE_WITH_TIME_FORMAT" - time="2020-08-17T14:23:32Z" - tooltipplacement="top" - /> + Published + <time + class="" + datetime="2020-05-17T14:23:32Z" + title="May 17, 2020 2:23pm UTC" + > + 1 month ago + </time> </span> </div> </div> diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js index 81ad47b1e13..523d5f855fc 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js @@ -5,9 +5,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; - import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue'; -import PackagePath from '~/packages_and_registries/shared/components/package_path.vue'; import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; import PublishMethod from '~/packages_and_registries/package_registry/components/list/publish_method.vue'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -29,14 +27,13 @@ describe('packages_list_row', () => { const defaultProvide = { isGroupPage: false, + canDeletePackages: true, }; const packageWithoutTags = { ...packageData(), project: packageProject(), ...linksData }; const packageWithTags = { ...packageWithoutTags, tags: { nodes: packageTags() } }; - const packageCannotDestroy = { ...packageData(), ...linksData, canDestroy: false }; const findPackageTags = () => wrapper.findComponent(PackageTags); - const findPackagePath = () => wrapper.findComponent(PackagePath); const findDeleteDropdown = () => wrapper.findByTestId('action-delete'); const findPackageType = () => wrapper.findByTestId('package-type'); const findPackageLink = () => wrapper.findByTestId('details-link'); @@ -44,8 +41,7 @@ describe('packages_list_row', () => { const findLeftSecondaryInfos = () => wrapper.findByTestId('left-secondary-infos'); const findPackageVersion = () => findLeftSecondaryInfos().findComponent(GlTruncate); const findPublishMethod = () => wrapper.findComponent(PublishMethod); - const findCreatedDateText = () => wrapper.findByTestId('created-date'); - const findTimeAgoTooltip = () => wrapper.findComponent(TimeagoTooltip); + const findRightSecondary = () => wrapper.findByTestId('right-secondary'); const findListItem = () => wrapper.findComponent(ListItem); const findBulkDeleteAction = () => wrapper.findComponent(GlFormCheckbox); const findPackageName = () => wrapper.findComponent(GlTruncate); @@ -60,6 +56,7 @@ describe('packages_list_row', () => { stubs: { ListItem, GlSprintf, + TimeagoTooltip, }, propsData: { packageEntity, @@ -106,18 +103,11 @@ describe('packages_list_row', () => { }); }); - describe('when it is group', () => { - it('has a package path component', () => { - mountComponent({ provide: { isGroupPage: true } }); - - expect(findPackagePath().exists()).toBe(true); - expect(findPackagePath().props()).toMatchObject({ path: 'gitlab-org/gitlab-test' }); - }); - }); - describe('delete button', () => { it('does not exist when package cannot be destroyed', () => { - mountComponent({ packageEntity: packageCannotDestroy }); + mountComponent({ + packageEntity: { ...packageWithoutTags, canDestroy: false }, + }); expect(findDeleteDropdown().exists()).toBe(false); }); @@ -180,7 +170,10 @@ describe('packages_list_row', () => { describe('left action template', () => { it('does not render checkbox if not permitted', () => { mountComponent({ - packageEntity: { ...packageWithoutTags, canDestroy: false }, + provide: { + ...defaultProvide, + canDeletePackages: false, + }, }); expect(findBulkDeleteAction().exists()).toBe(false); @@ -223,14 +216,6 @@ describe('packages_list_row', () => { }); }); - it('if the pipeline exists show the author message', () => { - mountComponent({ - packageEntity: { ...packageWithoutTags, pipelines: { nodes: packagePipelines() } }, - }); - - expect(findLeftSecondaryInfos().text()).toContain('published by Administrator'); - }); - it('has package type with middot', () => { mountComponent(); @@ -247,13 +232,50 @@ describe('packages_list_row', () => { expect(findPublishMethod().props('pipeline')).toEqual(packagePipelines()[0]); }); - it('has the created date', () => { - mountComponent(); + it('if the package is published through CI show the author name', () => { + mountComponent({ + packageEntity: { ...packageWithoutTags, pipelines: { nodes: packagePipelines() } }, + }); + + expect(findRightSecondary().text()).toBe(`Published by Administrator, 1 month ago`); + }); - expect(findCreatedDateText().text()).toMatchInterpolatedText(PackagesListRow.i18n.createdAt); - expect(findTimeAgoTooltip().props()).toMatchObject({ - time: packageData().createdAt, + it('if the package is published manually then dont show author name', () => { + mountComponent({ + packageEntity: { ...packageWithoutTags }, }); + + expect(findRightSecondary().text()).toBe(`Published 1 month ago`); + }); + }); + + describe('right info for a group registry', () => { + it('if the package is published through CI show the project and author name', () => { + mountComponent({ + provide: { + ...defaultProvide, + isGroupPage: true, + }, + packageEntity: { ...packageWithoutTags, pipelines: { nodes: packagePipelines() } }, + }); + + expect(findRightSecondary().text()).toBe( + `Published to ${packageWithoutTags.project.name} by Administrator, 1 month ago`, + ); + }); + + it('if the package is published manually dont show project and the author name', () => { + mountComponent({ + provide: { + ...defaultProvide, + isGroupPage: true, + }, + packageEntity: { ...packageWithoutTags }, + }); + + expect(findRightSecondary().text()).toBe( + `Published to ${packageWithoutTags.project.name}, 1 month ago`, + ); }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js index 483b7a9383d..fad8863e3d9 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js @@ -41,6 +41,10 @@ describe('packages_list', () => { groupSettings: defaultPackageGroupSettings, }; + const defaultProvide = { + canDeletePackages: true, + }; + const EmptySlotStub = { name: 'empty-slot-stub', template: '<div>bar</div>' }; const findPackagesListLoader = () => wrapper.findComponent(PackagesListLoader); @@ -52,8 +56,9 @@ describe('packages_list', () => { const showMock = jest.fn(); - const mountComponent = (props) => { + const mountComponent = ({ props = {}, provide = defaultProvide } = {}) => { wrapper = shallowMountExtended(PackagesList, { + provide, propsData: { ...defaultProps, ...props, @@ -75,7 +80,7 @@ describe('packages_list', () => { describe('when is loading', () => { beforeEach(() => { - mountComponent({ isLoading: true }); + mountComponent({ props: { isLoading: true } }); }); it('shows skeleton loader', () => { @@ -109,6 +114,7 @@ describe('packages_list', () => { title: '2 packages', items: defaultProps.list, pagination: defaultProps.pageInfo, + hiddenDelete: false, isLoading: false, }); }); @@ -137,6 +143,16 @@ describe('packages_list', () => { }); }); + describe('when the user does not have permission to destroy packages', () => { + beforeEach(() => { + mountComponent({ provide: { canDeletePackages: false } }); + }); + + it('sets the hidden delete prop of registry list to true', () => { + expect(findRegistryList().props('hiddenDelete')).toBe(true); + }); + }); + describe.each` description | finderFunction | deletePayload ${'when the user can destroy the package'} | ${findPackagesListRow} | ${firstPackage} @@ -262,7 +278,7 @@ describe('packages_list', () => { describe('when an error package is present', () => { beforeEach(() => { - mountComponent({ list: [firstPackage, errorPackage] }); + mountComponent({ props: { list: [firstPackage, errorPackage] } }); return nextTick(); }); @@ -290,7 +306,7 @@ describe('packages_list', () => { describe('when the list is empty', () => { beforeEach(() => { - mountComponent({ list: [] }); + mountComponent({ props: { list: [] } }); }); it('show the empty slot', () => { @@ -301,7 +317,7 @@ describe('packages_list', () => { describe('pagination', () => { beforeEach(() => { - mountComponent({ pageInfo: { hasPreviousPage: true } }); + mountComponent({ props: { pageInfo: { hasPreviousPage: true } } }); }); it('emits prev-page events when the prev event is fired', () => { diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js index 6995a4cc635..91dc02f8f39 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -9,7 +9,7 @@ export const packageTags = () => [ export const packagePipelines = (extend) => [ { commitPath: '/namespace14/project14/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0', - createdAt: '2020-08-17T14:23:32Z', + createdAt: '2020-05-17T14:23:32Z', id: 'gid://gitlab/Ci::Pipeline/36', path: '/namespace14/project14/-/pipelines/36', name: 'project14', @@ -38,7 +38,7 @@ export const packageFiles = () => [ fileSha1: 'be93151dc23ac34a82752444556fe79b32c7a1ad', fileSha256: 'fileSha256', size: '409600', - createdAt: '2020-08-17T14:23:32Z', + createdAt: '2020-05-17T14:23:32Z', downloadPath: 'downloadPath', __typename: 'PackageFile', }, @@ -49,7 +49,7 @@ export const packageFiles = () => [ fileSha1: 'be93151dc23ac34a82752444556fe79b32c7a1ss', fileSha256: null, size: '409600', - createdAt: '2020-08-17T14:23:32Z', + createdAt: '2020-05-17T14:23:32Z', downloadPath: 'downloadPath', __typename: 'PackageFile', }, @@ -92,6 +92,7 @@ export const dependencyLinks = () => [ export const packageProject = () => ({ id: '1', + name: 'gitlab-test', fullPath: 'gitlab-org/gitlab-test', webUrl: 'http://gdk.test:3000/gitlab-org/gitlab-test', __typename: 'Project', @@ -144,7 +145,7 @@ export const packageData = (extend) => ({ name: '@gitlab-org/package-15', packageType: 'NPM', version: '1.0.0', - createdAt: '2020-08-17T14:23:32Z', + createdAt: '2020-05-17T14:23:32Z', updatedAt: '2020-08-17T14:23:32Z', lastDownloadedAt: '2021-08-17T14:23:32Z', status: 'DEFAULT', @@ -278,15 +279,12 @@ export const packagePipelinesQuery = (pipelines = packagePipelines()) => ({ }, }); -export const packageFilesQuery = ({ files = packageFiles(), pageInfo = {} } = {}) => ({ +export const packageFilesQuery = ({ files = packageFiles(), extendPagination = {} } = {}) => ({ data: { package: { id: 'gid://gitlab/Packages::Package/111', packageFiles: { - pageInfo: { - hasNextPage: true, - ...pageInfo, - }, + pageInfo: pagination(extendPagination), nodes: files, __typename: 'PackageFileConnection', }, diff --git a/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js b/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js index 2ee24200ed3..0d262036ee7 100644 --- a/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js @@ -219,7 +219,11 @@ describe('PackagesListApp', () => { await waitForPromises(); expect(resolver).toHaveBeenCalledWith( - expect.objectContaining({ before: pagination().startCursor, last: GRAPHQL_PAGE_SIZE }), + expect.objectContaining({ + first: null, + before: pagination().startCursor, + last: GRAPHQL_PAGE_SIZE, + }), ); }); }); diff --git a/spec/frontend/pages/admin/jobs/components/cancel_jobs_modal_spec.js b/spec/frontend/pages/admin/jobs/components/cancel_jobs_modal_spec.js index b1d2e443d54..d90393d8ab3 100644 --- a/spec/frontend/pages/admin/jobs/components/cancel_jobs_modal_spec.js +++ b/spec/frontend/pages/admin/jobs/components/cancel_jobs_modal_spec.js @@ -1,10 +1,11 @@ -import Vue, { nextTick } from 'vue'; +import { nextTick } from 'vue'; import { mount } from '@vue/test-utils'; import { GlModal } from '@gitlab/ui'; import { TEST_HOST } from 'helpers/test_constants'; import axios from '~/lib/utils/axios_utils'; import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import CancelJobsModal from '~/pages/admin/jobs/components/cancel_jobs_modal.vue'; +import { setVueErrorHandler } from '../../../../__helpers__/set_vue_error_handler'; jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), @@ -45,8 +46,6 @@ describe('Cancel jobs modal', () => { }); it('displays error if canceling jobs failed', async () => { - Vue.config.errorHandler = () => {}; // silencing thrown error - const dummyError = new Error('canceling jobs failed'); // TODO: We can't use axios-mock-adapter because our current version // does not support responseURL @@ -57,6 +56,7 @@ describe('Cancel jobs modal', () => { return Promise.reject(dummyError); }); + setVueErrorHandler({ instance: wrapper.vm, handler: () => {} }); // silencing thrown error wrapper.findComponent(GlModal).vm.$emit('primary'); await nextTick(); diff --git a/spec/frontend/pages/groups/new/components/app_spec.js b/spec/frontend/pages/groups/new/components/app_spec.js index 19240f1a044..baf0ca2beca 100644 --- a/spec/frontend/pages/groups/new/components/app_spec.js +++ b/spec/frontend/pages/groups/new/components/app_spec.js @@ -1,4 +1,7 @@ import { shallowMount } from '@vue/test-utils'; +import GROUP_IMPORT_SVG_URL from '@gitlab/svgs/dist/illustrations/group-import.svg?url'; +import GROUP_NEW_SVG_URL from '@gitlab/svgs/dist/illustrations/group-new.svg?url'; + import App from '~/pages/groups/new/components/app.vue'; import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'; @@ -27,6 +30,7 @@ describe('App component', () => { { href: '#', text: 'New group' }, ]); expect(findCreateGroupPanel().title).toBe('Create group'); + expect(findCreateGroupPanel().imageSrc).toBe(GROUP_NEW_SVG_URL); }); it('creates correct component for subgroup creation', () => { @@ -45,5 +49,6 @@ describe('App component', () => { ]); expect(findCreateGroupPanel().title).toBe('Create subgroup'); expect(findCreateGroupPanel().detailProps).toEqual(detailProps); + expect(findCreateGroupPanel().imageSrc).toBe(GROUP_IMPORT_SVG_URL); }); }); diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js index 07d05293a3c..197a76f2c86 100644 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js @@ -219,4 +219,17 @@ describe('Interval Pattern Input Component', () => { expect(findIcon().exists()).toBe(false); }); }); + + describe('cronValue event', () => { + it('emits cronValue event with cron value', async () => { + createWrapper(); + + findCustomInput().element.value = '0 16 * * *'; + findCustomInput().trigger('input'); + + await nextTick(); + + expect(wrapper.emitted()).toEqual({ cronValue: [['0 16 * * *']] }); + }); + }); }); diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js index 1a3eb86a00e..db889abad88 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -7,16 +7,11 @@ import { mockTracking } from 'helpers/tracking_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import WikiForm from '~/pages/shared/wikis/components/wiki_form.vue'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; -import { - CONTENT_EDITOR_LOADED_ACTION, - SAVED_USING_CONTENT_EDITOR_ACTION, - WIKI_CONTENT_EDITOR_TRACKING_LABEL, - WIKI_FORMAT_LABEL, - WIKI_FORMAT_UPDATED_ACTION, -} from '~/pages/shared/wikis/constants'; +import { WIKI_FORMAT_LABEL, WIKI_FORMAT_UPDATED_ACTION } from '~/pages/shared/wikis/constants'; import { DRAWIO_ORIGIN } from 'spec/test_constants'; jest.mock('~/emoji'); +jest.mock('~/lib/graphql'); describe('WikiForm', () => { let wrapper; @@ -94,6 +89,15 @@ describe('WikiForm', () => { GlFormInput, GlFormGroup, }, + mocks: { + $apollo: { + queries: { + currentUser: { + loading: false, + }, + }, + }, + }, }), ); } @@ -224,7 +228,22 @@ describe('WikiForm', () => { }); it('triggers wiki format tracking event', () => { - expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'wiki_format_updated', { + extra: { + old_format: 'markdown', + project_path: '/project/path/-/wikis/home', + value: 'markdown', + }, + label: 'wiki_format', + }); + }); + + it('tracks editor type used', () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'editor_type_used', { + context: 'Wiki', + editorType: 'editor_type_plain_text_editor', + label: 'editor_tracking', + }); }); it('does not trim page content', () => { @@ -306,12 +325,6 @@ describe('WikiForm', () => { expect(findFormat().element.getAttribute('disabled')).toBeDefined(); }); - it('sends tracking event when editor loads', () => { - expect(trackingSpy).toHaveBeenCalledWith(undefined, CONTENT_EDITOR_LOADED_ACTION, { - label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, - }); - }); - describe('when triggering form submit', () => { const updatedMarkdown = 'hello **world**'; @@ -321,10 +334,6 @@ describe('WikiForm', () => { }); it('triggers tracking events on form submit', () => { - expect(trackingSpy).toHaveBeenCalledWith(undefined, SAVED_USING_CONTENT_EDITOR_ACTION, { - label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, - }); - expect(trackingSpy).toHaveBeenCalledWith(undefined, WIKI_FORMAT_UPDATED_ACTION, { label: WIKI_FORMAT_LABEL, extra: { diff --git a/spec/frontend/pipeline_wizard/components/commit_spec.js b/spec/frontend/pipeline_wizard/components/commit_spec.js index 7095525e948..bb9a4b85e0e 100644 --- a/spec/frontend/pipeline_wizard/components/commit_spec.js +++ b/spec/frontend/pipeline_wizard/components/commit_spec.js @@ -141,10 +141,6 @@ describe('Pipeline Wizard - Commit Page', () => { it('emits a done event', () => { expect(wrapper.emitted().done.length).toBe(1); }); - - afterEach(() => { - jest.clearAllMocks(); - }); }); describe('failed commit', () => { @@ -167,10 +163,6 @@ describe('Pipeline Wizard - Commit Page', () => { it('will not emit a done event', () => { expect(wrapper.emitted().done?.length).toBeUndefined(); }); - - afterEach(() => { - jest.clearAllMocks(); - }); }); }); diff --git a/spec/frontend/pipeline_wizard/components/editor_spec.js b/spec/frontend/pipeline_wizard/components/editor_spec.js index 6d7d4363189..2284c875f58 100644 --- a/spec/frontend/pipeline_wizard/components/editor_spec.js +++ b/spec/frontend/pipeline_wizard/components/editor_spec.js @@ -1,42 +1,58 @@ import { mount } from '@vue/test-utils'; import { Document } from 'yaml'; import YamlEditor from '~/pipeline_wizard/components/editor.vue'; +import SourceEditor from '~/editor/source_editor'; describe('Pages Yaml Editor wrapper', () => { let wrapper; + const defaultDoc = new Document({ foo: 'bar' }); + const defaultOptions = { - propsData: { doc: new Document({ foo: 'bar' }), filename: 'foo.yml' }, + propsData: { doc: defaultDoc, filename: 'foo.yml' }, + }; + + const getLatestValue = () => { + const latest = wrapper.emitted('update:yaml').pop(); + return latest[0]; }; describe('mount hook', () => { beforeEach(() => { + jest.spyOn(SourceEditor.prototype, 'createInstance'); + wrapper = mount(YamlEditor, defaultOptions); }); - it('editor is mounted', () => { - expect(wrapper.vm.editor).not.toBeUndefined(); - expect(wrapper.find('.gl-source-editor').exists()).toBe(true); + it('creates a source editor instance', () => { + expect(SourceEditor.prototype.createInstance).toHaveBeenCalledWith({ + el: wrapper.element, + blobPath: 'foo.yml', + language: 'yaml', + }); + }); + + it('editor is mounted in the wrapper', () => { + expect(wrapper.find('.gl-source-editor.monaco-editor').exists()).toBe(true); + }); + + it("causes the editor's value to be set to the stringified document", () => { + expect(getLatestValue()).toEqual(defaultDoc.toString()); }); }); describe('watchers', () => { + beforeEach(() => { + wrapper = mount(YamlEditor, defaultOptions); + }); + describe('doc', () => { const doc = new Document({ baz: ['bar'] }); - beforeEach(() => { - wrapper = mount(YamlEditor, defaultOptions); - }); - - it("causes the editor's value to be set to the stringified document", async () => { - await wrapper.setProps({ doc }); - expect(wrapper.vm.editor.getValue()).toEqual(doc.toString()); - }); - it('emits an update:yaml event with the yaml representation of doc', async () => { await wrapper.setProps({ doc }); - const changeEvents = wrapper.emitted('update:yaml'); - expect(changeEvents[2]).toEqual([doc.toString()]); + + expect(getLatestValue()).toEqual(doc.toString()); }); it('does not cause the touch event to be emitted', () => { diff --git a/spec/frontend/pipelines/components/pipelines_list/failure_widget/failed_job_details_spec.js b/spec/frontend/pipelines/components/pipelines_list/failure_widget/failed_job_details_spec.js new file mode 100644 index 00000000000..4ba1b82e971 --- /dev/null +++ b/spec/frontend/pipelines/components/pipelines_list/failure_widget/failed_job_details_spec.js @@ -0,0 +1,252 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlIcon, GlLink } from '@gitlab/ui'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import FailedJobDetails from '~/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue'; +import RetryMrFailedJobMutation from '~/pipelines/graphql/mutations/retry_mr_failed_job.mutation.graphql'; +import { job } from './mock'; + +Vue.use(VueApollo); +jest.mock('~/alert'); + +const createFakeEvent = () => ({ stopPropagation: jest.fn() }); + +describe('FailedJobDetails component', () => { + let wrapper; + let mockRetryResponse; + + const retrySuccessResponse = { + data: { + jobRetry: { + errors: [], + }, + }, + }; + + const defaultProps = { + job, + }; + + const createComponent = ({ props = {} } = {}) => { + const handlers = [[RetryMrFailedJobMutation, mockRetryResponse]]; + + wrapper = shallowMountExtended(FailedJobDetails, { + propsData: { + ...defaultProps, + ...props, + }, + apolloProvider: createMockApollo(handlers), + }); + }; + + const findArrowIcon = () => wrapper.findComponent(GlIcon); + const findJobId = () => wrapper.findComponent(GlLink); + const findHiddenJobLog = () => wrapper.findByTestId('log-is-hidden'); + const findVisibleJobLog = () => wrapper.findByTestId('log-is-visible'); + const findJobName = () => wrapper.findByText(defaultProps.job.name); + const findRetryButton = () => wrapper.findByLabelText('Retry'); + const findRow = () => wrapper.findByTestId('widget-row'); + const findStageName = () => wrapper.findByText(defaultProps.job.stage.name); + + beforeEach(() => { + mockRetryResponse = jest.fn(); + mockRetryResponse.mockResolvedValue(retrySuccessResponse); + }); + + describe('ui', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the job name', () => { + expect(findJobName().exists()).toBe(true); + }); + + it('renders the stage name', () => { + expect(findStageName().exists()).toBe(true); + }); + + it('renders the job id as a link', () => { + const jobId = getIdFromGraphQLId(defaultProps.job.id); + + expect(findJobId().exists()).toBe(true); + expect(findJobId().text()).toContain(String(jobId)); + }); + + it('does not renders the job lob', () => { + expect(findHiddenJobLog().exists()).toBe(true); + expect(findVisibleJobLog().exists()).toBe(false); + }); + }); + + describe('Retry action', () => { + describe('when the job is not retryable', () => { + beforeEach(() => { + createComponent({ props: { job: { ...job, retryable: false } } }); + }); + + it('disables the retry button', () => { + expect(findRetryButton().props().disabled).toBe(true); + }); + }); + + describe('when the job is retryable', () => { + describe('and user has permission to update the build', () => { + beforeEach(() => { + createComponent(); + }); + + it('enables the retry button', () => { + expect(findRetryButton().props().disabled).toBe(false); + }); + + describe('when clicking on the retry button', () => { + it('passes the loading state to the button', async () => { + await findRetryButton().vm.$emit('click', createFakeEvent()); + + expect(findRetryButton().props().loading).toBe(true); + }); + + describe('and it succeeds', () => { + beforeEach(async () => { + findRetryButton().vm.$emit('click', createFakeEvent()); + await waitForPromises(); + }); + + it('is no longer loading', () => { + expect(findRetryButton().props().loading).toBe(false); + }); + + it('calls the retry mutation', () => { + expect(mockRetryResponse).toHaveBeenCalled(); + expect(mockRetryResponse).toHaveBeenCalledWith({ + id: job.id, + }); + }); + + it('emits the `retried-job` event', () => { + expect(wrapper.emitted('job-retried')).toStrictEqual([[job.name]]); + }); + }); + + describe('and it fails', () => { + const customErrorMsg = 'Custom error message from API'; + + beforeEach(async () => { + mockRetryResponse.mockResolvedValue({ + data: { jobRetry: { errors: [customErrorMsg] } }, + }); + findRetryButton().vm.$emit('click', createFakeEvent()); + + await waitForPromises(); + }); + + it('shows an error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: customErrorMsg }); + }); + + it('does not emits the `refetch-jobs` event', () => { + expect(wrapper.emitted('refetch-jobs')).toBeUndefined(); + }); + }); + }); + }); + + describe('and user does not have permission to update the build', () => { + beforeEach(() => { + createComponent({ + props: { job: { ...job, retryable: true, userPermissions: { updateBuild: false } } }, + }); + }); + + it('disables the retry button', () => { + expect(findRetryButton().props().disabled).toBe(true); + }); + }); + }); + }); + + describe('Job log', () => { + describe('without permissions', () => { + beforeEach(async () => { + createComponent({ props: { job: { ...job, userPermissions: { readBuild: false } } } }); + await findRow().trigger('click'); + }); + + it('does not renders the received html of the job log', () => { + expect(findVisibleJobLog().html()).not.toContain(defaultProps.job.trace.htmlSummary); + }); + + it('shows a permission error message', () => { + expect(findVisibleJobLog().text()).toBe( + "You do not have permission to read this job's log", + ); + }); + }); + + describe('with permissions', () => { + beforeEach(() => { + createComponent(); + }); + + describe('when clicking on the row', () => { + beforeEach(async () => { + await findRow().trigger('click'); + }); + + describe('while collapsed', () => { + it('expands the job log', () => { + expect(findHiddenJobLog().exists()).toBe(false); + expect(findVisibleJobLog().exists()).toBe(true); + }); + + it('renders the down arrow', () => { + expect(findArrowIcon().props().name).toBe('chevron-down'); + }); + + it('renders the received html of the job log', () => { + expect(findVisibleJobLog().html()).toContain(defaultProps.job.trace.htmlSummary); + }); + }); + + describe('while expanded', () => { + it('collapes the job log', async () => { + expect(findHiddenJobLog().exists()).toBe(false); + expect(findVisibleJobLog().exists()).toBe(true); + + await findRow().trigger('click'); + + expect(findHiddenJobLog().exists()).toBe(true); + expect(findVisibleJobLog().exists()).toBe(false); + }); + + it('renders the right arrow', async () => { + expect(findArrowIcon().props().name).toBe('chevron-down'); + + await findRow().trigger('click'); + + expect(findArrowIcon().props().name).toBe('chevron-right'); + }); + }); + }); + + describe('when clicking on a link element within the row', () => { + it('does not expands/collapse the job log', async () => { + expect(findHiddenJobLog().exists()).toBe(true); + expect(findVisibleJobLog().exists()).toBe(false); + expect(findArrowIcon().props().name).toBe('chevron-right'); + + await findJobId().vm.$emit('click'); + + expect(findHiddenJobLog().exists()).toBe(true); + expect(findVisibleJobLog().exists()).toBe(false); + expect(findArrowIcon().props().name).toBe('chevron-right'); + }); + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/components/pipelines_list/failure_widget/failed_jobs_list_spec.js b/spec/frontend/pipelines/components/pipelines_list/failure_widget/failed_jobs_list_spec.js new file mode 100644 index 00000000000..fc8263c6c4d --- /dev/null +++ b/spec/frontend/pipelines/components/pipelines_list/failure_widget/failed_jobs_list_spec.js @@ -0,0 +1,236 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; + +import { GlLoadingIcon, GlToast } from '@gitlab/ui'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import FailedJobsList from '~/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue'; +import FailedJobDetails from '~/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue'; +import * as utils from '~/pipelines/components/pipelines_list/failure_widget/utils'; +import getPipelineFailedJobs from '~/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql'; +import { failedJobsMock, failedJobsMock2, failedJobsMockEmpty, activeFailedJobsMock } from './mock'; + +Vue.use(VueApollo); +Vue.use(GlToast); + +jest.mock('~/alert'); + +describe('FailedJobsList component', () => { + let wrapper; + let mockFailedJobsResponse; + const showToast = jest.fn(); + + const defaultProps = { + graphqlResourceEtag: 'api/graphql', + isPipelineActive: false, + pipelineIid: 1, + pipelinePath: '/pipelines/1', + }; + + const defaultProvide = { + fullPath: 'namespace/project/', + graphqlPath: 'api/graphql', + }; + + const createComponent = ({ props = {}, provide } = {}) => { + const handlers = [[getPipelineFailedJobs, mockFailedJobsResponse]]; + const mockApollo = createMockApollo(handlers); + + wrapper = shallowMountExtended(FailedJobsList, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + ...defaultProvide, + ...provide, + }, + apolloProvider: mockApollo, + mocks: { + $toast: { + show: showToast, + }, + }, + }); + }; + + const findAllHeaders = () => wrapper.findAllByTestId('header'); + const findFailedJobRows = () => wrapper.findAllComponents(FailedJobDetails); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findNoFailedJobsText = () => wrapper.findByText('No failed jobs in this pipeline 🎉'); + + beforeEach(() => { + mockFailedJobsResponse = jest.fn(); + }); + + describe('when loading failed jobs', () => { + beforeEach(() => { + mockFailedJobsResponse.mockResolvedValue(failedJobsMock); + createComponent(); + }); + + it('shows a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('when failed jobs have loaded', () => { + beforeEach(async () => { + mockFailedJobsResponse.mockResolvedValue(failedJobsMock); + jest.spyOn(utils, 'sortJobsByStatus'); + + createComponent(); + + await waitForPromises(); + }); + + it('does not renders a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders table column', () => { + expect(findAllHeaders()).toHaveLength(4); + }); + + it('shows the list of failed jobs', () => { + expect(findFailedJobRows()).toHaveLength( + failedJobsMock.data.project.pipeline.jobs.nodes.length, + ); + }); + + it('does not renders the empty state', () => { + expect(findNoFailedJobsText().exists()).toBe(false); + }); + + it('calls sortJobsByStatus', () => { + expect(utils.sortJobsByStatus).toHaveBeenCalledWith( + failedJobsMock.data.project.pipeline.jobs.nodes, + ); + }); + }); + + describe('when there are no failed jobs', () => { + beforeEach(async () => { + mockFailedJobsResponse.mockResolvedValue(failedJobsMockEmpty); + jest.spyOn(utils, 'sortJobsByStatus'); + + createComponent(); + + await waitForPromises(); + }); + + it('renders the empty state', () => { + expect(findNoFailedJobsText().exists()).toBe(true); + }); + }); + + describe('polling', () => { + it.each` + isGraphqlActive | text + ${true} | ${'polls'} + ${false} | ${'does not poll'} + `(`$text when isGraphqlActive: $isGraphqlActive`, async ({ isGraphqlActive }) => { + const defaultCount = 2; + const newCount = 1; + + const expectedCount = isGraphqlActive ? newCount : defaultCount; + const expectedCallCount = isGraphqlActive ? 2 : 1; + const mockResponse = isGraphqlActive ? activeFailedJobsMock : failedJobsMock; + + // Second result is to simulate polling with a different response + mockFailedJobsResponse.mockResolvedValueOnce(mockResponse); + mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock2); + + createComponent(); + await waitForPromises(); + + // Initially, we get the first response which is always the default + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); + expect(findFailedJobRows()).toHaveLength(defaultCount); + + jest.advanceTimersByTime(10000); + await waitForPromises(); + + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(expectedCallCount); + expect(findFailedJobRows()).toHaveLength(expectedCount); + }); + }); + + describe('when a REST action occurs', () => { + beforeEach(() => { + // Second result is to simulate polling with a different response + mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock); + mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock2); + }); + + it.each([true, false])('triggers a refetch of the jobs count', async (isPipelineActive) => { + const defaultCount = 2; + const newCount = 1; + + createComponent({ props: { isPipelineActive } }); + await waitForPromises(); + + // Initially, we get the first response which is always the default + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); + expect(findFailedJobRows()).toHaveLength(defaultCount); + + wrapper.setProps({ isPipelineActive: !isPipelineActive }); + await waitForPromises(); + + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(2); + expect(findFailedJobRows()).toHaveLength(newCount); + }); + }); + + describe('when an error occurs loading jobs', () => { + const errorMessage = "We couldn't fetch jobs for you because you are not qualified"; + + beforeEach(async () => { + mockFailedJobsResponse.mockRejectedValue({ message: errorMessage }); + + createComponent(); + + await waitForPromises(); + }); + it('does not renders a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('calls create Alert with the error message and danger variant', () => { + expect(createAlert).toHaveBeenCalledWith({ message: errorMessage, variant: 'danger' }); + }); + }); + + describe('when `refetch-jobs` job is fired from the widget', () => { + beforeEach(async () => { + mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock); + mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock2); + + createComponent(); + + await waitForPromises(); + }); + + it('refetches all failed jobs', async () => { + expect(findFailedJobRows()).not.toHaveLength( + failedJobsMock2.data.project.pipeline.jobs.nodes.length, + ); + + await findFailedJobRows().at(0).vm.$emit('job-retried', 'job-name'); + await waitForPromises(); + + expect(findFailedJobRows()).toHaveLength( + failedJobsMock2.data.project.pipeline.jobs.nodes.length, + ); + }); + + it('shows a toast message', async () => { + await findFailedJobRows().at(0).vm.$emit('job-retried', 'job-name'); + await waitForPromises(); + + expect(showToast).toHaveBeenCalledWith('job-name job is being retried'); + }); + }); +}); diff --git a/spec/frontend/pipelines/components/pipelines_list/failure_widget/mock.js b/spec/frontend/pipelines/components/pipelines_list/failure_widget/mock.js index a4c90fa3876..b047b57fc34 100644 --- a/spec/frontend/pipelines/components/pipelines_list/failure_widget/mock.js +++ b/spec/frontend/pipelines/components/pipelines_list/failure_widget/mock.js @@ -13,13 +13,17 @@ export const job = { }, name: 'job-name', retried: false, + retryable: true, stage: { id: '1', name: 'build', }, trace: { - htmlSummary: - '<span>To install the missing version, run `gem install bundler:2.4.13`<br/>\tfrom /System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/rubygems.rb:302:in `activate_bin_path\'<br/>\tfrom /usr/bin/bundle:23:in `<main>\'<br/></span><div class="section-start" data-timestamp="1685044123" data-section="upload-artifacts-on-failure" role="button"></div><span class="term-fg-l-cyan term-bold section section-header js-s-upload-artifacts-on-failure">Uploading artifacts for failed job</span><span class="section section-header js-s-upload-artifacts-on-failure"><br/></span><span class="term-fg-l-green term-bold section line js-s-upload-artifacts-on-failure">Uploading artifacts...</span><span class="section line js-s-upload-artifacts-on-failure"><br/>Runtime platform </span><span class="section line js-s-upload-artifacts-on-failure"> arch</span><span class="section line js-s-upload-artifacts-on-failure">=arm64 os</span><span class="section line js-s-upload-artifacts-on-failure">=darwin pid</span><span class="section line js-s-upload-artifacts-on-failure">=16706 revision</span><span class="section line js-s-upload-artifacts-on-failure">=43b2dc3d version</span><span class="section line js-s-upload-artifacts-on-failure">=15.4.0<br/></span><span class="term-fg-yellow section line js-s-upload-artifacts-on-failure">WARNING: rspec.xml: no matching files. Ensure that the artifact path is relative to the working directory</span><span class="section line js-s-upload-artifacts-on-failure"> <br/></span><span class="term-fg-l-red term-bold section line js-s-upload-artifacts-on-failure">ERROR: No files to upload </span><span class="section line js-s-upload-artifacts-on-failure"> <br/></span><div class="section-end" data-section="upload-artifacts-on-failure"></div><span class="term-fg-l-red term-bold">ERROR: Job failed: exit status 1<br/></span><span><br/></span>', + htmlSummary: '<h1>Hello</h1>', + }, + userPermissions: { + readBuild: true, + updateBuild: true, }, webPath: '/', }; @@ -30,16 +34,44 @@ export const allowedToFailJob = { allowFailure: true, }; -export const failedJobsMock = { - data: { - project: { - id: 'gid://gitlab/Project/20', - pipeline: { - id: 'gid://gitlab/Pipeline/20', - jobs: { - nodes: [allowedToFailJob, job], +export const createFailedJobsMockCount = ({ count = 4, active = false } = {}) => { + return { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + id: 'gid://gitlab/Pipeline/20', + active, + jobs: { + count, + }, }, }, }, - }, + }; +}; + +const createFailedJobsMock = (nodes, active = false) => { + return { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + active, + id: 'gid://gitlab/Pipeline/20', + jobs: { + count: nodes.length, + nodes, + }, + }, + }, + }, + }; }; + +export const failedJobsMock = createFailedJobsMock([allowedToFailJob, job]); +export const failedJobsMockEmpty = createFailedJobsMock([]); + +export const activeFailedJobsMock = createFailedJobsMock([allowedToFailJob, job], true); + +export const failedJobsMock2 = createFailedJobsMock([job]); diff --git a/spec/frontend/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js b/spec/frontend/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js index df6d114f683..c1a885391e9 100644 --- a/spec/frontend/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js +++ b/spec/frontend/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js @@ -1,25 +1,16 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; - -import { GlButton, GlIcon, GlLoadingIcon, GlPopover } from '@gitlab/ui'; -import createMockApollo from 'helpers/mock_apollo_helper'; +import { GlButton, GlIcon, GlPopover } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; import PipelineFailedJobsWidget from '~/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue'; -import { createAlert } from '~/alert'; -import WidgetFailedJobRow from '~/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue'; -import * as utils from '~/pipelines/components/pipelines_list/failure_widget/utils'; -import getPipelineFailedJobs from '~/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql'; -import { failedJobsMock } from './mock'; +import FailedJobsList from '~/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue'; -Vue.use(VueApollo); jest.mock('~/alert'); describe('PipelineFailedJobsWidget component', () => { let wrapper; - let mockFailedJobsResponse; const defaultProps = { + failedJobsCount: 4, + isPipelineActive: false, pipelineIid: 1, pipelinePath: '/pipelines/1', }; @@ -28,10 +19,7 @@ describe('PipelineFailedJobsWidget component', () => { fullPath: 'namespace/project/', }; - const createComponent = ({ props = {}, provide } = {}) => { - const handlers = [[getPipelineFailedJobs, mockFailedJobsResponse]]; - const mockApollo = createMockApollo(handlers); - + const createComponent = ({ props = {}, provide = {} } = {}) => { wrapper = shallowMountExtended(PipelineFailedJobsWidget, { propsData: { ...defaultProps, @@ -41,29 +29,35 @@ describe('PipelineFailedJobsWidget component', () => { ...defaultProvide, ...provide, }, - apolloProvider: mockApollo, }); }; - const findAllHeaders = () => wrapper.findAllByTestId('header'); const findFailedJobsButton = () => wrapper.findComponent(GlButton); - const findFailedJobRows = () => wrapper.findAllComponents(WidgetFailedJobRow); + const findFailedJobsList = () => wrapper.findAllComponents(FailedJobsList); const findInfoIcon = () => wrapper.findComponent(GlIcon); const findInfoPopover = () => wrapper.findComponent(GlPopover); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - beforeEach(() => { - mockFailedJobsResponse = jest.fn(); + describe('when there are no failed jobs', () => { + beforeEach(() => { + createComponent({ props: { failedJobsCount: 0 } }); + }); + + it('renders the show failed jobs button with a count of 0', () => { + expect(findFailedJobsButton().exists()).toBe(true); + expect(findFailedJobsButton().text()).toBe('Show failed jobs (0)'); + }); }); - describe('ui', () => { + describe('when there are failed jobs', () => { beforeEach(() => { createComponent(); }); - it('renders the show failed jobs button', () => { + it('renders the show failed jobs button with correct count', () => { expect(findFailedJobsButton().exists()).toBe(true); - expect(findFailedJobsButton().text()).toBe('Show failed jobs'); + expect(findFailedJobsButton().text()).toBe( + `Show failed jobs (${defaultProps.failedJobsCount})`, + ); }); it('renders the info icon', () => { @@ -74,71 +68,53 @@ describe('PipelineFailedJobsWidget component', () => { expect(findInfoPopover().exists()).toBe(true); }); - it('does not show the list of failed jobs', () => { - expect(findFailedJobRows()).toHaveLength(0); + it('does not render the failed jobs widget', () => { + expect(findFailedJobsList().exists()).toBe(false); }); }); - describe('when loading failed jobs', () => { + describe('when the job button is clicked', () => { beforeEach(async () => { - mockFailedJobsResponse.mockResolvedValue(failedJobsMock); createComponent(); await findFailedJobsButton().vm.$emit('click'); }); - it('shows a loading icon', () => { - expect(findLoadingIcon().exists()).toBe(true); + it('renders the failed jobs widget', () => { + expect(findFailedJobsList().exists()).toBe(true); }); }); - describe('when failed jobs have loaded', () => { - beforeEach(async () => { - mockFailedJobsResponse.mockResolvedValue(failedJobsMock); - jest.spyOn(utils, 'sortJobsByStatus'); - + describe('when the job count changes', () => { + beforeEach(() => { createComponent(); - - await findFailedJobsButton().vm.$emit('click'); - await waitForPromises(); - }); - it('does not renders a loading icon', () => { - expect(findLoadingIcon().exists()).toBe(false); }); - it('renders table column', () => { - expect(findAllHeaders()).toHaveLength(3); - }); + describe('from the prop', () => { + it('updates the job count', async () => { + const newJobCount = 12; - it('shows the list of failed jobs', () => { - expect(findFailedJobRows()).toHaveLength( - failedJobsMock.data.project.pipeline.jobs.nodes.length, - ); - }); + expect(findFailedJobsButton().text()).toContain(String(defaultProps.failedJobsCount)); - it('calls sortJobsByStatus', () => { - expect(utils.sortJobsByStatus).toHaveBeenCalledWith( - failedJobsMock.data.project.pipeline.jobs.nodes, - ); + await wrapper.setProps({ failedJobsCount: newJobCount }); + + expect(findFailedJobsButton().text()).toContain(String(newJobCount)); + }); }); - }); - describe('when an error occurs loading jobs', () => { - const errorMessage = "We couldn't fetch jobs for you because you are not qualified"; + describe('from the event', () => { + beforeEach(async () => { + await findFailedJobsButton().vm.$emit('click'); + }); - beforeEach(async () => { - mockFailedJobsResponse.mockRejectedValue({ message: errorMessage }); + it('updates the job count', async () => { + const newJobCount = 12; - createComponent(); + expect(findFailedJobsButton().text()).toContain(String(defaultProps.failedJobsCount)); - await findFailedJobsButton().vm.$emit('click'); - await waitForPromises(); - }); - it('does not renders a loading icon', () => { - expect(findLoadingIcon().exists()).toBe(false); - }); + await findFailedJobsList().at(0).vm.$emit('failed-jobs-count', newJobCount); - it('calls create Alert with the error message and danger variant', () => { - expect(createAlert).toHaveBeenCalledWith({ message: errorMessage, variant: 'danger' }); + expect(findFailedJobsButton().text()).toContain(String(newJobCount)); + }); }); }); }); diff --git a/spec/frontend/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row_spec.js b/spec/frontend/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row_spec.js deleted file mode 100644 index dfc2806840f..00000000000 --- a/spec/frontend/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row_spec.js +++ /dev/null @@ -1,140 +0,0 @@ -import { GlIcon, GlLink } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import WidgetFailedJobRow from '~/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue'; - -describe('WidgetFailedJobRow component', () => { - let wrapper; - - const defaultProps = { - job: { - id: 'gid://gitlab/Ci::Build/5240', - detailedStatus: { - group: 'running', - icon: 'icon_status_running', - }, - name: 'my-job', - stage: { - name: 'build', - }, - trace: { - htmlSummary: '<h1>job log</h1>', - }, - webpath: '/', - }, - }; - - const createComponent = ({ props = {} } = {}) => { - wrapper = shallowMountExtended(WidgetFailedJobRow, { - propsData: { - ...defaultProps, - ...props, - }, - }); - }; - - const findArrowIcon = () => wrapper.findComponent(GlIcon); - const findJobCiStatus = () => wrapper.findComponent(CiIcon); - const findJobId = () => wrapper.findComponent(GlLink); - const findHiddenJobLog = () => wrapper.findByTestId('log-is-hidden'); - const findVisibleJobLog = () => wrapper.findByTestId('log-is-visible'); - const findJobName = () => wrapper.findByText(defaultProps.job.name); - const findRow = () => wrapper.findByTestId('widget-row'); - const findStageName = () => wrapper.findByText(defaultProps.job.stage.name); - - describe('ui', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders the job name', () => { - expect(findJobName().exists()).toBe(true); - }); - - it('renders the stage name', () => { - expect(findStageName().exists()).toBe(true); - }); - - it('renders the job id as a link', () => { - const jobId = getIdFromGraphQLId(defaultProps.job.id); - - expect(findJobId().exists()).toBe(true); - expect(findJobId().text()).toContain(String(jobId)); - }); - - it('renders the ci status badge', () => { - expect(findJobCiStatus().exists()).toBe(true); - }); - - it('renders the right arrow', () => { - expect(findArrowIcon().props().name).toBe('chevron-right'); - }); - - it('does not renders the job lob', () => { - expect(findHiddenJobLog().exists()).toBe(true); - expect(findVisibleJobLog().exists()).toBe(false); - }); - }); - - describe('Job log', () => { - beforeEach(() => { - createComponent(); - }); - - describe('when clicking on the row', () => { - beforeEach(async () => { - await findRow().trigger('click'); - }); - - describe('while collapsed', () => { - it('expands the job log', () => { - expect(findHiddenJobLog().exists()).toBe(false); - expect(findVisibleJobLog().exists()).toBe(true); - }); - - it('renders the down arrow', () => { - expect(findArrowIcon().props().name).toBe('chevron-down'); - }); - - it('renders the received html', () => { - expect(findVisibleJobLog().html()).toContain(defaultProps.job.trace.htmlSummary); - }); - }); - - describe('while expanded', () => { - it('collapes the job log', async () => { - expect(findHiddenJobLog().exists()).toBe(false); - expect(findVisibleJobLog().exists()).toBe(true); - - await findRow().trigger('click'); - - expect(findHiddenJobLog().exists()).toBe(true); - expect(findVisibleJobLog().exists()).toBe(false); - }); - - it('renders the right arrow', async () => { - expect(findArrowIcon().props().name).toBe('chevron-down'); - - await findRow().trigger('click'); - - expect(findArrowIcon().props().name).toBe('chevron-right'); - }); - }); - }); - - describe('when clicking on a link element within the row', () => { - it('does not expands/collapse the job log', async () => { - expect(findHiddenJobLog().exists()).toBe(true); - expect(findVisibleJobLog().exists()).toBe(false); - expect(findArrowIcon().props().name).toBe('chevron-right'); - - await findJobId().vm.$emit('click'); - - expect(findHiddenJobLog().exists()).toBe(true); - expect(findVisibleJobLog().exists()).toBe(false); - expect(findArrowIcon().props().name).toBe('chevron-right'); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js index 9599b5e6b7b..7b59d82ae6f 100644 --- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -34,7 +34,11 @@ import getPipelineHeaderData from '~/pipelines/graphql/queries/get_pipeline_head import * as sentryUtils from '~/pipelines/utils'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { mockRunningPipelineHeaderData } from '../mock_data'; -import { mapCallouts, mockCalloutsResponse } from './mock_data'; +import { + mapCallouts, + mockCalloutsResponse, + mockPipelineResponseWithTooManyJobs, +} from './mock_data'; const defaultProvide = { graphqlResourceEtag: 'frog/amphibirama/etag/', @@ -49,7 +53,10 @@ describe('Pipeline graph wrapper', () => { let wrapper; let requestHandlers; - const findAlert = () => wrapper.findComponent(GlAlert); + let pipelineDetailsHandler; + + const findAlert = () => wrapper.findByTestId('error-alert'); + const findJobCountWarning = () => wrapper.findByTestId('job-count-warning'); const findDependenciesToggle = () => wrapper.findByTestId('show-links-toggle'); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findLinksLayer = () => wrapper.findComponent(LinksLayer); @@ -83,7 +90,6 @@ describe('Pipeline graph wrapper', () => { const createComponentWithApollo = ({ calloutsList = [], data = {}, - getPipelineDetailsHandler = jest.fn().mockResolvedValue(mockPipelineResponse), mountFn = shallowMountExtended, provide = {}, } = {}) => { @@ -92,7 +98,7 @@ describe('Pipeline graph wrapper', () => { requestHandlers = { getUserCalloutsHandler: jest.fn().mockResolvedValue(mockCalloutsResponse(callouts)), getPipelineHeaderDataHandler: jest.fn().mockResolvedValue(mockRunningPipelineHeaderData), - getPipelineDetailsHandler, + getPipelineDetailsHandler: pipelineDetailsHandler, }; const handlers = [ @@ -105,24 +111,29 @@ describe('Pipeline graph wrapper', () => { createComponent({ apolloProvider, data, provide, mountFn }); }; + beforeEach(() => { + pipelineDetailsHandler = jest.fn(); + pipelineDetailsHandler.mockResolvedValue(mockPipelineResponse); + }); + describe('when data is loading', () => { - it('displays the loading icon', () => { + beforeEach(() => { createComponentWithApollo(); + }); + + it('displays the loading icon', () => { expect(findLoadingIcon().exists()).toBe(true); }); it('does not display the alert', () => { - createComponentWithApollo(); expect(findAlert().exists()).toBe(false); }); it('does not display the graph', () => { - createComponentWithApollo(); expect(findGraph().exists()).toBe(false); }); it('skips querying headerPipeline', () => { - createComponentWithApollo(); expect(wrapper.vm.$apollo.queries.headerPipeline.skip).toBe(true); }); }); @@ -153,11 +164,25 @@ describe('Pipeline graph wrapper', () => { }); }); + describe('when a stage has 100 jobs or more', () => { + beforeEach(async () => { + pipelineDetailsHandler.mockResolvedValue(mockPipelineResponseWithTooManyJobs); + createComponentWithApollo(); + await waitForPromises(); + }); + + it('show a warning alert', () => { + expect(findJobCountWarning().exists()).toBe(true); + expect(findJobCountWarning().props().title).toBe( + 'Only the first 100 jobs per stage are displayed', + ); + }); + }); + describe('when there is an error', () => { beforeEach(async () => { - createComponentWithApollo({ - getPipelineDetailsHandler: jest.fn().mockRejectedValue(new Error('GraphQL error')), - }); + pipelineDetailsHandler.mockRejectedValue(new Error('GraphQL error')); + createComponentWithApollo(); await waitForPromises(); }); @@ -270,13 +295,12 @@ describe('Pipeline graph wrapper', () => { errors: [{ message: 'timeout' }], }; - const failSucceedFail = jest - .fn() + pipelineDetailsHandler .mockResolvedValueOnce(errorData) .mockResolvedValueOnce(mockPipelineResponse) .mockResolvedValueOnce(errorData); - createComponentWithApollo({ getPipelineDetailsHandler: failSucceedFail }); + createComponentWithApollo(); await waitForPromises(); }); @@ -438,9 +462,9 @@ describe('Pipeline graph wrapper', () => { localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW); + pipelineDetailsHandler.mockResolvedValue(nonNeedsResponse); createComponentWithApollo({ mountFn: mountExtended, - getPipelineDetailsHandler: jest.fn().mockResolvedValue(nonNeedsResponse), }); await waitForPromises(); @@ -460,9 +484,9 @@ describe('Pipeline graph wrapper', () => { const nonNeedsResponse = { ...mockPipelineResponse }; nonNeedsResponse.data.project.pipeline.usesNeeds = false; + pipelineDetailsHandler.mockResolvedValue(nonNeedsResponse); createComponentWithApollo({ mountFn: mountExtended, - getPipelineDetailsHandler: jest.fn().mockResolvedValue(nonNeedsResponse), }); jest.runOnlyPendingTimers(); diff --git a/spec/frontend/pipelines/graph/job_name_component_spec.js b/spec/frontend/pipelines/graph/job_name_component_spec.js index ec432e98fdf..fca4c43d9fa 100644 --- a/spec/frontend/pipelines/graph/job_name_component_spec.js +++ b/spec/frontend/pipelines/graph/job_name_component_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; import jobNameComponent from '~/pipelines/components/jobs_shared/job_name_component.vue'; -import ciIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; describe('job name component', () => { let wrapper; @@ -24,7 +24,7 @@ describe('job name component', () => { }); it('should render an icon with the provided status', () => { - expect(wrapper.findComponent(ciIcon).exists()).toBe(true); + expect(wrapper.findComponent(CiIcon).exists()).toBe(true); expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true); }); }); diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js index bf92cd585d9..8dae2aac664 100644 --- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js @@ -10,7 +10,7 @@ import { ACTION_FAILURE, UPSTREAM, DOWNSTREAM } from '~/pipelines/components/gra import LinkedPipelineComponent from '~/pipelines/components/graph/linked_pipeline.vue'; import CancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql'; import RetryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql'; -import CiStatus from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import mockPipeline from './linked_pipelines_mock_data'; describe('Linked pipeline', () => { @@ -87,7 +87,7 @@ describe('Linked pipeline', () => { }); it('should render an svg within the status container', () => { - const pipelineStatusElement = wrapper.findComponent(CiStatus); + const pipelineStatusElement = wrapper.findComponent(CiIcon); expect(pipelineStatusElement.find('svg').exists()).toBe(true); }); @@ -97,7 +97,7 @@ describe('Linked pipeline', () => { }); it('should have a ci-status child component', () => { - expect(wrapper.findComponent(CiStatus).exists()).toBe(true); + expect(wrapper.findComponent(CiIcon).exists()).toBe(true); }); it('should render the pipeline id', () => { diff --git a/spec/frontend/pipelines/graph/mock_data.js b/spec/frontend/pipelines/graph/mock_data.js index b012e7f66e1..8d06d6931ed 100644 --- a/spec/frontend/pipelines/graph/mock_data.js +++ b/spec/frontend/pipelines/graph/mock_data.js @@ -1,3 +1,4 @@ +import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; import { unwrapPipelineData } from '~/pipelines/components/graph/utils'; import { BUILD_KIND, @@ -5,6 +6,14 @@ import { RETRY_ACTION_TITLE, } from '~/pipelines/components/graph/constants'; +// We mock this instead of using fixtures for performance reason. +const mockPipelineResponseCopy = JSON.parse(JSON.stringify(mockPipelineResponse)); +const groups = new Array(100).fill({ + ...mockPipelineResponse.data.project.pipeline.stages.nodes[0].groups.nodes[0], +}); +mockPipelineResponseCopy.data.project.pipeline.stages.nodes[0].groups.nodes = groups; +export const mockPipelineResponseWithTooManyJobs = mockPipelineResponseCopy; + export const downstream = { nodes: [ { diff --git a/spec/frontend/pipelines/graph_shared/links_inner_spec.js b/spec/frontend/pipelines/graph_shared/links_inner_spec.js index 50f754393fe..b4ffd2658fe 100644 --- a/spec/frontend/pipelines/graph_shared/links_inner_spec.js +++ b/spec/frontend/pipelines/graph_shared/links_inner_spec.js @@ -80,7 +80,6 @@ describe('Links Inner component', () => { }; afterEach(() => { - jest.restoreAllMocks(); resetHTMLFixture(); }); diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js deleted file mode 100644 index 18def4ab62c..00000000000 --- a/spec/frontend/pipelines/header_component_spec.js +++ /dev/null @@ -1,246 +0,0 @@ -import { GlAlert, GlModal, GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import waitForPromises from 'helpers/wait_for_promises'; -import HeaderComponent from '~/pipelines/components/header_component.vue'; -import cancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql'; -import deletePipelineMutation from '~/pipelines/graphql/mutations/delete_pipeline.mutation.graphql'; -import retryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql'; -import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '~/pipelines/constants'; -import { - mockCancelledPipelineHeader, - mockFailedPipelineHeader, - mockFailedPipelineNoPermissions, - mockRunningPipelineHeader, - mockRunningPipelineNoPermissions, - mockSuccessfulPipelineHeader, -} from './mock_data'; - -describe('Pipeline details header', () => { - let wrapper; - let glModalDirective; - let mutate = jest.fn(); - - const findAlert = () => wrapper.findComponent(GlAlert); - const findDeleteModal = () => wrapper.findComponent(GlModal); - const findRetryButton = () => wrapper.find('[data-testid="retryPipeline"]'); - const findCancelButton = () => wrapper.find('[data-testid="cancelPipeline"]'); - const findDeleteButton = () => wrapper.find('[data-testid="deletePipeline"]'); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - - const defaultProvideOptions = { - pipelineId: '14', - pipelineIid: 1, - paths: { - pipelinesPath: '/namespace/my-project/-/pipelines', - fullProject: '/namespace/my-project', - }, - }; - - const createComponent = (pipelineMock = mockRunningPipelineHeader, { isLoading } = false) => { - glModalDirective = jest.fn(); - - const $apollo = { - queries: { - pipeline: { - loading: isLoading, - stopPolling: jest.fn(), - startPolling: jest.fn(), - }, - }, - mutate, - }; - - return shallowMount(HeaderComponent, { - data() { - return { - pipeline: pipelineMock, - }; - }, - provide: { - ...defaultProvideOptions, - }, - directives: { - glModal: { - bind(_, { value }) { - glModalDirective(value); - }, - }, - }, - mocks: { $apollo }, - }); - }; - - describe('initial loading', () => { - beforeEach(() => { - wrapper = createComponent(null, { isLoading: true }); - }); - - it('shows a loading state while graphQL is fetching initial data', () => { - expect(findLoadingIcon().exists()).toBe(true); - }); - }); - - describe('visible state', () => { - it.each` - state | pipelineData | retryValue | cancelValue - ${'cancelled'} | ${mockCancelledPipelineHeader} | ${true} | ${false} - ${'failed'} | ${mockFailedPipelineHeader} | ${true} | ${false} - ${'running'} | ${mockRunningPipelineHeader} | ${false} | ${true} - ${'successful'} | ${mockSuccessfulPipelineHeader} | ${false} | ${false} - `( - 'with a $state pipeline, it will show actions: retry $retryValue and cancel $cancelValue', - ({ pipelineData, retryValue, cancelValue }) => { - wrapper = createComponent(pipelineData); - - expect(findRetryButton().exists()).toBe(retryValue); - expect(findCancelButton().exists()).toBe(cancelValue); - }, - ); - }); - - describe('actions', () => { - describe('Retry action', () => { - beforeEach(() => { - wrapper = createComponent(mockCancelledPipelineHeader); - }); - - it('should call retryPipeline Mutation with pipeline id', () => { - findRetryButton().vm.$emit('click'); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: retryPipelineMutation, - variables: { id: mockCancelledPipelineHeader.id }, - }); - }); - - it('should render retry action tooltip', () => { - expect(findRetryButton().attributes('title')).toBe(BUTTON_TOOLTIP_RETRY); - }); - - it('should display error message on failure', async () => { - const failureMessage = 'failure message'; - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ - data: { - pipelineRetry: { - errors: [failureMessage], - }, - }, - }); - - findRetryButton().vm.$emit('click'); - await waitForPromises(); - - expect(findAlert().text()).toBe(failureMessage); - }); - }); - - describe('Retry action failed', () => { - beforeEach(() => { - mutate = jest.fn().mockRejectedValue('error'); - - wrapper = createComponent(mockCancelledPipelineHeader); - }); - - it('retry button loading state should reset on error', async () => { - findRetryButton().vm.$emit('click'); - - await nextTick(); - - expect(findRetryButton().props('loading')).toBe(true); - - await waitForPromises(); - - expect(findRetryButton().props('loading')).toBe(false); - }); - }); - - describe('Cancel action', () => { - beforeEach(() => { - wrapper = createComponent(mockRunningPipelineHeader); - }); - - it('should call cancelPipeline Mutation with pipeline id', () => { - findCancelButton().vm.$emit('click'); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: cancelPipelineMutation, - variables: { id: mockRunningPipelineHeader.id }, - }); - }); - - it('should render cancel action tooltip', () => { - expect(findCancelButton().attributes('title')).toBe(BUTTON_TOOLTIP_CANCEL); - }); - - it('should display error message on failure', async () => { - const failureMessage = 'failure message'; - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ - data: { - pipelineCancel: { - errors: [failureMessage], - }, - }, - }); - - findCancelButton().vm.$emit('click'); - await waitForPromises(); - - expect(findAlert().text()).toBe(failureMessage); - }); - }); - - describe('Delete action', () => { - beforeEach(() => { - wrapper = createComponent(mockFailedPipelineHeader); - }); - - it('displays delete modal when clicking on delete and does not call the delete action', () => { - findDeleteButton().vm.$emit('click'); - - expect(findDeleteModal().props('modalId')).toBe(wrapper.vm.$options.DELETE_MODAL_ID); - expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.$options.DELETE_MODAL_ID); - expect(wrapper.vm.$apollo.mutate).not.toHaveBeenCalled(); - }); - - it('should call deletePipeline Mutation with pipeline id when modal is submitted', () => { - findDeleteModal().vm.$emit('primary'); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: deletePipelineMutation, - variables: { id: mockFailedPipelineHeader.id }, - }); - }); - - it('should display error message on failure', async () => { - const failureMessage = 'failure message'; - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ - data: { - pipelineDestroy: { - errors: [failureMessage], - }, - }, - }); - - findDeleteModal().vm.$emit('primary'); - await waitForPromises(); - - expect(findAlert().text()).toBe(failureMessage); - }); - }); - - describe('Permissions', () => { - it('should not display the cancel action if user does not have permission', () => { - wrapper = createComponent(mockRunningPipelineNoPermissions); - - expect(findCancelButton().exists()).toBe(false); - }); - - it('should not display the retry action if user does not have permission', () => { - wrapper = createComponent(mockFailedPipelineNoPermissions); - - expect(findRetryButton().exists()).toBe(false); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js index 62c0d6e2d91..673db3b5178 100644 --- a/spec/frontend/pipelines/mock_data.js +++ b/spec/frontend/pipelines/mock_data.js @@ -26,19 +26,19 @@ export const pipelineRetryMutationResponseFailed = { }; export const pipelineCancelMutationResponseSuccess = { - data: { pipelineRetry: { errors: [] } }, + data: { pipelineCancel: { errors: [] } }, }; export const pipelineCancelMutationResponseFailed = { - data: { pipelineRetry: { errors: ['error'] } }, + data: { pipelineCancel: { errors: ['error'] } }, }; export const pipelineDeleteMutationResponseSuccess = { - data: { pipelineRetry: { errors: [] } }, + data: { pipelineDestroy: { errors: [] } }, }; export const pipelineDeleteMutationResponseFailed = { - data: { pipelineRetry: { errors: ['error'] } }, + data: { pipelineDestroy: { errors: ['error'] } }, }; export const mockPipelineHeader = { diff --git a/spec/frontend/pipelines/pipeline_details_header_spec.js b/spec/frontend/pipelines/pipeline_details_header_spec.js index deaf5c6f72f..5c75020afad 100644 --- a/spec/frontend/pipelines/pipeline_details_header_spec.js +++ b/spec/frontend/pipelines/pipeline_details_header_spec.js @@ -7,7 +7,6 @@ import waitForPromises from 'helpers/wait_for_promises'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import PipelineDetailsHeader from '~/pipelines/components/pipeline_details_header.vue'; import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '~/pipelines/constants'; -import TimeAgo from '~/pipelines/components/pipelines_list/time_ago.vue'; import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import cancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql'; import deletePipelineMutation from '~/pipelines/graphql/mutations/delete_pipeline.mutation.graphql'; @@ -59,19 +58,20 @@ describe('Pipeline details header', () => { const findAlert = () => wrapper.findComponent(GlAlert); const findStatus = () => wrapper.findComponent(CiBadgeLink); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findTimeAgo = () => wrapper.findComponent(TimeAgo); const findAllBadges = () => wrapper.findAllComponents(GlBadge); + const findDeleteModal = () => wrapper.findComponent(GlModal); + const findCreatedTimeAgo = () => wrapper.findByTestId('pipeline-created-time-ago'); + const findFinishedTimeAgo = () => wrapper.findByTestId('pipeline-finished-time-ago'); const findPipelineName = () => wrapper.findByTestId('pipeline-name'); const findCommitTitle = () => wrapper.findByTestId('pipeline-commit-title'); const findTotalJobs = () => wrapper.findByTestId('total-jobs'); - const findComputeCredits = () => wrapper.findByTestId('compute-credits'); + const findComputeMinutes = () => wrapper.findByTestId('compute-minutes'); const findCommitLink = () => wrapper.findByTestId('commit-link'); const findPipelineRunningText = () => wrapper.findByTestId('pipeline-running-text').text(); const findPipelineRefText = () => wrapper.findByTestId('pipeline-ref-text').text(); const findRetryButton = () => wrapper.findByTestId('retry-pipeline'); const findCancelButton = () => wrapper.findByTestId('cancel-pipeline'); const findDeleteButton = () => wrapper.findByTestId('delete-pipeline'); - const findDeleteModal = () => wrapper.findComponent(GlModal); const findPipelineUserLink = () => wrapper.findByTestId('pipeline-user-link'); const findPipelineDuration = () => wrapper.findByTestId('pipeline-duration-text'); @@ -89,7 +89,7 @@ describe('Pipeline details header', () => { const defaultProps = { name: 'Ruby 3.0 master branch pipeline', totalJobs: '50', - computeCredits: '0.65', + computeMinutes: '0.65', yamlErrors: 'errors', failureReason: 'pipeline failed', badges: { @@ -216,28 +216,36 @@ describe('Pipeline details header', () => { }); describe('finished pipeline', () => { - it('displays compute credits when not zero', async () => { + it('displays compute minutes when not zero', async () => { createComponent(); await waitForPromises(); - expect(findComputeCredits().text()).toBe('0.65'); + expect(findComputeMinutes().text()).toBe('0.65'); + }); + + it('does not display compute minutes when zero', async () => { + createComponent(defaultHandlers, { ...defaultProps, computeMinutes: '0.0' }); + + await waitForPromises(); + + expect(findComputeMinutes().exists()).toBe(false); }); - it('does not display compute credits when zero', async () => { - createComponent(defaultHandlers, { ...defaultProps, computeCredits: '0.0' }); + it('does not display created time ago', async () => { + createComponent(); await waitForPromises(); - expect(findComputeCredits().exists()).toBe(false); + expect(findCreatedTimeAgo().exists()).toBe(false); }); - it('displays time ago', async () => { + it('displays finished time ago', async () => { createComponent(); await waitForPromises(); - expect(findTimeAgo().exists()).toBe(true); + expect(findFinishedTimeAgo().exists()).toBe(true); }); it('displays pipeline duartion text', async () => { @@ -258,12 +266,12 @@ describe('Pipeline details header', () => { await waitForPromises(); }); - it('does not display compute credits', () => { - expect(findComputeCredits().exists()).toBe(false); + it('does not display compute minutes', () => { + expect(findComputeMinutes().exists()).toBe(false); }); - it('does not display time ago', () => { - expect(findTimeAgo().exists()).toBe(false); + it('does not display finished time ago', () => { + expect(findFinishedTimeAgo().exists()).toBe(false); }); it('does not display pipeline duration text', () => { @@ -273,6 +281,10 @@ describe('Pipeline details header', () => { it('displays pipeline running text', () => { expect(findPipelineRunningText()).toBe('In progress, queued for 3,600 seconds'); }); + + it('displays created time ago', () => { + expect(findCreatedTimeAgo().exists()).toBe(true); + }); }); describe('running pipeline with duration', () => { diff --git a/spec/frontend/pipelines/pipelines_artifacts_spec.js b/spec/frontend/pipelines/pipelines_artifacts_spec.js index 9fedbaf9b56..1abc2887682 100644 --- a/spec/frontend/pipelines/pipelines_artifacts_spec.js +++ b/spec/frontend/pipelines/pipelines_artifacts_spec.js @@ -1,4 +1,9 @@ -import { GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; +import { + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, + GlSprintf, +} from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; @@ -25,25 +30,27 @@ describe('Pipelines Artifacts dropdown', () => { }, stubs: { GlSprintf, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, }, }); }; - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findFirstGlDropdownItem = () => wrapper.findComponent(GlDropdownItem); - const findAllGlDropdownItems = () => - wrapper.findComponent(GlDropdown).findAllComponents(GlDropdownItem); + const findGlDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findFirstGlDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem); it('should render a dropdown with all the provided artifacts', () => { createComponent(); - expect(findAllGlDropdownItems()).toHaveLength(artifacts.length); + const [{ items }] = findGlDropdown().props('items'); + expect(items).toHaveLength(artifacts.length); }); it('should render a link with the provided path', () => { createComponent(); - expect(findFirstGlDropdownItem().attributes('href')).toBe(artifacts[0].path); + expect(findFirstGlDropdownItem().props('item').href).toBe(artifacts[0].path); expect(findFirstGlDropdownItem().text()).toBe(artifacts[0].name); }); @@ -51,7 +58,7 @@ describe('Pipelines Artifacts dropdown', () => { it('should not render the dropdown', () => { createComponent({ mockArtifacts: [] }); - expect(findDropdown().exists()).toBe(false); + expect(findGlDropdown().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js index 10752cee841..251d823cc37 100644 --- a/spec/frontend/pipelines/pipelines_table_spec.js +++ b/spec/frontend/pipelines/pipelines_table_spec.js @@ -10,7 +10,6 @@ import PipelineTriggerer from '~/pipelines/components/pipelines_list/pipeline_tr import PipelineUrl from '~/pipelines/components/pipelines_list/pipeline_url.vue'; import PipelinesTable from '~/pipelines/components/pipelines_list/pipelines_table.vue'; import PipelinesTimeago from '~/pipelines/components/pipelines_list/time_ago.vue'; -import PipelineFailedJobsWidget from '~/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue'; import { PipelineKeyOptions, BUTTON_TOOLTIP_RETRY, @@ -74,7 +73,6 @@ describe('Pipelines Table', () => { const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph); const findTimeAgo = () => wrapper.findComponent(PipelinesTimeago); const findActions = () => wrapper.findComponent(PipelineOperations); - const findPipelineFailedJobsWidget = () => wrapper.findComponent(PipelineFailedJobsWidget); const findTableRows = () => wrapper.findAllByTestId('pipeline-table-row'); const findStatusTh = () => wrapper.findByTestId('status-th'); @@ -218,30 +216,6 @@ describe('Pipelines Table', () => { }); }); }); - - describe('widget', () => { - describe('when there are no failed jobs', () => { - beforeEach(() => { - createComponent( - { pipelines: [{ ...pipeline, failed_builds: [] }] }, - provideWithDetails, - ); - }); - - it('does not renders', () => { - expect(findPipelineFailedJobsWidget().exists()).toBe(false); - }); - }); - - describe('when there are failed jobs', () => { - beforeEach(() => { - createComponent({ pipelines: [pipeline] }, provideWithDetails); - }); - it('renders', () => { - expect(findPipelineFailedJobsWidget().exists()).toBe(true); - }); - }); - }); }); describe('tracking', () => { diff --git a/spec/frontend/pipelines/time_ago_spec.js b/spec/frontend/pipelines/time_ago_spec.js index 5afe91c4784..d2aa340a980 100644 --- a/spec/frontend/pipelines/time_ago_spec.js +++ b/spec/frontend/pipelines/time_ago_spec.js @@ -65,22 +65,11 @@ describe('Timeago component', () => { expect(time.exists()).toBe(true); }); - it('should display calendar icon by default', () => { + it('should display calendar icon', () => { createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' }); expect(findCalendarIcon().exists()).toBe(true); }); - - it('should hide calendar icon if correct prop is passed', () => { - createComponent( - { duration: 0, finished_at: '2017-04-26T12:40:23.277Z' }, - { - displayCalendarIcon: false, - }, - ); - - expect(findCalendarIcon().exists()).toBe(false); - }); }); describe('without finishedTime', () => { diff --git a/spec/frontend/profile/account/components/update_username_spec.js b/spec/frontend/profile/account/components/update_username_spec.js index fa107600d64..a7052e53062 100644 --- a/spec/frontend/profile/account/components/update_username_spec.js +++ b/spec/frontend/profile/account/components/update_username_spec.js @@ -1,6 +1,6 @@ import { GlModal } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; -import Vue, { nextTick } from 'vue'; +import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'helpers/test_constants'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -8,6 +8,7 @@ import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import UpdateUsername from '~/profile/account/components/update_username.vue'; +import { setVueErrorHandler, resetVueErrorHandler } from 'helpers/set_vue_error_handler'; jest.mock('~/alert'); @@ -43,7 +44,7 @@ describe('UpdateUsername component', () => { afterEach(() => { axiosMock.restore(); - Vue.config.errorHandler = null; + resetVueErrorHandler(); }); const findElements = () => { @@ -60,7 +61,7 @@ describe('UpdateUsername component', () => { }; const clickModalWithErrorResponse = () => { - Vue.config.errorHandler = jest.fn(); // silence thrown error + setVueErrorHandler({ instance: wrapper.vm, handler: jest.fn() }); // silence thrown error const { modal } = findElements(); modal.vm.$emit('primary'); return waitForPromises(); diff --git a/spec/frontend/profile/components/follow_spec.js b/spec/frontend/profile/components/follow_spec.js index 2555e41257f..a2e8d065a46 100644 --- a/spec/frontend/profile/components/follow_spec.js +++ b/spec/frontend/profile/components/follow_spec.js @@ -1,11 +1,19 @@ -import { GlAvatarLabeled, GlAvatarLink, GlLoadingIcon, GlPagination } from '@gitlab/ui'; +import { + GlAvatarLabeled, + GlAvatarLink, + GlEmptyState, + GlLoadingIcon, + GlPagination, +} from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import users from 'test_fixtures/api/users/followers/get.json'; import Follow from '~/profile/components/follow.vue'; import { DEFAULT_PER_PAGE } from '~/api'; +import { isCurrentUser } from '~/lib/utils/common_utils'; jest.mock('~/rest_api'); +jest.mock('~/lib/utils/common_utils'); describe('FollowersTab', () => { let wrapper; @@ -15,6 +23,13 @@ describe('FollowersTab', () => { loading: false, page: 1, totalItems: 50, + currentUserEmptyStateTitle: 'UserProfile|You do not have any followers.', + visitorEmptyStateTitle: "UserProfile|This user doesn't have any followers.", + }; + + const defaultProvide = { + followEmptyState: '/illustrations/empty-state/empty-friends-md.svg', + userId: '1', }; const createComponent = ({ propsData = {} } = {}) => { @@ -23,11 +38,13 @@ describe('FollowersTab', () => { ...defaultPropsData, ...propsData, }, + provide: defaultProvide, }); }; const findPagination = () => wrapper.findComponent(GlPagination); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); describe('when `loading` prop is `true`', () => { it('renders loading icon', () => { @@ -95,5 +112,35 @@ describe('FollowersTab', () => { expect(wrapper.emitted('pagination-input')).toEqual([[nextPage]]); }); }); + + describe('when the users prop is empty', () => { + describe('when user is the current user', () => { + beforeEach(() => { + isCurrentUser.mockImplementation(() => true); + createComponent({ propsData: { users: [] } }); + }); + + it('displays empty state with correct message', () => { + expect(findEmptyState().props()).toMatchObject({ + svgPath: defaultProvide.followEmptyState, + title: defaultPropsData.currentUserEmptyStateTitle, + }); + }); + }); + + describe('when user is a visitor', () => { + beforeEach(() => { + isCurrentUser.mockImplementation(() => false); + createComponent({ propsData: { users: [] } }); + }); + + it('displays empty state with correct message', () => { + expect(findEmptyState().props()).toMatchObject({ + svgPath: defaultProvide.followEmptyState, + title: defaultPropsData.visitorEmptyStateTitle, + }); + }); + }); + }); }); }); diff --git a/spec/frontend/profile/components/followers_tab_spec.js b/spec/frontend/profile/components/followers_tab_spec.js index 0370005d0a4..75586a2c9ea 100644 --- a/spec/frontend/profile/components/followers_tab_spec.js +++ b/spec/frontend/profile/components/followers_tab_spec.js @@ -75,6 +75,8 @@ describe('FollowersTab', () => { loading: false, page: 1, totalItems: 6, + currentUserEmptyStateTitle: FollowersTab.i18n.currentUserEmptyStateTitle, + visitorEmptyStateTitle: FollowersTab.i18n.visitorEmptyStateTitle, }); }); diff --git a/spec/frontend/profile/components/following_tab_spec.js b/spec/frontend/profile/components/following_tab_spec.js index c0583cf4877..48d84187739 100644 --- a/spec/frontend/profile/components/following_tab_spec.js +++ b/spec/frontend/profile/components/following_tab_spec.js @@ -1,32 +1,114 @@ import { GlBadge, GlTab } from '@gitlab/ui'; - +import { shallowMount } from '@vue/test-utils'; +import following from 'test_fixtures/api/users/following/get.json'; import { s__ } from '~/locale'; import FollowingTab from '~/profile/components/following_tab.vue'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Follow from '~/profile/components/follow.vue'; +import { getUserFollowing } from '~/rest_api'; +import { createAlert } from '~/alert'; +import waitForPromises from 'helpers/wait_for_promises'; + +const MOCK_FOLLOWEES_COUNT = 2; +const MOCK_TOTAL_FOLLOWING = 6; +const MOCK_PAGE = 1; + +jest.mock('~/rest_api'); +jest.mock('~/alert'); describe('FollowingTab', () => { let wrapper; const createComponent = () => { - wrapper = shallowMountExtended(FollowingTab, { + wrapper = shallowMount(FollowingTab, { provide: { - followeesCount: 3, + followeesCount: MOCK_FOLLOWEES_COUNT, + userId: 1, + }, + stubs: { + GlTab, }, }); }; - it('renders `GlTab` and sets title', () => { - createComponent(); + const findGlBadge = () => wrapper.findComponent(GlBadge); + const findFollow = () => wrapper.findComponent(Follow); + + describe('when API request is loading', () => { + beforeEach(() => { + getUserFollowing.mockReturnValueOnce(new Promise(() => {})); + createComponent(); + }); + + it('renders `Follow` component and sets `loading` prop to `true`', () => { + expect(findFollow().props('loading')).toBe(true); + }); + }); + + describe('when API request is successful', () => { + beforeEach(() => { + getUserFollowing.mockResolvedValueOnce({ + data: following, + headers: { 'X-TOTAL': `${MOCK_TOTAL_FOLLOWING}` }, + }); + createComponent(); + }); + + it('renders `GlTab` and sets title', () => { + expect(wrapper.findComponent(GlTab).text()).toContain(s__('UserProfile|Following')); + }); + + it('renders `GlBadge`, sets size and content', () => { + expect(findGlBadge().props('size')).toBe('sm'); + expect(findGlBadge().text()).toBe(`${MOCK_FOLLOWEES_COUNT}`); + }); + + it('renders `Follow` component and passes correct props', () => { + expect(findFollow().props()).toMatchObject({ + users: following, + loading: false, + page: MOCK_PAGE, + totalItems: MOCK_TOTAL_FOLLOWING, + currentUserEmptyStateTitle: FollowingTab.i18n.currentUserEmptyStateTitle, + visitorEmptyStateTitle: FollowingTab.i18n.visitorEmptyStateTitle, + }); + }); + + describe('when `Follow` component emits `pagination-input` event', () => { + it('calls API and updates `users` and `page` props', async () => { + const NEXT_PAGE = MOCK_PAGE + 1; + const NEXT_PAGE_FOLLOWING = [{ id: 999, name: 'page 2 following' }]; - expect(wrapper.findComponent(GlTab).element.textContent).toContain( - s__('UserProfile|Following'), - ); + getUserFollowing.mockResolvedValueOnce({ + data: NEXT_PAGE_FOLLOWING, + headers: { 'X-TOTAL': `${MOCK_TOTAL_FOLLOWING}` }, + }); + + findFollow().vm.$emit('pagination-input', NEXT_PAGE); + + await waitForPromises(); + + expect(findFollow().props()).toMatchObject({ + users: NEXT_PAGE_FOLLOWING, + loading: false, + page: NEXT_PAGE, + totalItems: MOCK_TOTAL_FOLLOWING, + }); + }); + }); }); - it('renders `GlBadge`, sets size and content', () => { - createComponent(); + describe('when API request is not successful', () => { + beforeEach(() => { + getUserFollowing.mockRejectedValueOnce(new Error()); + createComponent(); + }); - expect(wrapper.findComponent(GlBadge).attributes('size')).toBe('sm'); - expect(wrapper.findComponent(GlBadge).element.textContent).toBe('3'); + it('shows error alert', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: FollowingTab.i18n.errorMessage, + error: new Error(), + captureError: true, + }); + }); }); }); diff --git a/spec/frontend/profile/components/profile_tabs_spec.js b/spec/frontend/profile/components/profile_tabs_spec.js index f3dda2e205f..3474bbf8d0c 100644 --- a/spec/frontend/profile/components/profile_tabs_spec.js +++ b/spec/frontend/profile/components/profile_tabs_spec.js @@ -4,6 +4,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createAlert } from '~/alert'; import { getUserProjects } from '~/rest_api'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { VISIBILITY_LEVEL_PUBLIC_STRING } from '~/visibility_level/constants'; import OverviewTab from '~/profile/components/overview_tab.vue'; import ActivityTab from '~/profile/components/activity_tab.vue'; import GroupsTab from '~/profile/components/groups_tab.vue'; @@ -60,18 +61,30 @@ describe('ProfileTabs', () => { }); describe('when personal projects API request is successful', () => { - beforeEach(async () => { + it('passes correct props to `OverviewTab` component', async () => { getUserProjects.mockResolvedValueOnce({ data: projects }); createComponent(); await waitForPromises(); - }); - it('passes correct props to `OverviewTab` component', () => { expect(wrapper.findComponent(OverviewTab).props()).toMatchObject({ personalProjects: convertObjectPropsToCamelCase(projects, { deep: true }), personalProjectsLoading: false, }); }); + + describe('when projects do not have `visibility` key', () => { + it('sets visibility to public', async () => { + const [{ visibility, ...projectWithoutVisibility }] = projects; + + getUserProjects.mockResolvedValueOnce({ data: [projectWithoutVisibility] }); + createComponent(); + await waitForPromises(); + + expect(wrapper.findComponent(OverviewTab).props('personalProjects')[0].visibility).toBe( + VISIBILITY_LEVEL_PUBLIC_STRING, + ); + }); + }); }); describe('when personal projects API request is not successful', () => { diff --git a/spec/frontend/profile/components/snippets/snippets_tab_spec.js b/spec/frontend/profile/components/snippets/snippets_tab_spec.js index 47e2fbcf2c0..5992bb03e4d 100644 --- a/spec/frontend/profile/components/snippets/snippets_tab_spec.js +++ b/spec/frontend/profile/components/snippets/snippets_tab_spec.js @@ -7,6 +7,7 @@ import { SNIPPET_MAX_LIST_COUNT } from '~/profile/constants'; import SnippetsTab from '~/profile/components/snippets/snippets_tab.vue'; import SnippetRow from '~/profile/components/snippets/snippet_row.vue'; import getUserSnippets from '~/profile/components/graphql/get_user_snippets.query.graphql'; +import { isCurrentUser } from '~/lib/utils/common_utils'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import { @@ -15,8 +16,14 @@ import { MOCK_USER_SNIPPETS_RES, MOCK_USER_SNIPPETS_PAGINATION_RES, MOCK_USER_SNIPPETS_EMPTY_RES, + MOCK_NEW_SNIPPET_PATH, } from 'jest/profile/mock_data'; +jest.mock('~/lib/utils/common_utils'); +jest.mock('~/helpers/help_page_helper', () => ({ + helpPagePath: jest.fn().mockImplementation(() => 'http://127.0.0.1:3000/help/user/snippets'), +})); + Vue.use(VueApollo); describe('UserProfileSnippetsTab', () => { @@ -32,6 +39,7 @@ describe('UserProfileSnippetsTab', () => { provide: { userId: MOCK_USER.id, snippetsEmptyState: MOCK_SNIPPETS_EMPTY_STATE, + newSnippetPath: MOCK_NEW_SNIPPET_PATH, }, }); }; @@ -52,9 +60,38 @@ describe('UserProfileSnippetsTab', () => { expect(findSnippetRows().exists()).toBe(false); }); - it('does render empty state with correct svg', () => { - expect(findGlEmptyState().exists()).toBe(true); - expect(findGlEmptyState().attributes('svgpath')).toBe(MOCK_SNIPPETS_EMPTY_STATE); + describe('when user is the current user', () => { + beforeEach(() => { + isCurrentUser.mockImplementation(() => true); + createComponent(); + }); + + it('displays empty state with correct message', () => { + expect(findGlEmptyState().props()).toMatchObject({ + svgPath: MOCK_SNIPPETS_EMPTY_STATE, + title: SnippetsTab.i18n.currentUserEmptyStateTitle, + description: SnippetsTab.i18n.emptyStateDescription, + primaryButtonLink: MOCK_NEW_SNIPPET_PATH, + primaryButtonText: SnippetsTab.i18n.newSnippet, + secondaryButtonLink: 'http://127.0.0.1:3000/help/user/snippets', + secondaryButtonText: SnippetsTab.i18n.learnMore, + }); + }); + }); + + describe('when user is a visitor', () => { + beforeEach(() => { + isCurrentUser.mockImplementation(() => false); + createComponent(); + }); + + it('displays empty state with correct message', () => { + expect(findGlEmptyState().props()).toMatchObject({ + svgPath: MOCK_SNIPPETS_EMPTY_STATE, + title: SnippetsTab.i18n.visitorEmptyStateTitle, + description: null, + }); + }); }); }); diff --git a/spec/frontend/profile/mock_data.js b/spec/frontend/profile/mock_data.js index 856534aebd3..6c4ff0a84f9 100644 --- a/spec/frontend/profile/mock_data.js +++ b/spec/frontend/profile/mock_data.js @@ -22,6 +22,7 @@ export const userCalendarResponse = { }; export const MOCK_SNIPPETS_EMPTY_STATE = 'illustrations/empty-state/empty-snippets-md.svg'; +export const MOCK_NEW_SNIPPET_PATH = '/-/snippets/new'; export const MOCK_USER = { id: '1', diff --git a/spec/frontend/profile/preferences/components/profile_preferences_spec.js b/spec/frontend/profile/preferences/components/profile_preferences_spec.js index 21167dccda9..144d9e76869 100644 --- a/spec/frontend/profile/preferences/components/profile_preferences_spec.js +++ b/spec/frontend/profile/preferences/components/profile_preferences_spec.js @@ -47,10 +47,6 @@ describe('ProfilePreferences component', () => { ); } - function findIntegrationsDivider() { - return wrapper.findByTestId('profile-preferences-integrations-rule'); - } - function findIntegrationsHeading() { return wrapper.findByTestId('profile-preferences-integrations-heading'); } @@ -86,21 +82,17 @@ describe('ProfilePreferences component', () => { it('should not render Integrations section', () => { wrapper = createComponent(); const views = wrapper.findAllComponents(IntegrationView); - const divider = findIntegrationsDivider(); const heading = findIntegrationsHeading(); - expect(divider.exists()).toBe(false); expect(heading.exists()).toBe(false); expect(views).toHaveLength(0); }); it('should render Integration section', () => { wrapper = createComponent({ provide: { integrationViews } }); - const divider = findIntegrationsDivider(); const heading = findIntegrationsHeading(); const views = wrapper.findAllComponents(IntegrationView); - expect(divider.exists()).toBe(true); expect(heading.exists()).toBe(true); expect(views).toHaveLength(integrationViews.length); }); diff --git a/spec/frontend/projects/commits/components/author_select_spec.js b/spec/frontend/projects/commits/components/author_select_spec.js index 630b8feafbc..50e3f2d0f37 100644 --- a/spec/frontend/projects/commits/components/author_select_spec.js +++ b/spec/frontend/projects/commits/components/author_select_spec.js @@ -1,12 +1,17 @@ -import { GlDropdown, GlDropdownSectionHeader, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui'; +import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures'; import setWindowLocation from 'helpers/set_window_location_helper'; -import * as urlUtility from '~/lib/utils/url_utility'; import AuthorSelect from '~/projects/commits/components/author_select.vue'; import { createStore } from '~/projects/commits/store'; +import { visitUrl } from '~/lib/utils/url_utility'; + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + visitUrl: jest.fn(), +})); Vue.use(Vuex); @@ -44,6 +49,10 @@ describe('Author Select', () => { propsData: { projectCommitsEl: document.querySelector('.js-project-commits-show'), }, + stubs: { + GlCollapsibleListbox, + GlListboxItem, + }, }); }; @@ -58,11 +67,9 @@ describe('Author Select', () => { resetHTMLFixture(); }); - const findDropdownContainer = () => wrapper.findComponent({ ref: 'dropdownContainer' }); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownHeader = () => wrapper.findComponent(GlDropdownSectionHeader); - const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findListboxContainer = () => wrapper.findComponent({ ref: 'listboxContainer' }); + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const findListboxItems = () => wrapper.findAllComponents(GlListboxItem); describe('user is searching via "filter by commit message"', () => { beforeEach(() => { @@ -70,24 +77,28 @@ describe('Author Select', () => { createComponent(); }); - it('does not disable dropdown container', () => { - expect(findDropdownContainer().attributes('disabled')).toBeUndefined(); + it('does not disable listbox container', () => { + expect(findListboxContainer().attributes('disabled')).toBeUndefined(); }); it('has correct tooltip message', () => { - expect(findDropdownContainer().attributes('title')).toBe( + expect(findListboxContainer().attributes('title')).toBe( 'Searching by both author and message is currently not supported.', ); }); - it('disables dropdown', () => { - expect(findDropdown().attributes('disabled')).toBeDefined(); + it('disables listbox', () => { + expect(findListbox().attributes('disabled')).toBeDefined(); }); }); - describe('dropdown', () => { + describe('listbox', () => { + beforeEach(() => { + store.state.commitsPath = commitsPath; + }); + it('displays correct default text', () => { - expect(findDropdown().attributes('text')).toBe('Author'); + expect(findListbox().props('toggleText')).toBe('Author'); }); it('displays the current selected author', async () => { @@ -95,81 +106,62 @@ describe('Author Select', () => { createComponent(); await nextTick(); - expect(findDropdown().attributes('text')).toBe(currentAuthor); + expect(findListbox().props('toggleText')).toBe(currentAuthor); }); it('displays correct header text', () => { - expect(findDropdownHeader().text()).toBe('Search by author'); + expect(findListbox().props('headerText')).toBe('Search by author'); }); it('does not have popover text by default', () => { expect(wrapper.attributes('title')).toBeUndefined(); }); + + it('passes selected author to redirectPath', () => { + const redirectPath = `${commitsPath}?author=${currentAuthor}`; + + findListbox().vm.$emit('select', currentAuthor); + + expect(visitUrl).toHaveBeenCalledWith(redirectPath); + }); + + it('does not pass any author to redirectPath', () => { + const redirectPath = commitsPath; + + findListbox().vm.$emit('select', ''); + + expect(visitUrl).toHaveBeenCalledWith(redirectPath); + }); }); - describe('dropdown search box', () => { + describe('listbox search box', () => { it('has correct placeholder', () => { - expect(findSearchBox().attributes('placeholder')).toBe('Search'); + expect(findListbox().props('searchPlaceholder')).toBe('Search'); }); it('fetch authors on input change', () => { const authorName = 'lorem'; - findSearchBox().vm.$emit('input', authorName); + findListbox().vm.$emit('search', authorName); expect(store.actions.fetchAuthors).toHaveBeenCalledWith(expect.anything(), authorName); }); }); - describe('dropdown list', () => { + describe('listbox list', () => { beforeEach(() => { store.state.commitsAuthors = authors; - store.state.commitsPath = commitsPath; }); it('has a "Any Author" as the first list item', () => { - expect(findDropdownItems().at(0).text()).toBe('Any Author'); + expect(findListboxItems().at(0).text()).toBe('Any Author'); }); it('displays the project authors', () => { - expect(findDropdownItems()).toHaveLength(authors.length + 1); - }); - - it('has the correct props', async () => { - setWindowLocation(`?author=${currentAuthor}`); - createComponent(); - - const [{ avatar_url: avatarUrl, username }] = authors; - const result = { - avatarUrl, - secondaryText: username, - isChecked: true, - }; - - await nextTick(); - expect(findDropdownItems().at(1).props()).toEqual(expect.objectContaining(result)); + expect(findListboxItems()).toHaveLength(authors.length + 1); }); it("display the author's name", () => { - expect(findDropdownItems().at(1).text()).toBe(currentAuthor); - }); - - it('passes selected author to redirectPath', () => { - const redirectToUrl = `${commitsPath}?author=${currentAuthor}`; - const spy = jest.spyOn(urlUtility, 'redirectTo'); - spy.mockImplementation(() => 'mock'); - - findDropdownItems().at(1).vm.$emit('click'); - - expect(spy).toHaveBeenCalledWith(redirectToUrl); - }); - - it('does not pass any author to redirectPath', () => { - const redirectToUrl = commitsPath; - const spy = jest.spyOn(urlUtility, 'redirectTo'); - spy.mockImplementation(); - - findDropdownItems().at(0).vm.$emit('click'); - expect(spy).toHaveBeenCalledWith(redirectToUrl); + expect(findListboxItems().at(1).text()).toContain(currentAuthor); }); }); }); diff --git a/spec/frontend/projects/compare/components/app_spec.js b/spec/frontend/projects/compare/components/app_spec.js index ee96f46ea0c..6cc76d4a573 100644 --- a/spec/frontend/projects/compare/components/app_spec.js +++ b/spec/frontend/projects/compare/components/app_spec.js @@ -1,23 +1,37 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlIcon, GlLink, GlSprintf, GlFormGroup, GlFormRadioGroup, GlFormRadio } from '@gitlab/ui'; import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import CompareApp from '~/projects/compare/components/app.vue'; +import { + COMPARE_REVISIONS_DOCS_URL, + I18N, + COMPARE_OPTIONS, + COMPARE_OPTIONS_INPUT_NAME, +} from '~/projects/compare/constants'; import RevisionCard from '~/projects/compare/components/revision_card.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { appDefaultProps as defaultProps } from './mock_data'; jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); describe('CompareApp component', () => { let wrapper; - const findSourceRevisionCard = () => wrapper.find('[data-testid="sourceRevisionCard"]'); - const findTargetRevisionCard = () => wrapper.find('[data-testid="targetRevisionCard"]'); + const findSourceRevisionCard = () => wrapper.findByTestId('sourceRevisionCard'); + const findTargetRevisionCard = () => wrapper.findByTestId('targetRevisionCard'); const createComponent = (props = {}) => { - wrapper = shallowMount(CompareApp, { + wrapper = shallowMountExtended(CompareApp, { propsData: { ...defaultProps, ...props, }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + stubs: { + GlSprintf, + GlFormRadioGroup, + }, }); }; @@ -37,6 +51,21 @@ describe('CompareApp component', () => { ); }); + it('renders title', () => { + const title = wrapper.find('h1'); + expect(title.text()).toBe(I18N.title); + }); + + it('renders subtitle', () => { + const subtitle = wrapper.find('p'); + expect(subtitle.text()).toMatchInterpolatedText(I18N.subtitle); + }); + + it('renders link to docs', () => { + const docsLink = wrapper.findComponent(GlLink); + expect(docsLink.attributes('href')).toBe(COMPARE_REVISIONS_DOCS_URL); + }); + it('contains the correct form attributes', () => { expect(wrapper.attributes('action')).toBe(defaultProps.projectCompareIndexPath); expect(wrapper.attributes('method')).toBe('POST'); @@ -48,20 +77,16 @@ describe('CompareApp component', () => { ); }); - it('has ellipsis', () => { - expect(wrapper.find('[data-testid="ellipsis"]').exists()).toBe(true); - }); - it('render Source and Target BranchDropdown components', () => { const revisionCards = wrapper.findAllComponents(RevisionCard); expect(revisionCards.length).toBe(2); - expect(revisionCards.at(0).props('revisionText')).toBe('Source'); - expect(revisionCards.at(1).props('revisionText')).toBe('Target'); + expect(revisionCards.at(0).props('revisionText')).toBe(I18N.source); + expect(revisionCards.at(1).props('revisionText')).toBe(I18N.target); }); describe('compare button', () => { - const findCompareButton = () => wrapper.findComponent(GlButton); + const findCompareButton = () => wrapper.findByTestId('compare-button'); it('renders button', () => { expect(findCompareButton().exists()).toBe(true); @@ -109,14 +134,19 @@ describe('CompareApp component', () => { }); describe('swap revisions button', () => { - const findSwapRevisionsButton = () => wrapper.find('[data-testid="swapRevisionsButton"]'); + const findSwapRevisionsButton = () => wrapper.findByTestId('swapRevisionsButton'); it('renders the swap revisions button', () => { expect(findSwapRevisionsButton().exists()).toBe(true); }); - it('has the correct text', () => { - expect(findSwapRevisionsButton().text()).toBe('Swap revisions'); + it('renders icon', () => { + expect(findSwapRevisionsButton().findComponent(GlIcon).props('name')).toBe('substitute'); + }); + + it('has tooltip', () => { + const tooltip = getBinding(findSwapRevisionsButton().element, 'gl-tooltip'); + expect(tooltip.value).toBe(I18N.swapRevisions); }); it('swaps revisions when clicked', async () => { @@ -129,43 +159,43 @@ describe('CompareApp component', () => { }); }); - describe('mode dropdown', () => { - const findModeDropdownButton = () => wrapper.find('[data-testid="modeDropdown"]'); - const findEnableStraightModeButton = () => - wrapper.find('[data-testid="enableStraightModeButton"]'); - const findDisableStraightModeButton = () => - wrapper.find('[data-testid="disableStraightModeButton"]'); + describe('compare options', () => { + const findGroup = () => wrapper.findComponent(GlFormGroup); + const findOptionsGroup = () => wrapper.findComponent(GlFormRadioGroup); - it('renders the mode dropdown button', () => { - expect(findModeDropdownButton().exists()).toBe(true); - }); + const findOptions = () => wrapper.findAllComponents(GlFormRadio); - it('has the correct text', () => { - expect(findEnableStraightModeButton().text()).toBe('...'); - expect(findDisableStraightModeButton().text()).toBe('..'); + it('renders label for the compare options', () => { + expect(findGroup().attributes('label')).toBe(I18N.optionsLabel); }); - it('straight mode button when clicked', async () => { - expect(wrapper.props('straight')).toBe(false); - expect(wrapper.find('input[name="straight"]').attributes('value')).toBe('false'); + it('correct input name', () => { + expect(findOptionsGroup().attributes('name')).toBe(COMPARE_OPTIONS_INPUT_NAME); + }); - findEnableStraightModeButton().vm.$emit('click'); + it('renders "only incoming changes" option', () => { + expect(findOptions().at(0).text()).toBe(COMPARE_OPTIONS[0].text); + }); - await nextTick(); + it('renders "since source was created" option', () => { + expect(findOptions().at(1).text()).toBe(COMPARE_OPTIONS[1].text); + }); - expect(wrapper.find('input[name="straight"]').attributes('value')).toBe('true'); + it('straight mode button when clicked', async () => { + expect(wrapper.props('straight')).toBe(false); + expect(wrapper.vm.isStraight).toBe(false); - findDisableStraightModeButton().vm.$emit('click'); + findOptionsGroup().vm.$emit('input', COMPARE_OPTIONS[1].value); await nextTick(); - expect(wrapper.find('input[name="straight"]').attributes('value')).toBe('false'); + expect(wrapper.vm.isStraight).toBe(true); }); }); describe('merge request buttons', () => { - const findProjectMrButton = () => wrapper.find('[data-testid="projectMrButton"]'); - const findCreateMrButton = () => wrapper.find('[data-testid="createMrButton"]'); + const findProjectMrButton = () => wrapper.findByTestId('projectMrButton'); + const findCreateMrButton = () => wrapper.findByTestId('createMrButton'); it('does not have merge request buttons', () => { createComponent(); diff --git a/spec/frontend/projects/new/components/__snapshots__/app_spec.js.snap b/spec/frontend/projects/new/components/__snapshots__/app_spec.js.snap new file mode 100644 index 00000000000..0c4d63c3509 --- /dev/null +++ b/spec/frontend/projects/new/components/__snapshots__/app_spec.js.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Experimental new project creation app creates correct panels 1`] = ` +Array [ + Object { + "description": "Create a blank project to store your files, plan your work, and collaborate on code, among other things.", + "imageSrc": "file-mock", + "key": "blank", + "name": "blank_project", + "selector": "#blank-project-pane", + "title": "Create blank project", + }, + Object { + "description": "Create a project pre-populated with the necessary files to get you started quickly.", + "imageSrc": "file-mock", + "key": "template", + "name": "create_from_template", + "selector": "#create-from-template-pane", + "title": "Create from template", + }, + Object { + "description": "Migrate your data from an external source like GitHub, Bitbucket, or another instance of GitLab.", + "imageSrc": "file-mock", + "key": "import", + "name": "import_project", + "selector": "#import-project-pane", + "title": "Import project", + }, +] +`; diff --git a/spec/frontend/projects/new/components/app_spec.js b/spec/frontend/projects/new/components/app_spec.js index 60d8385eb91..006114e7254 100644 --- a/spec/frontend/projects/new/components/app_spec.js +++ b/spec/frontend/projects/new/components/app_spec.js @@ -23,6 +23,12 @@ describe('Experimental new project creation app', () => { expect(wrapper.find(guidelineSelector).text()).toBe(DEMO_GUIDELINES); }); + it('creates correct panels', () => { + createComponent(); + + expect(findNewNamespacePage().props('panels')).toMatchSnapshot(); + }); + it.each` isCiCdAvailable | outcome ${false} | ${'do not show CI/CD panel'} diff --git a/spec/frontend/projects/settings/access_dropdown_spec.js b/spec/frontend/projects/settings/access_dropdown_spec.js index d51360a7597..a94d7669b2b 100644 --- a/spec/frontend/projects/settings/access_dropdown_spec.js +++ b/spec/frontend/projects/settings/access_dropdown_spec.js @@ -158,6 +158,31 @@ describe('AccessDropdown', () => { expect(template).not.toContain(user.name); }); + + it('show user avatar correctly', () => { + const user = { + id: 613, + avatar_url: 'some_valid_avatar.png', + name: 'test', + username: 'test', + }; + const template = dropdown.userRowHtml(user); + + expect(template).toContain(user.avatar_url); + expect(template).not.toContain('identicon'); + }); + + it('show identicon when user do not have avatar', () => { + const user = { + id: 613, + avatar_url: '', + name: 'test', + username: 'test', + }; + const template = dropdown.userRowHtml(user); + + expect(template).toContain('identicon'); + }); }); describe('deployKeyRowHtml', () => { diff --git a/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js b/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js index 077995ab6e4..76d45692a63 100644 --- a/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js +++ b/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js @@ -91,7 +91,6 @@ describe('View branch rules', () => { expect(findBranchName().text()).toBe(I18N.allBranches); expect(findBranchTitle().text()).toBe(I18N.targetBranch); - jest.restoreAllMocks(); }); it('renders the correct branch title', () => { diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js index 7f6ecbac748..b84d1c9c0aa 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js @@ -13,8 +13,8 @@ describe('ServiceDeskRoot', () => { let spy; const provideData = { - customEmail: 'custom.email@example.com', - customEmailEnabled: true, + serviceDeskEmail: 'custom.email@example.com', + serviceDeskEmailEnabled: true, endpoint: '/gitlab-org/gitlab-test/service_desk', initialIncomingEmail: 'servicedeskaddress@example.com', initialIsEnabled: true, @@ -52,8 +52,8 @@ describe('ServiceDeskRoot', () => { wrapper = createComponent(); expect(wrapper.findComponent(ServiceDeskSetting).props()).toEqual({ - customEmail: provideData.customEmail, - customEmailEnabled: provideData.customEmailEnabled, + serviceDeskEmail: provideData.serviceDeskEmail, + serviceDeskEmailEnabled: provideData.serviceDeskEmailEnabled, incomingEmail: provideData.initialIncomingEmail, initialOutgoingName: provideData.outgoingName, initialProjectKey: provideData.projectKey, @@ -80,7 +80,7 @@ describe('ServiceDeskRoot', () => { const alertBodyLink = alertEl.findComponent(GlLink); expect(alertBodyLink.exists()).toBe(true); expect(alertBodyLink.attributes('href')).toBe( - '/help/user/project/service_desk.html#use-a-custom-email-address', + '/help/user/project/service_desk.html#use-an-additional-service-desk-alias-email', ); expect(alertBodyLink.text()).toBe('How do I create a custom email address?'); }); diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js index 5631927cc2f..260fd200f03 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js @@ -134,26 +134,26 @@ describe('ServiceDeskSetting', () => { }); }); - describe('with customEmail', () => { - describe('customEmail is different than incomingEmail', () => { + describe('with serviceDeskEmail', () => { + describe('serviceDeskEmail is different than incomingEmail', () => { const incomingEmail = 'foo@bar.com'; - const customEmail = 'custom@bar.com'; + const serviceDeskEmail = 'servicedesk@bar.com'; beforeEach(() => { wrapper = createComponent({ - props: { incomingEmail, customEmail }, + props: { incomingEmail, serviceDeskEmail }, }); }); - it('should see custom email', () => { - expect(findIncomingEmail().element.value).toEqual(customEmail); + it('should see service desk email', () => { + expect(findIncomingEmail().element.value).toEqual(serviceDeskEmail); }); }); describe('project suffix', () => { it('input is hidden', () => { wrapper = createComponent({ - props: { customEmailEnabled: false }, + props: { serviceDeskEmailEnabled: false }, }); const input = wrapper.findByTestId('project-suffix'); @@ -163,7 +163,7 @@ describe('ServiceDeskSetting', () => { it('input is enabled', () => { wrapper = createComponent({ - props: { customEmailEnabled: true }, + props: { serviceDeskEmailEnabled: true }, }); const input = wrapper.findByTestId('project-suffix'); @@ -174,7 +174,7 @@ describe('ServiceDeskSetting', () => { it('shows error when value contains uppercase or special chars', async () => { wrapper = createComponent({ - props: { email: 'foo@bar.com', customEmailEnabled: true }, + props: { email: 'foo@bar.com', serviceDeskEmailEnabled: true }, }); const input = wrapper.findByTestId('project-suffix'); @@ -189,16 +189,16 @@ describe('ServiceDeskSetting', () => { }); }); - describe('customEmail is the same as incomingEmail', () => { + describe('serviceDeskEmail is the same as incomingEmail', () => { const email = 'foo@bar.com'; beforeEach(() => { wrapper = createComponent({ - props: { incomingEmail: email, customEmail: email }, + props: { incomingEmail: email, serviceDeskEmail: email }, }); }); - it('should see custom email', () => { + it('should see service desk email', () => { expect(findIncomingEmail().element.value).toEqual(email); }); }); diff --git a/spec/frontend/projects/terraform_notification/terraform_notification_spec.js b/spec/frontend/projects/terraform_notification/terraform_notification_spec.js index 1d0faebbcb2..89f4694d1f8 100644 --- a/spec/frontend/projects/terraform_notification/terraform_notification_spec.js +++ b/spec/frontend/projects/terraform_notification/terraform_notification_spec.js @@ -60,14 +60,15 @@ describe('TerraformNotificationBanner', () => { describe('when close button is clicked', () => { beforeEach(() => { - wrapper.vm.$refs.calloutDismisser.dismiss = userCalloutDismissSpy; findBanner().vm.$emit('close'); }); + it('should send the dismiss event', () => { expect(trackingSpy).toHaveBeenCalledWith(undefined, DISMISS_EVENT, { label: EVENT_LABEL, }); }); + it('should call the dismiss callback', () => { expect(userCalloutDismissSpy).toHaveBeenCalledTimes(1); }); diff --git a/spec/frontend/related_issues/components/related_issuable_input_spec.js b/spec/frontend/related_issues/components/related_issuable_input_spec.js index f7333bf6893..30f957a4c45 100644 --- a/spec/frontend/related_issues/components/related_issuable_input_spec.js +++ b/spec/frontend/related_issues/components/related_issuable_input_spec.js @@ -1,38 +1,37 @@ import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; import { TEST_HOST } from 'helpers/test_constants'; +import GfmAutoComplete from '~/gfm_auto_complete'; import { TYPE_ISSUE } from '~/issues/constants'; import RelatedIssuableInput from '~/related_issues/components/related_issuable_input.vue'; import { PathIdSeparator } from '~/related_issues/constants'; -jest.mock('ee_else_ce/gfm_auto_complete', () => { - return function gfmAutoComplete() { - return { - constructor() {}, - setup() {}, - }; - }; -}); +jest.mock('~/gfm_auto_complete'); describe('RelatedIssuableInput', () => { - let propsData; - - beforeEach(() => { - propsData = { - inputValue: '', - references: [], - pathIdSeparator: PathIdSeparator.Issue, - issuableType: TYPE_ISSUE, - autoCompleteSources: { - issues: `${TEST_HOST}/h5bp/html5-boilerplate/-/autocomplete_sources/issues`, + let wrapper; + + const autoCompleteSources = { + issues: `${TEST_HOST}/h5bp/html5-boilerplate/-/autocomplete_sources/issues`, + }; + + const mountComponent = (props = {}) => { + wrapper = shallowMount(RelatedIssuableInput, { + propsData: { + inputValue: '', + references: [], + pathIdSeparator: PathIdSeparator.Issue, + issuableType: TYPE_ISSUE, + autoCompleteSources, + ...props, }, - }; - }); + attachTo: document.body, + }); + }; describe('autocomplete', () => { describe('with autoCompleteSources', () => { it('shows placeholder text', () => { - const wrapper = shallowMount(RelatedIssuableInput, { propsData }); + mountComponent(); expect(wrapper.findComponent({ ref: 'input' }).element.placeholder).toBe( 'Paste issue link or <#issue id>', @@ -40,51 +39,32 @@ describe('RelatedIssuableInput', () => { }); it('has GfmAutoComplete', () => { - const wrapper = shallowMount(RelatedIssuableInput, { propsData }); + mountComponent(); - expect(wrapper.vm.gfmAutoComplete).toBeDefined(); + expect(GfmAutoComplete).toHaveBeenCalledWith(autoCompleteSources); }); }); describe('with no autoCompleteSources', () => { it('shows placeholder text', () => { - const wrapper = shallowMount(RelatedIssuableInput, { - propsData: { - ...propsData, - references: ['!1', '!2'], - }, - }); + mountComponent({ references: ['!1', '!2'] }); expect(wrapper.findComponent({ ref: 'input' }).element.value).toBe(''); }); it('does not have GfmAutoComplete', () => { - const wrapper = shallowMount(RelatedIssuableInput, { - propsData: { - ...propsData, - autoCompleteSources: {}, - }, - }); + mountComponent({ autoCompleteSources: {} }); - expect(wrapper.vm.gfmAutoComplete).not.toBeDefined(); + expect(GfmAutoComplete).not.toHaveBeenCalled(); }); }); }); describe('focus', () => { it('when clicking anywhere on the input wrapper it should focus the input', async () => { - const wrapper = shallowMount(RelatedIssuableInput, { - propsData: { - ...propsData, - references: ['foo', 'bar'], - }, - // We need to attach to document, so that `document.activeElement` is properly set in jsdom - attachTo: document.body, - }); - - wrapper.find('li').trigger('click'); + mountComponent({ references: ['foo', 'bar'] }); - await nextTick(); + await wrapper.find('li').trigger('click'); expect(document.activeElement).toBe(wrapper.findComponent({ ref: 'input' }).element); }); @@ -92,11 +72,7 @@ describe('RelatedIssuableInput', () => { describe('when filling in the input', () => { it('emits addIssuableFormInput with data', () => { - const wrapper = shallowMount(RelatedIssuableInput, { - propsData, - }); - - wrapper.vm.$emit = jest.fn(); + mountComponent(); const newInputValue = 'filling in things'; const untouchedRawReferences = newInputValue.trim().split(/\s/); @@ -108,12 +84,16 @@ describe('RelatedIssuableInput', () => { input.element.selectionEnd = newInputValue.length; input.trigger('input'); - expect(wrapper.vm.$emit).toHaveBeenCalledWith('addIssuableFormInput', { - newValue: newInputValue, - caretPos: newInputValue.length, - untouchedRawReferences, - touchedReference, - }); + expect(wrapper.emitted('addIssuableFormInput')).toEqual([ + [ + { + newValue: newInputValue, + caretPos: newInputValue.length, + untouchedRawReferences, + touchedReference, + }, + ], + ]); }); }); }); diff --git a/spec/frontend/releases/components/releases_pagination_spec.js b/spec/frontend/releases/components/releases_pagination_spec.js index 923d84ae2b3..b241eb9acd4 100644 --- a/spec/frontend/releases/components/releases_pagination_spec.js +++ b/spec/frontend/releases/components/releases_pagination_spec.js @@ -60,9 +60,22 @@ describe('releases_pagination.vue', () => { const findPrevButton = () => wrapper.findByTestId('prevButton'); const findNextButton = () => wrapper.findByTestId('nextButton'); + describe('when there is only one page of results', () => { + beforeEach(() => { + createComponent(singlePageInfo); + }); + + it('hides the "Prev" button', () => { + expect(findPrevButton().exists()).toBe(false); + }); + + it('hides the "Next" button', () => { + expect(findNextButton().exists()).toBe(false); + }); + }); + describe.each` description | pageInfo | prevEnabled | nextEnabled - ${'when there is only one page of results'} | ${singlePageInfo} | ${false} | ${false} ${'when there is a next page, but not a previous page'} | ${onlyNextPageInfo} | ${false} | ${true} ${'when there is a previous page, but not a next page'} | ${onlyPrevPageInfo} | ${true} | ${false} ${'when there is both a previous and next page'} | ${prevAndNextPageInfo} | ${true} | ${true} diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap index 6825d4afecf..ede04390586 100644 --- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap @@ -12,13 +12,15 @@ exports[`Repository last commit component renders commit widget 1`] = ` imgsize="32" imgsrc="https://test.com" linkhref="/test" + popoveruserid="" + popoverusername="" tooltipplacement="top" tooltiptext="" username="" /> <div - class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-flex-grow-1 gl-min-w-0" + class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-grow-1 gl-min-w-0" > <div class="commit-content" diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index ecd617ca44b..e2bb7cdb2d7 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -11,7 +11,7 @@ import BlobContent from '~/blob/components/blob_content.vue'; import BlobHeader from '~/blob/components/blob_header.vue'; import BlobButtonGroup from '~/repository/components/blob_button_group.vue'; import BlobContentViewer from '~/repository/components/blob_content_viewer.vue'; -import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; +import WebIdeLink from 'ee_else_ce/vue_shared/components/web_ide_link.vue'; import ForkSuggestion from '~/repository/components/fork_suggestion.vue'; import { loadViewer } from '~/repository/components/blob_viewers'; import DownloadViewer from '~/repository/components/blob_viewers/download_viewer.vue'; @@ -52,6 +52,8 @@ let userInfoMockResolver; let projectInfoMockResolver; let applicationInfoMockResolver; +Vue.use(Vuex); + const mockAxios = new MockAdapter(axios); const createMockStore = () => @@ -150,6 +152,7 @@ const createComponent = async (mockData = {}, mountFn = shallowMount, mockRoute ...inject, glFeatures: { highlightJs, + highlightJsWorker: false, }, }, }), @@ -403,7 +406,7 @@ describe('Blob content viewer component', () => { await waitForPromises(); - expect(loadViewer).toHaveBeenCalledWith(viewer, false); + expect(loadViewer).toHaveBeenCalledWith(viewer, false, false, 'javascript'); expect(wrapper.findComponent(loadViewerReturnValue).exists()).toBe(true); }); }); diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js index f4baa817d32..46a7f2ee1bb 100644 --- a/spec/frontend/repository/components/breadcrumbs_spec.js +++ b/spec/frontend/repository/components/breadcrumbs_spec.js @@ -1,6 +1,6 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -import { GlDropdown } from '@gitlab/ui'; +import { GlDisclosureDropdown, GlDisclosureDropdownGroup } from '@gitlab/ui'; import { shallowMount, RouterLinkStub } from '@vue/test-utils'; import Breadcrumbs from '~/repository/components/breadcrumbs.vue'; import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; @@ -11,6 +11,7 @@ import permissionsQuery from 'shared_queries/repository/permissions.query.graphq import projectPathQuery from '~/repository/queries/project_path.query.graphql'; import createApolloProvider from 'helpers/mock_apollo_helper'; +import { __ } from '~/locale'; const defaultMockRoute = { name: 'blobPath', @@ -61,6 +62,7 @@ describe('Repository breadcrumbs component', () => { }, stubs: { RouterLink: RouterLinkStub, + GlDisclosureDropdown, }, mocks: { $route: { @@ -71,7 +73,8 @@ describe('Repository breadcrumbs component', () => { }); }; - const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findDropdownGroup = () => wrapper.findComponent(GlDisclosureDropdownGroup); const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal); const findNewDirectoryModal = () => wrapper.findComponent(NewDirectoryModal); const findRouterLink = () => wrapper.findAllComponents(RouterLinkStub); @@ -146,7 +149,11 @@ describe('Repository breadcrumbs component', () => { `( 'does render add to tree dropdown $isRendered when route is $routeName', ({ routeName, isRendered }) => { - factory('app/assets/javascripts.js', { canCollaborate: true }, { name: routeName }); + factory( + 'app/assets/javascripts.js', + { canCollaborate: true, canEditTree: true }, + { name: routeName }, + ); expect(findDropdown().exists()).toBe(isRendered); }, ); @@ -156,7 +163,7 @@ describe('Repository breadcrumbs component', () => { createPermissionsQueryResponse({ forkProject: true, createMergeRequestIn: true }), ); - factory('/', { canCollaborate: true }); + factory('/', { canCollaborate: true, canEditTree: true }); await nextTick(); expect(findDropdown().exists()).toBe(true); @@ -193,4 +200,32 @@ describe('Repository breadcrumbs component', () => { expect(findNewDirectoryModal().props('path')).toBe('root/master/some_dir'); }); }); + + describe('"this repository" dropdown group', () => { + it('renders when user has pushCode permissions', async () => { + permissionsQuerySpy.mockResolvedValue( + createPermissionsQueryResponse({ + pushCode: true, + }), + ); + + factory('/', { canCollaborate: true }); + await waitForPromises(); + + expect(findDropdownGroup().props('group').name).toBe(__('This repository')); + }); + + it('does not render when user does not have pushCode permissions', async () => { + permissionsQuerySpy.mockResolvedValue( + createPermissionsQueryResponse({ + pushCode: false, + }), + ); + + factory('/', { canCollaborate: true }); + await waitForPromises(); + + expect(findDropdownGroup().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/repository/mixins/highlight_mixin_spec.js b/spec/frontend/repository/mixins/highlight_mixin_spec.js index 5f872749581..fd14f01747a 100644 --- a/spec/frontend/repository/mixins/highlight_mixin_spec.js +++ b/spec/frontend/repository/mixins/highlight_mixin_spec.js @@ -31,10 +31,13 @@ describe('HighlightMixin', () => { const dummyComponent = { mixins: [highlightMixin], - inject: { highlightWorker: { default: workerMock } }, + inject: { + highlightWorker: { default: workerMock }, + glFeatures: { default: { highlightJsWorker: true } }, + }, template: '<div>{{chunks[0]?.highlightedContent}}</div>', created() { - this.initHighlightWorker({ rawTextBlob, simpleViewer, language }); + this.initHighlightWorker({ rawTextBlob, simpleViewer, language, fileType }); }, methods: { onError: onErrorMock }, }; @@ -84,6 +87,7 @@ describe('HighlightMixin', () => { expect(workerMock.postMessage.mock.calls[1][0]).toMatchObject({ content: rawTextBlob, language: languageMock, + fileType: TEXT_FILE_TYPE, }); }); }); diff --git a/spec/frontend/scripts/frontend/po_to_json_spec.js b/spec/frontend/scripts/frontend/po_to_json_spec.js index 858e3c9d3c7..47d5ccfefd4 100644 --- a/spec/frontend/scripts/frontend/po_to_json_spec.js +++ b/spec/frontend/scripts/frontend/po_to_json_spec.js @@ -168,7 +168,7 @@ msgstr "" }); describe('escaping', () => { - it('escapes quotes in msgid and translation', () => { + it('escapes quotes in translation', () => { const poContent = ` # Escaped quotes in msgid and msgstr msgid "Changes the title to \\"%{title_param}\\"." @@ -183,7 +183,7 @@ msgstr "Ändert den Titel in \\"%{title_param}\\"." domain: 'app', lang: LOCALE, }, - 'Changes the title to \\"%{title_param}\\".': [ + 'Changes the title to "%{title_param}".': [ 'Ändert den Titel in \\"%{title_param}\\".', ], }, @@ -191,7 +191,7 @@ msgstr "Ändert den Titel in \\"%{title_param}\\"." }); }); - it('escapes backslashes in msgid and translation', () => { + it('escapes backslashes in translation', () => { const poContent = ` # Escaped backslashes in msgid and msgstr msgid "Example: ssh\\\\:\\\\/\\\\/" @@ -206,7 +206,7 @@ msgstr "Beispiel: ssh\\\\:\\\\/\\\\/" domain: 'app', lang: LOCALE, }, - 'Example: ssh\\\\:\\\\/\\\\/': ['Beispiel: ssh\\\\:\\\\/\\\\/'], + 'Example: ssh\\:\\/\\/': ['Beispiel: ssh\\\\:\\\\/\\\\/'], }, }, }); diff --git a/spec/frontend/search/mock_data.js b/spec/frontend/search/mock_data.js index 7cf8633d749..3f23803bbf6 100644 --- a/spec/frontend/search/mock_data.js +++ b/spec/frontend/search/mock_data.js @@ -132,6 +132,13 @@ export const MOCK_NAVIGATION = { active: true, count: '2,430', }, + epics: { + label: 'Epics', + scope: 'epics', + link: '/search?scope=epics&search=et', + active: true, + count: '0', + }, merge_requests: { label: 'Merge requests', scope: 'merge_requests', @@ -496,6 +503,14 @@ export const MOCK_NAVIGATION_ITEMS = [ items: [], }, { + title: 'Epics', + icon: 'epic', + link: '/search?scope=epics&search=et', + is_active: true, + pill_count: '0', + items: [], + }, + { title: 'Merge requests', icon: 'merge-request', link: '/search?scope=merge_requests&search=et', @@ -505,7 +520,7 @@ export const MOCK_NAVIGATION_ITEMS = [ }, { title: 'Wiki', - icon: 'overview', + icon: 'book', link: '/search?scope=wiki_blobs&search=et', is_active: false, pill_count: '0', @@ -529,7 +544,7 @@ export const MOCK_NAVIGATION_ITEMS = [ }, { title: 'Milestones', - icon: 'tag', + icon: 'clock', link: '/search?scope=milestones&search=et', is_active: false, pill_count: '0', @@ -887,3 +902,5 @@ export const MOCK_FILTERED_UNAPPLIED_SELECTED_LABELS = [ parent_full_name: 'Toolbox / Gitlab Smoke Tests', }, ]; + +export const CURRENT_SCOPE = 'blobs'; diff --git a/spec/frontend/search/sidebar/components/label_filter_spec.js b/spec/frontend/search/sidebar/components/label_filter_spec.js index c5df374d4ef..2a5b3a96045 100644 --- a/spec/frontend/search/sidebar/components/label_filter_spec.js +++ b/spec/frontend/search/sidebar/components/label_filter_spec.js @@ -92,6 +92,7 @@ describe('GlobalSearchSidebarLabelFilter', () => { const findCheckboxFilter = () => wrapper.findAllComponents(LabelDropdownItems); const findAlert = () => wrapper.findComponent(GlAlert); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findNoLabelsFoundMessage = () => wrapper.findComponentByTestId('no-labels-found-message'); describe('Renders correctly closed', () => { beforeEach(async () => { @@ -228,6 +229,33 @@ describe('GlobalSearchSidebarLabelFilter', () => { }); }); + describe('Renders no-labels state correctly', () => { + beforeEach(async () => { + createComponent(); + store.commit(REQUEST_AGGREGATIONS); + await Vue.nextTick(); + + findSearchBox().vm.$emit('focusin'); + findSearchBox().vm.$emit('input', 'ssssssss'); + }); + + it('renders checkbox filter', () => { + expect(findCheckboxFilter().exists()).toBe(false); + }); + + it("doesn't render alert", () => { + expect(findAlert().exists()).toBe(false); + }); + + it("doesn't render items", () => { + expect(findAllSelectedLabelsAbove().exists()).toBe(false); + }); + + it('renders no labels found text', () => { + expect(findNoLabelsFoundMessage().exists()).toBe(true); + }); + }); + describe('Renders error state correctly', () => { beforeEach(async () => { createComponent(); @@ -294,6 +322,8 @@ describe('GlobalSearchSidebarLabelFilter', () => { describe('dropdown checkboxes work', () => { beforeEach(async () => { createComponent(); + store.commit(RECEIVE_AGGREGATIONS_SUCCESS, MOCK_LABEL_AGGREGATIONS.data); + await Vue.nextTick(); await findSearchBox().vm.$emit('focusin'); await Vue.nextTick(); diff --git a/spec/frontend/search/sidebar/components/scope_legacy_navigation_spec.js b/spec/frontend/search/sidebar/components/scope_legacy_navigation_spec.js index 6a94da31a1b..786ad806ea6 100644 --- a/spec/frontend/search/sidebar/components/scope_legacy_navigation_spec.js +++ b/spec/frontend/search/sidebar/components/scope_legacy_navigation_spec.js @@ -7,6 +7,8 @@ import ScopeLegacyNavigation from '~/search/sidebar/components/scope_legacy_navi Vue.use(Vuex); +const MOCK_NAVIGATION_ENTRIES = Object.entries(MOCK_NAVIGATION); + describe('ScopeLegacyNavigation', () => { let wrapper; @@ -55,12 +57,12 @@ describe('ScopeLegacyNavigation', () => { }); it('renders all nav item components', () => { - expect(findGlNavItems()).toHaveLength(9); + expect(findGlNavItems()).toHaveLength(MOCK_NAVIGATION_ENTRIES.length); }); it('has all proper links', () => { const linkAtPosition = 3; - const { link } = MOCK_NAVIGATION[Object.keys(MOCK_NAVIGATION)[linkAtPosition]]; + const { link } = MOCK_NAVIGATION_ENTRIES[linkAtPosition][1]; expect(findGlNavItems().at(linkAtPosition).attributes('href')).toBe(link); }); diff --git a/spec/frontend/search/sidebar/components/scope_sidebar_navigation_spec.js b/spec/frontend/search/sidebar/components/scope_sidebar_navigation_spec.js index 4b71ff0bedc..86939bdc5d6 100644 --- a/spec/frontend/search/sidebar/components/scope_sidebar_navigation_spec.js +++ b/spec/frontend/search/sidebar/components/scope_sidebar_navigation_spec.js @@ -7,6 +7,8 @@ import { MOCK_QUERY, MOCK_NAVIGATION, MOCK_NAVIGATION_ITEMS } from '../../mock_d Vue.use(Vuex); +const MOCK_NAVIGATION_ENTRIES = Object.entries(MOCK_NAVIGATION); + describe('ScopeSidebarNavigation', () => { let wrapper; @@ -59,7 +61,7 @@ describe('ScopeSidebarNavigation', () => { }); it('renders all nav item components', () => { - expect(findNavItems()).toHaveLength(9); + expect(findNavItems()).toHaveLength(MOCK_NAVIGATION_ENTRIES.length); }); it('has all proper links', () => { diff --git a/spec/frontend/search/topbar/components/app_spec.js b/spec/frontend/search/topbar/components/app_spec.js index 423ec6ff63b..9dc14b97ce0 100644 --- a/spec/frontend/search/topbar/components/app_spec.js +++ b/spec/frontend/search/topbar/components/app_spec.js @@ -3,6 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; import { MOCK_QUERY } from 'jest/search/mock_data'; +import { stubComponent } from 'helpers/stub_component'; import GlobalSearchTopbar from '~/search/topbar/components/app.vue'; import GroupFilter from '~/search/topbar/components/group_filter.vue'; import ProjectFilter from '~/search/topbar/components/project_filter.vue'; @@ -93,11 +94,20 @@ describe('GlobalSearchTopbar', () => { }); it('dispatched correct click action', () => { - const draweToggleSpy = jest.fn(); - wrapper.vm.$refs.markdownDrawer.toggleDrawer = draweToggleSpy; + const drawerToggleSpy = jest.fn(); + + createComponent( + { query: { repository_ref: '' } }, + { elasticsearchEnabled: true, defaultBranchName: '' }, + { + MarkdownDrawer: stubComponent(MarkdownDrawer, { + methods: { toggleDrawer: drawerToggleSpy }, + }), + }, + ); findSyntaxOptionButton().vm.$emit('click'); - expect(draweToggleSpy).toHaveBeenCalled(); + expect(drawerToggleSpy).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/search/topbar/components/group_filter_spec.js b/spec/frontend/search/topbar/components/group_filter_spec.js index 78d9efbd686..94882d181d3 100644 --- a/spec/frontend/search/topbar/components/group_filter_spec.js +++ b/spec/frontend/search/topbar/components/group_filter_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; -import { MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data'; +import { MOCK_GROUP, MOCK_QUERY, CURRENT_SCOPE } from 'jest/search/mock_data'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { GROUPS_LOCAL_STORAGE_KEY } from '~/search/store/constants'; import GroupFilter from '~/search/topbar/components/group_filter.vue'; @@ -37,6 +37,7 @@ describe('GroupFilter', () => { actions: actionSpies, getters: { frequentGroups: () => [], + currentScope: () => CURRENT_SCOPE, }, }); @@ -89,6 +90,7 @@ describe('GroupFilter', () => { [GROUP_DATA.queryParam]: null, [PROJECT_DATA.queryParam]: null, nav_source: null, + scope: CURRENT_SCOPE, }); expect(visitUrl).toHaveBeenCalled(); @@ -109,6 +111,7 @@ describe('GroupFilter', () => { [GROUP_DATA.queryParam]: MOCK_GROUP.id, [PROJECT_DATA.queryParam]: null, nav_source: null, + scope: CURRENT_SCOPE, }); expect(visitUrl).toHaveBeenCalled(); diff --git a/spec/frontend/search/topbar/components/project_filter_spec.js b/spec/frontend/search/topbar/components/project_filter_spec.js index 9eda34b1633..c25d2b94027 100644 --- a/spec/frontend/search/topbar/components/project_filter_spec.js +++ b/spec/frontend/search/topbar/components/project_filter_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; -import { MOCK_PROJECT, MOCK_QUERY } from 'jest/search/mock_data'; +import { MOCK_PROJECT, MOCK_QUERY, CURRENT_SCOPE } from 'jest/search/mock_data'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { PROJECTS_LOCAL_STORAGE_KEY } from '~/search/store/constants'; import ProjectFilter from '~/search/topbar/components/project_filter.vue'; @@ -37,6 +37,7 @@ describe('ProjectFilter', () => { actions: actionSpies, getters: { frequentProjects: () => [], + currentScope: () => CURRENT_SCOPE, }, }); @@ -88,6 +89,7 @@ describe('ProjectFilter', () => { expect(setUrlParams).toHaveBeenCalledWith({ [PROJECT_DATA.queryParam]: null, nav_source: null, + scope: CURRENT_SCOPE, }); expect(visitUrl).toHaveBeenCalled(); }); @@ -107,6 +109,7 @@ describe('ProjectFilter', () => { [GROUP_DATA.queryParam]: MOCK_PROJECT.namespace.id, [PROJECT_DATA.queryParam]: MOCK_PROJECT.id, nav_source: null, + scope: CURRENT_SCOPE, }); expect(visitUrl).toHaveBeenCalled(); }); diff --git a/spec/frontend/service_desk/components/info_banner_spec.js b/spec/frontend/service_desk/components/info_banner_spec.js new file mode 100644 index 00000000000..7487d5d8b64 --- /dev/null +++ b/spec/frontend/service_desk/components/info_banner_spec.js @@ -0,0 +1,81 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink, GlButton } from '@gitlab/ui'; +import InfoBanner from '~/service_desk/components/info_banner.vue'; +import { infoBannerAdminNote, enableServiceDesk } from '~/service_desk/constants'; + +describe('InfoBanner', () => { + let wrapper; + + const defaultProvide = { + serviceDeskCalloutSvgPath: 'callout.svg', + serviceDeskEmailAddress: 'sd@gmail.com', + canAdminIssues: true, + canEditProjectSettings: true, + serviceDeskSettingsPath: 'path/to/project/settings', + serviceDeskHelpPath: 'path/to/documentation', + isServiceDeskEnabled: true, + }; + + const findEnableSDButton = () => wrapper.findComponent(GlButton); + + const mountComponent = (provide) => { + return shallowMount(InfoBanner, { + provide: { + ...defaultProvide, + ...provide, + }, + stubs: { + GlLink, + GlButton, + }, + }); + }; + + beforeEach(() => { + wrapper = mountComponent(); + }); + + describe('Service Desk email address', () => { + it('renders when user can admin issues and service desk is enabled', () => { + expect(wrapper.text()).toContain(infoBannerAdminNote); + expect(wrapper.text()).toContain(wrapper.vm.serviceDeskEmailAddress); + }); + + it('does not render, when user can not admin issues', () => { + wrapper = mountComponent({ canAdminIssues: false }); + + expect(wrapper.text()).not.toContain(infoBannerAdminNote); + expect(wrapper.text()).not.toContain(wrapper.vm.serviceDeskEmailAddress); + }); + + it('does not render, when service desk is not setup', () => { + wrapper = mountComponent({ isServiceDeskEnabled: false }); + + expect(wrapper.text()).not.toContain(infoBannerAdminNote); + expect(wrapper.text()).not.toContain(wrapper.vm.serviceDeskEmailAddress); + }); + }); + + describe('Link to Service Desk settings', () => { + it('renders when user can edit settings and service desk is not enabled', () => { + wrapper = mountComponent({ isServiceDeskEnabled: false }); + + expect(wrapper.text()).toContain(enableServiceDesk); + expect(findEnableSDButton().exists()).toBe(true); + }); + + it('does not render when service desk is enabled', () => { + wrapper = mountComponent(); + + expect(wrapper.text()).not.toContain(enableServiceDesk); + expect(findEnableSDButton().exists()).toBe(false); + }); + + it('does not render when user cannot edit settings', () => { + wrapper = mountComponent({ canEditProjectSettings: false }); + + expect(wrapper.text()).not.toContain(enableServiceDesk); + expect(findEnableSDButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/service_desk/components/service_desk_list_app_spec.js b/spec/frontend/service_desk/components/service_desk_list_app_spec.js new file mode 100644 index 00000000000..2ac789745aa --- /dev/null +++ b/spec/frontend/service_desk/components/service_desk_list_app_spec.js @@ -0,0 +1,151 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import * as Sentry from '@sentry/browser'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; +import { issuableListTabs } from '~/vue_shared/issuable/list/constants'; +import { STATUS_CLOSED, STATUS_OPEN } from '~/issues/constants'; +import getServiceDeskIssuesQuery from '~/service_desk/queries/get_service_desk_issues.query.graphql'; +import getServiceDeskIssuesCountsQuery from '~/service_desk/queries/get_service_desk_issues_counts.query.graphql'; +import ServiceDeskListApp from '~/service_desk/components/service_desk_list_app.vue'; +import InfoBanner from '~/service_desk/components/info_banner.vue'; +import { + getServiceDeskIssuesQueryResponse, + getServiceDeskIssuesCountsQueryResponse, +} from '../mock_data'; + +jest.mock('@sentry/browser'); + +describe('ServiceDeskListApp', () => { + let wrapper; + + Vue.use(VueApollo); + + const defaultProvide = { + emptyStateSvgPath: 'empty-state.svg', + isProject: true, + isSignedIn: true, + fullPath: 'path/to/project', + isServiceDeskSupported: true, + hasAnyIssues: true, + }; + + const defaultQueryResponse = getServiceDeskIssuesQueryResponse; + + const mockServiceDeskIssuesQueryResponse = jest.fn().mockResolvedValue(defaultQueryResponse); + const mockServiceDeskIssuesCountsQueryResponse = jest + .fn() + .mockResolvedValue(getServiceDeskIssuesCountsQueryResponse); + + const findIssuableList = () => wrapper.findComponent(IssuableList); + const findInfoBanner = () => wrapper.findComponent(InfoBanner); + + const mountComponent = ({ + provide = {}, + data = {}, + serviceDeskIssuesQueryResponse = mockServiceDeskIssuesQueryResponse, + serviceDeskIssuesCountsQueryResponse = mockServiceDeskIssuesCountsQueryResponse, + stubs = {}, + mountFn = shallowMount, + } = {}) => { + const requestHandlers = [ + [getServiceDeskIssuesQuery, serviceDeskIssuesQueryResponse], + [getServiceDeskIssuesCountsQuery, serviceDeskIssuesCountsQueryResponse], + ]; + + return mountFn(ServiceDeskListApp, { + apolloProvider: createMockApollo( + requestHandlers, + {}, + { + typePolicies: { + Query: { + fields: { + project: { + merge: true, + }, + }, + }, + }, + }, + ), + provide: { + ...defaultProvide, + ...provide, + }, + data() { + return data; + }, + stubs, + }); + }; + + beforeEach(() => { + wrapper = mountComponent(); + return waitForPromises(); + }); + + it('fetches service desk issues and renders them in the issuable list', () => { + expect(findIssuableList().props()).toMatchObject({ + namespace: 'service-desk', + recentSearchesStorageKey: 'issues', + issuables: defaultQueryResponse.data.project.issues.nodes, + tabs: issuableListTabs, + currentTab: STATUS_OPEN, + tabCounts: { + opened: 1, + closed: 1, + all: 1, + }, + }); + }); + + describe('InfoBanner', () => { + it('renders when Service Desk is supported and has any number of issues', () => { + expect(findInfoBanner().exists()).toBe(true); + }); + + it('does not render, when there are no issues', async () => { + wrapper = mountComponent({ provide: { hasAnyIssues: false } }); + await waitForPromises(); + + expect(findInfoBanner().exists()).toBe(false); + }); + }); + + describe('Events', () => { + describe('when "click-tab" event is emitted by IssuableList', () => { + beforeEach(() => { + mountComponent(); + + findIssuableList().vm.$emit('click-tab', STATUS_CLOSED); + }); + + it('updates ui to the new tab', () => { + expect(findIssuableList().props('currentTab')).toBe(STATUS_CLOSED); + }); + }); + }); + + describe('Errors', () => { + describe.each` + error | mountOption | message + ${'fetching issues'} | ${'serviceDeskIssuesQueryResponse'} | ${ServiceDeskListApp.i18n.errorFetchingIssues} + ${'fetching issue counts'} | ${'serviceDeskIssuesCountsQueryResponse'} | ${ServiceDeskListApp.i18n.errorFetchingCounts} + `('when there is an error $error', ({ mountOption, message }) => { + beforeEach(() => { + wrapper = mountComponent({ + [mountOption]: jest.fn().mockRejectedValue(new Error('ERROR')), + }); + return waitForPromises(); + }); + + it('shows an error message', () => { + expect(findIssuableList().props('error')).toBe(message); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('ERROR')); + }); + }); + }); +}); diff --git a/spec/frontend/service_desk/mock_data.js b/spec/frontend/service_desk/mock_data.js new file mode 100644 index 00000000000..17b400e8670 --- /dev/null +++ b/spec/frontend/service_desk/mock_data.js @@ -0,0 +1,118 @@ +export const getServiceDeskIssuesQueryResponse = { + data: { + project: { + id: '1', + __typename: 'Project', + issues: { + __persist: true, + pageInfo: { + __typename: 'PageInfo', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'startcursor', + endCursor: 'endcursor', + }, + nodes: [ + { + __persist: true, + __typename: 'Issue', + id: 'gid://gitlab/Issue/123456', + iid: '789', + confidential: false, + createdAt: '2021-05-22T04:08:01Z', + downvotes: 2, + dueDate: '2021-05-29', + hidden: false, + humanTimeEstimate: null, + mergeRequestsCount: false, + moved: false, + state: 'opened', + title: 'Issue title', + updatedAt: '2021-05-22T04:08:01Z', + closedAt: null, + upvotes: 3, + userDiscussionsCount: 4, + webPath: 'project/-/issues/789', + webUrl: 'project/-/issues/789', + type: 'issue', + assignees: { + nodes: [ + { + __persist: true, + __typename: 'UserCore', + id: 'gid://gitlab/User/234', + avatarUrl: 'avatar/url', + name: 'Marge Simpson', + username: 'msimpson', + webUrl: 'url/msimpson', + }, + ], + }, + author: { + __persist: true, + __typename: 'UserCore', + id: 'gid://gitlab/User/456', + avatarUrl: 'avatar/url', + name: 'GitLab Support Bot', + username: 'support-bot', + webUrl: 'url/hsimpson', + }, + labels: { + nodes: [ + { + __persist: true, + id: 'gid://gitlab/ProjectLabel/456', + color: '#333', + title: 'Label title', + description: 'Label description', + }, + ], + }, + milestone: null, + taskCompletionStatus: { + completedCount: 1, + count: 2, + }, + }, + ], + }, + }, + }, +}; + +export const getServiceDeskIssuesQueryEmptyResponse = { + data: { + project: { + id: '1', + __typename: 'Project', + issues: { + __persist: true, + pageInfo: { + __typename: 'PageInfo', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'startcursor', + endCursor: 'endcursor', + }, + nodes: [], + }, + }, + }, +}; + +export const getServiceDeskIssuesCountsQueryResponse = { + data: { + project: { + id: '1', + openedIssues: { + count: 1, + }, + closedIssues: { + count: 1, + }, + allIssues: { + count: 1, + }, + }, + }, +}; diff --git a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js index 81b65f4f050..52355806487 100644 --- a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js +++ b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js @@ -1,15 +1,12 @@ import { shallowMount } from '@vue/test-utils'; import { GlLink } from '@gitlab/ui'; -import { TEST_HOST } from 'helpers/test_constants'; import { TYPENAME_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import AssigneeAvatar from '~/sidebar/components/assignees/assignee_avatar.vue'; import AssigneeAvatarLink from '~/sidebar/components/assignees/assignee_avatar_link.vue'; import userDataMock from '../../user_data_mock'; -const TOOLTIP_PLACEMENT = 'bottom'; -const { name: USER_NAME } = userDataMock(); -const TEST_ISSUABLE_TYPE = 'merge_request'; +const TEST_ISSUABLE_TYPE = 'issue'; describe('AssigneeAvatarLink component', () => { let wrapper; @@ -17,10 +14,6 @@ describe('AssigneeAvatarLink component', () => { function createComponent(props = {}) { const propsData = { user: userDataMock(), - showLess: true, - rootPath: TEST_HOST, - tooltipPlacement: TOOLTIP_PLACEMENT, - singleUser: false, issuableType: TEST_ISSUABLE_TYPE, ...props, }; @@ -30,7 +23,6 @@ describe('AssigneeAvatarLink component', () => { }); } - const findTooltipText = () => wrapper.attributes('title'); const findUserLink = () => wrapper.findComponent(GlLink); it('has the root url present in the assigneeUrl method', () => { @@ -50,69 +42,6 @@ describe('AssigneeAvatarLink component', () => { ); }); - describe.each` - issuableType | tooltipHasName | canMerge | expected - ${'merge_request'} | ${true} | ${true} | ${USER_NAME} - ${'merge_request'} | ${true} | ${false} | ${`${USER_NAME} (cannot merge)`} - ${'merge_request'} | ${false} | ${true} | ${''} - ${'merge_request'} | ${false} | ${false} | ${'Cannot merge'} - ${'issue'} | ${true} | ${true} | ${USER_NAME} - ${'issue'} | ${true} | ${false} | ${USER_NAME} - ${'issue'} | ${false} | ${true} | ${''} - ${'issue'} | ${false} | ${false} | ${''} - `( - 'with $issuableType and tooltipHasName=$tooltipHasName and canMerge=$canMerge', - ({ issuableType, tooltipHasName, canMerge, expected }) => { - beforeEach(() => { - createComponent({ - issuableType, - tooltipHasName, - user: { - ...userDataMock(), - can_merge: canMerge, - }, - }); - }); - - it('sets tooltip', () => { - expect(findTooltipText()).toBe(expected); - }); - }, - ); - - describe.each` - tooltipHasName | name | availability | canMerge | expected - ${true} | ${"Rabbit O'Hare"} | ${''} | ${true} | ${"Rabbit O'Hare"} - ${true} | ${"Rabbit O'Hare"} | ${'Busy'} | ${false} | ${"Rabbit O'Hare (Busy) (cannot merge)"} - ${true} | ${'Root'} | ${'Busy'} | ${false} | ${'Root (Busy) (cannot merge)'} - ${true} | ${'Root'} | ${'Busy'} | ${true} | ${'Root (Busy)'} - ${true} | ${'Root'} | ${''} | ${false} | ${'Root (cannot merge)'} - ${true} | ${'Root'} | ${''} | ${true} | ${'Root'} - ${false} | ${'Root'} | ${'Busy'} | ${false} | ${'Cannot merge'} - ${false} | ${'Root'} | ${'Busy'} | ${true} | ${''} - ${false} | ${'Root'} | ${''} | ${false} | ${'Cannot merge'} - ${false} | ${'Root'} | ${''} | ${true} | ${''} - `( - "with name=$name tooltipHasName=$tooltipHasName and availability='$availability' and canMerge=$canMerge", - ({ name, tooltipHasName, availability, canMerge, expected }) => { - beforeEach(() => { - createComponent({ - tooltipHasName, - user: { - ...userDataMock(), - name, - can_merge: canMerge, - availability, - }, - }); - }); - - it(`sets tooltip to "${expected}"`, () => { - expect(findTooltipText()).toBe(expected); - }); - }, - ); - it('passes the correct user id for REST API', () => { createComponent({ tooltipHasName: true, @@ -135,15 +64,24 @@ describe('AssigneeAvatarLink component', () => { expect(findUserLink().attributes('data-user-id')).toBe(String(userId)); }); - it.each` - issuableType | userId - ${'merge_request'} | ${undefined} - ${'issue'} | ${'1'} - `('sets data-user-id as $userId for $issuableType', ({ issuableType, userId }) => { + it('passes the correct username, cannotMerge, and CSS class for popover support', () => { + const moctUserData = userDataMock(); + const { id, username } = moctUserData; + createComponent({ - issuableType, + tooltipHasName: true, + issuableType: 'merge_request', + user: { ...moctUserData, can_merge: false }, }); - expect(findUserLink().attributes('data-user-id')).toBe(userId); + const userLink = findUserLink(); + + expect(userLink.attributes()).toMatchObject({ + 'data-user-id': `${id}`, + 'data-username': username, + 'data-cannot-merge': 'true', + 'data-placement': 'left', + }); + expect(userLink.classes()).toContain('js-user-link'); }); }); diff --git a/spec/frontend/sidebar/components/assignees/assignee_title_spec.js b/spec/frontend/sidebar/components/assignees/assignee_title_spec.js index d561c761c99..b2d15e76e80 100644 --- a/spec/frontend/sidebar/components/assignees/assignee_title_spec.js +++ b/spec/frontend/sidebar/components/assignees/assignee_title_spec.js @@ -37,27 +37,6 @@ describe('AssigneeTitle component', () => { }); }); - describe('gutter toggle', () => { - it('does not show toggle by default', () => { - wrapper = createComponent({ - numberOfAssignees: 2, - editable: false, - }); - - expect(wrapper.vm.$el.querySelector('.gutter-toggle')).toBeNull(); - }); - - it('shows toggle when showToggle is true', () => { - wrapper = createComponent({ - numberOfAssignees: 2, - editable: false, - showToggle: true, - }); - - expect(wrapper.vm.$el.querySelector('.gutter-toggle')).toEqual(expect.any(Object)); - }); - }); - describe('when changing is false', () => { it('renders "Edit"', () => { wrapper = createComponent({ editable: true }); diff --git a/spec/frontend/sidebar/components/assignees/assignees_spec.js b/spec/frontend/sidebar/components/assignees/assignees_spec.js index 1661e28abd2..65a07382ebc 100644 --- a/spec/frontend/sidebar/components/assignees/assignees_spec.js +++ b/spec/frontend/sidebar/components/assignees/assignees_spec.js @@ -181,7 +181,10 @@ describe('Assignee component', () => { const userItems = findAllAvatarLinks(); expect(userItems).toHaveLength(3); - expect(userItems.at(0).attributes('title')).toBe(users[2].name); + expect(userItems.at(0).attributes()).toMatchObject({ + 'data-user-id': `${users[2].id}`, + 'data-username': users[2].username, + }); }); it('passes the sorted assignees to the collapsed-assignee-list', () => { diff --git a/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js b/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js index 40d3d090bb4..52d68d7047e 100644 --- a/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js +++ b/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js @@ -194,7 +194,7 @@ describe('CollapsedAssigneeList component', () => { ${[busyUser, canMergeUser]} | ${1} | ${1} | ${`${busyUser.name} (Busy), ${canMergeUser.name} (1/2 can merge)`} ${[busyUser]} | ${1} | ${0} | ${`${busyUser.name} (Busy) (cannot merge)`} ${[canMergeUser]} | ${0} | ${1} | ${`${canMergeUser.name}`} - ${[]} | ${0} | ${0} | ${'Assignee(s)'} + ${[]} | ${0} | ${0} | ${'Assignees'} `( 'with $users.length users, $busy is busy and $canMerge that can merge', ({ users, expected }) => { diff --git a/spec/frontend/sidebar/components/assignees/sidebar_assignees_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_assignees_spec.js index a189d3656a2..a8b2db66723 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_assignees_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_assignees_spec.js @@ -8,6 +8,7 @@ import SidebarAssignees from '~/sidebar/components/assignees/sidebar_assignees.v import SidebarService from '~/sidebar/services/sidebar_service'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarStore from '~/sidebar/stores/sidebar_store'; +import eventHub from '~/sidebar/event_hub'; import Mock from '../../mock_data'; describe('sidebar assignees', () => { @@ -30,6 +31,9 @@ describe('sidebar assignees', () => { }); }; + const findAssigness = () => wrapper.findComponent(Assigness); + const findAssigneesRealtime = () => wrapper.findComponent(AssigneesRealtime); + beforeEach(() => { axiosMock = new AxiosMockAdapter(axios); mediator = new SidebarMediator(Mock.mediator); @@ -50,18 +54,20 @@ describe('sidebar assignees', () => { expect(mediator.saveAssignees).not.toHaveBeenCalled(); - wrapper.vm.saveAssignees(); + eventHub.$emit('sidebar.saveAssignees'); expect(mediator.saveAssignees).toHaveBeenCalled(); }); - it('calls the mediator when "assignSelf" method is called', () => { + it('calls the mediator when "assignSelf" method is called', async () => { createComponent(); + mediator.store.isFetching.assignees = false; + await nextTick(); expect(mediator.assignYourself).not.toHaveBeenCalled(); expect(mediator.store.assignees.length).toBe(0); - wrapper.vm.assignSelf(); + await findAssigness().vm.$emit('assign-self'); expect(mediator.assignYourself).toHaveBeenCalled(); expect(mediator.store.assignees.length).toBe(1); @@ -70,19 +76,19 @@ describe('sidebar assignees', () => { it('hides assignees until fetched', async () => { createComponent(); - expect(wrapper.findComponent(Assigness).exists()).toBe(false); + expect(findAssigness().exists()).toBe(false); - wrapper.vm.store.isFetching.assignees = false; + mediator.store.isFetching.assignees = false; await nextTick(); - expect(wrapper.findComponent(Assigness).exists()).toBe(true); + expect(findAssigness().exists()).toBe(true); }); describe('when issuableType is issue', () => { it('finds AssigneesRealtime component', () => { createComponent(); - expect(wrapper.findComponent(AssigneesRealtime).exists()).toBe(true); + expect(findAssigneesRealtime().exists()).toBe(true); }); }); @@ -90,7 +96,7 @@ describe('sidebar assignees', () => { it('does not find AssigneesRealtime component', () => { createComponent({ issuableType: 'MR' }); - expect(wrapper.findComponent(AssigneesRealtime).exists()).toBe(false); + expect(findAssigneesRealtime().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js b/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js index 47f68e1fe83..da79daebb93 100644 --- a/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js +++ b/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js @@ -1,3 +1,4 @@ +import { GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; @@ -154,6 +155,13 @@ describe('IssuableLockForm', () => { expect(tooltip).toBeDefined(); expect(tooltip.value.title).toBe(isLocked ? 'Locked' : 'Unlocked'); }); + + it('renders lock icon', () => { + const icon = findSidebarCollapseIcon().findComponent(GlIcon).props('name'); + const expected = isLocked ? 'lock' : 'lock-open'; + + expect(icon).toBe(expected); + }); }); }); }); diff --git a/spec/frontend/sidebar/components/participants/participants_spec.js b/spec/frontend/sidebar/components/participants/participants_spec.js index 72d83ebeca4..2b0eac46313 100644 --- a/spec/frontend/sidebar/components/participants/participants_spec.js +++ b/spec/frontend/sidebar/components/participants/participants_spec.js @@ -63,6 +63,19 @@ describe('Participants component', () => { expect(findParticipantsAuthor()).toHaveLength(numberOfLessParticipants); }); + it('participants link has data attributes and class present for popover support', () => { + const numberOfLessParticipants = 2; + wrapper = mountComponent({ participants, numberOfLessParticipants }); + + const participantsLink = wrapper.find('.js-user-link'); + + expect(participantsLink.attributes()).toMatchObject({ + href: `${participant.web_url}`, + 'data-user-id': `${participant.id}`, + 'data-username': `${participant.username}`, + }); + }); + it('when only showing all participants, each has an avatar', async () => { wrapper = mountComponent({ participants, numberOfLessParticipants: 2 }); diff --git a/spec/frontend/sidebar/components/reviewers/reviewer_avatar_link_spec.js b/spec/frontend/sidebar/components/reviewers/reviewer_avatar_link_spec.js new file mode 100644 index 00000000000..79d12fa3992 --- /dev/null +++ b/spec/frontend/sidebar/components/reviewers/reviewer_avatar_link_spec.js @@ -0,0 +1,66 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink } from '@gitlab/ui'; +import { TEST_HOST } from 'helpers/test_constants'; +import ReviewerAvatar from '~/sidebar/components/reviewers/reviewer_avatar.vue'; +import ReviewerAvatarLink from '~/sidebar/components/reviewers/reviewer_avatar_link.vue'; +import userDataMock from '../../user_data_mock'; + +const TEST_ISSUABLE_TYPE = 'merge_request'; + +describe('ReviewerAvatarLink component', () => { + const mockUserData = { + ...userDataMock(), + webUrl: `${TEST_HOST}/root`, + }; + let wrapper; + + function createComponent(props = {}) { + const propsData = { + user: mockUserData, + rootPath: TEST_HOST, + issuableType: TEST_ISSUABLE_TYPE, + ...props, + }; + + wrapper = shallowMount(ReviewerAvatarLink, { + propsData, + }); + } + + const findUserLink = () => wrapper.findComponent(GlLink); + + it('has the root url present in the assigneeUrl method', () => { + createComponent(); + + expect(wrapper.attributes().href).toEqual(mockUserData.web_url); + }); + + it('renders reviewer avatar', () => { + createComponent(); + + expect(wrapper.findComponent(ReviewerAvatar).props()).toMatchObject({ + imgSize: 24, + user: mockUserData, + }); + }); + + it('passes the correct user id, username, cannotMerge, and CSS class for popover support', () => { + const { id, username } = mockUserData; + + createComponent({ + tooltipHasName: true, + issuableType: 'merge_request', + user: mockUserData, + }); + + const userLink = findUserLink(); + + expect(userLink.attributes()).toMatchObject({ + 'data-user-id': `${id}`, + 'data-username': username, + 'data-cannot-merge': 'true', + 'data-placement': 'left', + }); + expect(userLink.classes()).toContain('js-user-link'); + }); +}); diff --git a/spec/frontend/sidebar/components/severity/sidebar_severity_widget_spec.js b/spec/frontend/sidebar/components/severity/sidebar_severity_widget_spec.js index bee90d2b2b6..05fb75dc0fb 100644 --- a/spec/frontend/sidebar/components/severity/sidebar_severity_widget_spec.js +++ b/spec/frontend/sidebar/components/severity/sidebar_severity_widget_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui'; +import { GlCollapsibleListbox, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -51,8 +51,7 @@ describe('SidebarSeverityWidget', () => { const findSeverityToken = () => wrapper.findAllComponents(SeverityToken); const findEditBtn = () => wrapper.findByTestId('edit-button'); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findCriticalSeverityDropdownItem = () => wrapper.findComponent(GlDropdownItem); // First dropdown item is critical severity + const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findTooltip = () => wrapper.findComponent(GlTooltip); const findCollapsedSeverity = () => wrapper.findComponent({ ref: 'severity' }); @@ -87,7 +86,7 @@ describe('SidebarSeverityWidget', () => { }); createComponent({ mutationMock }); - findCriticalSeverityDropdownItem().vm.$emit('click'); + findDropdown().vm.$emit('select', severity); expect(mutationMock).toHaveBeenCalledWith({ iid, @@ -100,7 +99,7 @@ describe('SidebarSeverityWidget', () => { const mutationMock = jest.fn().mockRejectedValue('Something went wrong'); createComponent({ mutationMock }); - findCriticalSeverityDropdownItem().vm.$emit('click'); + findDropdown().vm.$emit('select', severity); await waitForPromises(); expect(createAlert).toHaveBeenCalled(); @@ -110,7 +109,7 @@ describe('SidebarSeverityWidget', () => { const mutationMock = jest.fn().mockRejectedValue({}); createComponent({ mutationMock }); - findCriticalSeverityDropdownItem().vm.$emit('click'); + findDropdown().vm.$emit('select', severity); await nextTick(); expect(findLoadingIcon().exists()).toBe(true); diff --git a/spec/frontend/sidebar/components/time_tracking/create_timelog_form_spec.js b/spec/frontend/sidebar/components/time_tracking/create_timelog_form_spec.js index a7c3867c359..a3b32e98506 100644 --- a/spec/frontend/sidebar/components/time_tracking/create_timelog_form_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/create_timelog_form_spec.js @@ -1,9 +1,10 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -import { GlAlert, GlModal } from '@gitlab/ui'; +import { GlAlert, GlModal, GlFormInput, GlDatepicker, GlFormTextarea } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { stubComponent } from 'helpers/stub_component'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import CreateTimelogForm from '~/sidebar/components/time_tracking/create_timelog_form.vue'; import createTimelogMutation from '~/sidebar/queries/create_timelog.mutation.graphql'; @@ -49,21 +50,19 @@ describe('Create Timelog Form', () => { const findSaveButton = () => findModal().props('actionPrimary'); const findSaveButtonLoadingState = () => findSaveButton().attributes.loading; const findSaveButtonDisabledState = () => findSaveButton().attributes.disabled; + const findGlFormInput = () => wrapper.findComponent(GlFormInput); + const findGlDatepicker = () => wrapper.findComponent(GlDatepicker); + const findGlFormTextarea = () => wrapper.findComponent(GlFormTextarea); const submitForm = () => findForm().trigger('submit'); const mountComponent = ( - { props, data, providedProps } = {}, + { props, providedProps } = {}, mutationResolverMock = rejectedMutationMock, ) => { fakeApollo = createMockApollo([[createTimelogMutation, mutationResolverMock]]); wrapper = shallowMountExtended(CreateTimelogForm, { - data() { - return { - ...data, - }; - }, provide: { issuableType: 'issue', ...providedProps, @@ -73,13 +72,17 @@ describe('Create Timelog Form', () => { ...props, }, apolloProvider: fakeApollo, + stubs: { + GlModal: stubComponent(GlModal, { + methods: { close: modalCloseMock }, + }), + }, }); - - wrapper.vm.$refs.modal.close = modalCloseMock; }; afterEach(() => { fakeApollo = null; + modalCloseMock.mockClear(); }); describe('save button', () => { @@ -90,15 +93,18 @@ describe('Create Timelog Form', () => { expect(findSaveButtonDisabledState()).toBe(true); }); - it('is enabled and not loading when time spent is not empty', () => { - mountComponent({ data: { timeSpent: '2d' } }); + it('is enabled and not loading when time spent is not empty', async () => { + mountComponent(); + + await findGlFormInput().vm.$emit('input', '2d'); expect(findSaveButtonLoadingState()).toBe(false); expect(findSaveButtonDisabledState()).toBe(false); }); it('is disabled and loading when the the form is submitted', async () => { - mountComponent({ data: { timeSpent: '2d' } }); + mountComponent(); + await findGlFormInput().vm.$emit('input', '2d'); submitForm(); @@ -109,7 +115,8 @@ describe('Create Timelog Form', () => { }); it('is enabled and not loading the when form is submitted but the mutation has errors', async () => { - mountComponent({ data: { timeSpent: '2d' } }); + mountComponent(); + await findGlFormInput().vm.$emit('input', '2d'); submitForm(); @@ -121,7 +128,8 @@ describe('Create Timelog Form', () => { }); it('is enabled and not loading the when form is submitted but the mutation returns errors', async () => { - mountComponent({ data: { timeSpent: '2d' } }, resolvedMutationWithErrorsMock); + mountComponent({}, resolvedMutationWithErrorsMock); + await findGlFormInput().vm.$emit('input', '2d'); submitForm(); @@ -145,7 +153,8 @@ describe('Create Timelog Form', () => { }); it('closes the modal after a successful mutation', async () => { - mountComponent({ data: { timeSpent: '2d' } }, resolvedMutationWithoutErrorsMock); + mountComponent({}, resolvedMutationWithoutErrorsMock); + await findGlFormInput().vm.$emit('input', '2d'); submitForm(); @@ -166,7 +175,10 @@ describe('Create Timelog Form', () => { const spentAt = '2022-11-20T21:53:00+0000'; const summary = 'Example'; - mountComponent({ data: { timeSpent, spentAt, summary }, providedProps: { issuableType } }); + mountComponent({ providedProps: { issuableType } }); + await findGlFormInput().vm.$emit('input', timeSpent); + await findGlDatepicker().vm.$emit('input', spentAt); + await findGlFormTextarea().vm.$emit('input', summary); submitForm(); @@ -187,7 +199,8 @@ describe('Create Timelog Form', () => { }); it('shows an error if the submission fails with a handled error', async () => { - mountComponent({ data: { timeSpent: '2d' } }, resolvedMutationWithErrorsMock); + mountComponent({}, resolvedMutationWithErrorsMock); + await findGlFormInput().vm.$emit('input', '2d'); submitForm(); @@ -198,7 +211,8 @@ describe('Create Timelog Form', () => { }); it('shows an error if the submission fails with an unhandled error', async () => { - mountComponent({ data: { timeSpent: '2d' } }); + mountComponent(); + await findGlFormInput().vm.$emit('input', '2d'); submitForm(); diff --git a/spec/frontend/sidebar/components/time_tracking/mock_data.js b/spec/frontend/sidebar/components/time_tracking/mock_data.js index f161ae677d0..08b6c71629a 100644 --- a/spec/frontend/sidebar/components/time_tracking/mock_data.js +++ b/spec/frontend/sidebar/components/time_tracking/mock_data.js @@ -148,3 +148,16 @@ export const getMrTimelogsQueryResponse = { }, }, }; + +export const deleteTimelogMutationResponse = { + data: { + timelogDelete: { + errors: [], + timelog: { + id: 'gid://gitlab/Issue/148', + issue: {}, + mergeRequest: {}, + }, + }, + }, +}; diff --git a/spec/frontend/sidebar/components/time_tracking/report_spec.js b/spec/frontend/sidebar/components/time_tracking/report_spec.js index 713ae83cbf1..6f25c4a10fd 100644 --- a/spec/frontend/sidebar/components/time_tracking/report_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/report_spec.js @@ -12,6 +12,7 @@ import getIssueTimelogsQuery from '~/sidebar/queries/get_issue_timelogs.query.gr import getMrTimelogsQuery from '~/sidebar/queries/get_mr_timelogs.query.graphql'; import deleteTimelogMutation from '~/sidebar/queries/delete_timelog.mutation.graphql'; import { + deleteTimelogMutationResponse, getIssueTimelogsQueryResponse, getMrTimelogsQueryResponse, timelogToRemoveId, @@ -22,7 +23,7 @@ jest.mock('~/alert'); describe('Issuable Time Tracking Report', () => { Vue.use(VueApollo); let wrapper; - let fakeApollo; + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findDeleteButton = () => wrapper.findByTestId('deleteButton'); const successIssueQueryHandler = jest.fn().mockResolvedValue(getIssueTimelogsQueryResponse); @@ -30,30 +31,27 @@ describe('Issuable Time Tracking Report', () => { const mountComponent = ({ queryHandler = successIssueQueryHandler, + mutationHandler, issuableType = 'issue', mountFunction = shallowMount, limitToHours = false, } = {}) => { - fakeApollo = createMockApollo([ - [getIssueTimelogsQuery, queryHandler], - [getMrTimelogsQuery, queryHandler], - ]); wrapper = extendedWrapper( mountFunction(Report, { + apolloProvider: createMockApollo([ + [getIssueTimelogsQuery, queryHandler], + [getMrTimelogsQuery, queryHandler], + [deleteTimelogMutation, mutationHandler], + ]), provide: { issuableId: 1, issuableType, }, propsData: { limitToHours, issuableId: '1' }, - apolloProvider: fakeApollo, }), ); }; - afterEach(() => { - fakeApollo = null; - }); - it('should render loading spinner', () => { mountComponent(); @@ -135,50 +133,27 @@ describe('Issuable Time Tracking Report', () => { }); describe('when clicking on the delete timelog button', () => { - beforeEach(() => { - mountComponent({ mountFunction: mount }); - }); - it('calls `$apollo.mutate` with deleteTimelogMutation mutation and removes the row', async () => { - const mutateSpy = jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ - data: { - timelogDelete: { - errors: [], - }, - }, - }); - + const mutateSpy = jest.fn().mockResolvedValue(deleteTimelogMutationResponse); + mountComponent({ mutationHandler: mutateSpy, mountFunction: mount }); await waitForPromises(); + await findDeleteButton().trigger('click'); await waitForPromises(); expect(createAlert).not.toHaveBeenCalled(); - expect(mutateSpy).toHaveBeenCalledWith({ - mutation: deleteTimelogMutation, - variables: { - input: { - id: timelogToRemoveId, - }, - }, - }); + expect(mutateSpy).toHaveBeenCalledWith({ input: { id: timelogToRemoveId } }); }); it('calls `createAlert` with errorMessage and does not remove the row on promise reject', async () => { - const mutateSpy = jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({}); - + const mutateSpy = jest.fn().mockRejectedValue({}); + mountComponent({ mutationHandler: mutateSpy, mountFunction: mount }); await waitForPromises(); + await findDeleteButton().trigger('click'); await waitForPromises(); - expect(mutateSpy).toHaveBeenCalledWith({ - mutation: deleteTimelogMutation, - variables: { - input: { - id: timelogToRemoveId, - }, - }, - }); - + expect(mutateSpy).toHaveBeenCalledWith({ input: { id: timelogToRemoveId } }); expect(createAlert).toHaveBeenCalledWith({ message: 'An error occurred while removing the timelog.', captureError: true, diff --git a/spec/frontend/sidebar/components/todo_toggle/__snapshots__/todo_spec.js.snap b/spec/frontend/sidebar/components/todo_toggle/__snapshots__/todo_spec.js.snap index 846f45345e7..fd525474923 100644 --- a/spec/frontend/sidebar/components/todo_toggle/__snapshots__/todo_spec.js.snap +++ b/spec/frontend/sidebar/components/todo_toggle/__snapshots__/todo_spec.js.snap @@ -27,6 +27,7 @@ exports[`SidebarTodo template renders component container element with proper da label="Loading" size="sm" style="display: none;" + variant="spinner" /> </button> `; diff --git a/spec/frontend/sidebar/components/todo_toggle/todo_button_spec.js b/spec/frontend/sidebar/components/todo_toggle/todo_button_spec.js index 472a89e9b21..4385db43a4a 100644 --- a/spec/frontend/sidebar/components/todo_toggle/todo_button_spec.js +++ b/spec/frontend/sidebar/components/todo_toggle/todo_button_spec.js @@ -23,7 +23,6 @@ describe('Todo Button', () => { afterEach(() => { dispatchEventSpy = null; - jest.clearAllMocks(); }); it('renders GlButton', () => { diff --git a/spec/frontend/sidebar/sidebar_mediator_spec.js b/spec/frontend/sidebar/sidebar_mediator_spec.js index f2003aee96e..9c12088216b 100644 --- a/spec/frontend/sidebar/sidebar_mediator_spec.js +++ b/spec/frontend/sidebar/sidebar_mediator_spec.js @@ -25,8 +25,6 @@ describe('Sidebar mediator', () => { SidebarService.singleton = null; SidebarStore.singleton = null; SidebarMediator.singleton = null; - - jest.clearAllMocks(); }); it('assigns yourself', () => { diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap index c8d972b19a3..05c1a6dd11d 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap @@ -24,7 +24,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = </div> <div - class="js-vue-markdown-field md-area position-relative gfm-form js-expanded" + class="js-vue-markdown-field md-area position-relative gfm-form gl-overflow-hidden js-expanded" data-uploads-path="" > <markdown-header-stub @@ -83,16 +83,17 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = <markdown-toolbar-stub canattachfile="true" markdowndocspath="help/" - quickactionsdocspath="" showcommenttoolbar="true" /> </div> </div> <div - class="js-vue-md-preview md md-preview-holder gl-px-5" + class="js-vue-md-preview md-preview-holder gl-px-5 md" style="display: none;" - /> + > + <div /> + </div> <!----> diff --git a/spec/frontend/snippets/components/snippet_visibility_edit_spec.js b/spec/frontend/snippets/components/snippet_visibility_edit_spec.js index 70eb719f706..e2a9967f6ad 100644 --- a/spec/frontend/snippets/components/snippet_visibility_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_visibility_edit_spec.js @@ -134,6 +134,17 @@ describe('Snippet Visibility Edit component', () => { description: SNIPPET_VISIBILITY.private.description_project, }); }); + + it('when project snippet, renders special public description', () => { + createComponent({ propsData: { isProjectSnippet: true }, deep: true }); + + expect(findRadiosData()[2]).toEqual({ + value: VISIBILITY_LEVEL_PUBLIC_STRING, + icon: SNIPPET_VISIBILITY.public.icon, + text: SNIPPET_VISIBILITY.public.label, + description: SNIPPET_VISIBILITY.public.description_project, + }); + }); }); }); diff --git a/spec/frontend/super_sidebar/components/create_menu_spec.js b/spec/frontend/super_sidebar/components/create_menu_spec.js index fe2fd17ae4d..510a3f5b913 100644 --- a/spec/frontend/super_sidebar/components/create_menu_spec.js +++ b/spec/frontend/super_sidebar/components/create_menu_spec.js @@ -20,8 +20,12 @@ describe('CreateMenu component', () => { const findInviteMembersTrigger = () => wrapper.findComponent(InviteMembersTrigger); const findGlTooltip = () => wrapper.findComponent(GlTooltip); - const createWrapper = () => { + const createWrapper = ({ provide = {} } = {}) => { wrapper = shallowMountExtended(CreateMenu, { + provide: { + isImpersonating: false, + ...provide, + }, propsData: { groups: createNewMenuGroups, }, @@ -90,4 +94,13 @@ describe('CreateMenu component', () => { expect(findGlTooltip().exists()).toBe(true); }); }); + + it('decreases the dropdown offset when impersonating a user', () => { + createWrapper({ provide: { isImpersonating: true } }); + + expect(findGlDisclosureDropdown().props('dropdownOffset')).toEqual({ + crossAxis: -115, + mainAxis: 4, + }); + }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/command_palette/command_palette_items_spec.js b/spec/frontend/super_sidebar/components/global_search/command_palette/command_palette_items_spec.js index 21d085dc0fb..85eb7e2e241 100644 --- a/spec/frontend/super_sidebar/components/global_search/command_palette/command_palette_items_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/command_palette/command_palette_items_spec.js @@ -6,18 +6,22 @@ import CommandPaletteItems from '~/super_sidebar/components/global_search/comman import { COMMAND_HANDLE, USERS_GROUP_TITLE, + PATH_GROUP_TITLE, USER_HANDLE, + PATH_HANDLE, SEARCH_SCOPE, + MAX_ROWS, } from '~/super_sidebar/components/global_search/command_palette/constants'; import { commandMapper, linksReducer, + fileMapper, } from '~/super_sidebar/components/global_search/command_palette/utils'; import { getFormattedItem } from '~/super_sidebar/components/global_search/utils'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import waitForPromises from 'helpers/wait_for_promises'; -import { COMMANDS, LINKS, USERS } from './mock_data'; +import { COMMANDS, LINKS, USERS, FILES } from './mock_data'; const links = LINKS.reduce(linksReducer, []); @@ -25,6 +29,8 @@ describe('CommandPaletteItems', () => { let wrapper; const autocompletePath = '/autocomplete'; const searchContext = { project: { id: 1 }, group: { id: 2 } }; + const projectFilesPath = 'project/files/path'; + const projectBlobPath = '/blob/main'; const createComponent = (props) => { wrapper = shallowMount(CommandPaletteItems, { @@ -42,6 +48,8 @@ describe('CommandPaletteItems', () => { commandPaletteLinks: LINKS, autocompletePath, searchContext, + projectFilesPath, + projectBlobPath, }, }); }; @@ -50,7 +58,7 @@ describe('CommandPaletteItems', () => { const findGroups = () => wrapper.findAllComponents(GlDisclosureDropdownGroup); const findLoader = () => wrapper.findComponent(GlLoadingIcon); - describe('COMMANDS & LINKS', () => { + describe('Commands and links', () => { it('renders all commands initially', () => { createComponent(); const commandGroup = COMMANDS.map(commandMapper)[0]; @@ -90,7 +98,7 @@ describe('CommandPaletteItems', () => { }); }); - describe('USERS, ISSUES, PROJECTS', () => { + describe('Users, issues, and projects', () => { let mockAxios; beforeEach(() => { @@ -140,4 +148,83 @@ describe('CommandPaletteItems', () => { expect(wrapper.text()).toBe('No results found'); }); }); + + describe('Project files', () => { + let mockAxios; + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + }); + + it('should request project files on first search', () => { + jest.spyOn(axios, 'get'); + const searchQuery = 'gitlab-ci.yml'; + createComponent({ handle: PATH_HANDLE, searchQuery }); + + expect(axios.get).toHaveBeenCalledWith(projectFilesPath); + expect(findLoader().exists()).toBe(true); + }); + + it(`should render all items when returned number of items is less than ${MAX_ROWS}`, async () => { + const numberOfItems = MAX_ROWS - 1; + const items = FILES.slice(0, numberOfItems).map(fileMapper.bind(null, projectBlobPath)); + mockAxios.onGet().replyOnce(HTTP_STATUS_OK, FILES.slice(0, numberOfItems)); + jest.spyOn(fuzzaldrinPlus, 'filter').mockReturnValue(items); + + const searchQuery = 'gitlab-ci.yml'; + createComponent({ handle: PATH_HANDLE, searchQuery }); + + await waitForPromises(); + + expect(findGroups().at(0).props('group')).toMatchObject({ + name: PATH_GROUP_TITLE, + items: items.slice(0, MAX_ROWS), + }); + + expect(findItems()).toHaveLength(numberOfItems); + }); + + it(`should render first ${MAX_ROWS} returned items when number of returned items exceeds ${MAX_ROWS}`, async () => { + const items = FILES.map(fileMapper.bind(null, projectBlobPath)); + mockAxios.onGet().replyOnce(HTTP_STATUS_OK, FILES); + jest.spyOn(fuzzaldrinPlus, 'filter').mockReturnValue(items); + + const searchQuery = 'gitlab-ci.yml'; + createComponent({ handle: PATH_HANDLE, searchQuery }); + + await waitForPromises(); + + expect(findItems()).toHaveLength(MAX_ROWS); + expect(findGroups().at(0).props('group')).toMatchObject({ + name: PATH_GROUP_TITLE, + items: items.slice(0, MAX_ROWS), + }); + }); + + it('should display no results message when no files matched the search query', async () => { + mockAxios.onGet().replyOnce(HTTP_STATUS_OK, []); + const searchQuery = 'gitlab-ci.yml'; + createComponent({ handle: PATH_HANDLE, searchQuery }); + await waitForPromises(); + expect(wrapper.text()).toBe('No results found'); + }); + + it('should not make additional server call on the search query change', async () => { + const searchQuery = 'gitlab-ci.yml'; + const newSearchQuery = 'package.json'; + + jest.spyOn(axios, 'get'); + + createComponent({ handle: PATH_HANDLE, searchQuery }); + + mockAxios.onGet().replyOnce(HTTP_STATUS_OK, FILES); + await waitForPromises(); + + expect(axios.get).toHaveBeenCalledTimes(1); + + await wrapper.setProps({ searchQuery: newSearchQuery }); + + expect(axios.get).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/command_palette/mock_data.js b/spec/frontend/super_sidebar/components/global_search/command_palette/mock_data.js index ec65a43d549..d01e5c85741 100644 --- a/spec/frontend/super_sidebar/components/global_search/command_palette/mock_data.js +++ b/spec/frontend/super_sidebar/components/global_search/command_palette/mock_data.js @@ -131,3 +131,46 @@ export const ISSUE = { project_name: 'Flight', url: '/flightjs/Flight/-/issues/37', }; + +export const FILES = [ + '.gitattributes', + '.gitignore', + '.gitmodules', + 'CHANGELOG', + 'CONTRIBUTING.md', + 'Gemfile.zip', + 'LICENSE', + 'MAINTENANCE.md', + 'PROCESS.md', + 'README', + 'README.md', + 'VERSION', + 'bar/branch-test.txt', + 'custom-highlighting/test.gitlab-custom', + 'encoding/feature-1.txt', + 'encoding/feature-2.txt', + 'encoding/hotfix-1.txt', + 'encoding/hotfix-2.txt', + 'encoding/iso8859.txt', + 'encoding/russian.rb', + 'encoding/test.txt', + 'encoding/テスト.txt', + 'encoding/テスト.xls', + 'files/flat/path/correct/content.txt', + 'files/html/500.html', + 'files/images/6049019_460s.jpg', + 'files/images/emoji.png', + 'files/images/logo-black.png', + 'files/images/logo-white.png', + 'files/images/wm.svg', + 'files/js/application.js', + 'files/js/commit.coffee', + 'files/lfs/lfs_object.iso', + 'files/markdown/ruby-style-guide.md', + 'files/ruby/popen.rb', + 'files/ruby/regex.rb', + 'files/ruby/version_info.rb', + 'files/whitespace', + 'foo/bar/.gitkeep', + 'with space/README.md', +]; diff --git a/spec/frontend/super_sidebar/components/global_search/command_palette/utils_spec.js b/spec/frontend/super_sidebar/components/global_search/command_palette/utils_spec.js index 0b75787723e..ebc52e2d910 100644 --- a/spec/frontend/super_sidebar/components/global_search/command_palette/utils_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/command_palette/utils_spec.js @@ -1,6 +1,7 @@ import { commandMapper, linksReducer, + fileMapper, } from '~/super_sidebar/components/global_search/command_palette/utils'; import { COMMANDS, LINKS, TRANSFORMED_LINKS } from './mock_data'; @@ -16,3 +17,15 @@ describe('commandMapper', () => { expect(COMMANDS.map(commandMapper)[0].items).toHaveLength(initialCommandsLength - 1); }); }); + +describe('fileMapper', () => { + it('should transform files', () => { + const file = 'file'; + const projectBlobPath = 'project/blob/path'; + expect(fileMapper(projectBlobPath, file)).toEqual({ + icon: 'doc-code', + text: file, + href: `${projectBlobPath}/${file}`, + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js index 9b7b9e288df..55108e116bd 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js @@ -12,6 +12,7 @@ import CommandPaletteItems from '~/super_sidebar/components/global_search/comman import { SEARCH_OR_COMMAND_MODE_PLACEHOLDER, COMMON_HANDLES, + PATH_HANDLE, } from '~/super_sidebar/components/global_search/command_palette/constants'; import { SEARCH_INPUT_DESCRIPTION, @@ -20,8 +21,6 @@ import { ICON_GROUP, ICON_SUBGROUP, SCOPE_TOKEN_MAX_LENGTH, - IS_SEARCHING, - SEARCH_SHORTCUTS_MIN_CHARACTERS, } from '~/super_sidebar/components/global_search/constants'; import { SEARCH_GITLAB } from '~/vue_shared/global_search/constants'; import { truncate } from '~/lib/utils/text_utility'; @@ -33,7 +32,6 @@ import { MOCK_USERNAME, MOCK_DEFAULT_SEARCH_OPTIONS, MOCK_SCOPED_SEARCH_OPTIONS, - MOCK_SEARCH_CONTEXT_FULL, MOCK_PROJECT, MOCK_GROUP, } from '../mock_data'; @@ -108,7 +106,6 @@ describe('GlobalSearchModal', () => { const findGlobalSearchModal = () => wrapper.findComponent(GlModal); - const findGlobalSearchForm = () => wrapper.findByTestId('global-search-form'); const findGlobalSearchInput = () => wrapper.findComponent(GlSearchBoxByType); const findScopeToken = () => wrapper.findComponent(GlToken); const findGlobalSearchDefaultItems = () => wrapper.findComponent(GlobalSearchDefaultItems); @@ -203,103 +200,70 @@ describe('GlobalSearchModal', () => { describe('input box', () => { describe.each` - search | searchOptions | hasToken - ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[0]]} | ${true} - ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[1]]} | ${true} - ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[2]]} | ${true} - ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[3]]} | ${true} - ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[4]]} | ${true} - ${'te'} | ${[MOCK_SCOPED_SEARCH_OPTIONS[5]]} | ${false} - ${'x'} | ${[]} | ${false} - `('token', ({ search, searchOptions, hasToken }) => { + search | hasToken + ${MOCK_SEARCH} | ${true} + ${'te'} | ${false} + ${'x'} | ${false} + ${''} | ${false} + `('token', ({ search, hasToken }) => { beforeEach(() => { window.gon.current_username = MOCK_USERNAME; - createComponent( - { search }, - { - searchOptions: () => searchOptions, - }, - ); + createComponent({ search }); findGlobalSearchInput().vm.$emit('click'); }); - it(`${hasToken ? 'is' : 'is NOT'} rendered when data set has type "${ - searchOptions[0]?.html_id - }"`, () => { + it(`${hasToken ? 'is' : 'is NOT'} rendered when search query is "${search}"`, () => { expect(findScopeToken().exists()).toBe(hasToken); }); - - it(`text ${hasToken ? 'is correctly' : 'is NOT'} rendered when text is "${ - searchOptions[0]?.scope || searchOptions[0]?.description - }"`, () => { - expect(findScopeToken().exists() && findScopeToken().text()).toBe( - formatScopeName(searchOptions[0]?.scope || searchOptions[0]?.description), - ); - }); }); - }); - describe('form', () => { - describe.each` - searchContext | search | searchOptions - ${MOCK_SEARCH_CONTEXT_FULL} | ${null} | ${[]} - ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${[]} - ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} - ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} - ${null} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} - ${null} | ${null} | ${MOCK_SCOPED_SEARCH_OPTIONS} - ${null} | ${null} | ${[]} - `('wrapper', ({ searchContext, search, searchOptions }) => { + describe.each(MOCK_SCOPED_SEARCH_OPTIONS)('token content', (searchOption) => { beforeEach(() => { window.gon.current_username = MOCK_USERNAME; - createComponent({ search, searchContext }, { searchOptions: () => searchOptions }); + createComponent( + { search: MOCK_SEARCH }, + { + searchOptions: () => [searchOption], + }, + ); + findGlobalSearchInput().vm.$emit('click'); }); - const isSearching = search?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS; - - it(`classes ${isSearching ? 'contain' : 'do not contain'} "${IS_SEARCHING}"`, () => { - if (isSearching) { - expect(findGlobalSearchForm().classes()).toContain(IS_SEARCHING); - return; - } - if (!isSearching) { - expect(findGlobalSearchForm().classes()).not.toContain(IS_SEARCHING); + it(`is correctly rendered`, () => { + if (searchOption.scope) { + expect(findScopeToken().text()).toBe(formatScopeName(searchOption.scope)); + } else { + expect(findScopeToken().text()).toBe(formatScopeName(searchOption.description)); } }); }); - }); - describe.each` - search | searchOptions | hasIcon | iconName - ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[0]]} | ${true} | ${ICON_PROJECT} - ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[2]]} | ${true} | ${ICON_GROUP} - ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[3]]} | ${true} | ${ICON_SUBGROUP} - ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[4]]} | ${false} | ${false} - `('token', ({ search, searchOptions, hasIcon, iconName }) => { - beforeEach(() => { - window.gon.current_username = MOCK_USERNAME; - createComponent( - { search }, - { - searchOptions: () => searchOptions, - }, - ); - findGlobalSearchInput().vm.$emit('click'); - }); - - it(`icon for data set type "${searchOptions[0]?.html_id}" ${ - hasIcon ? 'is' : 'is NOT' - } rendered`, () => { - expect(findScopeToken().findComponent(GlIcon).exists()).toBe(hasIcon); - }); + describe.each` + searchOptions | iconName + ${[MOCK_SCOPED_SEARCH_OPTIONS[0]]} | ${ICON_PROJECT} + ${[MOCK_SCOPED_SEARCH_OPTIONS[2]]} | ${ICON_GROUP} + ${[MOCK_SCOPED_SEARCH_OPTIONS[3]]} | ${ICON_SUBGROUP} + ${[MOCK_SCOPED_SEARCH_OPTIONS[4]]} | ${false} + `('token', ({ searchOptions, iconName }) => { + beforeEach(() => { + window.gon.current_username = MOCK_USERNAME; + createComponent( + { search: MOCK_SEARCH }, + { + searchOptions: () => searchOptions, + }, + ); + findGlobalSearchInput().vm.$emit('click'); + }); - it(`render ${iconName ? `"${iconName}"` : 'NO'} icon for data set type "${ - searchOptions[0]?.html_id - }"`, () => { - expect( - findScopeToken().findComponent(GlIcon).exists() && - findScopeToken().findComponent(GlIcon).attributes('name'), - ).toBe(iconName); + it(`renders ${iconName ? `"${iconName}"` : 'NO'} icon for "${ + searchOptions[0]?.text + }" scope`, () => { + expect( + findScopeToken().findComponent(GlIcon).exists() && + findScopeToken().findComponent(GlIcon).attributes('name'), + ).toBe(iconName); + }); }); }); @@ -319,7 +283,7 @@ describe('GlobalSearchModal', () => { }); }); - describe.each(COMMON_HANDLES)( + describe.each([...COMMON_HANDLES, PATH_HANDLE])( 'when FF `command_palette` is enabled and search handle is %s', (handle) => { beforeEach(() => { @@ -338,6 +302,10 @@ describe('GlobalSearchModal', () => { SEARCH_OR_COMMAND_MODE_PLACEHOLDER, ); }); + + it('should not render the scope token', () => { + expect(findScopeToken().exists()).toBe(false); + }); }, ); }); @@ -389,33 +357,41 @@ describe('GlobalSearchModal', () => { }); describe('Submitting a search', () => { - beforeEach(() => { - createComponent(); - }); - - it('onKey-enter submits a search', () => { + const submitSearch = () => findGlobalSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); - expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY); - }); - - describe('with less than min characters', () => { + describe('in command mode', () => { beforeEach(() => { - createComponent({ search: 'x' }); + createComponent({ search: '>' }, undefined, undefined, { + commandPalette: true, + }); + submitSearch(); }); - it('onKey-enter will NOT submit a search', () => { - findGlobalSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); + it('does not submit a search', () => { + expect(visitUrl).not.toHaveBeenCalled(); + }); + }); + describe('in search mode', () => { + it('will NOT submit a search with less than min characters', () => { + createComponent({ search: 'x' }); + submitSearch(); expect(visitUrl).not.toHaveBeenCalledWith(MOCK_SEARCH_QUERY); }); + + it('will submit a search with the sufficient number of characters', () => { + createComponent(); + submitSearch(); + expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY); + }); }); }); }); describe('Modal events', () => { beforeEach(() => { - createComponent(); + createComponent({ search: 'searchQuery' }); }); it('should emit `shown` event when modal shown`', () => { @@ -423,9 +399,10 @@ describe('GlobalSearchModal', () => { expect(wrapper.emitted('shown')).toHaveLength(1); }); - it('should emit `hidden` event when modal hidden`', () => { - findGlobalSearchModal().vm.$emit('hidden'); + it('should emit `hidden` event when modal hidden and clear the search input', () => { + findGlobalSearchModal().vm.$emit('hide'); expect(wrapper.emitted('hidden')).toHaveLength(1); + expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), ''); }); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/mock_data.js b/spec/frontend/super_sidebar/components/global_search/mock_data.js index 0884fce567c..ad7e7b0b30b 100644 --- a/spec/frontend/super_sidebar/components/global_search/mock_data.js +++ b/spec/frontend/super_sidebar/components/global_search/mock_data.js @@ -62,20 +62,6 @@ export const MOCK_SEARCH_CONTEXT = { group_metadata: {}, }; -export const MOCK_SEARCH_CONTEXT_FULL = { - group: { - id: 31, - name: 'testGroup', - full_name: 'testGroup', - }, - group_metadata: { - group_path: 'testGroup', - name: 'testGroup', - issues_path: '/groups/testGroup/-/issues', - mr_path: '/groups/testGroup/-/merge_requests', - }, -}; - export const MOCK_DEFAULT_SEARCH_OPTIONS = [ { text: MSG_ISSUES_ASSIGNED_TO_ME, diff --git a/spec/frontend/super_sidebar/components/help_center_spec.js b/spec/frontend/super_sidebar/components/help_center_spec.js index 6af1172e4d8..c92f8a68678 100644 --- a/spec/frontend/super_sidebar/components/help_center_spec.js +++ b/spec/frontend/super_sidebar/components/help_center_spec.js @@ -104,7 +104,7 @@ describe('HelpCenter component', () => { createWrapper({ ...sidebarData, show_tanuki_bot: true }); }); - it('shows Ask GitLab Chat with the help items', () => { + it('shows Ask GitLab Duo with the help items', () => { expect(findDropdownGroup(0).props('group').items).toEqual([ expect.objectContaining({ icon: 'tanuki-ai', @@ -115,9 +115,9 @@ describe('HelpCenter component', () => { ]); }); - describe('when Ask GitLab Chat button is clicked', () => { + describe('when Ask GitLab Duo button is clicked', () => { beforeEach(() => { - findButton('Ask GitLab Chat').click(); + findButton('Ask GitLab Duo').click(); }); it('sets helpCenterState.showTanukiBotChatDrawer to true', () => { diff --git a/spec/frontend/super_sidebar/components/sidebar_peek_behavior_spec.js b/spec/frontend/super_sidebar/components/sidebar_peek_behavior_spec.js index 047dc9a6599..abd9c1dc44d 100644 --- a/spec/frontend/super_sidebar/components/sidebar_peek_behavior_spec.js +++ b/spec/frontend/super_sidebar/components/sidebar_peek_behavior_spec.js @@ -9,6 +9,7 @@ import SidebarPeek, { STATE_OPEN, STATE_WILL_CLOSE, } from '~/super_sidebar/components/sidebar_peek_behavior.vue'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; // These are measured at runtime in the browser, but statically defined here // since Jest does not do layout/styling. @@ -32,6 +33,7 @@ jest.mock('~/lib/utils/css_utils', () => ({ describe('SidebarPeek component', () => { let wrapper; + let trackingSpy = null; const createComponent = () => { wrapper = mount(SidebarPeek); @@ -54,6 +56,11 @@ describe('SidebarPeek component', () => { beforeEach(() => { createComponent(); + trackingSpy = mockTracking(undefined, undefined, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); }); it('begins in the closed state', () => { @@ -87,6 +94,11 @@ describe('SidebarPeek component', () => { jest.advanceTimersByTime(1); expect(lastNChangeEvents(2)).toEqual([STATE_WILL_OPEN, STATE_OPEN]); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'nav_peek', { + label: 'nav_hover', + property: 'nav_sidebar', + }); }); it('cancels transition will-open -> open if mouse out of peek region', () => { diff --git a/spec/frontend/super_sidebar/components/super_sidebar_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_spec.js index b76c637caf4..0c785109b5e 100644 --- a/spec/frontend/super_sidebar/components/super_sidebar_spec.js +++ b/spec/frontend/super_sidebar/components/super_sidebar_spec.js @@ -19,6 +19,7 @@ import { isCollapsed, } from '~/super_sidebar/super_sidebar_collapsed_state_manager'; import { stubComponent } from 'helpers/stub_component'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { sidebarData as mockSidebarData } from '../mock_data'; const initialSidebarState = { ...sidebarState }; @@ -49,6 +50,7 @@ describe('SuperSidebar component', () => { const findTrialStatusWidget = () => wrapper.findByTestId(trialStatusWidgetStubTestId); const findTrialStatusPopover = () => wrapper.findByTestId(trialStatusPopoverStubTestId); const findSidebarMenu = () => wrapper.findComponent(SidebarMenu); + let trackingSpy = null; const createWrapper = ({ provide = {}, @@ -77,6 +79,11 @@ describe('SuperSidebar component', () => { beforeEach(() => { Object.assign(sidebarState, initialSidebarState); + trackingSpy = mockTracking(undefined, undefined, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); }); describe('default', () => { @@ -143,12 +150,20 @@ describe('SuperSidebar component', () => { expect(toggleSuperSidebarCollapsed).toHaveBeenCalledTimes(1); expect(toggleSuperSidebarCollapsed).toHaveBeenCalledWith(true, true); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'nav_hide', { + label: 'nav_toggle_keyboard_shortcut', + property: 'nav_sidebar', + }); isCollapsed.mockReturnValue(true); Mousetrap.trigger('mod+\\'); expect(toggleSuperSidebarCollapsed).toHaveBeenCalledTimes(2); expect(toggleSuperSidebarCollapsed).toHaveBeenCalledWith(false, true); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'nav_show', { + label: 'nav_toggle_keyboard_shortcut', + property: 'nav_sidebar', + }); jest.spyOn(Mousetrap, 'unbind'); diff --git a/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js index 8bb20186e16..23b735c2773 100644 --- a/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js +++ b/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js @@ -7,6 +7,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { JS_TOGGLE_COLLAPSE_CLASS, JS_TOGGLE_EXPAND_CLASS } from '~/super_sidebar/constants'; import SuperSidebarToggle from '~/super_sidebar/components/super_sidebar_toggle.vue'; import { toggleSuperSidebarCollapsed } from '~/super_sidebar/super_sidebar_collapsed_state_manager'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; jest.mock('~/super_sidebar/super_sidebar_collapsed_state_manager.js', () => ({ toggleSuperSidebarCollapsed: jest.fn(), @@ -61,7 +62,7 @@ describe('SuperSidebarToggle component', () => { }); }); - describe('toolip', () => { + describe('tooltip', () => { it('displays collapse when expanded', () => { createWrapper(); expect(getTooltip().title).toBe(__('Hide sidebar')); @@ -74,15 +75,19 @@ describe('SuperSidebarToggle component', () => { }); describe('toggle', () => { + let trackingSpy = null; + beforeEach(() => { setHTMLFixture(` <button class="${JS_TOGGLE_COLLAPSE_CLASS}">Hide</button> <button class="${JS_TOGGLE_EXPAND_CLASS}">Show</button> `); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); afterEach(() => { resetHTMLFixture(); + unmockTracking(); }); it('collapses the sidebar and focuses the other toggle', async () => { @@ -93,6 +98,10 @@ describe('SuperSidebarToggle component', () => { expect(document.activeElement).toEqual( document.querySelector(`.${JS_TOGGLE_COLLAPSE_CLASS}`), ); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'nav_hide', { + label: 'nav_toggle', + property: 'nav_sidebar', + }); }); it('expands the sidebar and focuses the other toggle', async () => { @@ -101,6 +110,10 @@ describe('SuperSidebarToggle component', () => { await nextTick(); expect(toggleSuperSidebarCollapsed).toHaveBeenCalledWith(false, true); expect(document.activeElement).toEqual(document.querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`)); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'nav_show', { + label: 'nav_toggle', + property: 'nav_sidebar', + }); }); }); }); diff --git a/spec/frontend/super_sidebar/components/user_bar_spec.js b/spec/frontend/super_sidebar/components/user_bar_spec.js index ae48c0f2a75..272e0237219 100644 --- a/spec/frontend/super_sidebar/components/user_bar_spec.js +++ b/spec/frontend/super_sidebar/components/user_bar_spec.js @@ -7,7 +7,6 @@ import CreateMenu from '~/super_sidebar/components/create_menu.vue'; import SearchModal from '~/super_sidebar/components/global_search/components/global_search.vue'; import BrandLogo from 'jh_else_ce/super_sidebar/components/brand_logo.vue'; import MergeRequestMenu from '~/super_sidebar/components/merge_request_menu.vue'; -import Counter from '~/super_sidebar/components/counter.vue'; import UserBar from '~/super_sidebar/components/user_bar.vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import waitForPromises from 'helpers/wait_for_promises'; @@ -19,10 +18,9 @@ describe('UserBar component', () => { let wrapper; const findCreateMenu = () => wrapper.findComponent(CreateMenu); - const findCounter = (at) => wrapper.findAllComponents(Counter).at(at); - const findIssuesCounter = () => findCounter(0); - const findMRsCounter = () => findCounter(1); - const findTodosCounter = () => findCounter(2); + const findIssuesCounter = () => wrapper.findByTestId('issues-shortcut-button'); + const findMRsCounter = () => wrapper.findByTestId('merge-requests-shortcut-button'); + const findTodosCounter = () => wrapper.findByTestId('todos-shortcut-button'); const findMergeRequestMenu = () => wrapper.findComponent(MergeRequestMenu); const findBrandLogo = () => wrapper.findComponent(BrandLogo); const findCollapseButton = () => wrapper.findByTestId('super-sidebar-collapse-button'); diff --git a/spec/frontend/super_sidebar/components/user_menu_spec.js b/spec/frontend/super_sidebar/components/user_menu_spec.js index f0f18ca9185..662677be40f 100644 --- a/spec/frontend/super_sidebar/components/user_menu_spec.js +++ b/spec/frontend/super_sidebar/components/user_menu_spec.js @@ -20,7 +20,7 @@ describe('UserMenu component', () => { const closeDropdownSpy = jest.fn(); - const createWrapper = (userDataChanges = {}, stubs = {}) => { + const createWrapper = (userDataChanges = {}, stubs = {}, provide = {}) => { wrapper = mountExtended(UserMenu, { propsData: { data: { @@ -35,6 +35,8 @@ describe('UserMenu component', () => { }, provide: { toggleNewNavEndpoint, + isImpersonating: false, + ...provide, }, }); @@ -50,6 +52,15 @@ describe('UserMenu component', () => { }); }); + it('decreases the dropdown offset when impersonating a user', () => { + createWrapper(null, null, { isImpersonating: true }); + + expect(findDropdown().props('dropdownOffset')).toEqual({ + crossAxis: -179, + mainAxis: 4, + }); + }); + describe('Toggle button', () => { let toggle; diff --git a/spec/frontend/super_sidebar/components/user_name_group_spec.js b/spec/frontend/super_sidebar/components/user_name_group_spec.js index 6e3b18d3107..bd02f3c17e3 100644 --- a/spec/frontend/super_sidebar/components/user_name_group_spec.js +++ b/spec/frontend/super_sidebar/components/user_name_group_spec.js @@ -91,7 +91,7 @@ describe('UserNameGroup component', () => { }); it('should render status message', () => { - expect(findUserStatus().text()).toContain(userMenuMockData.status.message); + expect(findUserStatus().html()).toContain(userMenuMockData.status.message_html); }); it("sets the tooltip's target to the status container", () => { diff --git a/spec/frontend/super_sidebar/mock_data.js b/spec/frontend/super_sidebar/mock_data.js index a3a74f7aac8..72c67e34038 100644 --- a/spec/frontend/super_sidebar/mock_data.js +++ b/spec/frontend/super_sidebar/mock_data.js @@ -126,6 +126,7 @@ export const userMenuMockStatus = { customized: false, emoji: 'art', message: 'Working on user menu in super sidebar', + message_html: '<gl-emoji></gl-emoji> Working on user menu in super sidebar', availability: 'busy', clear_after: '2023-02-09 20:06:35 UTC', }; diff --git a/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js b/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js index 771d1f07fea..9388d837186 100644 --- a/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js +++ b/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js @@ -11,8 +11,10 @@ import { findPage, bindSuperSidebarCollapsedEvents, } from '~/super_sidebar/super_sidebar_collapsed_state_manager'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; const { xl, sm } = breakpoints; +let trackingSpy = null; jest.mock('~/lib/utils/common_utils', () => ({ getCookie: jest.fn(), @@ -27,6 +29,15 @@ const pageHasCollapsedClass = (hasClass) => { } }; +const tracksCollapse = (shouldTrack) => { + if (shouldTrack) { + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'nav_hide', { + label: 'browser_resize', + property: 'nav_sidebar', + }); + } +}; + describe('Super Sidebar Collapsed State Manager', () => { beforeEach(() => { setHTMLFixture(` @@ -34,10 +45,12 @@ describe('Super Sidebar Collapsed State Manager', () => { <aside class="super-sidebar"></aside> </div> `); + trackingSpy = mockTracking(undefined, undefined, jest.spyOn); }); afterEach(() => { resetHTMLFixture(); + unmockTracking(); }); describe('toggleSuperSidebarCollapsed', () => { @@ -109,14 +122,20 @@ describe('Super Sidebar Collapsed State Manager', () => { }); it.each` - initialWindowWidth | updatedWindowWidth | hasClassBeforeResize | hasClassAfterResize - ${xl} | ${sm} | ${false} | ${true} - ${sm} | ${xl} | ${true} | ${false} - ${xl} | ${xl} | ${false} | ${false} - ${sm} | ${sm} | ${true} | ${true} + initialWindowWidth | updatedWindowWidth | hasClassBeforeResize | hasClassAfterResize | sendsTrackingEvent + ${xl} | ${sm} | ${false} | ${true} | ${true} + ${sm} | ${xl} | ${true} | ${false} | ${false} + ${xl} | ${xl} | ${false} | ${false} | ${false} + ${sm} | ${sm} | ${true} | ${true} | ${false} `( 'when changing width from $initialWindowWidth to $updatedWindowWidth expect page to have collapsed class before resize to be $hasClassBeforeResize and after resize to be $hasClassAfterResize', - ({ initialWindowWidth, updatedWindowWidth, hasClassBeforeResize, hasClassAfterResize }) => { + ({ + initialWindowWidth, + updatedWindowWidth, + hasClassBeforeResize, + hasClassAfterResize, + sendsTrackingEvent, + }) => { getCookie.mockReturnValue(undefined); window.innerWidth = initialWindowWidth; initSuperSidebarCollapsedState(); @@ -129,6 +148,7 @@ describe('Super Sidebar Collapsed State Manager', () => { window.dispatchEvent(new Event('resize')); pageHasCollapsedClass(hasClassAfterResize); + tracksCollapse(sendsTrackingEvent); }, ); }); diff --git a/spec/frontend/tags/components/delete_tag_modal_spec.js b/spec/frontend/tags/components/delete_tag_modal_spec.js index 8ec9925563a..5a3104fad9b 100644 --- a/spec/frontend/tags/components/delete_tag_modal_spec.js +++ b/spec/frontend/tags/components/delete_tag_modal_spec.js @@ -11,6 +11,9 @@ let wrapper; const tagName = 'test-tag'; const path = '/path/to/tag'; const isProtected = false; +const modalHideSpy = jest.fn(); +const modalShowSpy = jest.fn(); +const formSubmitSpy = jest.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(); const createComponent = (data = {}) => { wrapper = extendedWrapper( @@ -27,6 +30,10 @@ const createComponent = (data = {}) => { GlModal: stubComponent(GlModal, { template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>', + methods: { + hide: modalHideSpy, + show: modalShowSpy, + }, }), GlButton, GlFormInput, @@ -61,32 +68,26 @@ describe('Delete tag modal', () => { }); it('submits the form when the delete button is clicked', () => { - const submitFormSpy = jest.spyOn(wrapper.vm.$refs.form, 'submit'); - findDeleteButton().trigger('click'); expect(findForm().attributes('action')).toBe(path); - expect(submitFormSpy).toHaveBeenCalled(); + expect(formSubmitSpy).toHaveBeenCalledTimes(1); }); it('calls show on the modal when a `openModal` event is received through the event hub', () => { - const showSpy = jest.spyOn(wrapper.vm.$refs.modal, 'show'); - eventHub.$emit('openModal', { isProtected, tagName, path, }); - expect(showSpy).toHaveBeenCalled(); + expect(modalShowSpy).toHaveBeenCalled(); }); it('calls hide on the modal when cancel button is clicked', () => { - const closeModalSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide'); - findCancelButton().trigger('click'); - expect(closeModalSpy).toHaveBeenCalled(); + expect(modalHideSpy).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/token_access/outbound_token_access_spec.js b/spec/frontend/token_access/outbound_token_access_spec.js index 7f321495d72..f9eb201eb5c 100644 --- a/spec/frontend/token_access/outbound_token_access_spec.js +++ b/spec/frontend/token_access/outbound_token_access_spec.js @@ -6,7 +6,6 @@ import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_help import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; import OutboundTokenAccess from '~/token_access/components/outbound_token_access.vue'; -import addProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/add_project_ci_job_token_scope.mutation.graphql'; import removeProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql'; import updateCIJobTokenScopeMutation from '~/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql'; import getCIJobTokenScopeQuery from '~/token_access/graphql/queries/get_ci_job_token_scope.query.graphql'; @@ -15,7 +14,6 @@ import { enabledJobTokenScope, disabledJobTokenScope, projectsWithScope, - addProjectSuccess, removeProjectSuccess, updateScopeSuccess, } from './mock_data'; @@ -34,16 +32,13 @@ describe('TokenAccess component', () => { const enabledJobTokenScopeHandler = jest.fn().mockResolvedValue(enabledJobTokenScope); const disabledJobTokenScopeHandler = jest.fn().mockResolvedValue(disabledJobTokenScope); const getProjectsWithScopeHandler = jest.fn().mockResolvedValue(projectsWithScope); - const addProjectSuccessHandler = jest.fn().mockResolvedValue(addProjectSuccess); const removeProjectSuccessHandler = jest.fn().mockResolvedValue(removeProjectSuccess); const updateScopeSuccessHandler = jest.fn().mockResolvedValue(updateScopeSuccess); const failureHandler = jest.fn().mockRejectedValue(error); const findToggle = () => wrapper.findComponent(GlToggle); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findAddProjectBtn = () => wrapper.findByRole('button', { name: 'Add project' }); const findRemoveProjectBtn = () => wrapper.findByRole('button', { name: 'Remove access' }); - const findTokenDisabledAlert = () => wrapper.findByTestId('token-disabled-alert'); const findDeprecationAlert = () => wrapper.findByTestId('deprecation-alert'); const findProjectPathInput = () => wrapper.findByTestId('project-path-input'); @@ -51,19 +46,10 @@ describe('TokenAccess component', () => { return createMockApollo(requestHandlers); }; - const createComponent = ( - requestHandlers, - mountFn = shallowMountExtended, - frozenOutboundJobTokenScopes = false, - frozenOutboundJobTokenScopesOverride = false, - ) => { + const createComponent = (requestHandlers, mountFn = shallowMountExtended) => { wrapper = mountFn(OutboundTokenAccess, { provide: { fullPath: projectPath, - glFeatures: { - frozenOutboundJobTokenScopes, - frozenOutboundJobTokenScopesOverride, - }, }, apolloProvider: createMockApolloProvider(requestHandlers), data() { @@ -141,19 +127,6 @@ describe('TokenAccess component', () => { await waitForPromises(); expect(findToggle().props('value')).toBe(true); - expect(findTokenDisabledAlert().exists()).toBe(false); - }); - - it('the toggle is off and the alert is visible', async () => { - createComponent([ - [getCIJobTokenScopeQuery, disabledJobTokenScopeHandler], - [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScopeHandler], - ]); - - await waitForPromises(); - - expect(findToggle().props('value')).toBe(false); - expect(findTokenDisabledAlert().exists()).toBe(true); }); describe('update ci job token scope', () => { @@ -196,48 +169,37 @@ describe('TokenAccess component', () => { expect(createAlert).toHaveBeenCalledWith({ message }); }); }); - }); - describe('add project', () => { - it('calls add project mutation', async () => { + it('the toggle is off and the deprecation alert is visible', async () => { createComponent( [ - [getCIJobTokenScopeQuery, enabledJobTokenScopeHandler], + [getCIJobTokenScopeQuery, disabledJobTokenScopeHandler], [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScopeHandler], - [addProjectCIJobTokenScopeMutation, addProjectSuccessHandler], ], - mountExtended, + shallowMountExtended, + true, ); await waitForPromises(); - findAddProjectBtn().trigger('click'); - - expect(addProjectSuccessHandler).toHaveBeenCalledWith({ - input: { - projectPath, - targetProjectPath: 'root/test', - }, - }); + expect(findToggle().props('value')).toBe(false); + expect(findToggle().props('disabled')).toBe(true); + expect(findDeprecationAlert().exists()).toBe(true); }); - it('add project handles error correctly', async () => { + it('contains a warning message about disabling the current configuration', async () => { createComponent( [ - [getCIJobTokenScopeQuery, enabledJobTokenScopeHandler], + [getCIJobTokenScopeQuery, disabledJobTokenScopeHandler], [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScopeHandler], - [addProjectCIJobTokenScopeMutation, failureHandler], ], mountExtended, + true, ); await waitForPromises(); - findAddProjectBtn().trigger('click'); - - await waitForPromises(); - - expect(createAlert).toHaveBeenCalledWith({ message }); + expect(findToggle().text()).toContain('Disabling this feature is a permanent change.'); }); }); @@ -284,58 +246,21 @@ describe('TokenAccess component', () => { }); }); - describe('with the frozenOutboundJobTokenScopes feature flag enabled', () => { - describe('toggle', () => { - it('the toggle is off and the deprecation alert is visible', async () => { - createComponent( - [ - [getCIJobTokenScopeQuery, disabledJobTokenScopeHandler], - [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScopeHandler], - ], - shallowMountExtended, - true, - ); - - await waitForPromises(); - - expect(findToggle().props('value')).toBe(false); - expect(findToggle().props('disabled')).toBe(true); - expect(findDeprecationAlert().exists()).toBe(true); - expect(findTokenDisabledAlert().exists()).toBe(false); - }); - - it('contains a warning message about disabling the current configuration', async () => { - createComponent( - [ - [getCIJobTokenScopeQuery, disabledJobTokenScopeHandler], - [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScopeHandler], - ], - mountExtended, - true, - ); - - await waitForPromises(); - - expect(findToggle().text()).toContain('Disabling this feature is a permanent change.'); - }); - }); - - describe('adding a new project', () => { - it('disables the input to add new projects', async () => { - createComponent( - [ - [getCIJobTokenScopeQuery, disabledJobTokenScopeHandler], - [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScopeHandler], - ], - mountExtended, - true, - false, - ); + describe('adding a new project', () => { + it('disables the input to add new projects', async () => { + createComponent( + [ + [getCIJobTokenScopeQuery, disabledJobTokenScopeHandler], + [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScopeHandler], + ], + mountExtended, + true, + false, + ); - await waitForPromises(); + await waitForPromises(); - expect(findProjectPathInput().attributes('disabled')).toBe('disabled'); - }); + expect(findProjectPathInput().attributes('disabled')).toBe('disabled'); }); }); }); diff --git a/spec/frontend/tracing/components/tracing_empty_state_spec.js b/spec/frontend/tracing/components/tracing_empty_state_spec.js new file mode 100644 index 00000000000..c3df187e1c5 --- /dev/null +++ b/spec/frontend/tracing/components/tracing_empty_state_spec.js @@ -0,0 +1,44 @@ +import { GlButton, GlEmptyState } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import TracingEmptyState from '~/tracing/components/tracing_empty_state.vue'; + +describe('TracingEmptyState', () => { + let wrapper; + + const findEnableButton = () => wrapper.findComponent(GlButton); + + beforeEach(() => { + wrapper = shallowMountExtended(TracingEmptyState, { + propsData: { + enableTracing: jest.fn(), + }, + stubs: { GlButton }, + }); + }); + + it('renders the component properly', () => { + expect(wrapper.exists()).toBe(true); + }); + + it('displays the correct title', () => { + const { title } = wrapper.findComponent(GlEmptyState).props(); + expect(title).toBe('Get started with Tracing'); + }); + + it('displays the correct description', () => { + const description = wrapper.find('span').text(); + expect(description).toBe('Monitor your applications with GitLab Distributed Tracing.'); + }); + + it('displays the enable button', () => { + const enableButton = findEnableButton(); + expect(enableButton.exists()).toBe(true); + expect(enableButton.text()).toBe('Enable'); + }); + + it('calls enableTracing method when enable button is clicked', () => { + findEnableButton().vm.$emit('click'); + + expect(wrapper.props().enableTracing).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/tracing/components/tracing_list_spec.js b/spec/frontend/tracing/components/tracing_list_spec.js new file mode 100644 index 00000000000..183578cff31 --- /dev/null +++ b/spec/frontend/tracing/components/tracing_list_spec.js @@ -0,0 +1,131 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import TracingList from '~/tracing/components/tracing_list.vue'; +import TracingEmptyState from '~/tracing/components/tracing_empty_state.vue'; +import TracingTableList from '~/tracing/components/tracing_table_list.vue'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; + +jest.mock('~/alert'); + +describe('TracingList', () => { + let wrapper; + let observabilityClientMock; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findEmptyState = () => wrapper.findComponent(TracingEmptyState); + const findTableList = () => wrapper.findComponent(TracingTableList); + + const mountComponent = async () => { + wrapper = shallowMountExtended(TracingList, { + propsData: { + observabilityClient: observabilityClientMock, + stubs: { + GlLoadingIcon: true, + TracingEmptyState: true, + TracingTableList: true, + }, + }, + }); + await waitForPromises(); + }; + + beforeEach(() => { + observabilityClientMock = { + isTracingEnabled: jest.fn(), + enableTraces: jest.fn(), + fetchTraces: jest.fn(), + }; + }); + + it('renders the loading indicator while checking if tracing is enabled', () => { + mountComponent(); + expect(findLoadingIcon().exists()).toBe(true); + expect(observabilityClientMock.isTracingEnabled).toHaveBeenCalled(); + }); + + describe('when tracing is enabled', () => { + const mockTraces = ['trace1', 'trace2']; + beforeEach(async () => { + observabilityClientMock.isTracingEnabled.mockResolvedValueOnce(true); + observabilityClientMock.fetchTraces.mockResolvedValueOnce(mockTraces); + + await mountComponent(); + }); + it('fetches the traces and renders the trace list', () => { + expect(observabilityClientMock.isTracingEnabled).toHaveBeenCalled(); + expect(observabilityClientMock.fetchTraces).toHaveBeenCalled(); + expect(findLoadingIcon().exists()).toBe(false); + expect(findEmptyState().exists()).toBe(false); + expect(findTableList().exists()).toBe(true); + expect(findTableList().props('traces')).toBe(mockTraces); + }); + + it('calls fetchTraces method when TracingTableList emits reload event', () => { + observabilityClientMock.fetchTraces.mockClear(); + observabilityClientMock.fetchTraces.mockResolvedValueOnce(['trace1']); + + findTableList().vm.$emit('reload'); + + expect(observabilityClientMock.fetchTraces).toHaveBeenCalledTimes(1); + }); + }); + + describe('when tracing is not enabled', () => { + beforeEach(async () => { + observabilityClientMock.isTracingEnabled.mockResolvedValueOnce(false); + observabilityClientMock.fetchTraces.mockResolvedValueOnce([]); + + await mountComponent(); + }); + + it('renders TracingEmptyState', () => { + expect(findEmptyState().exists()).toBe(true); + }); + + it('set enableTracing as TracingEmptyState enable-tracing callback', () => { + findEmptyState().props('enableTracing')(); + + expect(observabilityClientMock.enableTraces).toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('if isTracingEnabled fails, it renders an alert and empty page', async () => { + observabilityClientMock.isTracingEnabled.mockRejectedValueOnce('error'); + + await mountComponent(); + + expect(createAlert).toHaveBeenCalledWith({ message: 'Failed to load page.' }); + expect(findLoadingIcon().exists()).toBe(false); + expect(findEmptyState().exists()).toBe(false); + expect(findTableList().exists()).toBe(false); + }); + + it('if fetchTraces fails, it renders an alert and empty list', async () => { + observabilityClientMock.fetchTraces.mockRejectedValueOnce('error'); + observabilityClientMock.isTracingEnabled.mockReturnValueOnce(true); + + await mountComponent(); + + expect(createAlert).toHaveBeenCalledWith({ message: 'Failed to load traces.' }); + expect(findTableList().exists()).toBe(true); + expect(findTableList().props('traces')).toEqual([]); + }); + + it('if enableTraces fails, it renders an alert and empty-state', async () => { + observabilityClientMock.isTracingEnabled.mockReturnValueOnce(false); + observabilityClientMock.enableTraces.mockRejectedValueOnce('error'); + + await mountComponent(); + + findEmptyState().props('enableTracing')(); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ message: 'Failed to enable tracing.' }); + expect(findLoadingIcon().exists()).toBe(false); + expect(findEmptyState().exists()).toBe(true); + expect(findTableList().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/tracing/components/tracing_table_list_spec.js b/spec/frontend/tracing/components/tracing_table_list_spec.js new file mode 100644 index 00000000000..773b3eb8ed2 --- /dev/null +++ b/spec/frontend/tracing/components/tracing_table_list_spec.js @@ -0,0 +1,63 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import TracingTableList from '~/tracing/components/tracing_table_list.vue'; + +describe('TracingTableList', () => { + let wrapper; + const mockTraces = [ + { + timestamp: '2023-07-10T15:02:30.677538Z', + service_name: 'tracegen', + operation: 'lets-go', + duration: 150, + }, + { + timestamp: '2023-07-10T15:02:30.677538Z', + service_name: 'tracegen', + operation: 'lets-go', + duration: 200, + }, + ]; + + const mountComponent = ({ traces = mockTraces } = {}) => { + wrapper = mountExtended(TracingTableList, { + propsData: { + traces, + }, + }); + }; + + const getRows = () => wrapper.findComponent({ name: 'GlTable' }).find('tbody').findAll('tr'); + + const getCells = (trIdx) => getRows().at(trIdx).findAll('td'); + + const getCell = (trIdx, tdIdx) => { + return getCells(trIdx).at(tdIdx); + }; + + it('renders traces as table', () => { + mountComponent(); + + const rows = wrapper.findAll('table tbody tr'); + + expect(rows.length).toBe(mockTraces.length); + + mockTraces.forEach((trace, i) => { + expect(getCells(i).length).toBe(4); + expect(getCell(i, 0).text()).toBe(trace.timestamp); + expect(getCell(i, 1).text()).toBe(trace.service_name); + expect(getCell(i, 2).text()).toBe(trace.operation); + expect(getCell(i, 3).text()).toBe(`${trace.duration} ms`); + }); + }); + + it('renders the empty state when no traces are provided', () => { + mountComponent({ traces: [] }); + + expect(getCell(0, 0).text()).toContain('No traces to display'); + const link = getCell(0, 0).findComponent({ name: 'GlLink' }); + expect(link.text()).toBe('Check again'); + + link.trigger('click'); + expect(wrapper.emitted('reload')).toHaveLength(1); + }); +}); diff --git a/spec/frontend/tracing/list_index_spec.js b/spec/frontend/tracing/list_index_spec.js new file mode 100644 index 00000000000..a5759035c2f --- /dev/null +++ b/spec/frontend/tracing/list_index_spec.js @@ -0,0 +1,37 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ListIndex from '~/tracing/list_index.vue'; +import TracingList from '~/tracing/components/tracing_list.vue'; +import ObservabilityContainer from '~/observability/components/observability_container.vue'; + +describe('ListIndex', () => { + const props = { + oauthUrl: 'https://example.com/oauth', + tracingUrl: 'https://example.com/tracing', + provisioningUrl: 'https://example.com/provisioning', + }; + + let wrapper; + + const mountComponent = () => { + wrapper = shallowMountExtended(ListIndex, { + propsData: props, + }); + }; + + it('renders ObservabilityContainer component', () => { + mountComponent(); + + const observabilityContainer = wrapper.findComponent(ObservabilityContainer); + expect(observabilityContainer.exists()).toBe(true); + expect(observabilityContainer.props('oauthUrl')).toBe(props.oauthUrl); + expect(observabilityContainer.props('tracingUrl')).toBe(props.tracingUrl); + expect(observabilityContainer.props('provisioningUrl')).toBe(props.provisioningUrl); + }); + + it('renders TracingList component inside ObservabilityContainer', () => { + mountComponent(); + + const observabilityContainer = wrapper.findComponent(ObservabilityContainer); + expect(observabilityContainer.findComponent(TracingList).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/tracking/internal_events_spec.js b/spec/frontend/tracking/internal_events_spec.js new file mode 100644 index 00000000000..ad2ffa7cef4 --- /dev/null +++ b/spec/frontend/tracking/internal_events_spec.js @@ -0,0 +1,100 @@ +import API from '~/api'; +import { mockTracking } from 'helpers/tracking_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import InternalEvents from '~/tracking/internal_events'; +import { GITLAB_INTERNAL_EVENT_CATEGORY, SERVICE_PING_SCHEMA } from '~/tracking/constants'; +import * as utils from '~/tracking/utils'; +import { Tracker } from '~/tracking/tracker'; + +jest.mock('~/api', () => ({ + trackRedisHllUserEvent: jest.fn(), +})); + +jest.mock('~/tracking/utils', () => ({ + ...jest.requireActual('~/tracking/utils'), + getInternalEventHandlers: jest.fn(), +})); + +Tracker.enabled = jest.fn(); + +describe('InternalEvents', () => { + describe('track_event', () => { + it('track_event calls trackRedisHllUserEvent with correct arguments', () => { + const event = 'TestEvent'; + + InternalEvents.track_event(event); + + expect(API.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); + expect(API.trackRedisHllUserEvent).toHaveBeenCalledWith(event); + }); + + it('track_event calls tracking.event functions with correct arguments', () => { + const trackingSpy = mockTracking(GITLAB_INTERNAL_EVENT_CATEGORY, undefined, jest.spyOn); + + const event = 'TestEvent'; + + InternalEvents.track_event(event); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(GITLAB_INTERNAL_EVENT_CATEGORY, event, { + context: { + schema: SERVICE_PING_SCHEMA, + data: { + event_name: event, + data_source: 'redis_hll', + }, + }, + }); + }); + }); + + describe('mixin', () => { + let wrapper; + + beforeEach(() => { + const Component = { + render() {}, + mixins: [InternalEvents.mixin()], + }; + wrapper = shallowMountExtended(Component); + }); + + it('this.track_event function calls InternalEvent`s track function with an event', () => { + const event = 'TestEvent'; + const trackEventSpy = jest.spyOn(InternalEvents, 'track_event'); + + wrapper.vm.track_event(event); + + expect(trackEventSpy).toHaveBeenCalledTimes(1); + expect(trackEventSpy).toHaveBeenCalledWith(event); + }); + }); + + describe('bindInternalEventDocument', () => { + it('should not bind event handlers if tracker is not enabled', () => { + Tracker.enabled.mockReturnValue(false); + const result = InternalEvents.bindInternalEventDocument(); + expect(result).toEqual([]); + expect(utils.getInternalEventHandlers).not.toHaveBeenCalled(); + }); + + it('should not bind event handlers if already bound', () => { + Tracker.enabled.mockReturnValue(true); + document.internalEventsTrackingBound = true; + const result = InternalEvents.bindInternalEventDocument(); + expect(result).toEqual([]); + expect(utils.getInternalEventHandlers).not.toHaveBeenCalled(); + }); + + it('should bind event handlers when not bound yet', () => { + Tracker.enabled.mockReturnValue(true); + document.internalEventsTrackingBound = false; + const addEventListenerMock = jest.spyOn(document, 'addEventListener'); + + const result = InternalEvents.bindInternalEventDocument(); + + expect(addEventListenerMock).toHaveBeenCalledWith('click', expect.any(Function)); + expect(result).toEqual({ name: 'click', func: expect.any(Function) }); + }); + }); +}); diff --git a/spec/frontend/tracking/tracking_spec.js b/spec/frontend/tracking/tracking_spec.js index c23790bb589..55ce8039399 100644 --- a/spec/frontend/tracking/tracking_spec.js +++ b/spec/frontend/tracking/tracking_spec.js @@ -59,7 +59,6 @@ describe('Tracking', () => { window.doNotTrack = undefined; navigator.doNotTrack = undefined; navigator.msDoNotTrack = undefined; - jest.clearAllMocks(); }); it('tracks to snowplow (our current tracking system)', () => { diff --git a/spec/frontend/tracking/utils_spec.js b/spec/frontend/tracking/utils_spec.js index d6f2c5095b4..7ba65cce15d 100644 --- a/spec/frontend/tracking/utils_spec.js +++ b/spec/frontend/tracking/utils_spec.js @@ -4,6 +4,8 @@ import { addExperimentContext, addReferrersCacheEntry, filterOldReferrersCacheEntries, + InternalEventHandler, + createInternalEventPayload, } from '~/tracking/utils'; import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; import { REFERRER_TTL, URLS_CACHE_STORAGE_KEY } from '~/tracking/constants'; @@ -95,5 +97,40 @@ describe('~/tracking/utils', () => { expect(cache[0].timestamp).toBeDefined(); }); }); + + describe('createInternalEventPayload', () => { + it('should return event name from element', () => { + const mockEl = { dataset: { eventTracking: 'click' } }; + const result = createInternalEventPayload(mockEl); + expect(result).toEqual('click'); + }); + }); + + describe('InternalEventHandler', () => { + it.each([ + ['should call the provided function with the correct event payload', 'click', true], + [ + 'should not call the provided function if the closest matching element is not found', + null, + false, + ], + ])('%s', (_, payload, shouldCallFunc) => { + const mockFunc = jest.fn(); + const mockEl = payload ? { dataset: { eventTracking: payload } } : null; + const mockEvent = { + target: { + closest: jest.fn().mockReturnValue(mockEl), + }, + }; + + InternalEventHandler(mockEvent, mockFunc); + + if (shouldCallFunc) { + expect(mockFunc).toHaveBeenCalledWith(payload); + } else { + expect(mockFunc).not.toHaveBeenCalled(); + } + }); + }); }); }); diff --git a/spec/frontend/usage_quotas/storage/components/usage_graph_spec.js b/spec/frontend/usage_quotas/storage/components/usage_graph_spec.js index 2662711076b..7fef20c900e 100644 --- a/spec/frontend/usage_quotas/storage/components/usage_graph_spec.js +++ b/spec/frontend/usage_quotas/storage/components/usage_graph_spec.js @@ -19,7 +19,7 @@ function findStorageTypeUsagesSerialized() { .wrappers.map((wp) => wp.element.style.flex); } -describe('Storage Counter usage graph component', () => { +describe('UsageGraph', () => { beforeEach(() => { data = { rootStorageStatistics: { @@ -29,7 +29,6 @@ describe('Storage Counter usage graph component', () => { containerRegistrySize: 2500, lfsObjectsSize: 2000, buildArtifactsSize: 700, - pipelineArtifactsSize: 300, snippetsSize: 2000, storageSize: 17000, }, @@ -43,7 +42,6 @@ describe('Storage Counter usage graph component', () => { const { buildArtifactsSize, - pipelineArtifactsSize, lfsObjectsSize, packagesSize, containerRegistrySize, @@ -69,9 +67,6 @@ describe('Storage Counter usage graph component', () => { expect(types.at(6).text()).toMatchInterpolatedText( `Job artifacts ${numberToHumanSize(buildArtifactsSize)}`, ); - expect(types.at(7).text()).toMatchInterpolatedText( - `Pipeline artifacts ${numberToHumanSize(pipelineArtifactsSize)}`, - ); }); describe('when storage type is not used', () => { @@ -111,7 +106,6 @@ describe('Storage Counter usage graph component', () => { '0.11764705882352941', '0.11764705882352941', '0.041176470588235294', - '0.01764705882352941', ]); }); }); @@ -131,7 +125,6 @@ describe('Storage Counter usage graph component', () => { '0.11764705882352941', '0.11764705882352941', '0.041176470588235294', - '0.01764705882352941', ]); }); }); diff --git a/spec/frontend/usage_quotas/storage/mock_data.js b/spec/frontend/usage_quotas/storage/mock_data.js index 8a7f941151b..452fa83b9a7 100644 --- a/spec/frontend/usage_quotas/storage/mock_data.js +++ b/spec/frontend/usage_quotas/storage/mock_data.js @@ -5,7 +5,7 @@ export const mockEmptyResponse = { data: { project: null } }; export const projectData = { storage: { - totalUsage: '13.8 MiB', + totalUsage: '13.4 MiB', storageTypes: [ { storageType: { @@ -29,15 +29,6 @@ export const projectData = { }, { storageType: { - id: 'pipelineArtifacts', - name: 'Pipeline artifacts', - description: 'Pipeline artifacts created by CI/CD.', - helpPath: '/pipeline-artifacts', - }, - value: 400000, - }, - { - storageType: { id: 'lfsObjects', name: 'LFS', description: 'Audio samples, videos, datasets, and graphics.', @@ -93,7 +84,6 @@ export const projectHelpLinks = { containerRegistry: '/container_registry', usageQuotas: '/usage-quotas', buildArtifacts: '/build-artifacts', - pipelineArtifacts: '/pipeline-artifacts', lfsObjects: '/lsf-objects', packages: '/packages', repository: '/repository', diff --git a/spec/frontend/users/profile/actions/components/user_actions_app_spec.js b/spec/frontend/users/profile/actions/components/user_actions_app_spec.js new file mode 100644 index 00000000000..d27962440ee --- /dev/null +++ b/spec/frontend/users/profile/actions/components/user_actions_app_spec.js @@ -0,0 +1,38 @@ +import { GlDisclosureDropdown } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import UserActionsApp from '~/users/profile/actions/components/user_actions_app.vue'; + +describe('User Actions App', () => { + let wrapper; + + const USER_ID = 'test-id'; + + const createWrapper = (propsData = {}) => { + wrapper = mountExtended(UserActionsApp, { + propsData: { + userId: USER_ID, + ...propsData, + }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findActions = () => wrapper.findAllByTestId('disclosure-dropdown-item'); + const findAction = (position = 0) => findActions().at(position); + + it('shows dropdown', () => { + createWrapper(); + expect(findDropdown().exists()).toBe(true); + }); + + it('shows actions correctly', () => { + createWrapper(); + expect(findActions()).toHaveLength(1); + }); + + it('shows copy user id action', () => { + createWrapper(); + expect(findAction().text()).toBe(`Copy user ID: ${USER_ID}`); + expect(findAction().findComponent('button').attributes('data-clipboard-text')).toBe(USER_ID); + }); +}); diff --git a/spec/frontend/vue_compat_test_setup.js b/spec/frontend/vue_compat_test_setup.js index 6eba9465c80..fe43f8f2617 100644 --- a/spec/frontend/vue_compat_test_setup.js +++ b/spec/frontend/vue_compat_test_setup.js @@ -76,9 +76,77 @@ if (global.document) { Vue.configureCompat(compatConfig); installVTUCompat(VTU, fullCompatConfig, compatH); + + jest.mock('vue', () => { + const actualVue = jest.requireActual('vue'); + actualVue.configureCompat(compatConfig); + return actualVue; + }); + + jest.mock('@vue/test-utils', () => { + const actualVTU = jest.requireActual('@vue/test-utils'); + + return { + ...actualVTU, + RouterLinkStub: { + ...actualVTU.RouterLinkStub, + render() { + const { default: defaultSlot } = this.$slots ?? {}; + const defaultSlotFn = + defaultSlot && typeof defaultSlot !== 'function' ? () => defaultSlot : defaultSlot; + return actualVTU.RouterLinkStub.render.call({ + $slots: defaultSlot ? { default: defaultSlotFn } : undefined, + custom: this.custom, + }); + }, + }, + }; + }); + + jest.mock('portal-vue', () => ({ + __esModule: true, + default: { + install: jest.fn(), + }, + Portal: {}, + PortalTarget: {}, + MountingPortal: { + template: '<h1>MOUNTING-PORTAL</h1>', + }, + Wormhole: {}, + })); + VTU.config.global.renderStubDefaultSlot = true; const noop = () => {}; + const invalidProperties = new Set(); + + const getDescriptor = (root, prop) => { + let obj = root; + while (obj != null) { + const desc = Object.getOwnPropertyDescriptor(obj, prop); + if (desc) { + return desc; + } + obj = Object.getPrototypeOf(obj); + } + return null; + }; + + const isPropertyValidOnDomNode = (prop) => { + if (invalidProperties.has(prop)) { + return false; + } + + const domNode = document.createElement('anonymous-stub'); + const descriptor = getDescriptor(domNode, prop); + if (descriptor && descriptor.get && !descriptor.set) { + invalidProperties.add(prop); + return false; + } + + return true; + }; VTU.config.plugins.createStubs = ({ name, component: rawComponent, registerStub }) => { const component = unwrapLegacyVueExtendComponent(rawComponent); @@ -126,7 +194,11 @@ if (global.document) { .filter(Boolean) : renderSlotByName('default'); - return Vue.h(`${hyphenatedName || 'anonymous'}-stub`, this.$props, slotContents); + const props = Object.fromEntries( + Object.entries(this.$props).filter(([prop]) => isPropertyValidOnDomNode(prop)), + ); + + return Vue.h(`${hyphenatedName || 'anonymous'}-stub`, props, slotContents); }, }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_commit_message_dropdown_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_commit_message_dropdown_spec.js index e4febda1daa..b0f9f123950 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_commit_message_dropdown_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_commit_message_dropdown_spec.js @@ -1,22 +1,22 @@ -import { GlDropdownItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; + import CommitMessageDropdown from '~/vue_merge_request_widget/components/states/commit_message_dropdown.vue'; const commits = [ { title: 'Commit 1', - short_id: '78d5b7', + shortId: '78d5b7', message: 'Update test.txt', }, { title: 'Commit 2', - short_id: '34cbe28b', + shortId: '34cbe28b', message: 'Fixed test', }, { title: 'Commit 3', - short_id: 'fa42932a', + shortId: 'fa42932a', message: 'Added changelog', }, ]; @@ -25,10 +25,14 @@ describe('Commits message dropdown component', () => { let wrapper; const createComponent = () => { - wrapper = shallowMount(CommitMessageDropdown, { + wrapper = mount(CommitMessageDropdown, { propsData: { commits, }, + stubs: { + GlDisclosureDropdown, + GlDisclosureDropdownItem, + }, }); }; @@ -36,7 +40,7 @@ describe('Commits message dropdown component', () => { createComponent(); }); - const findDropdownElements = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownElements = () => wrapper.findAllComponents(GlDisclosureDropdownItem); const findFirstDropdownElement = () => findDropdownElements().at(0); it('should have 3 elements in dropdown list', () => { @@ -48,10 +52,9 @@ describe('Commits message dropdown component', () => { expect(findFirstDropdownElement().text()).toContain('Commit 1'); }); - it('should emit a commit title on selecting commit', async () => { - findFirstDropdownElement().vm.$emit('click'); + it('should emit a commit title on selecting commit', () => { + findFirstDropdownElement().find('button').trigger('click'); - await nextTick(); expect(wrapper.emitted().input[0]).toEqual(['Update test.txt']); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_failed_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_failed_to_merge_spec.js index 38e5422325a..e1c88d7d3b6 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_failed_to_merge_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_failed_to_merge_spec.js @@ -1,4 +1,4 @@ -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import MrWidgetFailedToMerge from '~/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue'; import StateContainer from '~/vue_merge_request_widget/components/state_container.vue'; @@ -8,17 +8,14 @@ describe('MRWidgetFailedToMerge', () => { const dummyIntervalId = 1337; let wrapper; - const createComponent = (props = {}, data = {}) => { - wrapper = shallowMount(MrWidgetFailedToMerge, { + const createComponent = (props = {}, mountFn = shallowMount) => { + wrapper = mountFn(MrWidgetFailedToMerge, { propsData: { mr: { mergeError: 'Merge error happened', }, ...props, }, - data() { - return data; - }, }); }; @@ -121,7 +118,9 @@ describe('MRWidgetFailedToMerge', () => { describe('while it is refreshing', () => { it('renders Refresing now', async () => { - createComponent({}, { isRefreshing: true }); + createComponent({}); + + wrapper.vm.refresh(); await nextTick(); @@ -138,8 +137,10 @@ describe('MRWidgetFailedToMerge', () => { createComponent(); }); - it('renders warning icon and disabled merge button', () => { - expect(wrapper.find('.js-ci-status-icon-warning')).not.toBeNull(); + it('renders failed icon', () => { + createComponent({}, mount); + + expect(wrapper.find('[data-testid="status-failed-icon"]').exists()).toBe(true); }); it('renders given error', () => { diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js index 07fc0be9e51..48b86d879ad 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -58,7 +58,7 @@ const createTestMr = (customConfig) => { mergeImmediatelyDocsPath: 'path/to/merge/immediately/docs', transitionStateMachine: (transition) => eventHub.$emit('StateMachineValueChanged', transition), translateStateToMachine: () => this.transitionStateMachine(), - state: 'open', + state: 'readyToMerge', canMerge: true, mergeable: true, userPermissions: { @@ -113,11 +113,6 @@ const createComponent = (customConfig = {}, createState = true) => { GlSprintf, }, apolloProvider: createMockApollo([[readyToMergeQuery, readyToMergeResponseSpy]]), - provide: { - glFeatures: { - autoMergeLabelsMrWidget: false, - }, - }, }); }; @@ -144,6 +139,7 @@ const findDeleteSourceBranchCheckbox = () => const triggerApprovalUpdated = () => eventHub.$emit('ApprovalUpdated'); const triggerEditCommitInput = () => wrapper.find('[data-testid="widget_edit_commit_message"]').vm.$emit('input', true); +const findMergeHelperText = () => wrapper.find('[data-testid="auto-merge-helper-text"]'); describe('ReadyToMerge', () => { beforeEach(() => { @@ -185,47 +181,22 @@ describe('ReadyToMerge', () => { expect(wrapper.vm.status).toEqual('failed'); }); }); - - describe('status icon', () => { - it('defaults to tick icon', () => { - createComponent({ mr: { mergeable: true } }); - - expect(wrapper.vm.iconClass).toEqual('success'); - }); - - it('shows tick for success status', () => { - createComponent({ mr: { pipeline: { status: 'SUCCESS' }, mergeable: true } }); - - expect(wrapper.vm.iconClass).toEqual('success'); - }); - - it('shows tick for pending status', () => { - createComponent({ mr: { pipeline: { active: true }, mergeable: true } }); - - expect(wrapper.vm.iconClass).toEqual('success'); - }); - }); }); describe('merge button text', () => { it('should return "Merge" when no auto merge strategies are available', () => { - createComponent({ mr: { availableAutoMergeStrategies: [] } }); - - expect(findMergeButton().text()).toBe('Merge'); - }); - - it('should return "Merge when pipeline succeeds" when the MWPS auto merge strategy is available', () => { createComponent({ - mr: { preferredAutoMergeStrategy: MWPS_MERGE_STRATEGY }, + mr: { availableAutoMergeStrategies: [] }, }); - expect(findMergeButton().text()).toBe('Merge when pipeline succeeds'); + expect(findMergeButton().text()).toBe('Merge'); }); - it('should return Merge when pipeline succeeds', () => { + it('should return Set to auto-merge in the button and Merge when pipeline succeeds in the helper text', () => { createComponent({ mr: { preferredAutoMergeStrategy: MWPS_MERGE_STRATEGY } }); - expect(findMergeButton().text()).toBe('Merge when pipeline succeeds'); + expect(findMergeButton().text()).toBe('Set to auto-merge'); + expect(findMergeHelperText().text()).toBe('Merge when pipeline succeeds'); }); }); @@ -258,10 +229,10 @@ describe('ReadyToMerge', () => { expect(findMergeButton().props('disabled')).toBe(true); }); - it('should be disabled if merge is not allowed', () => { - createComponent({ mr: { preventMerge: true } }); + it('should not exist if merge is not allowed', () => { + createComponent({ mr: { state: 'checking' } }); - expect(findMergeButton().props('disabled')).toBe(true); + expect(findMergeButton().exists()).toBe(false); }); it('should be disabled when making request', async () => { @@ -321,7 +292,7 @@ describe('ReadyToMerge', () => { describe('Merge Button Variant', () => { it('defaults to confirm class', () => { createComponent({ - mr: { availableAutoMergeStrategies: [], mergeable: true }, + mr: { availableAutoMergeStrategies: [] }, }); expect(findMergeButton().attributes('variant')).toBe('confirm'); diff --git a/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap b/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap index 296d7924243..02d17b8dfd2 100644 --- a/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap +++ b/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap @@ -16,14 +16,16 @@ exports[`~/vue_merge_request_widget/components/widget/dynamic_content.vue render </div> <div class=\\"gl-display-flex gl-align-items-baseline\\"> <status-icon-stub level=\\"2\\" name=\\"MyWidget\\" iconname=\\"success\\"></status-icon-stub> - <div class=\\"gl-display-flex gl-flex-direction-column\\"> - <div> - <p class=\\"gl-mb-0\\">Main text for the row</p> - <gl-link-stub href=\\"https://gitlab.com\\">Optional link to display after text</gl-link-stub> - <!----> - <gl-badge-stub size=\\"md\\" variant=\\"info\\" iconsize=\\"md\\"> - Badge is optional. Text to be displayed inside badge - </gl-badge-stub> + <div class=\\"gl-w-full gl-display-flex\\"> + <div class=\\"gl-display-flex gl-flex-grow-1\\"> + <div class=\\"gl-display-flex gl-flex-grow-1 gl-flex-direction-column\\"> + <p class=\\"gl-mb-0 gl-mr-1\\">Main text for the row</p> + <gl-link-stub href=\\"https://gitlab.com\\">Optional link to display after text</gl-link-stub> + <!----> + <gl-badge-stub size=\\"md\\" variant=\\"info\\" iconsize=\\"md\\"> + Badge is optional. Text to be displayed inside badge + </gl-badge-stub> + </div> <actions-stub widget=\\"MyWidget\\" tertiarybuttons=\\"\\" class=\\"gl-ml-auto gl-pl-3\\"></actions-stub> <p class=\\"gl-m-0 gl-font-sm\\">Optional: Smaller sub-text to be displayed below the main text</p> </div> @@ -40,12 +42,14 @@ exports[`~/vue_merge_request_widget/components/widget/dynamic_content.vue render </div> <div class=\\"gl-display-flex gl-align-items-baseline\\"> <!----> - <div class=\\"gl-display-flex gl-flex-direction-column\\"> - <div> - <p class=\\"gl-mb-0\\">This is recursive. It will be listed in level 3.</p> - <!----> - <!----> - <!----> + <div class=\\"gl-w-full gl-display-flex\\"> + <div class=\\"gl-display-flex gl-flex-grow-1\\"> + <div class=\\"gl-display-flex gl-flex-grow-1 gl-flex-direction-column\\"> + <p class=\\"gl-mb-0 gl-mr-1\\">This is recursive. It will be listed in level 3.</p> + <!----> + <!----> + <!----> + </div> <actions-stub widget=\\"MyWidget\\" tertiarybuttons=\\"\\" class=\\"gl-ml-auto gl-pl-3\\"></actions-stub> <!----> </div> diff --git a/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js index 4972c522733..9343a3a5e90 100644 --- a/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js @@ -9,6 +9,7 @@ import ActionButtons from '~/vue_merge_request_widget/components/widget/action_b import Widget from '~/vue_merge_request_widget/components/widget/widget.vue'; import WidgetContentRow from '~/vue_merge_request_widget/components/widget/widget_content_row.vue'; import * as logger from '~/lib/logger'; +import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; jest.mock('~/vue_merge_request_widget/components/extensions/telemetry', () => ({ @@ -29,7 +30,7 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { const findHelpPopover = () => wrapper.findComponent(HelpPopover); const findDynamicScroller = () => wrapper.findByTestId('dynamic-content-scroller'); - const createComponent = ({ propsData, slots, mountFn = shallowMountExtended } = {}) => { + const createComponent = async ({ propsData, slots, mountFn = shallowMountExtended } = {}) => { wrapper = mountFn(Widget, { propsData: { isCollapsible: false, @@ -49,6 +50,8 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { ContentRow: WidgetContentRow, }, }); + + await axios.waitForAll(); }; describe('on mount', () => { @@ -105,9 +108,9 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { }, }); - expect(wrapper.text()).not.toContain('Loading'); - await nextTick(); expect(wrapper.text()).toContain('Loading'); + await axios.waitForAll(); + expect(wrapper.text()).not.toContain('Loading'); }); it('validates widget name', () => { @@ -185,10 +188,10 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { }); describe('content', () => { - it('displays summary property when summary slot is not provided', () => { - createComponent({ + it('displays summary property when summary slot is not provided', async () => { + await createComponent({ propsData: { - summary: 'Hello world', + summary: { title: 'Hello world' }, }, }); @@ -256,8 +259,8 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { }); describe('handle collapse toggle', () => { - it('displays the toggle button correctly', () => { - createComponent({ + it('displays the toggle button correctly', async () => { + await createComponent({ propsData: { isCollapsible: true, }, @@ -271,7 +274,7 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { }); it('does not display the content slot until toggle is clicked', async () => { - createComponent({ + await createComponent({ propsData: { isCollapsible: true, }, @@ -286,8 +289,8 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { expect(findExpandedSection().text()).toBe('More complex content'); }); - it('emits a toggle even when button is toggled', () => { - createComponent({ + it('emits a toggle even when button is toggled', async () => { + await createComponent({ propsData: { isCollapsible: true, }, @@ -301,8 +304,8 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { expect(wrapper.emitted('toggle')).toEqual([[{ expanded: true }]]); }); - it('does not display the toggle button if isCollapsible is false', () => { - createComponent({ + it('does not display the toggle button if isCollapsible is false', async () => { + await createComponent({ propsData: { isCollapsible: false, }, @@ -326,7 +329,7 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { const fetchExpandedData = jest.fn().mockResolvedValue(mockDataExpanded); - createComponent({ + await createComponent({ propsData: { isCollapsible: true, fetchCollapsedData: () => Promise.resolve(mockDataCollapsed), @@ -358,7 +361,7 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { it('allows refetching when fetch expanded data returns an error', async () => { const fetchExpandedData = jest.fn().mockRejectedValue({ error: true }); - createComponent({ + await createComponent({ propsData: { isCollapsible: true, fetchExpandedData, @@ -385,7 +388,7 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { it('resets the error message when another request is fetched', async () => { const fetchExpandedData = jest.fn().mockRejectedValue({ error: true }); - createComponent({ + await createComponent({ propsData: { isCollapsible: true, fetchExpandedData, @@ -465,8 +468,8 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { }, ]; - beforeEach(() => { - createComponent({ + beforeEach(async () => { + await createComponent({ mountFn: mountExtended, propsData: { isCollapsible: true, diff --git a/spec/frontend/vue_merge_request_widget/extentions/terraform/index_spec.js b/spec/frontend/vue_merge_request_widget/extentions/terraform/index_spec.js index 5baed8ff211..6aa12c37374 100644 --- a/spec/frontend/vue_merge_request_widget/extentions/terraform/index_spec.js +++ b/spec/frontend/vue_merge_request_widget/extentions/terraform/index_spec.js @@ -5,9 +5,7 @@ import api from '~/api'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import Poll from '~/lib/utils/poll'; -import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container'; -import { registerExtension } from '~/vue_merge_request_widget/components/extensions'; -import terraformExtension from '~/vue_merge_request_widget/extensions/terraform'; +import terraformExtension from '~/vue_merge_request_widget/extensions/terraform/index.vue'; import { plans, validPlanWithName, @@ -25,22 +23,20 @@ describe('Terraform extension', () => { const endpoint = '/path/to/terraform/report.json'; const findListItem = (at) => wrapper.findAllByTestId('extension-list-item').at(at); - - registerExtension(terraformExtension); + const findActionButton = (at) => wrapper.findAllByTestId('extension-actions-button').at(at); const mockPollingApi = (response, body, header) => { mock.onGet(endpoint).reply(response, body, header); }; const createComponent = () => { - wrapper = mountExtended(extensionsContainer, { + wrapper = mountExtended(terraformExtension, { propsData: { mr: { terraformReportsPath: endpoint, }, }, }); - return axios.waitForAll(); }; beforeEach(() => { @@ -54,24 +50,27 @@ describe('Terraform extension', () => { describe('summary', () => { describe('while loading', () => { const loadingText = 'Loading Terraform reports...'; + it('should render loading text', async () => { mockPollingApi(HTTP_STATUS_OK, plans, {}); createComponent(); expect(wrapper.text()).toContain(loadingText); + await waitForPromises(); expect(wrapper.text()).not.toContain(loadingText); }); }); describe('when the fetching fails', () => { - beforeEach(() => { + beforeEach(async () => { mockPollingApi(HTTP_STATUS_INTERNAL_SERVER_ERROR, null, {}); - return createComponent(); + createComponent(); + await axios.waitForAll(); }); - it('should generate one invalid plan and render correct summary text', () => { - expect(wrapper.text()).toContain('1 Terraform report failed to generate'); + it('should show the error text', () => { + expect(wrapper.text()).toContain('Failed to load Terraform reports'); }); }); @@ -82,9 +81,10 @@ describe('Terraform extension', () => { ${'2 valid reports'} | ${{ 0: validPlanWithName, 1: validPlanWithName }} | ${'2 Terraform reports were generated in your pipelines'} | ${''} ${'1 valid and 2 invalid reports'} | ${{ 0: validPlanWithName, 1: invalidPlanWithName, 2: invalidPlanWithName }} | ${'Terraform report was generated in your pipelines'} | ${'2 Terraform reports failed to generate'} `('and received $responseType', ({ response, summaryTitle, summarySubtitle }) => { - beforeEach(() => { + beforeEach(async () => { mockPollingApi(HTTP_STATUS_OK, response, {}); - return createComponent(); + createComponent(); + await axios.waitForAll(); }); it(`should render correct summary text`, () => { @@ -101,7 +101,8 @@ describe('Terraform extension', () => { describe('expanded data', () => { beforeEach(async () => { mockPollingApi(HTTP_STATUS_OK, plans, {}); - await createComponent(); + createComponent(); + await axios.waitForAll(); wrapper.findByTestId('toggle-button').trigger('click'); }); @@ -136,7 +137,7 @@ describe('Terraform extension', () => { api.trackRedisHllUserEvent.mockClear(); api.trackRedisCounterEvent.mockClear(); - findListItem(0).find('[data-testid="extension-actions-button"]').trigger('click'); + findActionButton(0).trigger('click'); expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( @@ -161,10 +162,10 @@ describe('Terraform extension', () => { }); describe('successful poll', () => { - beforeEach(() => { + beforeEach(async () => { mockPollingApi(HTTP_STATUS_OK, plans, {}); - - return createComponent(); + createComponent(); + await axios.waitForAll(); }); it('does not make additional requests after poll is successful', () => { @@ -173,13 +174,14 @@ describe('Terraform extension', () => { }); describe('polling fails', () => { - beforeEach(() => { + beforeEach(async () => { mockPollingApi(HTTP_STATUS_INTERNAL_SERVER_ERROR, null, {}); - return createComponent(); + createComponent(); + await axios.waitForAll(); }); - it('generates one broken plan', () => { - expect(wrapper.text()).toContain('1 Terraform report failed to generate'); + it('renders the error text', () => { + expect(wrapper.text()).toContain('Failed to load Terraform reports'); }); it('does not make additional requests after poll is unsuccessful', () => { diff --git a/spec/frontend/vue_merge_request_widget/mock_data.js b/spec/frontend/vue_merge_request_widget/mock_data.js index 47143bb2bb8..9da687c0ff8 100644 --- a/spec/frontend/vue_merge_request_widget/mock_data.js +++ b/spec/frontend/vue_merge_request_widget/mock_data.js @@ -188,7 +188,11 @@ export default { coverage: '92.16', path: '/root/acets-app/pipelines/172', details: { - artifacts, + artifacts: artifacts.map(({ text, url, ...rest }) => ({ + name: text, + path: url, + ...rest, + })), status: { icon: 'status_success', favicon: 'favicon_status_success', diff --git a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js index 0533471bece..ecb5a8448f9 100644 --- a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js @@ -1,5 +1,4 @@ import { GlBadge, GlLink, GlIcon, GlButton, GlDropdown } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; @@ -10,6 +9,7 @@ import getStateQueryResponse from 'test_fixtures/graphql/merge_requests/get_stat import readyToMergeResponse from 'test_fixtures/graphql/merge_requests/states/ready_to_merge.query.graphql.json'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import api from '~/api'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK, HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; @@ -28,6 +28,8 @@ import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue'; import Approvals from '~/vue_merge_request_widget/components/approvals/approvals.vue'; import Preparing from '~/vue_merge_request_widget/components/states/mr_widget_preparing.vue'; import WidgetContainer from '~/vue_merge_request_widget/components/widget/app.vue'; +import WidgetSuggestPipeline from '~/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue'; +import MrWidgetAlertMessage from '~/vue_merge_request_widget/components/mr_widget_alert_message.vue'; import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; import getStateQuery from '~/vue_merge_request_widget/queries/get_state.query.graphql'; import getStateSubscription from '~/vue_merge_request_widget/queries/get_state.subscription.graphql'; @@ -76,6 +78,9 @@ describe('MrWidgetOptions', () => { const COLLABORATION_MESSAGE = 'Members who can merge are allowed to add commits'; const findApprovalsWidget = () => wrapper.findComponent(Approvals); const findPreparingWidget = () => wrapper.findComponent(Preparing); + const findMergedPipelineContainer = () => wrapper.findByTestId('merged-pipeline-container'); + const findPipelineContainer = () => wrapper.findByTestId('pipeline-container'); + const findAlertMessage = () => wrapper.findComponent(MrWidgetAlertMessage); beforeEach(() => { gl.mrWidgetData = { ...mockData }; @@ -95,7 +100,12 @@ describe('MrWidgetOptions', () => { gl.mrWidgetData = {}; }); - const createComponent = (mrData = mockData, options = {}, data = {}, fullMount = true) => { + const createComponent = ({ + mrData = mockData, + options = {}, + data = {}, + mountFn = shallowMountExtended, + } = {}) => { const mockedApprovalsSubscription = createMockApolloSubscription(); queryResponse = { data: { @@ -114,7 +124,6 @@ describe('MrWidgetOptions', () => { stateQueryHandler = jest.fn().mockResolvedValue(queryResponse); stateSubscription = createMockApolloSubscription(); - const mounting = fullMount ? mount : shallowMount; const queryHandlers = [ [approvalsQuery, jest.fn().mockResolvedValue(approvedByCurrentUser)], [getStateQuery, stateQueryHandler], @@ -143,7 +152,7 @@ describe('MrWidgetOptions', () => { apolloProvider.defaultClient.setRequestHandler(query, stream); }); - wrapper = mounting(MrWidgetOptions, { + wrapper = mountFn(MrWidgetOptions, { propsData: { mrData: { ...mrData }, }, @@ -165,8 +174,7 @@ describe('MrWidgetOptions', () => { wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]'); const findExtensionLink = (linkHref) => wrapper.find(`[data-testid="widget-extension"] [href="${linkHref}"]`); - const findSuggestPipeline = () => wrapper.find('[data-testid="mr-suggest-pipeline"]'); - const findSuggestPipelineButton = () => findSuggestPipeline().find('button'); + const findSuggestPipeline = () => wrapper.findComponent(WidgetSuggestPipeline); const findWidgetContainer = () => wrapper.findComponent(WidgetContainer); describe('default', () => { @@ -175,7 +183,7 @@ describe('MrWidgetOptions', () => { return createComponent(); }); - // https://gitlab.com/gitlab-org/gitlab/-/issues/385238 + // quarantine: https://gitlab.com/gitlab-org/gitlab/-/issues/385238 // eslint-disable-next-line jest/no-disabled-tests describe.skip('data', () => { it('should instantiate Store and Service', () => { @@ -186,6 +194,7 @@ describe('MrWidgetOptions', () => { describe('computed', () => { describe('componentName', () => { + // quarantine: https://gitlab.com/gitlab-org/gitlab/-/issues/409365 // eslint-disable-next-line jest/no-disabled-tests it.skip.each` ${'merged'} | ${'mr-widget-merged'} @@ -206,60 +215,18 @@ describe('MrWidgetOptions', () => { }); }); - describe('shouldRenderPipelines', () => { - it('should return true when hasCI is true', () => { + describe('MrWidgetPipelineContainer', () => { + it('should return true when hasCI is true', async () => { wrapper.vm.mr.hasCI = true; - - expect(wrapper.vm.shouldRenderPipelines).toBe(true); + await nextTick(); + expect(findPipelineContainer().exists()).toBe(true); }); - it('should return false when hasCI is false', () => { + it('should return false when hasCI is false', async () => { wrapper.vm.mr.hasCI = false; + await nextTick(); - expect(wrapper.vm.shouldRenderPipelines).toBe(false); - }); - }); - - describe('shouldRenderSourceBranchRemovalStatus', () => { - beforeEach(() => { - wrapper.vm.mr.state = 'readyToMerge'; - }); - - it('should return true when cannot remove source branch and branch will be removed', () => { - wrapper.vm.mr.canRemoveSourceBranch = false; - wrapper.vm.mr.shouldRemoveSourceBranch = true; - - expect(wrapper.vm.shouldRenderSourceBranchRemovalStatus).toEqual(true); - }); - - it('should return false when can remove source branch and branch will be removed', () => { - wrapper.vm.mr.canRemoveSourceBranch = true; - wrapper.vm.mr.shouldRemoveSourceBranch = true; - - expect(wrapper.vm.shouldRenderSourceBranchRemovalStatus).toEqual(false); - }); - - it('should return false when cannot remove source branch and branch will not be removed', () => { - wrapper.vm.mr.canRemoveSourceBranch = false; - wrapper.vm.mr.shouldRemoveSourceBranch = false; - - expect(wrapper.vm.shouldRenderSourceBranchRemovalStatus).toEqual(false); - }); - - it('should return false when in merged state', () => { - wrapper.vm.mr.canRemoveSourceBranch = false; - wrapper.vm.mr.shouldRemoveSourceBranch = true; - wrapper.vm.mr.state = 'merged'; - - expect(wrapper.vm.shouldRenderSourceBranchRemovalStatus).toEqual(false); - }); - - it('should return false when in nothing to merge state', () => { - wrapper.vm.mr.canRemoveSourceBranch = false; - wrapper.vm.mr.shouldRemoveSourceBranch = true; - wrapper.vm.mr.state = 'nothingToMerge'; - - expect(wrapper.vm.shouldRenderSourceBranchRemovalStatus).toEqual(false); + expect(findPipelineContainer().exists()).toBe(false); }); }); @@ -320,7 +287,7 @@ describe('MrWidgetOptions', () => { }); it('should be false', () => { - expect(wrapper.vm.showMergePipelineForkWarning).toEqual(false); + expect(findAlertMessage().exists()).toBe(false); }); }); @@ -333,7 +300,7 @@ describe('MrWidgetOptions', () => { }); it('should be false', () => { - expect(wrapper.vm.showMergePipelineForkWarning).toEqual(false); + expect(findAlertMessage().exists()).toBe(false); }); }); @@ -346,22 +313,30 @@ describe('MrWidgetOptions', () => { }); it('should be true', () => { - expect(wrapper.vm.showMergePipelineForkWarning).toEqual(true); + expect(findAlertMessage().exists()).toBe(true); }); }); }); describe('formattedHumanAccess', () => { - it('when user is a tool admin but not a member of project', () => { + it('when user is a tool admin but not a member of project', async () => { wrapper.vm.mr.humanAccess = null; + wrapper.vm.mr.mergeRequestAddCiConfigPath = 'test'; + wrapper.vm.mr.hasCI = false; + wrapper.vm.mr.isDismissedSuggestPipeline = false; + await nextTick(); - expect(wrapper.vm.formattedHumanAccess).toEqual(''); + expect(findSuggestPipeline().props('humanAccess')).toBe(''); }); - it('when user a member of the project', () => { + it('when user a member of the project', async () => { wrapper.vm.mr.humanAccess = 'Owner'; + wrapper.vm.mr.mergeRequestAddCiConfigPath = 'test'; + wrapper.vm.mr.hasCI = false; + wrapper.vm.mr.isDismissedSuggestPipeline = false; + await nextTick(); - expect(wrapper.vm.formattedHumanAccess).toEqual('owner'); + expect(findSuggestPipeline().props('humanAccess')).toBe('owner'); }); }); }); @@ -570,10 +545,10 @@ describe('MrWidgetOptions', () => { beforeEach(() => { wrapper.destroy(); - return createComponent( - mockData, - {}, - { + return createComponent({ + mrData: mockData, + options: {}, + data: { pollInterval: interval, startingPollInterval: interval, mr: { @@ -584,8 +559,7 @@ describe('MrWidgetOptions', () => { checkStatus: mockCheckStatus, }, }, - false, - ); + }); }); describe('normal polling behavior', () => { @@ -653,7 +627,7 @@ describe('MrWidgetOptions', () => { environment_available: true, }; - beforeEach(() => { + it('renders multiple deployments', async () => { wrapper.vm.mr.deployments.push( { ...deploymentMockData, @@ -663,19 +637,10 @@ describe('MrWidgetOptions', () => { id: deploymentMockData.id + 1, }, ); - - return nextTick(); - }); - - it('renders multiple deployments', () => { - expect(wrapper.findAll('.deploy-heading').length).toBe(2); - }); - - it('renders dropdpown with multiple file changes', () => { - expect( - wrapper.find('.js-mr-wigdet-deployment-dropdown').findAll('.js-filtered-dropdown-result') - .length, - ).toEqual(changes.length); + await nextTick(); + expect(findPipelineContainer().props('isPostMerge')).toBe(false); + expect(findPipelineContainer().props('mr').deployments).toHaveLength(2); + expect(findPipelineContainer().props('mr').postMergeDeployments).toHaveLength(0); }); }); @@ -793,7 +758,7 @@ describe('MrWidgetOptions', () => { }); it('renders pipeline block', () => { - expect(wrapper.find('.js-post-merge-pipeline').exists()).toBe(true); + expect(findMergedPipelineContainer().exists()).toBe(true); }); describe('with post merge deployments', () => { @@ -833,7 +798,7 @@ describe('MrWidgetOptions', () => { }); it('renders post deployment information', () => { - expect(wrapper.find('.js-post-deployment').exists()).toBe(true); + expect(findMergedPipelineContainer().exists()).toBe(true); }); }); }); @@ -846,7 +811,7 @@ describe('MrWidgetOptions', () => { }); it('does not render pipeline block', () => { - expect(wrapper.find('.js-post-merge-pipeline').exists()).toBe(false); + expect(findMergedPipelineContainer().exists()).toBe(false); }); }); @@ -858,11 +823,7 @@ describe('MrWidgetOptions', () => { }); it('does not render pipeline block', () => { - expect(wrapper.find('.js-post-merge-pipeline').exists()).toBe(false); - }); - - it('does not render post deployment information', () => { - expect(wrapper.find('.js-post-deployment').exists()).toBe(false); + expect(findMergedPipelineContainer().exists()).toBe(false); }); }); }); @@ -880,7 +841,6 @@ describe('MrWidgetOptions', () => { describe('given feature flag is enabled', () => { beforeEach(async () => { await createComponent(); - wrapper.vm.mr.hasCI = false; }); @@ -901,7 +861,7 @@ describe('MrWidgetOptions', () => { }); it('should allow dismiss of the suggest pipeline message', async () => { - await findSuggestPipelineButton().trigger('click'); + await findSuggestPipeline().vm.$emit('dismiss'); expect(findSuggestPipeline().exists()).toBe(false); }); @@ -915,7 +875,7 @@ describe('MrWidgetOptions', () => { ${'merged'} | ${true} | ${'shows'} ${'open'} | ${true} | ${'shows'} `('$showText merge error when state is $state', async ({ state, show }) => { - createComponent({ ...mockData, state, mergeError: 'Error!' }); + createComponent({ mrData: { ...mockData, state, mergeError: 'Error!' } }); await waitForPromises(); @@ -927,7 +887,7 @@ describe('MrWidgetOptions', () => { beforeEach(() => { registerExtension(workingExtension()); - createComponent(); + createComponent({ mountFn: mountExtended }); }); afterEach(() => { @@ -987,7 +947,7 @@ describe('MrWidgetOptions', () => { it('shows collapse button', async () => { registerExtension(workingExtension(true)); - await createComponent(); + await createComponent({ mountFn: mountExtended }); expect(findExtensionToggleButton().exists()).toBe(true); }); @@ -1026,7 +986,7 @@ describe('MrWidgetOptions', () => { ]), ); - await createComponent(); + await createComponent({ mountFn: mountExtended }); expect(findWidgetTestExtension().html()).toContain( 'Multi polling test extension reports: parsed, count: 2', ); @@ -1048,7 +1008,7 @@ describe('MrWidgetOptions', () => { ]), ); - await createComponent(); + await createComponent({ mountFn: mountExtended }); expect(findWidgetTestExtension().html()).toContain('Test extension loading...'); }); }); @@ -1057,7 +1017,7 @@ describe('MrWidgetOptions', () => { it('does not make additional requests after poll is successful', async () => { registerExtension(pollingExtension); - await createComponent(); + await createComponent({ mountFn: mountExtended }); expect(pollRequest).toHaveBeenCalledTimes(1); }); @@ -1067,7 +1027,7 @@ describe('MrWidgetOptions', () => { it('sets data when polling is complete', async () => { registerExtension(pollingFullDataExtension); - await createComponent(); + await createComponent({ mountFn: mountExtended }); api.trackRedisHllUserEvent.mockClear(); api.trackRedisCounterEvent.mockClear(); @@ -1095,14 +1055,14 @@ describe('MrWidgetOptions', () => { describe('error', () => { it('does not make additional requests after poll has failed', async () => { registerExtension(pollingErrorExtension); - await createComponent(); + await createComponent({ mountFn: mountExtended }); expect(pollRequest).toHaveBeenCalledTimes(1); }); it('captures sentry error and displays error when poll has failed', async () => { registerExtension(pollingErrorExtension); - await createComponent(); + await createComponent({ mountFn: mountExtended }); expect(Sentry.captureException).toHaveBeenCalled(); expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Fetch error')); @@ -1118,7 +1078,7 @@ describe('MrWidgetOptions', () => { it('handles collapsed data fetch errors', async () => { registerExtension(collapsedDataErrorExtension); - await createComponent(); + await createComponent({ mountFn: mountExtended }); expect( wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]').exists(), @@ -1130,7 +1090,7 @@ describe('MrWidgetOptions', () => { it('handles full data fetch errors', async () => { registerExtension(fullDataErrorExtension); - await createComponent(); + await createComponent({ mountFn: mountExtended }); expect(wrapper.findComponent(StatusIcon).props('iconName')).not.toBe('error'); wrapper @@ -1153,7 +1113,7 @@ describe('MrWidgetOptions', () => { it('triggers view events when mounted', () => { registerExtension(workingExtension()); - createComponent(); + createComponent({ mountFn: mountExtended }); expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( @@ -1168,7 +1128,7 @@ describe('MrWidgetOptions', () => { describe('expand button', () => { it('triggers expand events when clicked', async () => { registerExtension(workingExtension()); - createComponent(); + createComponent({ mountFn: mountExtended }); await waitForPromises(); @@ -1197,7 +1157,7 @@ describe('MrWidgetOptions', () => { it('triggers the "full report clicked" events when the appropriate button is clicked', () => { registerExtension(fullReportExtension); - createComponent(); + createComponent({ mountFn: mountExtended }); api.trackRedisHllUserEvent.mockClear(); api.trackRedisCounterEvent.mockClear(); @@ -1221,7 +1181,7 @@ describe('MrWidgetOptions', () => { it("doesn't emit any telemetry events", async () => { registerExtension(noTelemetryExtension); - createComponent(); + createComponent({ mountFn: mountExtended }); await waitForPromises(); @@ -1249,7 +1209,7 @@ describe('MrWidgetOptions', () => { }); it('does not render the Preparing state component by default', async () => { - await createComponent(); + await createComponent({ mountFn: mountExtended }); expect(findApprovalsWidget().exists()).toBe(true); expect(findPreparingWidget().exists()).toBe(false); @@ -1257,9 +1217,11 @@ describe('MrWidgetOptions', () => { it('renders the Preparing state component when the MR state is initially "preparing"', async () => { await createComponent({ - ...mockData, - state: 'opened', - detailedMergeStatus: 'PREPARING', + mrData: { + ...mockData, + state: 'opened', + detailedMergeStatus: 'PREPARING', + }, }); expect(findApprovalsWidget().exists()).toBe(false); @@ -1272,31 +1234,29 @@ describe('MrWidgetOptions', () => { }); it("shows the Preparing widget when the MR reports it's not ready yet", async () => { - await createComponent( - { + await createComponent({ + mrData: { ...mockData, state: 'opened', detailedMergeStatus: 'PREPARING', }, - {}, - {}, - false, - ); + options: {}, + data: {}, + }); expect(wrapper.html()).toContain('mr-widget-preparing-stub'); }); it('removes the Preparing widget when the MR indicates it has been prepared', async () => { - await createComponent( - { + await createComponent({ + mrData: { ...mockData, state: 'opened', detailedMergeStatus: 'PREPARING', }, - {}, - {}, - false, - ); + options: {}, + data: {}, + }); expect(wrapper.html()).toContain('mr-widget-preparing-stub'); diff --git a/spec/frontend/vue_shared/alert_details/alert_management_sidebar_todo_spec.js b/spec/frontend/vue_shared/alert_details/alert_management_sidebar_todo_spec.js index 217103ab25c..cfd0d5bcf89 100644 --- a/spec/frontend/vue_shared/alert_details/alert_management_sidebar_todo_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_management_sidebar_todo_spec.js @@ -1,5 +1,7 @@ import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.mutation.graphql'; import SidebarTodo from '~/vue_shared/alert_details/components/sidebar/sidebar_todo.vue'; import createAlertTodoMutation from '~/vue_shared/alert_details/graphql/mutations/alert_todo_create.mutation.graphql'; @@ -9,41 +11,39 @@ const mockAlert = mockAlerts[0]; describe('Alert Details Sidebar To Do', () => { let wrapper; + let requestHandler; - function mountComponent({ data, sidebarCollapsed = true, loading = false, stubs = {} } = {}) { + const defaultHandler = { + createAlertTodo: jest.fn().mockResolvedValue({}), + markAsDone: jest.fn().mockResolvedValue({}), + }; + + const createMockApolloProvider = (handler) => { + Vue.use(VueApollo); + + requestHandler = handler; + + return createMockApollo([ + [todoMarkDoneMutation, handler.markAsDone], + [createAlertTodoMutation, handler.createAlertTodo], + ]); + }; + + function mountComponent({ data, sidebarCollapsed = true, handler = defaultHandler } = {}) { wrapper = mount(SidebarTodo, { + apolloProvider: createMockApolloProvider(handler), propsData: { alert: { ...mockAlert }, ...data, sidebarCollapsed, projectPath: 'projectPath', }, - mocks: { - $apollo: { - mutate: jest.fn(), - queries: { - alert: { - loading, - }, - }, - }, - }, - stubs, }); } const findToDoButton = () => wrapper.find('[data-testid="alert-todo-button"]'); describe('updating the alert to do', () => { - const mockUpdatedMutationResult = { - data: { - updateAlertTodo: { - errors: [], - alert: {}, - }, - }, - }; - describe('adding a todo', () => { beforeEach(() => { mountComponent({ @@ -60,18 +60,15 @@ describe('Alert Details Sidebar To Do', () => { }); it('calls `$apollo.mutate` with `createAlertTodoMutation` mutation and variables containing `iid`, `todoEvent`, & `projectPath`', async () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); - findToDoButton().trigger('click'); await nextTick(); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: createAlertTodoMutation, - variables: { + expect(requestHandler.createAlertTodo).toHaveBeenCalledWith( + expect.objectContaining({ iid: '1527542', projectPath: 'projectPath', - }, - }); + }), + ); }); }); @@ -91,17 +88,11 @@ describe('Alert Details Sidebar To Do', () => { }); it('calls `$apollo.mutate` with `todoMarkDoneMutation` mutation and variables containing `id`', async () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); - findToDoButton().trigger('click'); await nextTick(); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: todoMarkDoneMutation, - update: expect.anything(), - variables: { - id: '1234', - }, + expect(requestHandler.markAsDone).toHaveBeenCalledWith({ + id: '1234', }); }); }); diff --git a/spec/frontend/vue_shared/alert_details/alert_status_spec.js b/spec/frontend/vue_shared/alert_details/alert_status_spec.js index 98cb2f5cb0b..90d29f0bfd4 100644 --- a/spec/frontend/vue_shared/alert_details/alert_status_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_status_spec.js @@ -1,7 +1,9 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; import updateAlertStatusMutation from '~/graphql_shared//mutations/alert_status_update.mutation.graphql'; import Tracking from '~/tracking'; import AlertManagementStatus from '~/vue_shared/alert_details/components/alert_status.vue'; @@ -11,6 +13,27 @@ const mockAlert = mockAlerts[0]; describe('AlertManagementStatus', () => { let wrapper; + let requestHandler; + + const iid = '1527542'; + const mockUpdatedMutationResult = ({ errors = [], nodes = [] } = {}) => + jest.fn().mockResolvedValue({ + data: { + updateAlertStatus: { + errors, + alert: { + id: '1', + iid, + status: 'acknowledged', + endedAt: 'endedAt', + notes: { + nodes, + }, + }, + }, + }, + }); + const findStatusDropdown = () => wrapper.findComponent(GlDropdown); const findFirstStatusOption = () => findStatusDropdown().findComponent(GlDropdownItem); const findAllStatusOptions = () => findStatusDropdown().findAllComponents(GlDropdownItem); @@ -22,8 +45,20 @@ describe('AlertManagementStatus', () => { return waitForPromises(); }; - function mountComponent({ props = {}, provide = {}, loading = false, stubs = {} } = {}) { + const createMockApolloProvider = (handler) => { + Vue.use(VueApollo); + requestHandler = handler; + + return createMockApollo([[updateAlertStatusMutation, handler]]); + }; + + function mountComponent({ + props = {}, + provide = {}, + handler = mockUpdatedMutationResult(), + } = {}) { wrapper = shallowMountExtended(AlertManagementStatus, { + apolloProvider: createMockApolloProvider(handler), propsData: { alert: { ...mockAlert }, projectPath: 'gitlab-org/gitlab', @@ -31,17 +66,6 @@ describe('AlertManagementStatus', () => { ...props, }, provide, - mocks: { - $apollo: { - mutate: jest.fn(), - queries: { - alert: { - loading, - }, - }, - }, - }, - stubs, }); } @@ -63,43 +87,32 @@ describe('AlertManagementStatus', () => { }); describe('updating the alert status', () => { - const iid = '1527542'; - const mockUpdatedMutationResult = { - data: { - updateAlertStatus: { - errors: [], - alert: { - iid, - status: 'acknowledged', - }, - }, - }, - }; - beforeEach(() => { - mountComponent({}); + mountComponent(); }); - it('calls `$apollo.mutate` with `updateAlertStatus` mutation and variables containing `iid`, `status`, & `projectPath`', () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); + it('calls `$apollo.mutate` with `updateAlertStatus` mutation and variables containing `iid`, `status`, & `projectPath`', async () => { findFirstStatusOption().vm.$emit('click'); + await waitForPromises(); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: updateAlertStatusMutation, - variables: { - iid, - status: 'TRIGGERED', - projectPath: 'gitlab-org/gitlab', - }, + expect(requestHandler).toHaveBeenCalledWith({ + iid, + status: 'TRIGGERED', + projectPath: 'gitlab-org/gitlab', }); }); describe('when a request fails', () => { - beforeEach(() => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); + beforeEach(async () => { + mountComponent({ + handler: mockUpdatedMutationResult({ errors: ['<span data-testid="htmlError" />'] }), + }); + await waitForPromises(); }); it('emits an error', async () => { + mountComponent({ handler: jest.fn().mockRejectedValue({}) }); + await waitForPromises(); await selectFirstStatusOption(); expect(wrapper.emitted('alert-error')[0]).toEqual([ @@ -116,7 +129,6 @@ describe('AlertManagementStatus', () => { it('emits an error when triggered a second time', async () => { await selectFirstStatusOption(); - await nextTick(); await selectFirstStatusOption(); // Should emit two errors [0,1] expect(wrapper.emitted('alert-error').length > 1).toBe(true); @@ -124,19 +136,9 @@ describe('AlertManagementStatus', () => { }); it('shows an error when response includes HTML errors', async () => { - const mockUpdatedMutationErrorResult = { - data: { - updateAlertStatus: { - errors: ['<span data-testid="htmlError" />'], - alert: { - iid, - status: 'acknowledged', - }, - }, - }, - }; - - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationErrorResult); + mountComponent({ + handler: mockUpdatedMutationResult({ errors: ['<span data-testid="htmlError" />'] }), + }); await selectFirstStatusOption(); @@ -160,7 +162,7 @@ describe('AlertManagementStatus', () => { mountComponent({ props: { alert: { ...mockAlert, status }, statuses: { [status]: translatedStatus } }, }); - expect(findAllStatusOptions().length).toBe(1); + expect(findAllStatusOptions()).toHaveLength(1); expect(findFirstStatusOption().text()).toBe(translatedStatus); }); }); @@ -173,10 +175,10 @@ describe('AlertManagementStatus', () => { it('should not track alert status updates when the tracking options do not exist', async () => { mountComponent({}); Tracking.event.mockClear(); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({}); + findFirstStatusOption().vm.$emit('click'); - await nextTick(); + await waitForPromises(); expect(Tracking.event).not.toHaveBeenCalled(); }); @@ -187,12 +189,14 @@ describe('AlertManagementStatus', () => { action: 'update_alert_status', label: 'Status', }; - mountComponent({ provide: { trackAlertStatusUpdateOptions } }); + mountComponent({ + provide: { trackAlertStatusUpdateOptions }, + handler: mockUpdatedMutationResult({ nodes: mockAlerts }), + }); Tracking.event.mockClear(); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({}); findFirstStatusOption().vm.$emit('click'); - await nextTick(); + await waitForPromises(); const status = findFirstStatusOption().text(); const { category, action, label } = trackAlertStatusUpdateOptions; diff --git a/spec/frontend/vue_shared/components/actions_button_spec.js b/spec/frontend/vue_shared/components/actions_button_spec.js index e7663e2adb2..9f9a27c6997 100644 --- a/spec/frontend/vue_shared/components/actions_button_spec.js +++ b/spec/frontend/vue_shared/components/actions_button_spec.js @@ -31,12 +31,13 @@ const TEST_ACTION_2 = { describe('vue_shared/components/actions_button', () => { let wrapper; - function createComponent(props) { + function createComponent({ props = {}, slots = {} } = {}) { wrapper = shallowMountExtended(ActionsButton, { propsData: { actions: [TEST_ACTION, TEST_ACTION_2], toggleText: 'Edit', ...props }, stubs: { GlDisclosureDropdownItem, }, + slots, }); } const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); @@ -47,11 +48,29 @@ describe('vue_shared/components/actions_button', () => { expect(findDropdown().props().toggleText).toBe('Edit'); }); + it('dropdown has a fluid width', () => { + createComponent(); + + expect(findDropdown().props().fluidWidth).toBe(true); + }); + + it('provides a default slot', () => { + const slotContent = 'default text'; + + createComponent({ + slots: { + default: slotContent, + }, + }); + + expect(findDropdown().text()).toContain(slotContent); + }); + it('allows customizing variant and category', () => { const variant = 'confirm'; const category = 'secondary'; - createComponent({ variant, category }); + createComponent({ props: { variant, category } }); expect(findDropdown().props()).toMatchObject({ category, variant }); }); @@ -88,4 +107,13 @@ describe('vue_shared/components/actions_button', () => { }); }); }); + + it.each(['shown', 'hidden'])( + 'bubbles up %s event from the disclosure dropdown component', + (event) => { + createComponent(); + findDropdown().vm.$emit(event); + expect(wrapper.emitted(event)).toHaveLength(1); + }, + ); }); diff --git a/spec/frontend/vue_shared/components/awards_list_spec.js b/spec/frontend/vue_shared/components/awards_list_spec.js index da5516f8db1..6c28347503c 100644 --- a/spec/frontend/vue_shared/components/awards_list_spec.js +++ b/spec/frontend/vue_shared/components/awards_list_spec.js @@ -74,6 +74,7 @@ describe('vue_shared/components/awards_list', () => { return { classes: x.classes(), title: x.attributes('title'), + emojiName: x.attributes('data-emoji-name'), html: x.find('[data-testid="award-html"]').html(), count: Number(x.find('.js-counter').text()), }; @@ -96,48 +97,56 @@ describe('vue_shared/components/awards_list', () => { count: 3, html: matchingEmojiTag(EMOJI_THUMBSUP), title: `Ada, Leonardo, and Marie reacted with :${EMOJI_THUMBSUP}:`, + emojiName: EMOJI_THUMBSUP, }, { classes: [...REACTION_CONTROL_CLASSES, 'selected'], count: 3, html: matchingEmojiTag(EMOJI_THUMBSDOWN), title: `You, Ada, and Marie reacted with :${EMOJI_THUMBSDOWN}:`, + emojiName: EMOJI_THUMBSDOWN, }, { classes: REACTION_CONTROL_CLASSES, count: 1, html: matchingEmojiTag(EMOJI_100), title: `Ada reacted with :${EMOJI_100}:`, + emojiName: EMOJI_100, }, { classes: REACTION_CONTROL_CLASSES, count: 2, html: matchingEmojiTag(EMOJI_SMILE), title: `Ada and Jane reacted with :${EMOJI_SMILE}:`, + emojiName: EMOJI_SMILE, }, { classes: [...REACTION_CONTROL_CLASSES, 'selected'], count: 4, html: matchingEmojiTag(EMOJI_OK), title: `You, Ada, Jane, and Leonardo reacted with :${EMOJI_OK}:`, + emojiName: EMOJI_OK, }, { classes: [...REACTION_CONTROL_CLASSES, 'selected'], count: 1, html: matchingEmojiTag(EMOJI_CACTUS), title: `You reacted with :${EMOJI_CACTUS}:`, + emojiName: EMOJI_CACTUS, }, { classes: REACTION_CONTROL_CLASSES, count: 1, html: matchingEmojiTag(EMOJI_A), title: `Marie reacted with :${EMOJI_A}:`, + emojiName: EMOJI_A, }, { classes: [...REACTION_CONTROL_CLASSES, 'selected'], count: 1, html: matchingEmojiTag(EMOJI_B), title: `You reacted with :${EMOJI_B}:`, + emojiName: EMOJI_B, }, ]); }); @@ -226,12 +235,14 @@ describe('vue_shared/components/awards_list', () => { count: 0, html: matchingEmojiTag(EMOJI_THUMBSUP), title: '', + emojiName: EMOJI_THUMBSUP, }, { classes: REACTION_CONTROL_CLASSES, count: 0, html: matchingEmojiTag(EMOJI_THUMBSDOWN), title: '', + emojiName: EMOJI_THUMBSDOWN, }, // We expect the EMOJI_100 before the EMOJI_SMILE because it was given as a defaultAward { @@ -239,12 +250,14 @@ describe('vue_shared/components/awards_list', () => { count: 1, html: matchingEmojiTag(EMOJI_100), title: `Marie reacted with :${EMOJI_100}:`, + emojiName: EMOJI_100, }, { classes: REACTION_CONTROL_CLASSES, count: 1, html: matchingEmojiTag(EMOJI_SMILE), title: `Marie reacted with :${EMOJI_SMILE}:`, + emojiName: EMOJI_SMILE, }, ]); }); diff --git a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js index 6acd1f51a86..1f3029435ee 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js +++ b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js @@ -1,3 +1,4 @@ +import { nextTick } from 'vue'; import { shallowMount } from '@vue/test-utils'; import { handleBlobRichViewer } from '~/blob/viewer'; import RichViewer from '~/vue_shared/components/blob_viewers/rich_viewer.vue'; @@ -21,16 +22,24 @@ describe('Blob Rich Viewer component', () => { } beforeEach(() => { + const execImmediately = (callback) => callback(); + jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); + createComponent(); }); + it('listens to requestIdleCallback', () => { + expect(window.requestIdleCallback).toHaveBeenCalled(); + }); + it('renders the passed content without transformations', () => { expect(wrapper.html()).toContain(content); }); - it('renders the richViewer if one is present', () => { + it('renders the richViewer if one is present', async () => { const richViewer = '<div class="js-pdf-viewer"></div>'; createComponent('pdf', richViewer); + await nextTick(); expect(wrapper.html()).toContain(richViewer); }); diff --git a/spec/frontend/vue_shared/components/ci_icon_spec.js b/spec/frontend/vue_shared/components/ci_icon_spec.js index 31d63654168..c907b776b91 100644 --- a/spec/frontend/vue_shared/components/ci_icon_spec.js +++ b/spec/frontend/vue_shared/components/ci_icon_spec.js @@ -1,18 +1,23 @@ import { GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import ciIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; describe('CI Icon component', () => { let wrapper; - const findIconWrapper = () => wrapper.find('[data-testid="ci-icon-wrapper"]'); + const createComponent = (props) => { + wrapper = shallowMount(CiIcon, { + propsData: { + ...props, + }, + }); + }; it('should render a span element with an svg', () => { - wrapper = shallowMount(ciIcon, { - propsData: { - status: { - icon: 'status_success', - }, + createComponent({ + status: { + group: 'success', + icon: 'status_success', }, }); @@ -20,49 +25,43 @@ describe('CI Icon component', () => { expect(wrapper.findComponent(GlIcon).exists()).toBe(true); }); - describe('active icons', () => { - it.each` - isActive | cssClass - ${true} | ${'active'} - ${false} | ${'active'} - `('active should be $isActive', ({ isActive, cssClass }) => { - wrapper = shallowMount(ciIcon, { + describe.each` + isActive + ${true} + ${false} + `('when isActive is $isActive', ({ isActive }) => { + it(`"active" class is ${isActive ? 'not ' : ''}added`, () => { + wrapper = shallowMount(CiIcon, { propsData: { status: { + group: 'success', icon: 'status_success', }, isActive, }, }); - if (isActive) { - expect(findIconWrapper().classes()).toContain(cssClass); - } else { - expect(findIconWrapper().classes()).not.toContain(cssClass); - } + expect(wrapper.classes('active')).toBe(isActive); }); }); - describe('interactive icons', () => { - it.each` - isInteractive | cssClass - ${true} | ${'interactive'} - ${false} | ${'interactive'} - `('interactive should be $isInteractive', ({ isInteractive, cssClass }) => { - wrapper = shallowMount(ciIcon, { + describe.each` + isInteractive + ${true} + ${false} + `('when isInteractive is $isInteractive', ({ isInteractive }) => { + it(`"interactive" class is ${isInteractive ? 'not ' : ''}added`, () => { + wrapper = shallowMount(CiIcon, { propsData: { status: { + group: 'success', icon: 'status_success', }, isInteractive, }, }); - if (isInteractive) { - expect(findIconWrapper().classes()).toContain(cssClass); - } else { - expect(findIconWrapper().classes()).not.toContain(cssClass); - } + expect(wrapper.classes('interactive')).toBe(isInteractive); }); }); @@ -79,7 +78,7 @@ describe('CI Icon component', () => { ${'status_canceled'} | ${'canceled'} | ${'ci-status-icon-canceled'} ${'status_manual'} | ${'manual'} | ${'ci-status-icon-manual'} `('should render a $group status', ({ icon, group, cssClass }) => { - wrapper = shallowMount(ciIcon, { + wrapper = shallowMount(CiIcon, { propsData: { status: { icon, diff --git a/spec/frontend/vue_shared/components/code_block_highlighted_spec.js b/spec/frontend/vue_shared/components/code_block_highlighted_spec.js index 25283eb1211..5720f45f4dd 100644 --- a/spec/frontend/vue_shared/components/code_block_highlighted_spec.js +++ b/spec/frontend/vue_shared/components/code_block_highlighted_spec.js @@ -58,4 +58,11 @@ describe('Code Block Highlighted', () => { </code-block-stub> `); }); + + it('renders content as plain text language is not supported', () => { + const content = '<script>alert("xss")</script>'; + createComponent({ code: content, language: 'foobar' }); + + expect(wrapper.text()).toContain(content); + }); }); diff --git a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js index d7f94c00d09..0b5c8d9afc3 100644 --- a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js +++ b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js @@ -60,9 +60,7 @@ describe('Confirm Danger Modal', () => { }); it('renders the correct confirmation phrase', () => { - expect(findConfirmationPhrase().text()).toBe( - `Please type ${phrase} to proceed or close this modal to cancel.`, - ); + expect(findConfirmationPhrase().text()).toBe(`Please type ${phrase} to proceed.`); }); describe('without injected data', () => { diff --git a/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js b/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js index 2a4037d76b7..40232eb367a 100644 --- a/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js @@ -8,6 +8,7 @@ import { import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; import DiffStatsDropdown, { i18n } from '~/vue_shared/components/diff_stats_dropdown.vue'; jest.mock('fuzzaldrin-plus', () => ({ @@ -38,6 +39,7 @@ const mockFiles = [ describe('Diff Stats Dropdown', () => { let wrapper; + const focusInputMock = jest.fn(); const createComponent = ({ changed = 0, added = 0, deleted = 0, files = [] } = {}) => { wrapper = shallowMountExtended(DiffStatsDropdown, { @@ -50,6 +52,9 @@ describe('Diff Stats Dropdown', () => { stubs: { GlSprintf, GlDropdown, + GlSearchBoxByType: stubComponent(GlSearchBoxByType, { + methods: { focusInput: focusInputMock }, + }), }, }); }; @@ -151,10 +156,8 @@ describe('Diff Stats Dropdown', () => { }); it('should set the search input focus', () => { - wrapper.vm.$refs.search.focusInput = jest.fn(); findChanged().vm.$emit('shown'); - - expect(wrapper.vm.$refs.search.focusInput).toHaveBeenCalled(); + expect(focusInputMock).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js b/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js index 6e2e854adae..36772ad03fe 100644 --- a/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js +++ b/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js @@ -125,7 +125,8 @@ describe('EntitySelect', () => { it('emits `input` event with the select value', async () => { createComponent(); await selectGroup(); - expect(wrapper.emitted('input')[0]).toEqual(['1']); + + expect(wrapper.emitted('input')[0][0]).toMatchObject(itemMock); }); it(`uses the selected group's name as the toggle text`, async () => { @@ -153,14 +154,14 @@ describe('EntitySelect', () => { expect(findListbox().props('toggleText')).toBe(defaultToggleText); }); - it('emits `input` event with `null` on reset', async () => { + it('emits `input` event with an empty object on reset', async () => { createComponent(); await selectGroup(); findListbox().vm.$emit('reset'); await nextTick(); - expect(wrapper.emitted('input')[2]).toEqual([null]); + expect(Object.keys(wrapper.emitted('input')[2][0]).length).toBe(0); }); }); }); diff --git a/spec/frontend/vue_shared/components/entity_select/group_select_spec.js b/spec/frontend/vue_shared/components/entity_select/group_select_spec.js index 83560e367ea..ae551116560 100644 --- a/spec/frontend/vue_shared/components/entity_select/group_select_spec.js +++ b/spec/frontend/vue_shared/components/entity_select/group_select_spec.js @@ -39,6 +39,8 @@ describe('GroupSelect', () => { const findEntitySelect = () => wrapper.findComponent(EntitySelect); const findAlert = () => wrapper.findComponent(GlAlert); + const handleInput = jest.fn(); + // Helpers const createComponent = ({ props = {} } = {}) => { wrapper = shallowMountExtended(GroupSelect, { @@ -52,6 +54,9 @@ describe('GroupSelect', () => { GlAlert, EntitySelect, }, + listeners: { + input: handleInput, + }, }); }; const openListbox = () => findListbox().vm.$emit('shown'); @@ -132,4 +137,11 @@ describe('GroupSelect', () => { expect(findAlert().exists()).toBe(true); expect(findAlert().text()).toBe(FETCH_GROUPS_ERROR); }); + + it('forwards events to the parent scope via `v-on="$listeners"`', () => { + createComponent(); + findEntitySelect().vm.$emit('input'); + + expect(handleInput).toHaveBeenCalledTimes(1); + }); }); diff --git a/spec/frontend/vue_shared/components/entity_select/project_select_spec.js b/spec/frontend/vue_shared/components/entity_select/project_select_spec.js index 0a174c98efb..9113152c975 100644 --- a/spec/frontend/vue_shared/components/entity_select/project_select_spec.js +++ b/spec/frontend/vue_shared/components/entity_select/project_select_spec.js @@ -45,6 +45,8 @@ describe('ProjectSelect', () => { const findEntitySelect = () => wrapper.findComponent(EntitySelect); const findAlert = () => wrapper.findComponent(GlAlert); + const handleInput = jest.fn(); + // Helpers const createComponent = ({ props = {} } = {}) => { wrapper = mountExtended(ProjectSelect, { @@ -59,6 +61,9 @@ describe('ProjectSelect', () => { GlAlert, EntitySelect, }, + listeners: { + input: handleInput, + }, }); }; const openListbox = () => findListbox().vm.$emit('shown'); @@ -255,4 +260,11 @@ describe('ProjectSelect', () => { expect(findAlert().exists()).toBe(true); expect(findAlert().text()).toBe(FETCH_PROJECTS_ERROR); }); + + it('forwards events to the parent scope via `v-on="$listeners"`', () => { + createComponent(); + findEntitySelect().vm.$emit('input'); + + expect(handleInput).toHaveBeenCalledTimes(1); + }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js index c0cb17f0d16..00a412d9de8 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js @@ -125,46 +125,23 @@ describe('FilteredSearchBarRoot', () => { }); describe('sortDirectionIcon', () => { - it('returns string "sort-lowest" when `selectedSortDirection` is "ascending"', () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - selectedSortDirection: SORT_DIRECTION.ascending, - }); - - expect(wrapper.vm.sortDirectionIcon).toBe('sort-lowest'); - }); - - it('returns string "sort-highest" when `selectedSortDirection` is "descending"', () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - selectedSortDirection: SORT_DIRECTION.descending, + it('renders `sort-highest` descending icon by default', () => { + expect(findGlButton().props('icon')).toBe('sort-highest'); + expect(findGlButton().attributes()).toMatchObject({ + 'aria-label': 'Sort direction: Descending', + title: 'Sort direction: Descending', }); - - expect(wrapper.vm.sortDirectionIcon).toBe('sort-highest'); }); - }); - describe('sortDirectionTooltip', () => { - it('returns string "Sort direction: Ascending" when `selectedSortDirection` is "ascending"', () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - selectedSortDirection: SORT_DIRECTION.ascending, - }); - - expect(wrapper.vm.sortDirectionTooltip).toBe('Sort direction: Ascending'); - }); + it('renders `sort-lowest` ascending icon when the sort button is clicked', async () => { + findGlButton().vm.$emit('click'); + await nextTick(); - it('returns string "Sort direction: Descending" when `selectedSortDirection` is "descending"', () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - selectedSortDirection: SORT_DIRECTION.descending, + expect(findGlButton().props('icon')).toBe('sort-lowest'); + expect(findGlButton().attributes()).toMatchObject({ + 'aria-label': 'Sort direction: Ascending', + title: 'Sort direction: Ascending', }); - - expect(wrapper.vm.sortDirectionTooltip).toBe('Sort direction: Descending'); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js index fb8cea09a9b..d34d7ff48c2 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js @@ -39,7 +39,6 @@ describe('CrmContactToken', () => { Vue.use(VueApollo); let wrapper; - let fakeApollo; const getBaseToken = () => wrapper.findComponent(BaseToken); @@ -58,9 +57,8 @@ describe('CrmContactToken', () => { listeners = {}, queryHandler = searchGroupCrmContactsQueryHandler, } = {}) => { - fakeApollo = createMockApollo([[searchCrmContactsQuery, queryHandler]]); - wrapper = mount(CrmContactToken, { + apolloProvider: createMockApollo([[searchCrmContactsQuery, queryHandler]]), propsData: { config, value, @@ -75,14 +73,9 @@ describe('CrmContactToken', () => { }, stubs, listeners, - apolloProvider: fakeApollo, }); }; - afterEach(() => { - fakeApollo = null; - }); - describe('methods', () => { describe('fetchContacts', () => { describe('for groups', () => { @@ -160,9 +153,7 @@ describe('CrmContactToken', () => { }); it('calls `createAlert` with alert error message when request fails', async () => { - mountComponent(); - - jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); + mountComponent({ queryHandler: jest.fn().mockRejectedValue({}) }); getBaseToken().vm.$emit('fetch-suggestions'); await waitForPromises(); @@ -173,12 +164,9 @@ describe('CrmContactToken', () => { }); it('sets `loading` to false when request completes', async () => { - mountComponent(); - - jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); + mountComponent({ queryHandler: jest.fn().mockRejectedValue({}) }); getBaseToken().vm.$emit('fetch-suggestions'); - await waitForPromises(); expect(getBaseToken().props('suggestionsLoading')).toBe(false); @@ -195,13 +183,7 @@ describe('CrmContactToken', () => { value: { data: '1' }, }); - const baseTokenEl = wrapper.findComponent(BaseToken); - - expect(baseTokenEl.exists()).toBe(true); - expect(baseTokenEl.props()).toMatchObject({ - suggestions: mockCrmContacts, - getActiveTokenValue: wrapper.vm.getActiveContact, - }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmContacts); }); it.each(mockCrmContacts)('renders token item when value is selected', (contact) => { @@ -270,12 +252,9 @@ describe('CrmContactToken', () => { it('emits listeners in the base-token', () => { const mockInput = jest.fn(); - mountComponent({ - listeners: { - input: mockInput, - }, - }); - wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]); + mountComponent({ listeners: { input: mockInput } }); + + getBaseToken().vm.$emit('input', [{ data: 'mockData', operator: '=' }]); expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js index 20369342220..17cf39e726c 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js @@ -39,7 +39,6 @@ describe('CrmOrganizationToken', () => { Vue.use(VueApollo); let wrapper; - let fakeApollo; const getBaseToken = () => wrapper.findComponent(BaseToken); @@ -58,8 +57,8 @@ describe('CrmOrganizationToken', () => { listeners = {}, queryHandler = searchGroupCrmOrganizationsQueryHandler, } = {}) => { - fakeApollo = createMockApollo([[searchCrmOrganizationsQuery, queryHandler]]); wrapper = mount(CrmOrganizationToken, { + apolloProvider: createMockApollo([[searchCrmOrganizationsQuery, queryHandler]]), propsData: { config, value, @@ -74,14 +73,9 @@ describe('CrmOrganizationToken', () => { }, stubs, listeners, - apolloProvider: fakeApollo, }); }; - afterEach(() => { - fakeApollo = null; - }); - describe('methods', () => { describe('fetchOrganizations', () => { describe('for groups', () => { @@ -159,9 +153,7 @@ describe('CrmOrganizationToken', () => { }); it('calls `createAlert` when request fails', async () => { - mountComponent(); - - jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); + mountComponent({ queryHandler: jest.fn().mockRejectedValue({}) }); getBaseToken().vm.$emit('fetch-suggestions'); await waitForPromises(); @@ -172,9 +164,7 @@ describe('CrmOrganizationToken', () => { }); it('sets `loading` to false when request completes', async () => { - mountComponent(); - - jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); + mountComponent({ queryHandler: jest.fn().mockRejectedValue({}) }); getBaseToken().vm.$emit('fetch-suggestions'); @@ -194,13 +184,7 @@ describe('CrmOrganizationToken', () => { value: { data: '1' }, }); - const baseTokenEl = wrapper.findComponent(BaseToken); - - expect(baseTokenEl.exists()).toBe(true); - expect(baseTokenEl.props()).toMatchObject({ - suggestions: mockCrmOrganizations, - getActiveTokenValue: wrapper.vm.getActiveOrganization, - }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmOrganizations); }); it.each(mockCrmOrganizations)('renders token item when value is selected', (organization) => { @@ -269,12 +253,9 @@ describe('CrmOrganizationToken', () => { it('emits listeners in the base-token', () => { const mockInput = jest.fn(); - mountComponent({ - listeners: { - input: mockInput, - }, - }); - wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]); + mountComponent({ listeners: { input: mockInput } }); + + getBaseToken().vm.$emit('input', [{ data: 'mockData', operator: '=' }]); expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]); }); diff --git a/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js b/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js index 397fd270344..b782a2b19da 100644 --- a/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js +++ b/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js @@ -7,7 +7,7 @@ describe('ListboxInput', () => { // Props const label = 'label'; - const decription = 'decription'; + const description = 'description'; const name = 'name'; const defaultToggleText = 'defaultToggleText'; const items = [ @@ -34,7 +34,7 @@ describe('ListboxInput', () => { wrapper = shallowMount(ListboxInput, { propsData: { label, - decription, + description, name, defaultToggleText, items, @@ -72,8 +72,8 @@ describe('ListboxInput', () => { expect(findGlFormGroup().attributes('label')).toBe(label); }); - it('passes the decription to the form group', () => { - expect(findGlFormGroup().attributes('decription')).toBe(decription); + it('passes the description to the form group', () => { + expect(findGlFormGroup().attributes('description')).toBe(description); }); it('sets the input name', () => { @@ -89,6 +89,26 @@ describe('ListboxInput', () => { }); }); + describe('props', () => { + it.each([true, false])("passes %s to the listbox's fluidWidth prop", (fluidWidth) => { + createComponent({ fluidWidth }); + + expect(findGlListbox().props('fluidWidth')).toBe(fluidWidth); + }); + + it.each(['right', 'left'])("passes %s to the listbox's placement prop", (placement) => { + createComponent({ placement }); + + expect(findGlListbox().props('placement')).toBe(placement); + }); + + it.each([true, false])("passes %s to the listbox's block prop", (block) => { + createComponent({ block }); + + expect(findGlListbox().props('block')).toBe(block); + }); + }); + describe('toggle text', () => { it('uses the default toggle text while no value is selected', () => { createComponent(); diff --git a/spec/frontend/vue_shared/components/markdown/comment_templates_dropdown_spec.js b/spec/frontend/vue_shared/components/markdown/comment_templates_dropdown_spec.js index aea25abb324..2bef6dd15df 100644 --- a/spec/frontend/vue_shared/components/markdown/comment_templates_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/markdown/comment_templates_dropdown_spec.js @@ -4,13 +4,9 @@ import savedRepliesResponse from 'test_fixtures/graphql/comment_templates/saved_ import { mountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import { updateText } from '~/lib/utils/text_markdown'; import CommentTemplatesDropdown from '~/vue_shared/components/markdown/comment_templates_dropdown.vue'; import savedRepliesQuery from '~/vue_shared/components/markdown/saved_replies.query.graphql'; -jest.mock('~/lib/utils/text_markdown'); - let wrapper; let savedRepliesResp; @@ -28,7 +24,6 @@ function createComponent(options = {}) { const { mockApollo } = options; return mountExtended(CommentTemplatesDropdown, { - attachTo: '#root', propsData: { newCommentTemplatePath: '/new', }, @@ -37,14 +32,6 @@ function createComponent(options = {}) { } describe('Comment templates dropdown', () => { - beforeEach(() => { - setHTMLFixture('<div class="md-area"><textarea></textarea><div id="root"></div></div>'); - }); - - afterEach(() => { - resetHTMLFixture(); - }); - it('fetches data when dropdown gets opened', async () => { const mockApollo = createMockApolloProvider(savedRepliesResponse); wrapper = createComponent({ mockApollo }); @@ -56,7 +43,7 @@ describe('Comment templates dropdown', () => { expect(savedRepliesResp).toHaveBeenCalled(); }); - it('adds content to textarea', async () => { + it('adds emits a select event on selecting a comment', async () => { const mockApollo = createMockApolloProvider(savedRepliesResponse); wrapper = createComponent({ mockApollo }); @@ -66,11 +53,6 @@ describe('Comment templates dropdown', () => { wrapper.find('.gl-new-dropdown-item').trigger('click'); - expect(updateText).toHaveBeenCalledWith({ - textArea: document.querySelector('textarea'), - tag: savedRepliesResponse.data.currentUser.savedReplies.nodes[0].content, - cursorOffset: 0, - wrap: false, - }); + expect(wrapper.emitted().select[0]).toEqual(['Saved Reply Content']); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/editor_mode_switcher_spec.js b/spec/frontend/vue_shared/components/markdown/editor_mode_switcher_spec.js index 693353ed604..712e78458c6 100644 --- a/spec/frontend/vue_shared/components/markdown/editor_mode_switcher_spec.js +++ b/spec/frontend/vue_shared/components/markdown/editor_mode_switcher_spec.js @@ -1,25 +1,47 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { GlButton, GlLink, GlPopover } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue'; +import { counter } from '~/vue_shared/components/markdown/utils'; +import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; +import { stubComponent } from 'helpers/stub_component'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; + +jest.mock('~/vue_shared/components/markdown/utils', () => ({ + counter: jest.fn().mockReturnValue(0), +})); describe('vue_shared/component/markdown/editor_mode_switcher', () => { let wrapper; + useLocalStorageSpy(); - const createComponent = ({ value } = {}) => { - wrapper = shallowMount(EditorModeSwitcher, { + const createComponent = ({ + value, + userCalloutDismisserSlotProps = { dismiss: jest.fn() }, + } = {}) => { + wrapper = mount(EditorModeSwitcher, { propsData: { value, }, + stubs: { + UserCalloutDismisser: stubComponent(UserCalloutDismisser, { + render() { + return this.$scopedSlots.default(userCalloutDismisserSlotProps); + }, + }), + }, }); }; const findSwitcherButton = () => wrapper.findComponent(GlButton); + const findUserCalloutDismisser = () => wrapper.findComponent(UserCalloutDismisser); + const findCalloutPopover = () => wrapper.findComponent(GlPopover); describe.each` - modeText | value | buttonText - ${'Rich text'} | ${'richText'} | ${'Switch to Markdown'} - ${'Markdown'} | ${'markdown'} | ${'Switch to rich text'} - `('when $modeText', ({ modeText, value, buttonText }) => { + value | buttonText + ${'richText'} | ${'Switch to plain text editing'} + ${'markdown'} | ${'Switch to rich text editing'} + `('when $value', ({ value, buttonText }) => { beforeEach(() => { createComponent({ value }); }); @@ -28,10 +50,66 @@ describe('vue_shared/component/markdown/editor_mode_switcher', () => { expect(findSwitcherButton().text()).toEqual(buttonText); }); - it('emits event on click', () => { - findSwitcherButton(modeText).vm.$emit('click'); + it('emits event on click', async () => { + await nextTick(); + findSwitcherButton().vm.$emit('click'); + + expect(wrapper.emitted().switch).toEqual([[false]]); + }); + }); + + describe('rich text editor callout', () => { + let dismiss; + + beforeEach(() => { + dismiss = jest.fn(); + createComponent({ value: 'markdown', userCalloutDismisserSlotProps: { dismiss } }); + }); + + it('does not skip the user_callout_dismisser query', () => { + expect(findUserCalloutDismisser().props()).toMatchObject({ + skipQuery: false, + featureName: 'rich_text_editor', + }); + }); + + it('mounts new rich text editor popover', () => { + expect(findCalloutPopover().props()).toMatchObject({ + showCloseButton: '', + triggers: 'manual', + target: 'switch-to-rich-text-editor', + }); + }); + + it('dismisses the callout and emits "switch" event when popover close button is clicked', async () => { + await findCalloutPopover().findComponent(GlLink).vm.$emit('click'); + + expect(wrapper.emitted().switch).toEqual([[true]]); + expect(dismiss).toHaveBeenCalled(); + }); + + it('dismisses the callout when action button is clicked', () => { + findSwitcherButton().vm.$emit('click'); + + expect(dismiss).toHaveBeenCalled(); + }); + + it('does not show the callout if rich text is already enabled', async () => { + await wrapper.setProps({ value: 'richText' }); + + expect(findCalloutPopover().props()).toMatchObject({ + show: false, + }); + }); + + it('does not show the callout if already displayed once on the page', () => { + counter.mockReturnValue(1); + + createComponent({ value: 'markdown' }); - expect(wrapper.emitted().input).toEqual([[]]); + expect(findCalloutPopover().props()).toMatchObject({ + show: false, + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index b29f0d58d77..4ade8f28fd0 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -65,6 +65,16 @@ describe('Markdown field component', () => { enablePreview, restrictedToolBarItems, showContentEditorSwitcher, + supportsQuickActions: true, + }, + mocks: { + $apollo: { + queries: { + currentUser: { + loading: false, + }, + }, + }, }, }, ); @@ -206,12 +216,12 @@ describe('Markdown field component', () => { expect(findMarkdownToolbar().props()).toEqual({ canAttachFile: true, markdownDocsPath, - quickActionsDocsPath: '', showCommentToolBar: true, + showContentEditorSwitcher: false, }); expect(findMarkdownHeader().props()).toMatchObject({ - showContentEditorSwitcher: false, + supportsQuickActions: true, }); }); }); @@ -368,13 +378,13 @@ describe('Markdown field component', () => { it('defaults to false', () => { createSubject(); - expect(findMarkdownHeader().props('showContentEditorSwitcher')).toBe(false); + expect(findMarkdownToolbar().props('showContentEditorSwitcher')).toBe(false); }); it('passes showContentEditorSwitcher', () => { createSubject({ showContentEditorSwitcher: true }); - expect(findMarkdownHeader().props('showContentEditorSwitcher')).toBe(true); + expect(findMarkdownToolbar().props('showContentEditorSwitcher')).toBe(true); }); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js index 48fe5452e74..eb728879fb7 100644 --- a/spec/frontend/vue_shared/components/markdown/header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/header_spec.js @@ -1,22 +1,28 @@ import $ from 'jquery'; import { nextTick } from 'vue'; -import { GlToggle } from '@gitlab/ui'; +import { GlToggle, GlButton } from '@gitlab/ui'; import HeaderComponent from '~/vue_shared/components/markdown/header.vue'; +import CommentTemplatesDropdown from '~/vue_shared/components/markdown/comment_templates_dropdown.vue'; import ToolbarButton from '~/vue_shared/components/markdown/toolbar_button.vue'; import DrawioToolbarButton from '~/vue_shared/components/markdown/drawio_toolbar_button.vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue'; +import { updateText } from '~/lib/utils/text_markdown'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; + +jest.mock('~/lib/utils/text_markdown'); describe('Markdown field header component', () => { let wrapper; - const createWrapper = (props) => { + const createWrapper = ({ props = {}, provide = {}, attachTo = document.body } = {}) => { wrapper = shallowMountExtended(HeaderComponent, { + attachTo, propsData: { previewMarkdown: false, ...props, }, stubs: { GlToggle }, + provide, }); }; @@ -28,6 +34,7 @@ describe('Markdown field header component', () => { .filter((button) => button.props(prop) === value) .at(0); const findDrawioToolbarButton = () => wrapper.findComponent(DrawioToolbarButton); + const findCommentTemplatesDropdown = () => wrapper.findComponent(CommentTemplatesDropdown); beforeEach(() => { window.gl = { @@ -65,6 +72,39 @@ describe('Markdown field header component', () => { }); }); + it('renders correct title on non MacOS systems', () => { + window.gl = { + client: { + isMac: false, + }, + }; + + createWrapper(); + + const buttons = [ + 'Insert suggestion', + 'Add bold text (Ctrl+B)', + 'Add italic text (Ctrl+I)', + 'Add strikethrough text (Ctrl+Shift+X)', + 'Insert a quote', + 'Insert code', + 'Add a link (Ctrl+K)', + 'Add a bullet list', + 'Add a numbered list', + 'Add a checklist', + 'Indent line (Ctrl+])', + 'Outdent line (Ctrl+[)', + 'Add a collapsible section', + 'Add a table', + 'Go full screen', + ]; + const elements = findToolbarButtons(); + + elements.wrappers.forEach((buttonEl, index) => { + expect(buttonEl.props('buttonTitle')).toBe(buttons[index]); + }); + }); + it('renders "Attach a file or image" button using gl-button', () => { const button = wrapper.findByTestId('button-attach-file'); @@ -92,15 +132,16 @@ describe('Markdown field header component', () => { }); it('shows markdown preview when previewMarkdown is true', () => { - createWrapper({ previewMarkdown: true }); + createWrapper({ props: { previewMarkdown: true } }); expect(findPreviewToggle().text()).toBe('Continue editing'); }); it('hides toolbar in preview mode', () => { - createWrapper({ previewMarkdown: true }); + createWrapper({ props: { previewMarkdown: true } }); - expect(findToolbar().classes().includes('gl-display-none!')).toBe(true); + // only one button is rendered in preview mode + expect(findToolbar().findAllComponents(GlButton)).toHaveLength(1); }); it('emits toggle markdown event when clicking preview toggle', async () => { @@ -150,7 +191,9 @@ describe('Markdown field header component', () => { it('does not render suggestion button if `canSuggest` is set to false', () => { createWrapper({ - canSuggest: false, + props: { + canSuggest: false, + }, }); expect(wrapper.find('.js-suggestion-btn').exists()).toBe(false); @@ -158,7 +201,9 @@ describe('Markdown field header component', () => { it('hides markdown preview when previewMarkdown property is false', () => { createWrapper({ - enablePreview: false, + props: { + enablePreview: false, + }, }); expect(wrapper.findByTestId('preview-toggle').exists()).toBe(false); @@ -173,7 +218,9 @@ describe('Markdown field header component', () => { it('restricts items as per input', () => { createWrapper({ - restrictedToolBarItems: ['quote'], + props: { + restrictedToolBarItems: ['quote'], + }, }); expect(findToolbarButtons().length).toBe(defaultCount - 1); @@ -192,9 +239,11 @@ describe('Markdown field header component', () => { beforeEach(() => { createWrapper({ - drawioEnabled: true, - uploadsPath, - markdownPreviewPath, + props: { + drawioEnabled: true, + uploadsPath, + markdownPreviewPath, + }, }); }); @@ -206,17 +255,46 @@ describe('Markdown field header component', () => { }); }); - describe('with content editor switcher', () => { + describe('when selecting a saved reply from the comment templates dropdown', () => { beforeEach(() => { + setHTMLFixture('<div class="md-area"><textarea></textarea><div id="root"></div></div>'); + }); + + afterEach(() => { + resetHTMLFixture(); + }); + + it('updates the textarea with the saved comment', async () => { createWrapper({ - showContentEditorSwitcher: true, + attachTo: '#root', + provide: { + newCommentTemplatePath: 'some/path', + glFeatures: { + savedReplies: true, + }, + }, + }); + + await findCommentTemplatesDropdown().vm.$emit('select', 'Some saved comment'); + + expect(updateText).toHaveBeenCalledWith({ + textArea: document.querySelector('textarea'), + tag: 'Some saved comment', + cursorOffset: 0, + wrap: false, }); }); - it('re-emits event from switcher', () => { - wrapper.findComponent(EditorModeSwitcher).vm.$emit('input', 'richText'); + it('does not show the saved replies button if newCommentTemplatePath is not defined', () => { + createWrapper({ + provide: { + glFeatures: { + savedReplies: true, + }, + }, + }); - expect(wrapper.emitted('enableContentEditor')).toEqual([[]]); + expect(findCommentTemplatesDropdown().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js index e54e261b8e4..31c0fa6f699 100644 --- a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js +++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js @@ -21,6 +21,7 @@ import waitForPromises from 'helpers/wait_for_promises'; jest.mock('~/emoji'); jest.mock('autosize'); +jest.mock('~/lib/graphql'); describe('vue_shared/component/markdown/markdown_editor', () => { useLocalStorageSpy(); @@ -29,7 +30,6 @@ describe('vue_shared/component/markdown/markdown_editor', () => { const value = 'test markdown'; const renderMarkdownPath = '/api/markdown'; const markdownDocsPath = '/help/markdown'; - const quickActionsDocsPath = '/help/quickactions'; const enableAutocomplete = true; const enablePreview = false; const formFieldId = 'markdown_field'; @@ -43,7 +43,6 @@ describe('vue_shared/component/markdown/markdown_editor', () => { value, renderMarkdownPath, markdownDocsPath, - quickActionsDocsPath, enableAutocomplete, autocompleteDataSources, enablePreview, @@ -65,6 +64,15 @@ describe('vue_shared/component/markdown/markdown_editor', () => { BubbleMenu: stubComponent(BubbleMenu), ...stubs, }, + mocks: { + $apollo: { + queries: { + currentUser: { + loading: false, + }, + }, + }, + }, }); }; @@ -110,7 +118,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { expect(findMarkdownField().props()).toMatchObject({ autocompleteDataSources, markdownPreviewPath: renderMarkdownPath, - quickActionsDocsPath, + supportsQuickActions: true, canAttachFile: true, enableAutocomplete, textareaValue: value, @@ -120,7 +128,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { }); }); - // quarantine flaky spec:https://gitlab.com/gitlab-org/gitlab/-/issues/412618 + // quarantine: https://gitlab.com/gitlab-org/gitlab/-/issues/412618 // eslint-disable-next-line jest/no-disabled-tests it.skip('passes render_quick_actions param to renderMarkdownPath if quick actions are enabled', async () => { buildWrapper({ propsData: { supportsQuickActions: true } }); @@ -131,7 +139,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { expect(mock.history.post[0].url).toContain(`render_quick_actions=true`); }); - // quarantine flaky spec: https://gitlab.com/gitlab-org/gitlab/-/issues/411565 + // quarantine: https://gitlab.com/gitlab-org/gitlab/-/issues/411565 // eslint-disable-next-line jest/no-disabled-tests it.skip('does not pass render_quick_actions param to renderMarkdownPath if quick actions are disabled', async () => { buildWrapper({ propsData: { supportsQuickActions: false } }); @@ -145,27 +153,31 @@ describe('vue_shared/component/markdown/markdown_editor', () => { it('enables content editor switcher when contentEditorEnabled prop is true', () => { buildWrapper({ propsData: { enableContentEditor: true } }); - expect(findMarkdownField().text()).toContain('Switch to rich text'); + expect(findMarkdownField().text()).toContain('Switch to rich text editing'); }); it('hides content editor switcher when contentEditorEnabled prop is false', () => { buildWrapper({ propsData: { enableContentEditor: false } }); - expect(findMarkdownField().text()).not.toContain('Switch to rich text'); + expect(findMarkdownField().text()).not.toContain('Switch to rich text editing'); }); it('passes down any additional props to markdown field component', () => { - const propsData = { + const codeSuggestionsConfig = { line: { text: 'hello world', richText: 'hello world' }, lines: [{ text: 'hello world', richText: 'hello world' }], canSuggest: true, }; buildWrapper({ - propsData: { ...propsData, myCustomProp: 'myCustomValue', 'data-testid': 'custom id' }, + propsData: { + codeSuggestionsConfig, + myCustomProp: 'myCustomValue', + 'data-testid': 'custom id', + }, }); - expect(findMarkdownField().props()).toMatchObject(propsData); + expect(findMarkdownField().props()).toMatchObject(codeSuggestionsConfig); expect(findMarkdownField().vm.$attrs).toMatchObject({ myCustomProp: 'myCustomValue', @@ -201,7 +213,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { expect(findMarkdownField().find('textarea').attributes('disabled')).toBe(undefined); }); - // quarantine flaky spec: https://gitlab.com/gitlab-org/gitlab/-/issues/404734 + // quarantine: https://gitlab.com/gitlab-org/gitlab/-/issues/404734 // eslint-disable-next-line jest/no-disabled-tests it.skip('disables content editor when disabled prop is true', async () => { buildWrapper({ propsData: { disabled: true } }); @@ -436,8 +448,6 @@ describe('vue_shared/component/markdown/markdown_editor', () => { describe('when contentEditor is disabled', () => { it('resets the editingMode to markdownField', () => { - localStorage.setItem('gl-markdown-editor-mode', 'contentEditor'); - buildWrapper({ propsData: { autosaveKey: 'issue/1234', enableContentEditor: false } }); expect(wrapper.vm.editingMode).toBe(EDITING_MODE_MARKDOWN_FIELD); diff --git a/spec/frontend/vue_shared/components/markdown/non_gfm_markdown_spec.js b/spec/frontend/vue_shared/components/markdown/non_gfm_markdown_spec.js new file mode 100644 index 00000000000..cd73ef6892a --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/non_gfm_markdown_spec.js @@ -0,0 +1,157 @@ +import { nextTick } from 'vue'; +import Markdown from '~/vue_shared/components/markdown/non_gfm_markdown.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CodeBlockHighlighted from '~/vue_shared/components/code_block_highlighted.vue'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; + +describe('NonGitlabMarkdown', () => { + let wrapper; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = shallowMountExtended(Markdown, { + propsData, + }); + }; + + const codeBlockContent = 'stages:\n - build\n - test\n - deploy\n'; + const codeBlockLanguage = 'yaml'; + const nonCodeContent = + "Certainly! Here's an updated GitLab CI/CD configuration in YAML format that includes Kubernetes deployment:"; + const testMarkdownWithCodeBlock = `${nonCodeContent}\n\n\`\`\`${codeBlockLanguage}\n${codeBlockContent}\n\`\`\`\n\nIn this updated configuration, we have added a \`deploy\` job that deploys the Python app to a Kubernetes cluster. The \`script\` section of the job includes commands to authenticate with GCP, set the project and zone, configure kubectl to use the GKE cluster, and deploy the application using a deployment.yaml file.\n\nNote that you will need to modify this configuration to fit your specific deployment needs, including replacing the placeholders (\`<PROJECT_ID>\`, \`<COMPUTE_ZONE>\`, \`<CLUSTER_NAME>\`, and \`<COMPUTE_REGION>\`) with your GCP and Kubernetes deployment information, and creating the deployment.yaml file with your Kubernetes deployment configuration.`; + const codeOnlyMarkdown = `\`\`\`${codeBlockLanguage}\n${codeBlockContent}\n\`\`\``; + const markdownWithMultipleCodeSnippets = `${testMarkdownWithCodeBlock}\n${testMarkdownWithCodeBlock}`; + const codeBlockNoLanguage = ` + \`\`\` + const foo = 'bar'; + \`\`\` + `; + + const findCodeBlock = () => wrapper.findComponent(CodeBlockHighlighted); + const findCopyCodeButton = () => wrapper.findComponent(ModalCopyButton); + const findCodeBlockWrapper = () => wrapper.findByTestId('code-block-wrapper'); + const findMarkdownBlock = () => wrapper.findByTestId('non-code-markdown'); + + describe('rendering markdown without code snippet', () => { + beforeEach(() => { + createComponent({ propsData: { markdown: nonCodeContent } }); + }); + it('should render non-code content', () => { + const markdownBlock = findMarkdownBlock(); + expect(markdownBlock.exists()).toBe(true); + expect(markdownBlock.text()).toBe(nonCodeContent); + }); + it('should not render code block', () => { + const codeBlock = findCodeBlock(); + expect(codeBlock.exists()).toBe(false); + }); + }); + + describe('rendering code snippet without other markdown', () => { + beforeEach(() => { + createComponent({ propsData: { markdown: codeOnlyMarkdown } }); + }); + it('should not render non-code content', () => { + const markdownBlock = findMarkdownBlock(); + expect(markdownBlock.exists()).toBe(false); + }); + it('should render code block', () => { + const codeBlock = findCodeBlock(); + expect(codeBlock.exists()).toBe(true); + }); + }); + + describe('rendering code snippet with no language specified', () => { + beforeEach(() => { + createComponent({ propsData: { markdown: codeBlockNoLanguage } }); + }); + + it('should render code block', () => { + const codeBlock = findCodeBlock(); + expect(codeBlock.exists()).toBe(true); + expect(codeBlock.props('language')).toBe('text'); + }); + }); + + describe.each` + markdown | codeBlocksCount | markdownBlocksCount + ${testMarkdownWithCodeBlock} | ${1} | ${2} + ${markdownWithMultipleCodeSnippets} | ${2} | ${3} + ${codeOnlyMarkdown} | ${1} | ${0} + ${nonCodeContent} | ${0} | ${1} + `( + 'extracting tokens in markdownBlocks computed', + ({ markdown, codeBlocksCount, markdownBlocksCount }) => { + beforeEach(() => { + createComponent({ propsData: { markdown } }); + }); + + it('should create correct number of tokens', () => { + const findAllCodeBlocks = () => wrapper.findAllByTestId('code-block-wrapper'); + const findAllMarkdownBlocks = () => wrapper.findAllByTestId('non-code-markdown'); + + expect(findAllCodeBlocks()).toHaveLength(codeBlocksCount); + expect(findAllMarkdownBlocks()).toHaveLength(markdownBlocksCount); + }); + }, + ); + + describe('rendering markdown with multiple code snippets', () => { + beforeEach(() => { + createComponent({ propsData: { markdown: markdownWithMultipleCodeSnippets } }); + }); + + it('should render code block with correct props', () => { + const codeBlock = findCodeBlock(); + expect(codeBlock.exists()).toBe(true); + expect(codeBlock.props()).toEqual( + expect.objectContaining({ + language: codeBlockLanguage, + code: codeBlockContent, + }), + ); + expect(wrapper.findAllComponents(CodeBlockHighlighted)).toHaveLength(2); + }); + + it('should not show copy code button', () => { + const copyCodeButton = findCopyCodeButton(); + expect(copyCodeButton.exists()).toBe(false); + }); + + it('should render non-code content', () => { + const markdownBlock = findMarkdownBlock(); + expect(markdownBlock.exists()).toBe(true); + expect(markdownBlock.text()).toContain(nonCodeContent); + }); + + describe('copy code button', () => { + beforeEach(() => { + const codeBlock = findCodeBlockWrapper(); + codeBlock.trigger('mouseenter'); + }); + + it('should render only one copy button per code block', () => { + const copyCodeButtons = wrapper.findAllComponents(ModalCopyButton); + expect(copyCodeButtons).toHaveLength(1); + }); + + it('should render code block button with correct props', () => { + const copyCodeButton = findCopyCodeButton(); + expect(copyCodeButton.exists()).toBe(true); + expect(copyCodeButton.props()).toEqual( + expect.objectContaining({ + text: codeBlockContent, + title: 'Copy code', + }), + ); + }); + + it('should hide code block button on mouseleave', async () => { + const codeBlock = findCodeBlockWrapper(); + codeBlock.trigger('mouseleave'); + await nextTick(); + const copyCodeButton = findCopyCodeButton(); + expect(copyCodeButton.exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js index 2489421b697..5bf11ff2b26 100644 --- a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js +++ b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js @@ -1,18 +1,33 @@ import { mount } from '@vue/test-utils'; import Toolbar from '~/vue_shared/components/markdown/toolbar.vue'; +import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue'; +import { updateText } from '~/lib/utils/text_markdown'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; + +jest.mock('~/lib/utils/text_markdown'); describe('toolbar', () => { let wrapper; - const createMountedWrapper = (props = {}) => { + const createWrapper = (props = {}, attachTo = document.body) => { wrapper = mount(Toolbar, { + attachTo, propsData: { markdownDocsPath: '', ...props }, + mocks: { + $apollo: { + queries: { + currentUser: { + loading: false, + }, + }, + }, + }, }); }; describe('user can attach file', () => { beforeEach(() => { - createMountedWrapper(); + createWrapper(); }); it('should render uploading-container', () => { @@ -22,7 +37,7 @@ describe('toolbar', () => { describe('user cannot attach file', () => { beforeEach(() => { - createMountedWrapper({ canAttachFile: false }); + createWrapper({ canAttachFile: false }); }); it('should not render uploading-container', () => { @@ -32,15 +47,63 @@ describe('toolbar', () => { describe('comment tool bar settings', () => { it('does not show comment tool bar div', () => { - createMountedWrapper({ showCommentToolBar: false }); + createWrapper({ showCommentToolBar: false }); expect(wrapper.find('.comment-toolbar').exists()).toBe(false); }); it('shows comment tool bar by default', () => { - createMountedWrapper(); + createWrapper(); expect(wrapper.find('.comment-toolbar').exists()).toBe(true); }); }); + + describe('with content editor switcher', () => { + beforeEach(() => { + setHTMLFixture( + '<div class="md-area"><textarea>some value</textarea><div id="root"></div></div>', + ); + createWrapper( + { + showContentEditorSwitcher: true, + }, + '#root', + ); + }); + + afterEach(() => { + resetHTMLFixture(); + }); + + it('re-emits event from switcher', () => { + wrapper.findComponent(EditorModeSwitcher).vm.$emit('switch'); + + expect(wrapper.emitted('enableContentEditor')).toEqual([[]]); + expect(updateText).not.toHaveBeenCalled(); + }); + + it('does not insert a template text if textarea has some value', () => { + wrapper.findComponent(EditorModeSwitcher).vm.$emit('switch', true); + + expect(updateText).not.toHaveBeenCalled(); + }); + + it('inserts a "getting started with rich text" template when switched for the first time', () => { + document.querySelector('textarea').value = ''; + + wrapper.findComponent(EditorModeSwitcher).vm.$emit('switch', true); + + expect(updateText).toHaveBeenCalledWith( + expect.objectContaining({ + tag: `### Rich text editor + +Try out **styling** _your_ content right here or read the [direction](https://about.gitlab.com/direction/plan/knowledge/content_editor/).`, + textArea: document.querySelector('textarea'), + cursorOffset: 0, + wrap: false, + }), + ); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js b/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js index a116233a065..f04e1976a5f 100644 --- a/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js @@ -38,7 +38,6 @@ describe('NewResourceDropdown component', () => { }; const mountComponent = ({ - search = '', query = searchUserProjectsWithIssuesEnabledQuery, queryResponse = searchProjectsQueryResponse, mountFn = shallowMount, @@ -47,16 +46,14 @@ describe('NewResourceDropdown component', () => { const requestHandlers = [[query, jest.fn().mockResolvedValue(queryResponse)]]; const apolloProvider = createMockApollo(requestHandlers); - return mountFn(NewResourceDropdown, { + wrapper = mountFn(NewResourceDropdown, { apolloProvider, propsData, - data() { - return { search }; - }, }); }; const findDropdown = () => wrapper.findComponent(GlDropdown); + const findGlDropdownItem = () => wrapper.findComponent(GlDropdownItem); const findInput = () => wrapper.findComponent(GlSearchBoxByType); const showDropdown = async () => { findDropdown().vm.$emit('shown'); @@ -70,13 +67,13 @@ describe('NewResourceDropdown component', () => { }); it('renders a split dropdown', () => { - wrapper = mountComponent(); + mountComponent(); expect(findDropdown().props('split')).toBe(true); }); it('renders a label for the dropdown toggle button', () => { - wrapper = mountComponent(); + mountComponent(); expect(findDropdown().attributes('toggle-text')).toBe( NewResourceDropdown.i18n.toggleButtonLabel, @@ -84,7 +81,7 @@ describe('NewResourceDropdown component', () => { }); it('focuses on input when dropdown is shown', async () => { - wrapper = mountComponent({ mountFn: mount }); + mountComponent({ mountFn: mount }); const inputSpy = jest.spyOn(findInput().vm, 'focusInput'); @@ -99,7 +96,7 @@ describe('NewResourceDropdown component', () => { ${'within a group'} | ${withinGroupProps} | ${searchProjectsWithinGroupQuery} | ${searchProjectsWithinGroupQueryResponse} | ${emptySearchProjectsWithinGroupQueryResponse} `('$description', ({ propsData, query, queryResponse, emptyResponse }) => { it('renders projects options', async () => { - wrapper = mountComponent({ mountFn: mount, query, queryResponse, propsData }); + mountComponent({ mountFn: mount, query, queryResponse, propsData }); await showDropdown(); const listItems = wrapper.findAll('li'); @@ -110,14 +107,14 @@ describe('NewResourceDropdown component', () => { }); it('renders `No matches found` when there are no matches', async () => { - wrapper = mountComponent({ - search: 'no matches', + mountComponent({ query, queryResponse: emptyResponse, mountFn: mount, propsData, }); + await findInput().vm.$emit('input', 'no matches'); await showDropdown(); expect(wrapper.find('li').text()).toBe(NewResourceDropdown.i18n.noMatchesFound); @@ -133,7 +130,7 @@ describe('NewResourceDropdown component', () => { ({ resourceType, expectedDefaultLabel, expectedPath, expectedLabel }) => { describe('when no project is selected', () => { beforeEach(() => { - wrapper = mountComponent({ + mountComponent({ query, queryResponse, propsData: { ...propsData, resourceType }, @@ -151,7 +148,7 @@ describe('NewResourceDropdown component', () => { describe('when a project is selected', () => { beforeEach(async () => { - wrapper = mountComponent({ + mountComponent({ mountFn: mount, query, queryResponse, @@ -159,7 +156,7 @@ describe('NewResourceDropdown component', () => { }); await showDropdown(); - wrapper.findComponent(GlDropdownItem).vm.$emit('click', project1); + findGlDropdownItem().vm.$emit('click', project1); }); it('dropdown button is a link', () => { @@ -178,12 +175,12 @@ describe('NewResourceDropdown component', () => { describe('without localStorage', () => { beforeEach(() => { - wrapper = mountComponent({ mountFn: mount }); + mountComponent({ mountFn: mount }); }); it('does not attempt to save the selected project to the localStorage', async () => { await showDropdown(); - wrapper.findComponent(GlDropdownItem).vm.$emit('click', project1); + findGlDropdownItem().vm.$emit('click', project1); expect(localStorage.setItem).not.toHaveBeenCalled(); }); @@ -198,7 +195,7 @@ describe('NewResourceDropdown component', () => { name: project1.name, }), ); - wrapper = mountComponent({ mountFn: mount, propsData: { withLocalStorage: true } }); + mountComponent({ mountFn: mount, propsData: { withLocalStorage: true } }); await nextTick(); const dropdown = findDropdown(); @@ -216,7 +213,7 @@ describe('NewResourceDropdown component', () => { name: project1.name, }), ); - wrapper = mountComponent({ mountFn: mount, propsData: { withLocalStorage: true } }); + mountComponent({ mountFn: mount, propsData: { withLocalStorage: true } }); await nextTick(); const dropdown = findDropdown(); @@ -228,12 +225,12 @@ describe('NewResourceDropdown component', () => { describe.each(RESOURCE_TYPES)('with resource type %s', (resourceType) => { it('computes the local storage key without a group', async () => { - wrapper = mountComponent({ + mountComponent({ mountFn: mount, propsData: { resourceType, withLocalStorage: true }, }); await showDropdown(); - wrapper.findComponent(GlDropdownItem).vm.$emit('click', project1); + findGlDropdownItem().vm.$emit('click', project1); await nextTick(); expect(localStorage.setItem).toHaveBeenLastCalledWith( @@ -244,12 +241,12 @@ describe('NewResourceDropdown component', () => { it('computes the local storage key with a group', async () => { const groupId = '22'; - wrapper = mountComponent({ + mountComponent({ mountFn: mount, propsData: { groupId, resourceType, withLocalStorage: true }, }); await showDropdown(); - wrapper.findComponent(GlDropdownItem).vm.$emit('click', project1); + findGlDropdownItem().vm.$emit('click', project1); await nextTick(); expect(localStorage.setItem).toHaveBeenLastCalledWith( diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js index a27877e7ba8..e5b641c61fd 100644 --- a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js +++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js @@ -300,6 +300,7 @@ describe('AlertManagementEmptyState', () => { unique: true, symbol: '@', token: UserToken, + dataType: 'user', operators: OPERATORS_IS, fetchPath: '/link', fetchUsers: expect.any(Function), @@ -311,6 +312,7 @@ describe('AlertManagementEmptyState', () => { unique: true, symbol: '@', token: UserToken, + dataType: 'user', operators: OPERATORS_IS, fetchPath: '/link', fetchUsers: expect.any(Function), diff --git a/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js index 3e4d5c558f6..0e387d1c139 100644 --- a/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js +++ b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js @@ -38,6 +38,7 @@ describe('ProjectsListItem', () => { const findProjectTopics = () => wrapper.findByTestId('project-topics'); const findPopover = () => findProjectTopics().findComponent(GlPopover); const findProjectDescription = () => wrapper.findByTestId('project-description'); + const findVisibilityIcon = () => findAvatarLabeled().findComponent(GlIcon); it('renders project avatar', () => { createComponent(); @@ -48,11 +49,11 @@ describe('ProjectsListItem', () => { label: project.name, labelLink: project.webUrl, }); + expect(avatarLabeled.attributes()).toMatchObject({ 'entity-id': project.id.toString(), 'entity-name': project.name, shape: 'rect', - size: '48', }); }); @@ -66,6 +67,19 @@ describe('ProjectsListItem', () => { expect(tooltip.value).toBe(PROJECT_VISIBILITY_TYPE[VISIBILITY_LEVEL_PRIVATE_STRING]); }); + describe('when visibility is not provided', () => { + it('does not render visibility icon', () => { + const { visibility, ...projectWithoutVisibility } = project; + createComponent({ + propsData: { + project: projectWithoutVisibility, + }, + }); + + expect(findVisibilityIcon().exists()).toBe(false); + }); + }); + it('renders access role badge', () => { createComponent(); @@ -113,6 +127,19 @@ describe('ProjectsListItem', () => { expect(wrapper.findComponent(TimeAgoTooltip).props('time')).toBe(project.updatedAt); }); + describe('when updated at is not available', () => { + it('does not render updated at', () => { + const { updatedAt, ...projectWithoutUpdatedAt } = project; + createComponent({ + propsData: { + project: projectWithoutUpdatedAt, + }, + }); + + expect(wrapper.findComponent(TimeAgoTooltip).exists()).toBe(false); + }); + }); + describe('when issues are enabled', () => { it('renders issues count', () => { createComponent(); @@ -263,4 +290,20 @@ describe('ProjectsListItem', () => { expect(findProjectDescription().exists()).toBe(false); }); }); + + describe('when `showProjectIcon` prop is `true`', () => { + it('shows project icon', () => { + createComponent({ propsData: { showProjectIcon: true } }); + + expect(wrapper.findByTestId('project-icon').exists()).toBe(true); + }); + }); + + describe('when `showProjectIcon` prop is `false`', () => { + it('does not show project icon', () => { + createComponent(); + + expect(wrapper.findByTestId('project-icon').exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js b/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js index 9380e19c39e..a0adbb89894 100644 --- a/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js +++ b/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js @@ -28,6 +28,7 @@ describe('ProjectsList', () => { expect(expectedProps).toEqual( defaultPropsData.projects.map((project) => ({ project, + showProjectIcon: false, })), ); }); diff --git a/spec/frontend/vue_shared/components/registry/list_item_spec.js b/spec/frontend/vue_shared/components/registry/list_item_spec.js index 298fa163d59..4a230f72f21 100644 --- a/spec/frontend/vue_shared/components/registry/list_item_spec.js +++ b/spec/frontend/vue_shared/components/registry/list_item_spec.js @@ -1,6 +1,7 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import component from '~/vue_shared/components/registry/list_item.vue'; describe('list item', () => { @@ -27,6 +28,9 @@ describe('list item', () => { 'right-action': '<div data-testid="right-action" />', ...slots, }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, }); }; @@ -90,6 +94,48 @@ describe('list item', () => { expect(findToggleDetailsButton().exists()).toBe(true); }); + describe('when visible', () => { + beforeEach(async () => { + mountComponent({}, { 'details-foo': '<span></span>' }); + await nextTick(); + }); + + it('has tooltip', () => { + const tooltip = getBinding(findToggleDetailsButton().element, 'gl-tooltip'); + + expect(tooltip).toBeDefined(); + expect(findToggleDetailsButton().attributes('title')).toBe( + component.i18n.toggleDetailsLabel, + ); + }); + + it('has correct attributes and props', () => { + expect(findToggleDetailsButton().props()).toMatchObject({ + selected: false, + }); + + expect(findToggleDetailsButton().attributes()).toMatchObject({ + title: component.i18n.toggleDetailsLabel, + 'aria-label': component.i18n.toggleDetailsLabel, + }); + }); + + it('has correct attributes and props when clicked', async () => { + findToggleDetailsButton().vm.$emit('click'); + await nextTick(); + + expect(findToggleDetailsButton().props()).toMatchObject({ + selected: true, + }); + + expect(findToggleDetailsButton().attributes()).toMatchObject({ + title: component.i18n.toggleDetailsLabel, + 'aria-label': component.i18n.toggleDetailsLabel, + 'aria-expanded': 'true', + }); + }); + }); + it('is hidden without details slot', () => { mountComponent(); expect(findToggleDetailsButton().exists()).toBe(false); diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_docker_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_docker_instructions_spec.js index 94823bb640b..b94d8c1de21 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_docker_instructions_spec.js +++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_docker_instructions_spec.js @@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import { GlButton } from '@gitlab/ui'; import RunnerDockerInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_docker_instructions.vue'; +import { DOCS_URL } from 'jh_else_ce/lib/utils/url_utility'; describe('RunnerDockerInstructions', () => { let wrapper; @@ -25,8 +26,6 @@ describe('RunnerDockerInstructions', () => { }); it('renders link', () => { - expect(findButton().attributes('href')).toBe( - 'https://docs.gitlab.com/runner/install/docker.html', - ); + expect(findButton().attributes('href')).toBe(`${DOCS_URL}/runner/install/docker.html`); }); }); diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions_spec.js index 9d6658e002c..f0b033a2ca2 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions_spec.js +++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions_spec.js @@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import { GlButton } from '@gitlab/ui'; import RunnerKubernetesInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions.vue'; +import { DOCS_URL } from 'jh_else_ce/lib/utils/url_utility'; describe('RunnerKubernetesInstructions', () => { let wrapper; @@ -25,8 +26,6 @@ describe('RunnerKubernetesInstructions', () => { }); it('renders link', () => { - expect(findButton().attributes('href')).toBe( - 'https://docs.gitlab.com/runner/install/kubernetes.html', - ); + expect(findButton().attributes('href')).toBe(`${DOCS_URL}/runner/install/kubernetes.html`); }); }); diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js index 2eaf46e6209..e307e53147b 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js +++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js @@ -90,14 +90,6 @@ describe('RunnerInstructionsModal component', () => { await waitForPromises(); }); - it('should not show alert', () => { - expect(findAlert().exists()).toBe(false); - }); - - it('should not show deprecation alert', () => { - expect(findAlert('warning').exists()).toBe(false); - }); - it('should contain a number of platforms buttons', () => { expect(runnerPlatformsHandler).toHaveBeenCalledWith({}); @@ -112,19 +104,8 @@ describe('RunnerInstructionsModal component', () => { expect(architectures).toEqual(mockPlatformList[0].architectures.nodes); }); - describe.each` - glFeatures | deprecationAlertExists - ${{}} | ${false} - ${{ createRunnerWorkflowForAdmin: true }} | ${true} - ${{ createRunnerWorkflowForNamespace: true }} | ${true} - `('with features $glFeatures', ({ glFeatures, deprecationAlertExists }) => { - beforeEach(() => { - createComponent({ provide: { glFeatures } }); - }); - - it(`alert is ${deprecationAlertExists ? 'shown' : 'not shown'}`, () => { - expect(findAlert('warning').exists()).toBe(deprecationAlertExists); - }); + it('alert is shown', () => { + expect(findAlert('warning').exists()).toBe(true); }); describe('when the modal resizes', () => { diff --git a/spec/frontend/vue_shared/components/security_reports/__snapshots__/security_summary_spec.js.snap b/spec/frontend/vue_shared/components/security_reports/__snapshots__/security_summary_spec.js.snap deleted file mode 100644 index 66d27b5d605..00000000000 --- a/spec/frontend/vue_shared/components/security_reports/__snapshots__/security_summary_spec.js.snap +++ /dev/null @@ -1,144 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SecuritySummary component given the message {"countMessage": "%{criticalStart}0 Critical%{criticalEnd} %{highStart}1 High%{highEnd} and %{otherStart}0 Others%{otherEnd}", "critical": 0, "high": 1, "message": "Security scanning detected %{totalStart}1%{totalEnd} potential vulnerability", "other": 0, "status": "", "total": 1} interpolates correctly 1`] = ` -<span> - Security scanning detected - <strong> - 1 - </strong> - potential vulnerability - <span - class="gl-font-sm" - > - <span> - <span - class="gl-pl-4" - > - - 0 Critical - - </span> - </span> - - <span> - <strong - class="gl-text-red-600 gl-px-2" - > - - 1 High - - </strong> - </span> - and - <span> - <span - class="gl-px-2" - > - - 0 Others - - </span> - </span> - </span> -</span> -`; - -exports[`SecuritySummary component given the message {"countMessage": "%{criticalStart}1 Critical%{criticalEnd} %{highStart}0 High%{highEnd} and %{otherStart}0 Others%{otherEnd}", "critical": 1, "high": 0, "message": "Security scanning detected %{totalStart}1%{totalEnd} potential vulnerability", "other": 0, "status": "", "total": 1} interpolates correctly 1`] = ` -<span> - Security scanning detected - <strong> - 1 - </strong> - potential vulnerability - <span - class="gl-font-sm" - > - <span> - <strong - class="gl-text-red-800 gl-pl-4" - > - - 1 Critical - - </strong> - </span> - - <span> - <span - class="gl-px-2" - > - - 0 High - - </span> - </span> - and - <span> - <span - class="gl-px-2" - > - - 0 Others - - </span> - </span> - </span> -</span> -`; - -exports[`SecuritySummary component given the message {"countMessage": "%{criticalStart}1 Critical%{criticalEnd} %{highStart}2 High%{highEnd} and %{otherStart}0 Others%{otherEnd}", "critical": 1, "high": 2, "message": "Security scanning detected %{totalStart}3%{totalEnd} potential vulnerabilities", "other": 0, "status": "", "total": 3} interpolates correctly 1`] = ` -<span> - Security scanning detected - <strong> - 3 - </strong> - potential vulnerabilities - <span - class="gl-font-sm" - > - <span> - <strong - class="gl-text-red-800 gl-pl-4" - > - - 1 Critical - - </strong> - </span> - - <span> - <strong - class="gl-text-red-600 gl-px-2" - > - - 2 High - - </strong> - </span> - and - <span> - <span - class="gl-px-2" - > - - 0 Others - - </span> - </span> - </span> -</span> -`; - -exports[`SecuritySummary component given the message {"message": ""} interpolates correctly 1`] = ` -<span> - - <!----> -</span> -`; - -exports[`SecuritySummary component given the message {"message": "foo"} interpolates correctly 1`] = ` -<span> - foo - <!----> -</span> -`; diff --git a/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js b/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js deleted file mode 100644 index 6eebd129beb..00000000000 --- a/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js +++ /dev/null @@ -1,104 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { - expectedDownloadDropdownPropsWithTitle, - securityReportMergeRequestDownloadPathsQueryResponse, -} from 'jest/vue_shared/security_reports/mock_data'; -import { createAlert } from '~/alert'; -import Component from '~/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue'; -import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue'; -import { - REPORT_TYPE_SAST, - REPORT_TYPE_SECRET_DETECTION, -} from '~/vue_shared/security_reports/constants'; -import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql'; - -jest.mock('~/alert'); - -describe('Merge request artifact Download', () => { - let wrapper; - - const defaultProps = { - reportTypes: [REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION], - targetProjectFullPath: '/path', - mrIid: 123, - }; - - const createWrapper = ({ propsData, options }) => { - wrapper = shallowMount(Component, { - stubs: { - SecurityReportDownloadDropdown, - }, - propsData: { - ...defaultProps, - ...propsData, - }, - ...options, - }); - }; - - const pendingHandler = () => new Promise(() => {}); - const successHandler = () => - Promise.resolve({ data: securityReportMergeRequestDownloadPathsQueryResponse }); - const failureHandler = () => Promise.resolve({ errors: [{ message: 'some error' }] }); - const createMockApolloProvider = (handler) => { - Vue.use(VueApollo); - const requestHandlers = [[securityReportMergeRequestDownloadPathsQuery, handler]]; - - return createMockApollo(requestHandlers); - }; - - const findDownloadDropdown = () => wrapper.findComponent(SecurityReportDownloadDropdown); - - describe('given the query is loading', () => { - beforeEach(() => { - createWrapper({ - options: { - apolloProvider: createMockApolloProvider(pendingHandler), - }, - }); - }); - - it('loading is true', () => { - expect(findDownloadDropdown().props('loading')).toBe(true); - }); - }); - - describe('given the query loads successfully', () => { - beforeEach(() => { - createWrapper({ - options: { - apolloProvider: createMockApolloProvider(successHandler), - }, - }); - }); - - it('renders the download dropdown', () => { - expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownPropsWithTitle); - }); - }); - - describe('given the query fails', () => { - beforeEach(() => { - createWrapper({ - options: { - apolloProvider: createMockApolloProvider(failureHandler), - }, - }); - }); - - it('calls createAlert correctly', () => { - expect(createAlert).toHaveBeenCalledWith({ - message: Component.i18n.apiError, - captureError: true, - error: expect.any(Error), - }); - }); - - it('renders nothing', () => { - expect(findDownloadDropdown().props('artifacts')).toEqual([]); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/security_reports/help_icon_spec.js b/spec/frontend/vue_shared/components/security_reports/help_icon_spec.js deleted file mode 100644 index 2f6e633fb34..00000000000 --- a/spec/frontend/vue_shared/components/security_reports/help_icon_spec.js +++ /dev/null @@ -1,63 +0,0 @@ -import { GlLink, GlPopover } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import HelpIcon from '~/vue_shared/security_reports/components/help_icon.vue'; - -const helpPath = '/docs'; -const discoverProjectSecurityPath = '/discoverProjectSecurityPath'; - -describe('HelpIcon component', () => { - let wrapper; - - const createWrapper = (props) => { - wrapper = shallowMount(HelpIcon, { - propsData: { - helpPath, - ...props, - }, - }); - }; - - const findLink = () => wrapper.findComponent(GlLink); - const findPopover = () => wrapper.findComponent(GlPopover); - const findPopoverTarget = () => wrapper.findComponent({ ref: 'discoverProjectSecurity' }); - - describe('given a help path only', () => { - beforeEach(() => { - createWrapper(); - }); - - it('does not render a popover', () => { - expect(findPopover().exists()).toBe(false); - }); - - it('renders a help link', () => { - expect(findLink().attributes()).toMatchObject({ - href: helpPath, - target: '_blank', - }); - }); - }); - - describe('given a help path and discover project security path', () => { - beforeEach(() => { - createWrapper({ discoverProjectSecurityPath }); - }); - - it('renders a popover', () => { - const popover = findPopover(); - expect(popover.props('target')()).toBe(findPopoverTarget().element); - expect(popover.attributes()).toMatchObject({ - title: HelpIcon.i18n.upgradeToManageVulnerabilities, - triggers: 'click blur', - }); - expect(popover.text()).toContain(HelpIcon.i18n.upgradeToInteract); - }); - - it('renders a link to the discover path', () => { - expect(findLink().attributes()).toMatchObject({ - href: discoverProjectSecurityPath, - target: '_blank', - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/security_reports/security_summary_spec.js b/spec/frontend/vue_shared/components/security_reports/security_summary_spec.js deleted file mode 100644 index 61cdc329220..00000000000 --- a/spec/frontend/vue_shared/components/security_reports/security_summary_spec.js +++ /dev/null @@ -1,33 +0,0 @@ -import { GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import SecuritySummary from '~/vue_shared/security_reports/components/security_summary.vue'; -import { groupedTextBuilder } from '~/vue_shared/security_reports/store/utils'; - -describe('SecuritySummary component', () => { - let wrapper; - - const createWrapper = (message) => { - wrapper = shallowMount(SecuritySummary, { - propsData: { message }, - stubs: { - GlSprintf, - }, - }); - }; - - describe.each([ - { message: '' }, - { message: 'foo' }, - groupedTextBuilder({ reportType: 'Security scanning', critical: 1, high: 0, total: 1 }), - groupedTextBuilder({ reportType: 'Security scanning', critical: 0, high: 1, total: 1 }), - groupedTextBuilder({ reportType: 'Security scanning', critical: 1, high: 2, total: 3 }), - ])('given the message %p', (message) => { - beforeEach(() => { - createWrapper(message); - }); - - it('interpolates correctly', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_new_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_new_spec.js index 919abc26e05..1154c930e5d 100644 --- a/spec/frontend/vue_shared/components/source_viewer/components/chunk_new_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_new_spec.js @@ -40,8 +40,6 @@ describe('Chunk component', () => { describe('rendering', () => { it('does not register window.requestIdleCallback for the first chunk, renders content immediately', () => { - jest.clearAllMocks(); - expect(window.requestIdleCallback).not.toHaveBeenCalled(); expect(findContent().text()).toBe(CHUNK_1.highlightedContent); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js b/spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js index d2dd4afe09e..49e3083f8ed 100644 --- a/spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js @@ -1,10 +1,11 @@ -import hljs from 'highlight.js'; +import hljs from 'highlight.js/lib/core'; import { registerPlugins } from '~/vue_shared/components/source_viewer/plugins/index'; import { highlight } from '~/vue_shared/components/source_viewer/workers/highlight_utils'; import { LINES_PER_CHUNK, NEWLINE } from '~/vue_shared/components/source_viewer/constants'; -jest.mock('highlight.js', () => ({ +jest.mock('highlight.js/lib/core', () => ({ highlight: jest.fn().mockReturnValue({ value: 'highlighted content' }), + registerLanguage: jest.fn(), })); jest.mock('~/vue_shared/components/source_viewer/plugins/index', () => ({ @@ -14,11 +15,15 @@ jest.mock('~/vue_shared/components/source_viewer/plugins/index', () => ({ const fileType = 'text'; const rawContent = 'function test() { return true }; \n // newline'; const highlightedContent = 'highlighted content'; -const language = 'javascript'; +const language = 'json'; describe('Highlight utility', () => { beforeEach(() => highlight(fileType, rawContent, language)); + it('registers the language', () => { + expect(hljs.registerLanguage).toHaveBeenCalledWith(language, expect.any(Function)); + }); + it('registers the plugins', () => { expect(registerPlugins).toHaveBeenCalled(); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js index 9d2bf002d73..45fef09aa84 100644 --- a/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js @@ -8,14 +8,15 @@ describe('Highlight.js plugin for wrapping _emitter nodes', () => { children: [ { scope: 'string', children: ['Text 1'] }, { scope: 'string', children: ['Text 2', { scope: 'comment', children: ['Text 3'] }] }, - { scope: undefined, sublanguage: true, children: ['Text 3 (sublanguage)'] }, - 'Text4\nText5', + { scope: undefined, sublanguage: true, children: ['Text 4 (sublanguage)'] }, + { scope: undefined, sublanguage: undefined, children: ['Text 5'] }, + 'Text6\nText7', ], }, }, }; - const outputValue = `<span class="hljs-string">Text 1</span><span class="hljs-string"><span class="hljs-string">Text 2</span><span class="hljs-comment">Text 3</span></span><span class="">Text 3 (sublanguage)</span><span class="">Text4</span>\n<span class="">Text5</span>`; + const outputValue = `<span class="hljs-string">Text 1</span><span class="hljs-string"><span class="hljs-string">Text 2</span><span class="hljs-comment">Text 3</span></span><span class="">Text 4 (sublanguage)</span><span class="">Text 5</span><span class="">Text6</span>\n<span class="">Text7</span>`; wrapChildNodes(hljsResultMock); expect(hljsResultMock.value).toBe(outputValue); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_lines_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_lines_spec.js new file mode 100644 index 00000000000..def76856dba --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_lines_spec.js @@ -0,0 +1,12 @@ +import wrapLines from '~/vue_shared/components/source_viewer/plugins/wrap_lines'; + +describe('Highlight.js plugin for wrapping lines', () => { + it('mutates the input value by wrapping each line in a div with the correct attributes', () => { + const inputValue = `// some content`; + const outputValue = `<div id="LC1" lang="javascript" class="line">${inputValue}</div>`; + const hljsResultMock = { value: inputValue, language: 'javascript' }; + + wrapLines(hljsResultMock); + expect(hljsResultMock.value).toBe(outputValue); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js index 715234e56fd..6b711b6b6b2 100644 --- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js @@ -3,9 +3,11 @@ import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer_ne import Chunk from '~/vue_shared/components/source_viewer/components/chunk_new.vue'; import { EVENT_ACTION, EVENT_LABEL_VIEWER } from '~/vue_shared/components/source_viewer/constants'; import Tracking from '~/tracking'; +import LineHighlighter from '~/blob/line_highlighter'; import addBlobLinksTracking from '~/blob/blob_links_tracking'; import { BLOB_DATA_MOCK, CHUNK_1, CHUNK_2, LANGUAGE_MOCK } from './mock_data'; +jest.mock('~/blob/line_highlighter'); jest.mock('~/blob/blob_links_tracking'); describe('Source Viewer component', () => { @@ -25,6 +27,10 @@ describe('Source Viewer component', () => { return createComponent(); }); + it('instantiates the lineHighlighter class', () => { + expect(LineHighlighter).toHaveBeenCalled(); + }); + describe('event tracking', () => { it('fires a tracking event when the component is created', () => { const eventData = { label: EVENT_LABEL_VIEWER, property: LANGUAGE_MOCK }; diff --git a/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js b/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js index 24f96195e05..776395b9717 100644 --- a/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js +++ b/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js @@ -1,6 +1,6 @@ import { GlIcon, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; describe('Upload dropzone component', () => { @@ -11,13 +11,13 @@ describe('Upload dropzone component', () => { }; const findDropzoneCard = () => wrapper.find('.upload-dropzone-card'); - const findDropzoneArea = () => wrapper.find('[data-testid="dropzone-area"]'); + const findDropzoneArea = () => wrapper.findByTestId('dropzone-area'); const findIcon = () => wrapper.findComponent(GlIcon); - const findUploadText = () => wrapper.find('[data-testid="upload-text"]').text(); + const findUploadText = () => wrapper.findByTestId('upload-text').text(); const findFileInput = () => wrapper.find('input[type="file"]'); - function createComponent({ slots = {}, data = {}, props = {} } = {}) { - wrapper = shallowMount(UploadDropzone, { + function createComponent({ slots = {}, props = {} } = {}) { + wrapper = shallowMountExtended(UploadDropzone, { slots, propsData: { displayAsCard: true, @@ -26,9 +26,6 @@ describe('Upload dropzone component', () => { stubs: { GlSprintf, }, - data() { - return data; - }, }); } @@ -112,53 +109,50 @@ describe('Upload dropzone component', () => { wrapper.trigger('drop', mockEvent); await nextTick(); - expect(wrapper.emitted().change[0]).toEqual([[mockFile]]); + expect(wrapper.emitted('change')).toEqual([[[mockFile]]]); }); }); describe('ondrop', () => { - const mockData = { dragCounter: 1, isDragDataValid: true }; - describe('when drag data is valid', () => { it('emits upload event for valid files', () => { - createComponent({ data: mockData }); + createComponent(); const mockFile = { type: 'image/jpg' }; const mockEvent = mockDragEvent({ files: [mockFile] }); - wrapper.vm.ondrop(mockEvent); - expect(wrapper.emitted().change[0]).toEqual([[mockFile]]); + wrapper.trigger('drop', mockEvent); + expect(wrapper.emitted('change')).toEqual([[[mockFile]]]); }); it('emits error event when files are invalid', () => { - createComponent({ data: mockData }); + createComponent(); const mockEvent = mockDragEvent({ files: [{ type: 'audio/midi' }] }); - wrapper.vm.ondrop(mockEvent); + wrapper.trigger('drop', mockEvent); expect(wrapper.emitted()).toHaveProperty('error'); }); it('allows validation function to be overwritten', () => { - createComponent({ data: mockData, props: { isFileValid: () => true } }); + createComponent({ props: { isFileValid: () => true } }); const mockEvent = mockDragEvent({ files: [{ type: 'audio/midi' }] }); - wrapper.vm.ondrop(mockEvent); + wrapper.trigger('drop', mockEvent); expect(wrapper.emitted()).not.toHaveProperty('error'); }); describe('singleFileSelection = true', () => { it('emits a single file on drop', () => { createComponent({ - data: mockData, props: { singleFileSelection: true }, }); const mockFile = { type: 'image/jpg' }; const mockEvent = mockDragEvent({ files: [mockFile] }); - wrapper.vm.ondrop(mockEvent); - expect(wrapper.emitted().change[0]).toEqual([mockFile]); + wrapper.trigger('drop', mockEvent); + expect(wrapper.emitted('change')).toEqual([[mockFile]]); }); }); }); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js index 90f9156af38..443d4e32580 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js @@ -95,4 +95,18 @@ describe('User Avatar Link Component', () => { expect(wrapper.html()).toContain(badge); }); }); + + describe('when popover props provided', () => { + beforeEach(() => { + createWrapper({ popoverUserId: 1, popoverUsername: defaultProps.username }); + }); + + it('should render GlAvatarLink with popover support', () => { + expect(wrapper.attributes()).toMatchObject({ + href: defaultProps.linkHref, + 'data-user-id': '1', + 'data-username': `${defaultProps.username}`, + }); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js index 075cb753301..32f9df8a63c 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js @@ -14,6 +14,7 @@ const DEFAULT_EMPTY_MESSAGE = 'None'; const createUser = (id) => ({ id, name: 'Lorem', + username: 'lorem.ipsum', web_url: `${TEST_HOST}/${id}`, avatar_url: `${TEST_HOST}/${id}/avatar`, }); @@ -90,6 +91,8 @@ describe('UserAvatarList', () => { imgAlt: x.name, tooltipText: x.name, imgSize: TEST_IMAGE_SIZE, + popoverUserId: x.id, + popoverUsername: x.username, }), ), ); @@ -107,6 +110,8 @@ describe('UserAvatarList', () => { imgAlt: x.name, tooltipText: x.name, imgSize: TEST_IMAGE_SIZE, + popoverUserId: x.id, + popoverUsername: x.username, }), ), ); diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js index 41181ab9a68..0457044f985 100644 --- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js @@ -31,6 +31,7 @@ const DEFAULT_PROPS = { name: 'Administrator', location: 'Vienna', localTime: '2:30 PM', + webUrl: '/root', bot: false, bio: null, workInformation: null, @@ -71,11 +72,11 @@ describe('User Popover Component', () => { }); }; - const createWrapper = (props = {}) => { + const createWrapper = (props = {}, target = findTarget()) => { wrapper = mountExtended(UserPopover, { propsData: { ...DEFAULT_PROPS, - target: findTarget(), + target, ...props, }, }); @@ -518,4 +519,35 @@ describe('User Popover Component', () => { expect(findToggleFollowButton().exists()).toBe(false); }); }); + + describe('when current user is assignee/reviewer in a Merge Request', () => { + const { id, username, webUrl } = DEFAULT_PROPS.user; + const target = document.createElement('a'); + target.setAttribute('href', webUrl); + target.classList.add('js-user-link'); + target.dataset.currentUserId = id; + target.dataset.currentUsername = username; + + it('renders popover with warning when user unable to merge', () => { + target.dataset.cannotMerge = 'true'; + + createWrapper({}, target); + + const cannotMergeWarning = wrapper.findByTestId('cannot-merge'); + + expect(cannotMergeWarning.exists()).toBe(true); + expect(cannotMergeWarning.text()).toContain('Cannot merge'); + expect(cannotMergeWarning.findComponent(GlIcon).props('name')).toBe('warning-solid'); + }); + + it('renders popover without any warning when user is able to merge', () => { + delete target.dataset.cannotMerge; + + createWrapper({}, target); + + const cannotMergeWarning = wrapper.findByTestId('cannot-merge'); + + expect(cannotMergeWarning.exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/user_select_spec.js b/spec/frontend/vue_shared/components/user_select_spec.js index e881bfed35e..8c7657da8bc 100644 --- a/spec/frontend/vue_shared/components/user_select_spec.js +++ b/spec/frontend/vue_shared/components/user_select_spec.js @@ -1,10 +1,10 @@ import { GlSearchBoxByType } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import { cloneDeep } from 'lodash'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphql'; import searchUsersQueryOnMR from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql'; import { TYPE_MERGE_REQUEST } from '~/issues/constants'; @@ -44,20 +44,20 @@ Vue.use(VueApollo); describe('User select dropdown', () => { let wrapper; let fakeApollo; + const hideDropdownMock = jest.fn(); const findSearchField = () => wrapper.findComponent(GlSearchBoxByType); - const findParticipantsLoading = () => wrapper.find('[data-testid="loading-participants"]'); - const findSelectedParticipants = () => wrapper.findAll('[data-testid="selected-participant"]'); + const findParticipantsLoading = () => wrapper.findByTestId('loading-participants'); + const findSelectedParticipants = () => wrapper.findAllByTestId('selected-participant'); const findSelectedParticipantByIndex = (index) => findSelectedParticipants().at(index).findComponent(SidebarParticipant); - const findUnselectedParticipants = () => - wrapper.findAll('[data-testid="unselected-participant"]'); + const findUnselectedParticipants = () => wrapper.findAllByTestId('unselected-participant'); const findUnselectedParticipantByIndex = (index) => findUnselectedParticipants().at(index).findComponent(SidebarParticipant); - const findCurrentUser = () => wrapper.findAll('[data-testid="current-user"]'); - const findIssuableAuthor = () => wrapper.findAll('[data-testid="issuable-author"]'); - const findUnassignLink = () => wrapper.find('[data-testid="unassign"]'); - const findEmptySearchResults = () => wrapper.find('[data-testid="empty-results"]'); + const findCurrentUser = () => wrapper.findAllByTestId('current-user'); + const findIssuableAuthor = () => wrapper.findAllByTestId('issuable-author'); + const findUnassignLink = () => wrapper.findByTestId('unassign'); + const findEmptySearchResults = () => wrapper.findAllByTestId('empty-results'); const searchQueryHandlerSuccess = jest.fn().mockResolvedValue(projectMembersResponse); const participantsQueryHandlerSuccess = jest.fn().mockResolvedValue(participantsQueryResponse); @@ -72,7 +72,7 @@ describe('User select dropdown', () => { [searchUsersQueryOnMR, jest.fn().mockResolvedValue(searchResponseOnMR)], [getIssueParticipantsQuery, participantsQueryHandler], ]); - wrapper = shallowMount(UserSelect, { + wrapper = shallowMountExtended(UserSelect, { apolloProvider: fakeApollo, propsData: { headerText: 'test', @@ -97,7 +97,7 @@ describe('User select dropdown', () => { </div> `, methods: { - hide: jest.fn(), + hide: hideDropdownMock, }, }, }, @@ -106,6 +106,7 @@ describe('User select dropdown', () => { afterEach(() => { fakeApollo = null; + hideDropdownMock.mockClear(); }); it('renders a loading spinner if participants are loading', () => { @@ -290,12 +291,12 @@ describe('User select dropdown', () => { value: [assignee], }, }); - wrapper.vm.$refs.dropdown.hide = jest.fn(); + await waitForPromises(); findUnassignLink().trigger('click'); - expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalledTimes(1); + expect(hideDropdownMock).toHaveBeenCalledTimes(1); }); it('emits an empty array after unselecting the only selected assignee', async () => { diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js index e54de25dc0d..b6c22ceaa23 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -85,7 +85,7 @@ describe('vue_shared/components/web_ide_link', () => { let wrapper; - function createComponent(props, { mountFn = shallowMountExtended, glFeatures = {} } = {}) { + function createComponent(props, { mountFn = shallowMountExtended, slots = {} } = {}) { const fakeApollo = createMockApollo([ [getWritableForksQuery, jest.fn().mockResolvedValue(getWritableForksResponse)], ]); @@ -98,9 +98,7 @@ describe('vue_shared/components/web_ide_link', () => { forkPath, ...props, }, - provide: { - glFeatures, - }, + slots, stubs: { GlModal: stubComponent(GlModal, { template: ` @@ -215,6 +213,27 @@ describe('vue_shared/components/web_ide_link', () => { expect(findActionsButton().props('actions')).toEqual(expectedActions); }); + it('bubbles up shown and hidden events triggered by actions button component', () => { + createComponent(); + + expect(wrapper.emitted('shown')).toBe(undefined); + expect(wrapper.emitted('hidden')).toBe(undefined); + + findActionsButton().vm.$emit('shown'); + findActionsButton().vm.$emit('hidden'); + + expect(wrapper.emitted('shown')).toHaveLength(1); + expect(wrapper.emitted('hidden')).toHaveLength(1); + }); + + it('exposes a default slot', () => { + const slotContent = 'default slot content'; + + createComponent({}, { slots: { default: slotContent } }); + + expect(wrapper.text()).toContain(slotContent); + }); + describe('when pipeline editor action is available', () => { beforeEach(() => { createComponent({ diff --git a/spec/frontend/vue_shared/issuable/list/mock_data.js b/spec/frontend/vue_shared/issuable/list/mock_data.js index 964b48f4275..f8cf3ba5271 100644 --- a/spec/frontend/vue_shared/issuable/list/mock_data.js +++ b/spec/frontend/vue_shared/issuable/list/mock_data.js @@ -5,6 +5,7 @@ import { } from 'jest/vue_shared/components/filtered_search_bar/mock_data'; export const mockAuthor = { + __typename: 'UserCore', id: 'gid://gitlab/User/1', avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', name: 'Administrator', @@ -13,6 +14,7 @@ export const mockAuthor = { }; export const mockRegularLabel = { + __typename: 'Label', id: 'gid://gitlab/GroupLabel/2048', title: 'Documentation Update', description: null, @@ -21,6 +23,7 @@ export const mockRegularLabel = { }; export const mockScopedLabel = { + __typename: 'Label', id: 'gid://gitlab/ProjectLabel/2049', title: 'status::confirmed', description: null, @@ -31,6 +34,7 @@ export const mockScopedLabel = { export const mockLabels = [mockRegularLabel, mockScopedLabel]; export const mockCurrentUserTodo = { + __typename: 'Todo', id: 'gid://gitlab/Todo/489', state: 'done', }; diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js index fa38ab8d44d..d2b7b2e89c8 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js @@ -1,13 +1,16 @@ import { GlButton, GlBadge, GlIcon, GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { TYPE_ISSUE, WORKSPACE_PROJECT } from '~/issues/constants'; +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; import IssuableHeader from '~/vue_shared/issuable/show/components/issuable_header.vue'; - import { mockIssuableShowProps, mockIssuable } from '../mock_data'; const issuableHeaderProps = { ...mockIssuable, ...mockIssuableShowProps, + issuableType: TYPE_ISSUE, + workspaceType: WORKSPACE_PROJECT, }; describe('IssuableHeader', () => { @@ -53,6 +56,14 @@ describe('IssuableHeader', () => { setHTMLFixture('<button class="js-toggle-right-sidebar-button">Collapse sidebar</button>'); }); + it('emits a "toggle" event', () => { + createComponent(); + + findButton().vm.$emit('click'); + + expect(wrapper.emitted('toggle')).toEqual([[]]); + }); + it('dispatches `click` event on sidebar toggle button', () => { createComponent(); const toggleSidebarButtonEl = document.querySelector('.js-toggle-right-sidebar-button'); @@ -94,14 +105,12 @@ describe('IssuableHeader', () => { }); it('renders confidential icon when issuable is confidential', () => { - createComponent({ - confidential: true, - }); + createComponent({ confidential: true }); - const confidentialEl = wrapper.findByTestId('confidential'); - - expect(confidentialEl.exists()).toBe(true); - expect(confidentialEl.findComponent(GlIcon).props('name')).toBe('eye-slash'); + expect(wrapper.findComponent(ConfidentialityBadge).props()).toEqual({ + issuableType: 'issue', + workspaceType: 'project', + }); }); it('renders issuable author avatar', () => { diff --git a/spec/frontend/vue_shared/new_namespace/components/welcome_spec.js b/spec/frontend/vue_shared/new_namespace/components/welcome_spec.js index cc8a8d86d19..3306e316ed0 100644 --- a/spec/frontend/vue_shared/new_namespace/components/welcome_spec.js +++ b/spec/frontend/vue_shared/new_namespace/components/welcome_spec.js @@ -39,6 +39,18 @@ describe('Welcome page', () => { expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_tab', { label: 'test' }); }); + it('renders image', () => { + const mockImgSrc = 'image1.svg'; + + createComponent({ + propsData: { + panels: [{ name: 'test', href: '#', imageSrc: mockImgSrc }], + }, + }); + + expect(wrapper.find('img').attributes('src')).toBe(mockImgSrc); + }); + it('renders footer slot if provided', () => { const DUMMY = 'Test message'; createComponent({ diff --git a/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js b/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js index abc69da7a58..a7ddcbdd8bc 100644 --- a/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js +++ b/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js @@ -15,6 +15,7 @@ describe('Experimental new namespace creation app', () => { const findWelcomePage = () => wrapper.findComponent(WelcomePage); const findLegacyContainer = () => wrapper.findComponent(LegacyContainer); const findBreadcrumb = () => wrapper.findComponent(GlBreadcrumb); + const findImage = () => wrapper.find('img'); const findNewTopLevelGroupAlert = () => wrapper.findComponent(NewTopLevelGroupAlert); const findSuperSidebarToggle = () => wrapper.findComponent(SuperSidebarToggle); @@ -22,8 +23,8 @@ describe('Experimental new namespace creation app', () => { title: 'Create something', initialBreadcrumbs: [{ text: 'Something', href: '#' }], panels: [ - { name: 'panel1', selector: '#some-selector1' }, - { name: 'panel2', selector: '#some-selector2' }, + { name: 'panel1', selector: '#some-selector1', imageSrc: 'panel1.svg' }, + { name: 'panel2', selector: '#some-selector2', imageSrc: 'panel2.svg' }, ], persistenceKey: 'DEMO-PERSISTENCE-KEY', }; @@ -82,6 +83,10 @@ describe('Experimental new namespace creation app', () => { expect(breadcrumb.exists()).toBe(true); expect(breadcrumb.props().items[0].text).toBe(DEFAULT_PROPS.initialBreadcrumbs[0].text); }); + + it('renders images', () => { + expect(findImage().attributes('src')).toBe(DEFAULT_PROPS.panels[1].imageSrc); + }); }); it('renders extra description if provided', () => { diff --git a/spec/frontend/vue_shared/security_reports/mock_data.js b/spec/frontend/vue_shared/security_reports/mock_data.js index a9ad675e538..533d312a4de 100644 --- a/spec/frontend/vue_shared/security_reports/mock_data.js +++ b/spec/frontend/vue_shared/security_reports/mock_data.js @@ -341,120 +341,6 @@ export const securityReportMergeRequestDownloadPathsQueryNoArtifactsResponse = { }, }; -export const securityReportMergeRequestDownloadPathsQueryResponse = { - project: { - id: '1', - mergeRequest: { - id: 'mr-1', - headPipeline: { - id: 'gid://gitlab/Ci::Pipeline/176', - jobs: { - nodes: [ - { - id: 'job-1', - name: 'secret_detection', - artifacts: { - nodes: [ - { - downloadPath: - '/gitlab-org/secrets-detection-test/-/jobs/1399/artifacts/download?file_type=trace', - fileType: 'TRACE', - __typename: 'CiJobArtifact', - }, - { - downloadPath: - '/gitlab-org/secrets-detection-test/-/jobs/1399/artifacts/download?file_type=secret_detection', - fileType: 'SECRET_DETECTION', - __typename: 'CiJobArtifact', - }, - ], - __typename: 'CiJobArtifactConnection', - }, - __typename: 'CiJob', - }, - { - id: 'job-2', - name: 'bandit-sast', - artifacts: { - nodes: [ - { - downloadPath: - '/gitlab-org/secrets-detection-test/-/jobs/1400/artifacts/download?file_type=trace', - fileType: 'TRACE', - __typename: 'CiJobArtifact', - }, - { - downloadPath: - '/gitlab-org/secrets-detection-test/-/jobs/1400/artifacts/download?file_type=sast', - fileType: 'SAST', - __typename: 'CiJobArtifact', - }, - ], - __typename: 'CiJobArtifactConnection', - }, - __typename: 'CiJob', - }, - { - id: 'job-3', - name: 'eslint-sast', - artifacts: { - nodes: [ - { - downloadPath: - '/gitlab-org/secrets-detection-test/-/jobs/1401/artifacts/download?file_type=trace', - fileType: 'TRACE', - __typename: 'CiJobArtifact', - }, - { - downloadPath: - '/gitlab-org/secrets-detection-test/-/jobs/1401/artifacts/download?file_type=sast', - fileType: 'SAST', - __typename: 'CiJobArtifact', - }, - ], - __typename: 'CiJobArtifactConnection', - }, - __typename: 'CiJob', - }, - { - id: 'job-4', - name: 'all_artifacts', - artifacts: { - nodes: [ - { - downloadPath: - '/gitlab-org/secrets-detection-test/-/jobs/1402/artifacts/download?file_type=archive', - fileType: 'ARCHIVE', - __typename: 'CiJobArtifact', - }, - { - downloadPath: - '/gitlab-org/secrets-detection-test/-/jobs/1402/artifacts/download?file_type=trace', - fileType: 'TRACE', - __typename: 'CiJobArtifact', - }, - { - downloadPath: - '/gitlab-org/secrets-detection-test/-/jobs/1402/artifacts/download?file_type=metadata', - fileType: 'METADATA', - __typename: 'CiJobArtifact', - }, - ], - __typename: 'CiJobArtifactConnection', - }, - __typename: 'CiJob', - }, - ], - __typename: 'CiJobConnection', - }, - __typename: 'Pipeline', - }, - __typename: 'MergeRequest', - }, - __typename: 'Project', - }, -}; - export const securityReportPipelineDownloadPathsQueryResponse = { project: { id: 'project-1', @@ -566,9 +452,6 @@ export const securityReportPipelineDownloadPathsQueryResponse = { __typename: 'Project', }; -/** - * These correspond to SAST jobs in the securityReportMergeRequestDownloadPathsQueryResponse above. - */ export const sastArtifacts = [ { name: 'bandit-sast', @@ -582,9 +465,6 @@ export const sastArtifacts = [ }, ]; -/** - * These correspond to Secret Detection jobs in the securityReportMergeRequestDownloadPathsQueryResponse above. - */ export const secretDetectionArtifacts = [ { name: 'secret_detection', @@ -594,13 +474,6 @@ export const secretDetectionArtifacts = [ }, ]; -export const expectedDownloadDropdownPropsWithTitle = { - loading: false, - artifacts: [...secretDetectionArtifacts, ...sastArtifacts], - text: '', - title: 'Download results', -}; - export const expectedDownloadDropdownPropsWithText = { loading: false, artifacts: [...secretDetectionArtifacts, ...sastArtifacts], @@ -608,9 +481,6 @@ export const expectedDownloadDropdownPropsWithText = { text: 'Download results', }; -/** - * These correspond to any jobs with zip archives in the securityReportMergeRequestDownloadPathsQueryResponse above. - */ export const archiveArtifacts = [ { name: 'all_artifacts Archive', @@ -619,9 +489,6 @@ export const archiveArtifacts = [ }, ]; -/** - * These correspond to any jobs with trace data in the securityReportMergeRequestDownloadPathsQueryResponse above. - */ export const traceArtifacts = [ { name: 'secret_detection Trace', @@ -645,9 +512,6 @@ export const traceArtifacts = [ }, ]; -/** - * These correspond to any jobs with metadata data in the securityReportMergeRequestDownloadPathsQueryResponse above. - */ export const metadataArtifacts = [ { name: 'all_artifacts Metadata', diff --git a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js deleted file mode 100644 index 257f59612e8..00000000000 --- a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js +++ /dev/null @@ -1,267 +0,0 @@ -import { mount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import { merge } from 'lodash'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import Vuex from 'vuex'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { trimText } from 'helpers/text_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { - expectedDownloadDropdownPropsWithText, - securityReportMergeRequestDownloadPathsQueryNoArtifactsResponse, - securityReportMergeRequestDownloadPathsQueryResponse, - sastDiffSuccessMock, - secretDetectionDiffSuccessMock, -} from 'jest/vue_shared/security_reports/mock_data'; -import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import HelpIcon from '~/vue_shared/security_reports/components/help_icon.vue'; -import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue'; -import { - REPORT_TYPE_SAST, - REPORT_TYPE_SECRET_DETECTION, -} from '~/vue_shared/security_reports/constants'; -import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql'; -import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_app.vue'; - -jest.mock('~/alert'); - -Vue.use(VueApollo); -Vue.use(Vuex); - -const SAST_COMPARISON_PATH = '/sast.json'; -const SECRET_DETECTION_COMPARISON_PATH = '/secret_detection.json'; - -describe('Security reports app', () => { - let wrapper; - - const props = { - pipelineId: 123, - projectId: 456, - securityReportsDocsPath: '/docs', - discoverProjectSecurityPath: '/discoverProjectSecurityPath', - }; - - const createComponent = (options) => { - wrapper = mount( - SecurityReportsApp, - merge( - { - propsData: { ...props }, - stubs: { - HelpIcon: true, - }, - }, - options, - ), - ); - }; - - const pendingHandler = () => new Promise(() => {}); - const successHandler = () => - Promise.resolve({ data: securityReportMergeRequestDownloadPathsQueryResponse }); - const successEmptyHandler = () => - Promise.resolve({ data: securityReportMergeRequestDownloadPathsQueryNoArtifactsResponse }); - const failureHandler = () => Promise.resolve({ errors: [{ message: 'some error' }] }); - const createMockApolloProvider = (handler) => { - const requestHandlers = [[securityReportMergeRequestDownloadPathsQuery, handler]]; - - return createMockApollo(requestHandlers); - }; - - const findDownloadDropdown = () => wrapper.findComponent(SecurityReportDownloadDropdown); - const findHelpIconComponent = () => wrapper.findComponent(HelpIcon); - - describe('given the artifacts query is loading', () => { - beforeEach(() => { - createComponent({ - apolloProvider: createMockApolloProvider(pendingHandler), - }); - }); - - // TODO: Remove this assertion as part of - // https://gitlab.com/gitlab-org/gitlab/-/issues/273431 - it('initially renders nothing', () => { - expect(wrapper.html()).toBe(''); - }); - }); - - describe('given the artifacts query loads successfully', () => { - beforeEach(() => { - createComponent({ - apolloProvider: createMockApolloProvider(successHandler), - }); - }); - - it('renders the download dropdown', () => { - expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownPropsWithText); - }); - - it('renders the expected message', () => { - expect(wrapper.text()).toContain(SecurityReportsApp.i18n.scansHaveRun); - }); - - it('renders a help link', () => { - expect(findHelpIconComponent().props()).toEqual({ - helpPath: props.securityReportsDocsPath, - discoverProjectSecurityPath: props.discoverProjectSecurityPath, - }); - }); - }); - - describe('given the artifacts query loads successfully with no artifacts', () => { - beforeEach(() => { - createComponent({ - apolloProvider: createMockApolloProvider(successEmptyHandler), - }); - }); - - // TODO: Remove this assertion as part of - // https://gitlab.com/gitlab-org/gitlab/-/issues/273431 - it('initially renders nothing', () => { - expect(wrapper.html()).toBe(''); - }); - }); - - describe('given the artifacts query fails', () => { - beforeEach(() => { - createComponent({ - apolloProvider: createMockApolloProvider(failureHandler), - }); - }); - - it('calls createAlert correctly', () => { - expect(createAlert).toHaveBeenCalledWith({ - message: SecurityReportsApp.i18n.apiError, - captureError: true, - error: expect.any(Error), - }); - }); - - // TODO: Remove this assertion as part of - // https://gitlab.com/gitlab-org/gitlab/-/issues/273431 - it('renders nothing', () => { - expect(wrapper.html()).toBe(''); - }); - }); - - describe('given the coreSecurityMrWidgetCounts feature flag is enabled', () => { - let mock; - - const createComponentWithFlagEnabled = (options) => - createComponent( - merge(options, { - provide: { - glFeatures: { - coreSecurityMrWidgetCounts: true, - }, - }, - apolloProvider: createMockApolloProvider(successHandler), - }), - ); - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - const SAST_SUCCESS_MESSAGE = - 'Security scanning detected 1 potential vulnerability 1 Critical 0 High and 0 Others'; - const SECRET_DETECTION_SUCCESS_MESSAGE = - 'Security scanning detected 2 potential vulnerabilities 1 Critical 1 High and 0 Others'; - describe.each` - reportType | pathProp | path | successResponse | successMessage - ${REPORT_TYPE_SAST} | ${'sastComparisonPath'} | ${SAST_COMPARISON_PATH} | ${sastDiffSuccessMock} | ${SAST_SUCCESS_MESSAGE} - ${REPORT_TYPE_SECRET_DETECTION} | ${'secretDetectionComparisonPath'} | ${SECRET_DETECTION_COMPARISON_PATH} | ${secretDetectionDiffSuccessMock} | ${SECRET_DETECTION_SUCCESS_MESSAGE} - `( - 'given a $pathProp and $reportType artifact', - ({ pathProp, path, successResponse, successMessage }) => { - describe('when loading', () => { - beforeEach(() => { - mock = new MockAdapter(axios, { delayResponse: 1 }); - mock.onGet(path).replyOnce(HTTP_STATUS_OK, successResponse); - - createComponentWithFlagEnabled({ - propsData: { - [pathProp]: path, - }, - }); - - return waitForPromises(); - }); - - it('should have loading message', () => { - expect(wrapper.text()).toContain('Security scanning is loading'); - }); - - it('renders the download dropdown', () => { - expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownPropsWithText); - }); - }); - - describe('when successfully loaded', () => { - beforeEach(() => { - mock.onGet(path).replyOnce(HTTP_STATUS_OK, successResponse); - - createComponentWithFlagEnabled({ - propsData: { - [pathProp]: path, - }, - }); - - return waitForPromises(); - }); - - it('should show counts', () => { - expect(trimText(wrapper.text())).toContain(successMessage); - }); - - it('renders the download dropdown', () => { - expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownPropsWithText); - }); - }); - - describe('when an error occurs', () => { - beforeEach(() => { - mock.onGet(path).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); - - createComponentWithFlagEnabled({ - propsData: { - [pathProp]: path, - }, - }); - - return waitForPromises(); - }); - - it('should show error message', () => { - expect(trimText(wrapper.text())).toContain('Loading resulted in an error'); - }); - - it('renders the download dropdown', () => { - expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownPropsWithText); - }); - }); - - describe('when the comparison endpoint is not provided', () => { - beforeEach(() => { - mock.onGet(path).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); - - createComponentWithFlagEnabled(); - - return waitForPromises(); - }); - - it('renders the basic scansHaveRun message', () => { - expect(wrapper.text()).toContain(SecurityReportsApp.i18n.scansHaveRun); - }); - }); - }, - ); - }); -}); diff --git a/spec/frontend/vue_shared/security_reports/store/getters_spec.js b/spec/frontend/vue_shared/security_reports/store/getters_spec.js deleted file mode 100644 index bcc8955ba02..00000000000 --- a/spec/frontend/vue_shared/security_reports/store/getters_spec.js +++ /dev/null @@ -1,182 +0,0 @@ -import { - groupedSummaryText, - allReportsHaveError, - areReportsLoading, - anyReportHasError, - areAllReportsLoading, - anyReportHasIssues, - summaryCounts, -} from '~/vue_shared/security_reports/store/getters'; -import createSastState from '~/vue_shared/security_reports/store/modules/sast/state'; -import createSecretDetectionState from '~/vue_shared/security_reports/store/modules/secret_detection/state'; -import createState from '~/vue_shared/security_reports/store/state'; -import { groupedTextBuilder } from '~/vue_shared/security_reports/store/utils'; -import { CRITICAL, HIGH, LOW } from '~/vulnerabilities/constants'; - -const generateVuln = (severity) => ({ severity }); - -describe('Security reports getters', () => { - let state; - - beforeEach(() => { - state = createState(); - state.sast = createSastState(); - state.secretDetection = createSecretDetectionState(); - }); - - describe('summaryCounts', () => { - it('returns 0 count for empty state', () => { - expect(summaryCounts(state)).toEqual({ - critical: 0, - high: 0, - other: 0, - }); - }); - - describe('combines all reports', () => { - it('of the same severity', () => { - state.sast.newIssues = [generateVuln(CRITICAL)]; - state.secretDetection.newIssues = [generateVuln(CRITICAL)]; - - expect(summaryCounts(state)).toEqual({ - critical: 2, - high: 0, - other: 0, - }); - }); - - it('of different severities', () => { - state.sast.newIssues = [generateVuln(CRITICAL)]; - state.secretDetection.newIssues = [generateVuln(HIGH), generateVuln(LOW)]; - - expect(summaryCounts(state)).toEqual({ - critical: 1, - high: 1, - other: 1, - }); - }); - }); - }); - - describe('groupedSummaryText', () => { - it('returns failed text', () => { - expect( - groupedSummaryText(state, { - allReportsHaveError: true, - areReportsLoading: false, - summaryCounts: {}, - }), - ).toEqual({ message: 'Security scanning failed loading any results' }); - }); - - it('returns `is loading` as status text', () => { - expect( - groupedSummaryText(state, { - allReportsHaveError: false, - areReportsLoading: true, - summaryCounts: {}, - }), - ).toEqual( - groupedTextBuilder({ - reportType: 'Security scanning', - critical: 0, - high: 0, - other: 0, - status: 'is loading', - }), - ); - }); - - it('returns no new status text if there are existing ones', () => { - expect( - groupedSummaryText(state, { - allReportsHaveError: false, - areReportsLoading: false, - summaryCounts: {}, - }), - ).toEqual( - groupedTextBuilder({ - reportType: 'Security scanning', - critical: 0, - high: 0, - other: 0, - status: '', - }), - ); - }); - }); - - describe('areReportsLoading', () => { - it('returns true when any report is loading', () => { - state.sast.isLoading = true; - - expect(areReportsLoading(state)).toEqual(true); - }); - - it('returns false when none of the reports are loading', () => { - expect(areReportsLoading(state)).toEqual(false); - }); - }); - - describe('areAllReportsLoading', () => { - it('returns true when all reports are loading', () => { - state.sast.isLoading = true; - state.secretDetection.isLoading = true; - - expect(areAllReportsLoading(state)).toEqual(true); - }); - - it('returns false when some of the reports are loading', () => { - state.sast.isLoading = true; - - expect(areAllReportsLoading(state)).toEqual(false); - }); - - it('returns false when none of the reports are loading', () => { - expect(areAllReportsLoading(state)).toEqual(false); - }); - }); - - describe('allReportsHaveError', () => { - it('returns true when all reports have error', () => { - state.sast.hasError = true; - state.secretDetection.hasError = true; - - expect(allReportsHaveError(state)).toEqual(true); - }); - - it('returns false when none of the reports have error', () => { - expect(allReportsHaveError(state)).toEqual(false); - }); - - it('returns false when one of the reports does not have error', () => { - state.secretDetection.hasError = true; - - expect(allReportsHaveError(state)).toEqual(false); - }); - }); - - describe('anyReportHasError', () => { - it('returns true when any of the reports has error', () => { - state.sast.hasError = true; - - expect(anyReportHasError(state)).toEqual(true); - }); - - it('returns false when none of the reports has error', () => { - expect(anyReportHasError(state)).toEqual(false); - }); - }); - - describe('anyReportHasIssues', () => { - it('returns true when any of the reports has new issues', () => { - state.sast.newIssues.push(generateVuln(LOW)); - - expect(anyReportHasIssues(state)).toEqual(true); - }); - - it('returns false when none of the reports has error', () => { - expect(anyReportHasIssues(state)).toEqual(false); - }); - }); -}); diff --git a/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js deleted file mode 100644 index 0cab950cb77..00000000000 --- a/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js +++ /dev/null @@ -1,197 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import testAction from 'helpers/vuex_action_helper'; - -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import * as actions from '~/vue_shared/security_reports/store/modules/sast/actions'; -import * as types from '~/vue_shared/security_reports/store/modules/sast/mutation_types'; -import createState from '~/vue_shared/security_reports/store/modules/sast/state'; - -const diffEndpoint = 'diff-endpoint.json'; -const blobPath = 'blob-path.json'; -const reports = { - base: 'base', - head: 'head', - enrichData: 'enrichData', - diff: 'diff', -}; -const error = 'Something went wrong'; -const vulnerabilityFeedbackPath = 'vulnerability-feedback-path'; -const rootState = { vulnerabilityFeedbackPath, blobPath }; - -let state; - -describe('sast report actions', () => { - beforeEach(() => { - state = createState(); - }); - - describe('setDiffEndpoint', () => { - it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, () => { - return testAction( - actions.setDiffEndpoint, - diffEndpoint, - state, - [ - { - type: types.SET_DIFF_ENDPOINT, - payload: diffEndpoint, - }, - ], - [], - ); - }); - }); - - describe('requestDiff', () => { - it(`should commit ${types.REQUEST_DIFF}`, () => { - return testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], []); - }); - }); - - describe('receiveDiffSuccess', () => { - it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, () => { - return testAction( - actions.receiveDiffSuccess, - reports, - state, - [ - { - type: types.RECEIVE_DIFF_SUCCESS, - payload: reports, - }, - ], - [], - ); - }); - }); - - describe('receiveDiffError', () => { - it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, () => { - return testAction( - actions.receiveDiffError, - error, - state, - [ - { - type: types.RECEIVE_DIFF_ERROR, - payload: error, - }, - ], - [], - ); - }); - }); - - describe('fetchDiff', () => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - state.paths.diffEndpoint = diffEndpoint; - rootState.canReadVulnerabilityFeedback = true; - }); - - afterEach(() => { - mock.restore(); - }); - - describe('when diff and vulnerability feedback endpoints respond successfully', () => { - beforeEach(() => { - mock - .onGet(diffEndpoint) - .replyOnce(HTTP_STATUS_OK, reports.diff) - .onGet(vulnerabilityFeedbackPath) - .replyOnce(HTTP_STATUS_OK, reports.enrichData); - }); - - it('should dispatch the `receiveDiffSuccess` action', () => { - const { diff, enrichData } = reports; - return testAction( - actions.fetchDiff, - {}, - { ...rootState, ...state }, - [], - [ - { type: 'requestDiff' }, - { - type: 'receiveDiffSuccess', - payload: { - diff, - enrichData, - }, - }, - ], - ); - }); - }); - - describe('when diff endpoint responds successfully and fetching vulnerability feedback is not authorized', () => { - beforeEach(() => { - rootState.canReadVulnerabilityFeedback = false; - mock.onGet(diffEndpoint).replyOnce(HTTP_STATUS_OK, reports.diff); - }); - - it('should dispatch the `receiveDiffSuccess` action with empty enrich data', () => { - const { diff } = reports; - const enrichData = []; - return testAction( - actions.fetchDiff, - {}, - { ...rootState, ...state }, - [], - [ - { type: 'requestDiff' }, - { - type: 'receiveDiffSuccess', - payload: { - diff, - enrichData, - }, - }, - ], - ); - }); - }); - - describe('when the vulnerability feedback endpoint fails', () => { - beforeEach(() => { - mock - .onGet(diffEndpoint) - .replyOnce(HTTP_STATUS_OK, reports.diff) - .onGet(vulnerabilityFeedbackPath) - .replyOnce(HTTP_STATUS_NOT_FOUND); - }); - - it('should dispatch the `receiveError` action', () => { - return testAction( - actions.fetchDiff, - {}, - { ...rootState, ...state }, - [], - [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], - ); - }); - }); - - describe('when the diff endpoint fails', () => { - beforeEach(() => { - mock - .onGet(diffEndpoint) - .replyOnce(HTTP_STATUS_NOT_FOUND) - .onGet(vulnerabilityFeedbackPath) - .replyOnce(HTTP_STATUS_OK, reports.enrichData); - }); - - it('should dispatch the `receiveDiffError` action', () => { - return testAction( - actions.fetchDiff, - {}, - { ...rootState, ...state }, - [], - [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], - ); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/security_reports/store/modules/sast/mutations_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/sast/mutations_spec.js deleted file mode 100644 index d6119f44619..00000000000 --- a/spec/frontend/vue_shared/security_reports/store/modules/sast/mutations_spec.js +++ /dev/null @@ -1,84 +0,0 @@ -import * as types from '~/vue_shared/security_reports/store/modules/sast/mutation_types'; -import mutations from '~/vue_shared/security_reports/store/modules/sast/mutations'; -import createState from '~/vue_shared/security_reports/store/modules/sast/state'; - -const createIssue = ({ ...config }) => ({ changed: false, ...config }); - -describe('sast module mutations', () => { - const path = 'path'; - let state; - - beforeEach(() => { - state = createState(); - }); - - describe(types.SET_DIFF_ENDPOINT, () => { - it('should set the SAST diff endpoint', () => { - mutations[types.SET_DIFF_ENDPOINT](state, path); - - expect(state.paths.diffEndpoint).toBe(path); - }); - }); - - describe(types.REQUEST_DIFF, () => { - it('should set the `isLoading` status to `true`', () => { - mutations[types.REQUEST_DIFF](state); - - expect(state.isLoading).toBe(true); - }); - }); - - describe(types.RECEIVE_DIFF_SUCCESS, () => { - beforeEach(() => { - const reports = { - diff: { - added: [ - createIssue({ cve: 'CVE-1' }), - createIssue({ cve: 'CVE-2' }), - createIssue({ cve: 'CVE-3' }), - ], - fixed: [createIssue({ cve: 'CVE-4' }), createIssue({ cve: 'CVE-5' })], - existing: [createIssue({ cve: 'CVE-6' })], - base_report_out_of_date: true, - }, - }; - state.isLoading = true; - mutations[types.RECEIVE_DIFF_SUCCESS](state, reports); - }); - - it('should set the `isLoading` status to `false`', () => { - expect(state.isLoading).toBe(false); - }); - - it('should set the `baseReportOutofDate` status to `false`', () => { - expect(state.baseReportOutofDate).toBe(true); - }); - - it('should have the relevant `new` issues', () => { - expect(state.newIssues).toHaveLength(3); - }); - - it('should have the relevant `resolved` issues', () => { - expect(state.resolvedIssues).toHaveLength(2); - }); - - it('should have the relevant `all` issues', () => { - expect(state.allIssues).toHaveLength(1); - }); - }); - - describe(types.RECEIVE_DIFF_ERROR, () => { - beforeEach(() => { - state.isLoading = true; - mutations[types.RECEIVE_DIFF_ERROR](state); - }); - - it('should set the `isLoading` status to `false`', () => { - expect(state.isLoading).toBe(false); - }); - - it('should set the `hasError` status to `true`', () => { - expect(state.hasError).toBe(true); - }); - }); -}); diff --git a/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js deleted file mode 100644 index 7197784c3e8..00000000000 --- a/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js +++ /dev/null @@ -1,198 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import testAction from 'helpers/vuex_action_helper'; - -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import * as actions from '~/vue_shared/security_reports/store/modules/secret_detection/actions'; -import * as types from '~/vue_shared/security_reports/store/modules/secret_detection/mutation_types'; -import createState from '~/vue_shared/security_reports/store/modules/secret_detection/state'; - -const diffEndpoint = 'diff-endpoint.json'; -const blobPath = 'blob-path.json'; -const reports = { - base: 'base', - head: 'head', - enrichData: 'enrichData', - diff: 'diff', -}; -const error = 'Something went wrong'; -const vulnerabilityFeedbackPath = 'vulnerability-feedback-path'; -const rootState = { vulnerabilityFeedbackPath, blobPath }; - -let state; - -describe('secret detection report actions', () => { - beforeEach(() => { - state = createState(); - }); - - describe('setDiffEndpoint', () => { - it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, () => { - return testAction( - actions.setDiffEndpoint, - diffEndpoint, - state, - [ - { - type: types.SET_DIFF_ENDPOINT, - payload: diffEndpoint, - }, - ], - [], - ); - }); - }); - - describe('requestDiff', () => { - it(`should commit ${types.REQUEST_DIFF}`, () => { - return testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], []); - }); - }); - - describe('receiveDiffSuccess', () => { - it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, () => { - return testAction( - actions.receiveDiffSuccess, - reports, - state, - [ - { - type: types.RECEIVE_DIFF_SUCCESS, - payload: reports, - }, - ], - [], - ); - }); - }); - - describe('receiveDiffError', () => { - it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, () => { - return testAction( - actions.receiveDiffError, - error, - state, - [ - { - type: types.RECEIVE_DIFF_ERROR, - payload: error, - }, - ], - [], - ); - }); - }); - - describe('fetchDiff', () => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - state.paths.diffEndpoint = diffEndpoint; - rootState.canReadVulnerabilityFeedback = true; - }); - - afterEach(() => { - mock.restore(); - }); - - describe('when diff and vulnerability feedback endpoints respond successfully', () => { - beforeEach(() => { - mock - .onGet(diffEndpoint) - .replyOnce(HTTP_STATUS_OK, reports.diff) - .onGet(vulnerabilityFeedbackPath) - .replyOnce(HTTP_STATUS_OK, reports.enrichData); - }); - - it('should dispatch the `receiveDiffSuccess` action', () => { - const { diff, enrichData } = reports; - - return testAction( - actions.fetchDiff, - {}, - { ...rootState, ...state }, - [], - [ - { type: 'requestDiff' }, - { - type: 'receiveDiffSuccess', - payload: { - diff, - enrichData, - }, - }, - ], - ); - }); - }); - - describe('when diff endpoint responds successfully and fetching vulnerability feedback is not authorized', () => { - beforeEach(() => { - rootState.canReadVulnerabilityFeedback = false; - mock.onGet(diffEndpoint).replyOnce(HTTP_STATUS_OK, reports.diff); - }); - - it('should dispatch the `receiveDiffSuccess` action with empty enrich data', () => { - const { diff } = reports; - const enrichData = []; - return testAction( - actions.fetchDiff, - {}, - { ...rootState, ...state }, - [], - [ - { type: 'requestDiff' }, - { - type: 'receiveDiffSuccess', - payload: { - diff, - enrichData, - }, - }, - ], - ); - }); - }); - - describe('when the vulnerability feedback endpoint fails', () => { - beforeEach(() => { - mock - .onGet(diffEndpoint) - .replyOnce(HTTP_STATUS_OK, reports.diff) - .onGet(vulnerabilityFeedbackPath) - .replyOnce(HTTP_STATUS_NOT_FOUND); - }); - - it('should dispatch the `receiveDiffError` action', () => { - return testAction( - actions.fetchDiff, - {}, - { ...rootState, ...state }, - [], - [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], - ); - }); - }); - - describe('when the diff endpoint fails', () => { - beforeEach(() => { - mock - .onGet(diffEndpoint) - .replyOnce(HTTP_STATUS_NOT_FOUND) - .onGet(vulnerabilityFeedbackPath) - .replyOnce(HTTP_STATUS_OK, reports.enrichData); - }); - - it('should dispatch the `receiveDiffError` action', () => { - return testAction( - actions.fetchDiff, - {}, - { ...rootState, ...state }, - [], - [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], - ); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/mutations_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/mutations_spec.js deleted file mode 100644 index 42da7476a40..00000000000 --- a/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/mutations_spec.js +++ /dev/null @@ -1,84 +0,0 @@ -import * as types from '~/vue_shared/security_reports/store/modules/secret_detection/mutation_types'; -import mutations from '~/vue_shared/security_reports/store/modules/secret_detection/mutations'; -import createState from '~/vue_shared/security_reports/store/modules/secret_detection/state'; - -const createIssue = ({ ...config }) => ({ changed: false, ...config }); - -describe('secret detection module mutations', () => { - const path = 'path'; - let state; - - beforeEach(() => { - state = createState(); - }); - - describe(types.SET_DIFF_ENDPOINT, () => { - it('should set the secret detection diff endpoint', () => { - mutations[types.SET_DIFF_ENDPOINT](state, path); - - expect(state.paths.diffEndpoint).toBe(path); - }); - }); - - describe(types.REQUEST_DIFF, () => { - it('should set the `isLoading` status to `true`', () => { - mutations[types.REQUEST_DIFF](state); - - expect(state.isLoading).toBe(true); - }); - }); - - describe(types.RECEIVE_DIFF_SUCCESS, () => { - beforeEach(() => { - const reports = { - diff: { - added: [ - createIssue({ cve: 'CVE-1' }), - createIssue({ cve: 'CVE-2' }), - createIssue({ cve: 'CVE-3' }), - ], - fixed: [createIssue({ cve: 'CVE-4' }), createIssue({ cve: 'CVE-5' })], - existing: [createIssue({ cve: 'CVE-6' })], - base_report_out_of_date: true, - }, - }; - state.isLoading = true; - mutations[types.RECEIVE_DIFF_SUCCESS](state, reports); - }); - - it('should set the `isLoading` status to `false`', () => { - expect(state.isLoading).toBe(false); - }); - - it('should set the `baseReportOutofDate` status to `true`', () => { - expect(state.baseReportOutofDate).toBe(true); - }); - - it('should have the relevant `new` issues', () => { - expect(state.newIssues).toHaveLength(3); - }); - - it('should have the relevant `resolved` issues', () => { - expect(state.resolvedIssues).toHaveLength(2); - }); - - it('should have the relevant `all` issues', () => { - expect(state.allIssues).toHaveLength(1); - }); - }); - - describe(types.RECEIVE_DIFF_ERROR, () => { - beforeEach(() => { - state.isLoading = true; - mutations[types.RECEIVE_DIFF_ERROR](state); - }); - - it('should set the `isLoading` status to `false`', () => { - expect(state.isLoading).toBe(false); - }); - - it('should set the `hasError` status to `true`', () => { - expect(state.hasError).toBe(true); - }); - }); -}); diff --git a/spec/frontend/vue_shared/security_reports/store/utils_spec.js b/spec/frontend/vue_shared/security_reports/store/utils_spec.js deleted file mode 100644 index c8750cd58a0..00000000000 --- a/spec/frontend/vue_shared/security_reports/store/utils_spec.js +++ /dev/null @@ -1,63 +0,0 @@ -import { enrichVulnerabilityWithFeedback } from '~/vue_shared/security_reports/store/utils'; -import { - FEEDBACK_TYPE_DISMISSAL, - FEEDBACK_TYPE_ISSUE, - FEEDBACK_TYPE_MERGE_REQUEST, -} from '~/vue_shared/security_reports/constants'; - -describe('security reports store utils', () => { - const vulnerability = { uuid: 1 }; - - describe('enrichVulnerabilityWithFeedback', () => { - const dismissalFeedback = { - feedback_type: FEEDBACK_TYPE_DISMISSAL, - finding_uuid: vulnerability.uuid, - }; - const dismissalVuln = { ...vulnerability, isDismissed: true, dismissalFeedback }; - - const issueFeedback = { - feedback_type: FEEDBACK_TYPE_ISSUE, - issue_iid: 1, - finding_uuid: vulnerability.uuid, - }; - const issueVuln = { ...vulnerability, hasIssue: true, issue_feedback: issueFeedback }; - const mrFeedback = { - feedback_type: FEEDBACK_TYPE_MERGE_REQUEST, - merge_request_iid: 1, - finding_uuid: vulnerability.uuid, - }; - const mrVuln = { - ...vulnerability, - hasMergeRequest: true, - merge_request_feedback: mrFeedback, - }; - - it.each` - feedbacks | expected - ${[dismissalFeedback]} | ${dismissalVuln} - ${[{ ...issueFeedback, issue_iid: null }]} | ${vulnerability} - ${[issueFeedback]} | ${issueVuln} - ${[{ ...mrFeedback, merge_request_iid: null }]} | ${vulnerability} - ${[mrFeedback]} | ${mrVuln} - ${[dismissalFeedback, issueFeedback, mrFeedback]} | ${{ ...dismissalVuln, ...issueVuln, ...mrVuln }} - `('returns expected enriched vulnerability: $expected', ({ feedbacks, expected }) => { - const enrichedVulnerability = enrichVulnerabilityWithFeedback(vulnerability, feedbacks); - - expect(enrichedVulnerability).toEqual(expected); - }); - - it('matches correct feedback objects to vulnerability', () => { - const feedbacks = [ - dismissalFeedback, - issueFeedback, - mrFeedback, - { ...dismissalFeedback, finding_uuid: 2 }, - { ...issueFeedback, finding_uuid: 2 }, - { ...mrFeedback, finding_uuid: 2 }, - ]; - const enrichedVulnerability = enrichVulnerabilityWithFeedback(vulnerability, feedbacks); - - expect(enrichedVulnerability).toEqual({ ...dismissalVuln, ...issueVuln, ...mrVuln }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/security_reports/utils_spec.js b/spec/frontend/vue_shared/security_reports/utils_spec.js deleted file mode 100644 index b7129ece698..00000000000 --- a/spec/frontend/vue_shared/security_reports/utils_spec.js +++ /dev/null @@ -1,48 +0,0 @@ -import { - REPORT_TYPE_SAST, - REPORT_TYPE_SECRET_DETECTION, - REPORT_FILE_TYPES, -} from '~/vue_shared/security_reports/constants'; -import { - extractSecurityReportArtifactsFromMergeRequest, - extractSecurityReportArtifactsFromPipeline, -} from '~/vue_shared/security_reports/utils'; -import { - securityReportMergeRequestDownloadPathsQueryResponse, - securityReportPipelineDownloadPathsQueryResponse, - sastArtifacts, - secretDetectionArtifacts, - archiveArtifacts, - traceArtifacts, - metadataArtifacts, -} from './mock_data'; - -describe.each([ - [ - 'extractSecurityReportArtifactsFromMergeRequest', - extractSecurityReportArtifactsFromMergeRequest, - securityReportMergeRequestDownloadPathsQueryResponse, - ], - [ - 'extractSecurityReportArtifactsFromPipelines', - extractSecurityReportArtifactsFromPipeline, - securityReportPipelineDownloadPathsQueryResponse, - ], -])('%s', (funcName, extractFunc, response) => { - it.each` - reportTypes | expectedArtifacts - ${[]} | ${[]} - ${['foo']} | ${[]} - ${[REPORT_TYPE_SAST]} | ${sastArtifacts} - ${[REPORT_TYPE_SECRET_DETECTION]} | ${secretDetectionArtifacts} - ${[REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION]} | ${[...secretDetectionArtifacts, ...sastArtifacts]} - ${[REPORT_FILE_TYPES.ARCHIVE]} | ${archiveArtifacts} - ${[REPORT_FILE_TYPES.TRACE]} | ${traceArtifacts} - ${[REPORT_FILE_TYPES.METADATA]} | ${metadataArtifacts} - `( - 'returns the expected artifacts given report types $reportTypes', - ({ reportTypes, expectedArtifacts }) => { - expect(extractFunc(reportTypes, response)).toEqual(expectedArtifacts); - }, - ); -}); diff --git a/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js b/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js index 2e901783e07..e4180b2d178 100644 --- a/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js @@ -1,11 +1,10 @@ import { GlDisclosureDropdown } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createMockDirective } from 'helpers/vue_mock_directive'; -import EmojiPicker from '~/emoji/components/picker.vue'; -import waitForPromises from 'helpers/wait_for_promises'; +import { stubComponent } from 'helpers/stub_component'; import ReplyButton from '~/notes/components/note_actions/reply_button.vue'; import WorkItemNoteActions from '~/work_items/components/notes/work_item_note_actions.vue'; import addAwardEmojiMutation from '~/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql'; @@ -15,18 +14,19 @@ Vue.use(VueApollo); describe('Work Item Note Actions', () => { let wrapper; const noteId = '1'; + const showSpy = jest.fn(); const findReplyButton = () => wrapper.findComponent(ReplyButton); - const findEditButton = () => wrapper.find('[data-testid="edit-work-item-note"]'); - const findEmojiButton = () => wrapper.find('[data-testid="note-emoji-button"]'); + const findEditButton = () => wrapper.findByTestId('edit-work-item-note'); + const findEmojiButton = () => wrapper.findByTestId('note-emoji-button'); const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); - const findDeleteNoteButton = () => wrapper.find('[data-testid="delete-note-action"]'); - const findCopyLinkButton = () => wrapper.find('[data-testid="copy-link-action"]'); - const findAssignUnassignButton = () => wrapper.find('[data-testid="assign-note-action"]'); - const findReportAbuseToAdminButton = () => wrapper.find('[data-testid="abuse-note-action"]'); - const findAuthorBadge = () => wrapper.find('[data-testid="author-badge"]'); - const findMaxAccessLevelBadge = () => wrapper.find('[data-testid="max-access-level-badge"]'); - const findContributorBadge = () => wrapper.find('[data-testid="contributor-badge"]'); + const findDeleteNoteButton = () => wrapper.findByTestId('delete-note-action'); + const findCopyLinkButton = () => wrapper.findByTestId('copy-link-action'); + const findAssignUnassignButton = () => wrapper.findByTestId('assign-note-action'); + const findReportAbuseToAdminButton = () => wrapper.findByTestId('abuse-note-action'); + const findAuthorBadge = () => wrapper.findByTestId('author-badge'); + const findMaxAccessLevelBadge = () => wrapper.findByTestId('max-access-level-badge'); + const findContributorBadge = () => wrapper.findByTestId('contributor-badge'); const addEmojiMutationResolver = jest.fn().mockResolvedValue({ data: { @@ -34,11 +34,6 @@ describe('Work Item Note Actions', () => { }, }); - const EmojiPickerStub = { - props: EmojiPicker.props, - template: '<div></div>', - }; - const createComponent = ({ showReply = true, showEdit = true, @@ -51,10 +46,12 @@ describe('Work Item Note Actions', () => { maxAccessLevelOfAuthor = '', projectName = 'Project name', } = {}) => { - wrapper = shallowMount(WorkItemNoteActions, { + wrapper = shallowMountExtended(WorkItemNoteActions, { propsData: { showReply, showEdit, + workItemIid: '1', + note: {}, noteId, showAwardEmoji, showAssignUnassign, @@ -66,21 +63,27 @@ describe('Work Item Note Actions', () => { projectName, }, provide: { + fullPath: 'gitlab-org', glFeatures: { workItemsMvc2: true, }, }, stubs: { - EmojiPicker: EmojiPickerStub, + GlDisclosureDropdown: stubComponent(GlDisclosureDropdown, { + methods: { close: showSpy }, + }), }, apolloProvider: createMockApollo([[addAwardEmojiMutation, addEmojiMutationResolver]]), directives: { GlTooltip: createMockDirective('gl-tooltip'), }, }); - wrapper.vm.$refs.dropdown.close = jest.fn(); }; + afterEach(() => { + showSpy.mockClear(); + }); + describe('reply button', () => { it('is visible by default', () => { createComponent(); @@ -128,22 +131,6 @@ describe('Work Item Note Actions', () => { expect(findEmojiButton().exists()).toBe(false); }); - - it('commits mutation on click', async () => { - const awardName = 'carrot'; - - createComponent(); - - findEmojiButton().vm.$emit('click', awardName); - - await waitForPromises(); - - expect(findEmojiButton().emitted('errors')).toEqual(undefined); - expect(addEmojiMutationResolver).toHaveBeenCalledWith({ - awardableId: noteId, - name: awardName, - }); - }); }); describe('delete note', () => { @@ -173,6 +160,7 @@ describe('Work Item Note Actions', () => { findDeleteNoteButton().vm.$emit('action'); expect(wrapper.emitted('deleteNote')).toEqual([[]]); + expect(showSpy).toHaveBeenCalled(); }); }); @@ -188,6 +176,7 @@ describe('Work Item Note Actions', () => { findCopyLinkButton().vm.$emit('action'); expect(wrapper.emitted('notifyCopyDone')).toEqual([[]]); + expect(showSpy).toHaveBeenCalled(); }); }); @@ -214,6 +203,7 @@ describe('Work Item Note Actions', () => { findAssignUnassignButton().vm.$emit('action'); expect(wrapper.emitted('assignUser')).toEqual([[]]); + expect(showSpy).toHaveBeenCalled(); }); }); @@ -240,6 +230,7 @@ describe('Work Item Note Actions', () => { findReportAbuseToAdminButton().vm.$emit('action'); expect(wrapper.emitted('reportAbuse')).toEqual([[]]); + expect(showSpy).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/work_items/components/notes/work_item_note_awards_list_spec.js b/spec/frontend/work_items/components/notes/work_item_note_awards_list_spec.js new file mode 100644 index 00000000000..d425f1e50dc --- /dev/null +++ b/spec/frontend/work_items/components/notes/work_item_note_awards_list_spec.js @@ -0,0 +1,147 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import mockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { __ } from '~/locale'; +import AwardsList from '~/vue_shared/components/awards_list.vue'; +import WorkItemNoteAwardsList from '~/work_items/components/notes/work_item_note_awards_list.vue'; +import addAwardEmojiMutation from '~/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql'; +import removeAwardEmojiMutation from '~/work_items/graphql/notes/work_item_note_remove_award_emoji.mutation.graphql'; +import workItemNotesByIidQuery from '~/work_items/graphql/notes/work_item_notes_by_iid.query.graphql'; +import { + mockWorkItemNotesResponseWithComments, + mockAwardEmojiThumbsUp, +} from 'jest/work_items/mock_data'; +import { EMOJI_THUMBSUP, EMOJI_THUMBSDOWN } from '~/work_items/constants'; + +Vue.use(VueApollo); + +describe('Work Item Note Awards List', () => { + let wrapper; + const workItem = mockWorkItemNotesResponseWithComments.data.workspace.workItems.nodes[0]; + const firstNote = workItem.widgets.find((w) => w.type === 'NOTES').discussions.nodes[0].notes + .nodes[0]; + const fullPath = 'test-project-path'; + const workItemIid = workItem.iid; + const currentUserId = getIdFromGraphQLId(mockAwardEmojiThumbsUp.user.id); + + const addAwardEmojiMutationSuccessHandler = jest.fn().mockResolvedValue({ + data: { + awardEmojiAdd: { + errors: [], + }, + }, + }); + const removeAwardEmojiMutationSuccessHandler = jest.fn().mockResolvedValue({ + data: { + awardEmojiRemove: { + errors: [], + }, + }, + }); + + const findAwardsList = () => wrapper.findComponent(AwardsList); + + const createComponent = ({ + note = firstNote, + addAwardEmojiMutationHandler = addAwardEmojiMutationSuccessHandler, + removeAwardEmojiMutationHandler = removeAwardEmojiMutationSuccessHandler, + } = {}) => { + const apolloProvider = mockApollo([ + [addAwardEmojiMutation, addAwardEmojiMutationHandler], + [removeAwardEmojiMutation, removeAwardEmojiMutationHandler], + ]); + + apolloProvider.clients.defaultClient.writeQuery({ + query: workItemNotesByIidQuery, + variables: { fullPath, iid: workItemIid }, + ...mockWorkItemNotesResponseWithComments, + }); + + wrapper = shallowMount(WorkItemNoteAwardsList, { + provide: { + fullPath, + }, + propsData: { + workItemIid, + note, + isModal: false, + }, + apolloProvider, + }); + }; + + beforeEach(() => { + window.gon.current_user_id = currentUserId; + }); + + describe('when not editing', () => { + it.each([true, false])('passes emoji permission to awards-list', (hasAwardEmojiPermission) => { + const note = { + ...firstNote, + userPermissions: { + ...firstNote.userPermissions, + awardEmoji: hasAwardEmojiPermission, + }, + }; + createComponent({ note }); + + expect(findAwardsList().props('canAwardEmoji')).toBe(hasAwardEmojiPermission); + }); + + it('adds award if not already awarded', async () => { + createComponent(); + await waitForPromises(); + + findAwardsList().vm.$emit('award', EMOJI_THUMBSUP); + + expect(addAwardEmojiMutationSuccessHandler).toHaveBeenCalledWith({ + awardableId: firstNote.id, + name: EMOJI_THUMBSUP, + }); + }); + + it('emits error if awarding emoji fails', async () => { + createComponent({ + addAwardEmojiMutationHandler: jest.fn().mockRejectedValue('oh no'), + }); + await waitForPromises(); + + findAwardsList().vm.$emit('award', EMOJI_THUMBSUP); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[__('Failed to add emoji. Please try again')]]); + }); + + it('removes award if already awarded', async () => { + const removeAwardEmojiMutationHandler = removeAwardEmojiMutationSuccessHandler; + + createComponent({ removeAwardEmojiMutationHandler }); + + findAwardsList().vm.$emit('award', EMOJI_THUMBSDOWN); + + await waitForPromises(); + + expect(removeAwardEmojiMutationHandler).toHaveBeenCalledWith({ + awardableId: firstNote.id, + name: EMOJI_THUMBSDOWN, + }); + }); + + it('restores award if remove fails', async () => { + createComponent({ + removeAwardEmojiMutationHandler: jest.fn().mockRejectedValue('oh no'), + }); + await waitForPromises(); + + findAwardsList().vm.$emit('award', EMOJI_THUMBSDOWN); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[__('Failed to remove emoji. Please try again')]]); + }); + }); +}); diff --git a/spec/frontend/work_items/components/notes/work_item_note_spec.js b/spec/frontend/work_items/components/notes/work_item_note_spec.js index 8dbd2818fc5..c5d1decfb42 100644 --- a/spec/frontend/work_items/components/notes/work_item_note_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_note_spec.js @@ -1,11 +1,13 @@ import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; +import { GlAvatarLink } from '@gitlab/ui'; import mockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { updateDraft, clearDraft } from '~/lib/utils/autosave'; import EditedAt from '~/issues/show/components/edited.vue'; import WorkItemNote from '~/work_items/components/notes/work_item_note.vue'; +import WorkItemNoteAwardsList from '~/work_items/components/notes/work_item_note_awards_list.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import NoteBody from '~/work_items/components/notes/work_item_note_body.vue'; import NoteHeader from '~/notes/components/note_header.vue'; @@ -76,6 +78,7 @@ describe('Work Item Note', () => { const errorHandler = jest.fn().mockRejectedValue('Oops'); + const findAwardsList = () => wrapper.findComponent(WorkItemNoteAwardsList); const findTimelineEntryItem = () => wrapper.findComponent(TimelineEntryItem); const findNoteHeader = () => wrapper.findComponent(NoteHeader); const findNoteBody = () => wrapper.findComponent(NoteBody); @@ -148,6 +151,13 @@ describe('Work Item Note', () => { expect(findCommentForm().exists()).toBe(false); expect(findNoteWrapper().exists()).toBe(true); }); + + it('should show the awards list when in edit mode', async () => { + createComponent({ note: mockWorkItemCommentNote, workItemsMvc2: true }); + findNoteActions().vm.$emit('startEditing'); + await nextTick(); + expect(findAwardsList().exists()).toBe(true); + }); }); describe('when submitting a form to edit a note', () => { @@ -264,6 +274,19 @@ describe('Work Item Note', () => { createComponent(); }); + it('should show avatar link with popover support', () => { + const avatarLink = findTimelineEntryItem().findComponent(GlAvatarLink); + const { author } = mockWorkItemCommentNote; + + expect(avatarLink.exists()).toBe(true); + expect(avatarLink.classes()).toContain('js-user-link'); + expect(avatarLink.attributes()).toMatchObject({ + href: author.webUrl, + 'data-user-id': '1', + 'data-username': `${author.username}`, + }); + }); + it('should have the note header, actions and body', () => { expect(findTimelineEntryItem().exists()).toBe(true); expect(findNoteHeader().exists()).toBe(true); @@ -404,5 +427,12 @@ describe('Work Item Note', () => { }); }); }); + + it('passes note props to awards list', () => { + createComponent({ note: mockWorkItemCommentNote, workItemsMvc2: true }); + + expect(findAwardsList().props('note')).toBe(mockWorkItemCommentNote); + expect(findAwardsList().props('workItemIid')).toBe('1'); + }); }); }); diff --git a/spec/frontend/work_items/components/work_item_assignees_spec.js b/spec/frontend/work_items/components/work_item_assignees_spec.js index 94d47bfb3be..ff1998ab2ed 100644 --- a/spec/frontend/work_items/components/work_item_assignees_spec.js +++ b/spec/frontend/work_items/components/work_item_assignees_spec.js @@ -274,14 +274,14 @@ describe('WorkItemAssignees component', () => { }); describe('when assigning to current user', () => { - it('does not show `Assign myself` button if current user is loading', () => { + it('does not show `Assign yourself` button if current user is loading', () => { createComponent(); findTokenSelector().trigger('mouseover'); expect(findAssignSelfButton().exists()).toBe(false); }); - it('does not show `Assign myself` button if work item has assignees', async () => { + it('does not show `Assign yourself` button if work item has assignees', async () => { createComponent(); await waitForPromises(); findTokenSelector().trigger('mouseover'); @@ -289,7 +289,7 @@ describe('WorkItemAssignees component', () => { expect(findAssignSelfButton().exists()).toBe(false); }); - it('does now show `Assign myself` button if user is not logged in', async () => { + it('does now show `Assign yourself` button if user is not logged in', async () => { createComponent({ currentUserQueryHandler: noCurrentUserQueryHandler, assignees: [] }); await waitForPromises(); findTokenSelector().trigger('mouseover'); @@ -304,7 +304,7 @@ describe('WorkItemAssignees component', () => { return waitForPromises(); }); - it('renders `Assign myself` button', () => { + it('renders `Assign yourself` button', () => { findTokenSelector().trigger('mouseover'); expect(findAssignSelfButton().exists()).toBe(true); }); diff --git a/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js b/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js new file mode 100644 index 00000000000..ba9af7b2b68 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js @@ -0,0 +1,107 @@ +import { shallowMount } from '@vue/test-utils'; +import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; +import WorkItemDueDate from '~/work_items/components/work_item_due_date.vue'; +import WorkItemState from '~/work_items/components/work_item_state.vue'; +import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; +import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue'; + +import WorkItemAttributesWrapper from '~/work_items/components/work_item_attributes_wrapper.vue'; +import { workItemResponseFactory } from '../mock_data'; + +describe('WorkItemAttributesWrapper component', () => { + let wrapper; + + const workItemQueryResponse = workItemResponseFactory({ canUpdate: true, canDelete: true }); + + const findWorkItemState = () => wrapper.findComponent(WorkItemState); + const findWorkItemDueDate = () => wrapper.findComponent(WorkItemDueDate); + const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees); + const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels); + const findWorkItemMilestone = () => wrapper.findComponent(WorkItemMilestone); + + const createComponent = ({ workItem = workItemQueryResponse.data.workItem } = {}) => { + wrapper = shallowMount(WorkItemAttributesWrapper, { + propsData: { + workItem, + }, + provide: { + hasIssueWeightsFeature: true, + hasIterationsFeature: true, + hasOkrsFeature: true, + hasIssuableHealthStatusFeature: true, + projectNamespace: 'namespace', + fullPath: 'group/project', + }, + stubs: { + WorkItemWeight: true, + WorkItemIteration: true, + WorkItemHealthStatus: true, + }, + }); + }; + + describe('work item state', () => { + it('renders the work item state', () => { + createComponent(); + + expect(findWorkItemState().exists()).toBe(true); + }); + }); + + describe('assignees widget', () => { + it('renders assignees component when widget is returned from the API', () => { + createComponent(); + + expect(findWorkItemAssignees().exists()).toBe(true); + }); + + it('does not render assignees component when widget is not returned from the API', () => { + createComponent({ + workItem: workItemResponseFactory({ assigneesWidgetPresent: false }).data.workItem, + }); + + expect(findWorkItemAssignees().exists()).toBe(false); + }); + }); + + describe('labels widget', () => { + it.each` + description | labelsWidgetPresent | exists + ${'renders when widget is returned from API'} | ${true} | ${true} + ${'does not render when widget is not returned from API'} | ${false} | ${false} + `('$description', ({ labelsWidgetPresent, exists }) => { + const response = workItemResponseFactory({ labelsWidgetPresent }); + createComponent({ workItem: response.data.workItem }); + + expect(findWorkItemLabels().exists()).toBe(exists); + }); + }); + + describe('dates widget', () => { + describe.each` + description | datesWidgetPresent | exists + ${'when widget is returned from API'} | ${true} | ${true} + ${'when widget is not returned from API'} | ${false} | ${false} + `('$description', ({ datesWidgetPresent, exists }) => { + it(`${datesWidgetPresent ? 'renders' : 'does not render'} due date component`, () => { + const response = workItemResponseFactory({ datesWidgetPresent }); + createComponent({ workItem: response.data.workItem }); + + expect(findWorkItemDueDate().exists()).toBe(exists); + }); + }); + }); + + describe('milestone widget', () => { + it.each` + description | milestoneWidgetPresent | exists + ${'renders when widget is returned from API'} | ${true} | ${true} + ${'does not render when widget is not returned from API'} | ${false} | ${false} + `('$description', ({ milestoneWidgetPresent, exists }) => { + const response = workItemResponseFactory({ milestoneWidgetPresent }); + createComponent({ workItem: response.data.workItem }); + + expect(findWorkItemMilestone().exists()).toBe(exists); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_award_emoji_spec.js b/spec/frontend/work_items/components/work_item_award_emoji_spec.js index 82be6d990e4..f8c5f8edc4c 100644 --- a/spec/frontend/work_items/components/work_item_award_emoji_spec.js +++ b/spec/frontend/work_items/components/work_item_award_emoji_spec.js @@ -1,4 +1,4 @@ -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMount } from '@vue/test-utils'; @@ -9,36 +9,67 @@ import { isLoggedIn } from '~/lib/utils/common_utils'; import AwardList from '~/vue_shared/components/awards_list.vue'; import WorkItemAwardEmoji from '~/work_items/components/work_item_award_emoji.vue'; import updateAwardEmojiMutation from '~/work_items/graphql/update_award_emoji.mutation.graphql'; -import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; -import { EMOJI_THUMBSUP, EMOJI_THUMBSDOWN } from '~/work_items/constants'; +import workItemAwardEmojiQuery from '~/work_items/graphql/award_emoji.query.graphql'; +import { + EMOJI_THUMBSUP, + EMOJI_THUMBSDOWN, + DEFAULT_PAGE_SIZE_EMOJIS, + I18N_WORK_ITEM_FETCH_AWARD_EMOJI_ERROR, +} from '~/work_items/constants'; import { workItemByIidResponseFactory, mockAwardsWidget, mockAwardEmojiThumbsUp, getAwardEmojiResponse, + mockMoreThanDefaultAwardEmojisWidget, } from '../mock_data'; jest.mock('~/lib/utils/common_utils'); +jest.mock('~/work_items/constants', () => ({ + ...jest.requireActual('~/work_items/constants'), + DEFAULT_PAGE_SIZE_EMOJIS: 5, +})); + Vue.use(VueApollo); describe('WorkItemAwardEmoji component', () => { let wrapper; let mockApolloProvider; - const errorMessage = 'Failed to update the award'; + const mutationErrorMessage = 'Failed to update the award'; + const workItemQueryResponse = workItemByIidResponseFactory(); - const workItemQueryAddAwardEmojiResponse = workItemByIidResponseFactory({ - awardEmoji: { ...mockAwardsWidget, nodes: [mockAwardEmojiThumbsUp] }, - }); - const workItemQueryRemoveAwardEmojiResponse = workItemByIidResponseFactory({ - awardEmoji: { ...mockAwardsWidget, nodes: [] }, - }); + const mockWorkItem = workItemQueryResponse.data.workspace.workItems.nodes[0]; + + const awardEmojiQuerySuccessHandler = jest.fn().mockResolvedValue(workItemQueryResponse); + const awardEmojiQueryEmptyHandler = jest.fn().mockResolvedValue( + workItemByIidResponseFactory({ + awardEmoji: { + ...mockAwardsWidget, + nodes: [], + }, + }), + ); + const awardEmojiQueryThumbsUpHandler = jest.fn().mockResolvedValue( + workItemByIidResponseFactory({ + awardEmoji: { + ...mockAwardsWidget, + nodes: [mockAwardEmojiThumbsUp], + }, + }), + ); + const awardEmojiQueryFailureHandler = jest + .fn() + .mockRejectedValue(new Error(I18N_WORK_ITEM_FETCH_AWARD_EMOJI_ERROR)); + const awardEmojiAddSuccessHandler = jest.fn().mockResolvedValue(getAwardEmojiResponse(true)); const awardEmojiRemoveSuccessHandler = jest.fn().mockResolvedValue(getAwardEmojiResponse(false)); - const awardEmojiUpdateFailureHandler = jest.fn().mockRejectedValue(new Error(errorMessage)); - const mockWorkItem = workItemQueryResponse.data.workspace.workItems.nodes[0]; - const mockAwardEmojiDifferentUserThumbsUp = { + const awardEmojiUpdateFailureHandler = jest + .fn() + .mockRejectedValue(new Error(mutationErrorMessage)); + + const mockAwardEmojiDifferentUser = { name: 'thumbsup', __typename: 'AwardEmoji', user: { @@ -49,35 +80,37 @@ describe('WorkItemAwardEmoji component', () => { }; const createComponent = ({ - awardMutationHandler = awardEmojiAddSuccessHandler, - workItem = mockWorkItem, + awardEmojiQueryHandler = awardEmojiQuerySuccessHandler, + awardEmojiMutationHandler = awardEmojiAddSuccessHandler, workItemIid = '1', - awardEmoji = { ...mockAwardsWidget, nodes: [] }, } = {}) => { - mockApolloProvider = createMockApollo([[updateAwardEmojiMutation, awardMutationHandler]]); - - mockApolloProvider.clients.defaultClient.writeQuery({ - query: workItemByIidQuery, - variables: { fullPath: workItem.project.fullPath, iid: workItemIid }, - data: { - ...workItemQueryResponse.data, - workspace: { - __typename: 'Project', - id: 'gid://gitlab/Project/1', - workItems: { - nodes: [workItem], + mockApolloProvider = createMockApollo( + [ + [workItemAwardEmojiQuery, awardEmojiQueryHandler], + [updateAwardEmojiMutation, awardEmojiMutationHandler], + ], + {}, + { + typePolicies: { + WorkItemWidgetAwardEmoji: { + fields: { + // If we add any key args, the awardEmoji field becomes awardEmoji({"first":10}) and + // kills any possibility to handle it on the widget level without hardcoding a string. + awardEmoji: { + keyArgs: false, + }, + }, }, }, }, - }); + ); wrapper = shallowMount(WorkItemAwardEmoji, { isLoggedIn: isLoggedIn(), apolloProvider: mockApolloProvider, propsData: { - workItemId: workItem.id, - workItemFullpath: workItem.project.fullPath, - awardEmoji, + workItemId: 'gid://gitlab/WorkItem/1', + workItemFullpath: 'test-project-path', workItemIid, }, }); @@ -85,17 +118,23 @@ describe('WorkItemAwardEmoji component', () => { const findAwardsList = () => wrapper.findComponent(AwardList); - beforeEach(() => { + beforeEach(async () => { isLoggedIn.mockReturnValue(true); window.gon = { current_user_id: 5, current_user_fullname: 'Dave Smith', }; - createComponent(); + await createComponent(); }); - it('renders the award-list component with default props', () => { + it('renders the award-list component with default props', async () => { + createComponent({ + awardEmojiQueryHandler: awardEmojiQueryEmptyHandler, + }); + + await waitForPromises(); + expect(findAwardsList().exists()).toBe(true); expect(findAwardsList().props()).toEqual({ boundary: '', @@ -108,8 +147,6 @@ describe('WorkItemAwardEmoji component', () => { }); it('renders awards-list component with awards present', () => { - createComponent({ awardEmoji: mockAwardsWidget }); - expect(findAwardsList().props('awards')).toEqual([ { name: EMOJI_THUMBSUP, @@ -128,13 +165,32 @@ describe('WorkItemAwardEmoji component', () => { ]); }); - it('renders awards list given by multiple users', () => { + it('emits error when there is an error while fetching award emojis', async () => { createComponent({ + awardEmojiQueryHandler: awardEmojiQueryFailureHandler, + }); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[I18N_WORK_ITEM_FETCH_AWARD_EMOJI_ERROR]]); + }); + + it('renders awards list given by multiple users', async () => { + const mockWorkItemAwardEmojiDifferentUser = workItemByIidResponseFactory({ awardEmoji: { ...mockAwardsWidget, - nodes: [mockAwardEmojiThumbsUp, mockAwardEmojiDifferentUserThumbsUp], + nodes: [mockAwardEmojiThumbsUp, mockAwardEmojiDifferentUser], }, }); + const awardEmojiWithDifferentUsersQueryHandler = jest + .fn() + .mockResolvedValue(mockWorkItemAwardEmojiDifferentUser); + + createComponent({ + awardEmojiQueryHandler: awardEmojiWithDifferentUsersQueryHandler, + }); + + await waitForPromises(); expect(findAwardsList().props('awards')).toEqual([ { @@ -155,21 +211,19 @@ describe('WorkItemAwardEmoji component', () => { }); it.each` - expectedAssertion | awardEmojiMutationHandler | mockAwardEmojiNodes | workItem - ${'added'} | ${awardEmojiAddSuccessHandler} | ${[]} | ${workItemQueryRemoveAwardEmojiResponse.data.workspace.workItems.nodes[0]} - ${'removed'} | ${awardEmojiRemoveSuccessHandler} | ${[mockAwardEmojiThumbsUp]} | ${workItemQueryAddAwardEmojiResponse.data.workspace.workItems.nodes[0]} + expectedAssertion | awardEmojiMutationHandler | awardEmojiQueryHandler + ${'added'} | ${awardEmojiAddSuccessHandler} | ${awardEmojiQueryEmptyHandler} + ${'removed'} | ${awardEmojiRemoveSuccessHandler} | ${awardEmojiQueryThumbsUpHandler} `( 'calls mutation when an award emoji is $expectedAssertion', - ({ awardEmojiMutationHandler, mockAwardEmojiNodes, workItem }) => { + async ({ awardEmojiMutationHandler, awardEmojiQueryHandler }) => { createComponent({ - awardMutationHandler: awardEmojiMutationHandler, - awardEmoji: { - ...mockAwardsWidget, - nodes: mockAwardEmojiNodes, - }, - workItem, + awardEmojiMutationHandler, + awardEmojiQueryHandler, }); + await waitForPromises(); + findAwardsList().vm.$emit('award', EMOJI_THUMBSUP); expect(awardEmojiMutationHandler).toHaveBeenCalledWith({ @@ -183,21 +237,24 @@ describe('WorkItemAwardEmoji component', () => { it('emits error when the update mutation fails', async () => { createComponent({ - awardMutationHandler: awardEmojiUpdateFailureHandler, + awardEmojiMutationHandler: awardEmojiUpdateFailureHandler, + awardEmojiQueryHandler: awardEmojiQueryEmptyHandler, }); + await waitForPromises(); + findAwardsList().vm.$emit('award', EMOJI_THUMBSUP); await waitForPromises(); - expect(wrapper.emitted('error')).toEqual([[errorMessage]]); + expect(wrapper.emitted('error')).toEqual([[mutationErrorMessage]]); }); describe('when user is not logged in', () => { - beforeEach(() => { + beforeEach(async () => { isLoggedIn.mockReturnValue(false); - createComponent(); + await createComponent(); }); it('renders the component with required props and canAwardEmoji false', () => { @@ -213,15 +270,13 @@ describe('WorkItemAwardEmoji component', () => { }; }); - it('calls mutation succesfully and adds the award emoji with proper user details', () => { + it('calls mutation succesfully and adds the award emoji with proper user details', async () => { createComponent({ - awardMutationHandler: awardEmojiAddSuccessHandler, - awardEmoji: { - ...mockAwardsWidget, - nodes: [mockAwardEmojiThumbsUp], - }, + awardEmojiMutationHandler: awardEmojiAddSuccessHandler, }); + await waitForPromises(); + findAwardsList().vm.$emit('award', EMOJI_THUMBSUP); expect(awardEmojiAddSuccessHandler).toHaveBeenCalledWith({ @@ -232,4 +287,62 @@ describe('WorkItemAwardEmoji component', () => { }); }); }); + + describe('pagination', () => { + describe('when there is no next page', () => { + const awardEmojiQuerySingleItemHandler = jest.fn().mockResolvedValue( + workItemByIidResponseFactory({ + awardEmoji: { + ...mockAwardsWidget, + nodes: [mockAwardEmojiThumbsUp], + }, + }), + ); + + it('fetch more award emojis should not be called', async () => { + createComponent({ awardEmojiQueryHandler: awardEmojiQuerySingleItemHandler }); + await waitForPromises(); + + expect(awardEmojiQuerySingleItemHandler).toHaveBeenCalledWith({ + fullPath: 'test-project-path', + iid: '1', + pageSize: DEFAULT_PAGE_SIZE_EMOJIS, + after: undefined, + }); + expect(awardEmojiQuerySingleItemHandler).toHaveBeenCalledTimes(1); + }); + }); + + describe('when there is next page', () => { + const awardEmojisQueryMoreThanDefaultHandler = jest.fn().mockResolvedValueOnce( + workItemByIidResponseFactory({ + awardEmoji: mockMoreThanDefaultAwardEmojisWidget, + }), + ); + + it('fetch more award emojis should be called', async () => { + createComponent({ + awardEmojiQueryHandler: awardEmojisQueryMoreThanDefaultHandler, + }); + await waitForPromises(); + + expect(awardEmojisQueryMoreThanDefaultHandler).toHaveBeenCalledWith({ + fullPath: 'test-project-path', + iid: '1', + pageSize: DEFAULT_PAGE_SIZE_EMOJIS, + after: 'endCursor', + }); + + await nextTick(); + + expect(awardEmojisQueryMoreThanDefaultHandler).toHaveBeenCalledWith({ + fullPath: 'test-project-path', + iid: '1', + pageSize: DEFAULT_PAGE_SIZE_EMOJIS, + after: mockMoreThanDefaultAwardEmojisWidget.pageInfo.endCursor, + }); + expect(awardEmojisQueryMoreThanDefaultHandler).toHaveBeenCalledTimes(2); + }); + }); + }); }); diff --git a/spec/frontend/work_items/components/work_item_description_spec.js b/spec/frontend/work_items/components/work_item_description_spec.js index b910e9854f8..8b9963b2476 100644 --- a/spec/frontend/work_items/components/work_item_description_spec.js +++ b/spec/frontend/work_items/components/work_item_description_spec.js @@ -12,14 +12,12 @@ import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue import WorkItemDescription from '~/work_items/components/work_item_description.vue'; import WorkItemDescriptionRendered from '~/work_items/components/work_item_description_rendered.vue'; import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; -import workItemDescriptionSubscription from '~/work_items/graphql/work_item_description.subscription.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import { autocompleteDataSources, markdownPreviewPath } from '~/work_items/utils'; import { updateWorkItemMutationResponse, workItemByIidResponseFactory, - workItemDescriptionSubscriptionResponse, workItemQueryResponse, } from '../mock_data'; @@ -34,7 +32,6 @@ describe('WorkItemDescription', () => { Vue.use(VueApollo); const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); - const subscriptionHandler = jest.fn().mockResolvedValue(workItemDescriptionSubscriptionResponse); let workItemResponseHandler; const findForm = () => wrapper.findComponent(GlForm); @@ -63,7 +60,6 @@ describe('WorkItemDescription', () => { apolloProvider: createMockApollo([ [workItemByIidQuery, workItemResponseHandler], [updateWorkItemMutation, mutationHandler], - [workItemDescriptionSubscription, subscriptionHandler], ]), propsData: { workItemId: id, @@ -83,14 +79,6 @@ describe('WorkItemDescription', () => { } }; - it('has a subscription', async () => { - await createComponent(); - - expect(subscriptionHandler).toHaveBeenCalledWith({ - issuableId: workItemQueryResponse.data.workItem.id, - }); - }); - describe('editing description', () => { it('passes correct autocompletion data and preview markdown sources and enables quick actions', async () => { const { @@ -103,7 +91,6 @@ describe('WorkItemDescription', () => { expect(findMarkdownEditor().props()).toMatchObject({ supportsQuickActions: true, renderMarkdownPath: markdownPreviewPath(fullPath, iid), - quickActionsDocsPath: wrapper.vm.$options.quickActionsDocsPath, autocompleteDataSources: autocompleteDataSources(fullPath, iid), }); }); diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js index d8ba8ea74f2..7ceae935d2d 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -5,10 +5,11 @@ import { GlSkeletonLoader, GlButton, GlEmptyState, + GlIntersectionObserver, } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { isLoggedIn } from '~/lib/utils/common_utils'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -18,12 +19,8 @@ import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; import WorkItemActions from '~/work_items/components/work_item_actions.vue'; import WorkItemDescription from '~/work_items/components/work_item_description.vue'; import WorkItemCreatedUpdated from '~/work_items/components/work_item_created_updated.vue'; -import WorkItemDueDate from '~/work_items/components/work_item_due_date.vue'; -import WorkItemState from '~/work_items/components/work_item_state.vue'; +import WorkItemAttributesWrapper from '~/work_items/components/work_item_attributes_wrapper.vue'; import WorkItemTitle from '~/work_items/components/work_item_title.vue'; -import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; -import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; -import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue'; import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue'; import WorkItemNotes from '~/work_items/components/work_item_notes.vue'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; @@ -31,20 +28,13 @@ import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_sel import WorkItemTodos from '~/work_items/components/work_item_todos.vue'; import { i18n } from '~/work_items/constants'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; -import workItemDatesSubscription from '~/graphql_shared/subscriptions/work_item_dates.subscription.graphql'; -import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql'; -import workItemAssigneesSubscription from '~/work_items/graphql/work_item_assignees.subscription.graphql'; -import workItemMilestoneSubscription from '~/work_items/graphql/work_item_milestone.subscription.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql'; +import workItemUpdatedSubscription from '~/work_items/graphql/work_item_updated.subscription.graphql'; import { mockParent, - workItemDatesSubscriptionResponse, workItemByIidResponseFactory, - workItemTitleSubscriptionResponse, - workItemAssigneesSubscriptionResponse, - workItemMilestoneSubscriptionResponse, objectiveType, mockWorkItemCommentNote, } from '../mock_data'; @@ -63,16 +53,11 @@ describe('WorkItemDetail component', () => { canDelete: true, }); const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse); - const datesSubscriptionHandler = jest.fn().mockResolvedValue(workItemDatesSubscriptionResponse); - const titleSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse); - const milestoneSubscriptionHandler = jest - .fn() - .mockResolvedValue(workItemMilestoneSubscriptionResponse); - const assigneesSubscriptionHandler = jest - .fn() - .mockResolvedValue(workItemAssigneesSubscriptionResponse); const showModalHandler = jest.fn(); const { id } = workItemQueryResponse.data.workspace.workItems.nodes[0]; + const workItemUpdatedSubscriptionHandler = jest + .fn() + .mockResolvedValue({ data: { workItemUpdated: null } }); const findAlert = () => wrapper.findComponent(GlAlert); const findEmptyState = () => wrapper.findComponent(GlEmptyState); @@ -81,42 +66,39 @@ describe('WorkItemDetail component', () => { const findWorkItemActions = () => wrapper.findComponent(WorkItemActions); const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle); const findCreatedUpdated = () => wrapper.findComponent(WorkItemCreatedUpdated); - const findWorkItemState = () => wrapper.findComponent(WorkItemState); const findWorkItemDescription = () => wrapper.findComponent(WorkItemDescription); - const findWorkItemDueDate = () => wrapper.findComponent(WorkItemDueDate); - const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees); - const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels); - const findWorkItemMilestone = () => wrapper.findComponent(WorkItemMilestone); - const findParent = () => wrapper.find('[data-testid="work-item-parent"]'); + const findWorkItemAttributesWrapper = () => wrapper.findComponent(WorkItemAttributesWrapper); + const findParent = () => wrapper.findByTestId('work-item-parent'); const findParentButton = () => findParent().findComponent(GlButton); - const findCloseButton = () => wrapper.find('[data-testid="work-item-close"]'); - const findWorkItemType = () => wrapper.find('[data-testid="work-item-type"]'); + const findCloseButton = () => wrapper.findByTestId('work-item-close'); + const findWorkItemType = () => wrapper.findByTestId('work-item-type'); const findHierarchyTree = () => wrapper.findComponent(WorkItemTree); const findNotesWidget = () => wrapper.findComponent(WorkItemNotes); const findModal = () => wrapper.findComponent(WorkItemDetailModal); const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector); const findWorkItemTodos = () => wrapper.findComponent(WorkItemTodos); + const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); + const findStickyHeader = () => wrapper.findByTestId('work-item-sticky-header'); + const findWorkItemTwoColumnViewContainer = () => wrapper.findByTestId('work-item-overview'); + const findRightSidebar = () => wrapper.findByTestId('work-item-overview-right-sidebar'); + const triggerPageScroll = () => findIntersectionObserver().vm.$emit('disappear'); const createComponent = ({ isModal = false, updateInProgress = false, workItemIid = '1', handler = successHandler, - subscriptionHandler = titleSubscriptionHandler, confidentialityMock = [updateWorkItemMutation, jest.fn()], error = undefined, workItemsMvc2Enabled = false, } = {}) => { const handlers = [ [workItemByIidQuery, handler], - [workItemTitleSubscription, subscriptionHandler], - [workItemDatesSubscription, datesSubscriptionHandler], - [workItemAssigneesSubscription, assigneesSubscriptionHandler], - [workItemMilestoneSubscription, milestoneSubscriptionHandler], + [workItemUpdatedSubscription, workItemUpdatedSubscriptionHandler], confidentialityMock, ]; - wrapper = shallowMount(WorkItemDetail, { + wrapper = shallowMountExtended(WorkItemDetail, { apolloProvider: createMockApollo(handlers), isLoggedIn: isLoggedIn(), propsData: { @@ -163,13 +145,18 @@ describe('WorkItemDetail component', () => { }); describe('when there is no `workItemIid` prop', () => { - beforeEach(() => { + beforeEach(async () => { createComponent({ workItemIid: null }); + await waitForPromises(); }); it('skips the work item query', () => { expect(successHandler).not.toHaveBeenCalled(); }); + + it('skips the work item updated subscription', () => { + expect(workItemUpdatedSubscriptionHandler).not.toHaveBeenCalled(); + }); }); describe('when loading', () => { @@ -179,7 +166,6 @@ describe('WorkItemDetail component', () => { it('renders skeleton loader', () => { expect(findSkeleton().exists()).toBe(true); - expect(findWorkItemState().exists()).toBe(false); expect(findWorkItemTitle().exists()).toBe(false); }); }); @@ -192,7 +178,6 @@ describe('WorkItemDetail component', () => { it('does not render skeleton', () => { expect(findSkeleton().exists()).toBe(false); - expect(findWorkItemState().exists()).toBe(true); expect(findWorkItemTitle().exists()).toBe(true); }); @@ -203,6 +188,10 @@ describe('WorkItemDetail component', () => { it('renders todos widget if logged in', () => { expect(findWorkItemTodos().exists()).toBe(true); }); + + it('calls the work item updated subscription', () => { + expect(workItemUpdatedSubscriptionHandler).toHaveBeenCalledWith({ id }); + }); }); describe('close button', () => { @@ -488,159 +477,6 @@ describe('WorkItemDetail component', () => { expect(findAlert().text()).toBe(updateError); }); - describe('subscriptions', () => { - it('calls the title subscription', async () => { - createComponent(); - await waitForPromises(); - - expect(titleSubscriptionHandler).toHaveBeenCalledWith({ issuableId: id }); - }); - - describe('assignees subscription', () => { - describe('when the assignees widget exists', () => { - it('calls the assignees subscription', async () => { - createComponent(); - await waitForPromises(); - - expect(assigneesSubscriptionHandler).toHaveBeenCalledWith({ issuableId: id }); - }); - }); - - describe('when the assignees widget does not exist', () => { - it('does not call the assignees subscription', async () => { - const response = workItemByIidResponseFactory({ assigneesWidgetPresent: false }); - const handler = jest.fn().mockResolvedValue(response); - createComponent({ handler }); - await waitForPromises(); - - expect(assigneesSubscriptionHandler).not.toHaveBeenCalled(); - }); - }); - }); - - describe('dates subscription', () => { - describe('when the due date widget exists', () => { - it('calls the dates subscription', async () => { - createComponent(); - await waitForPromises(); - - expect(datesSubscriptionHandler).toHaveBeenCalledWith({ issuableId: id }); - }); - }); - - describe('when the due date widget does not exist', () => { - it('does not call the dates subscription', async () => { - const response = workItemByIidResponseFactory({ datesWidgetPresent: false }); - const handler = jest.fn().mockResolvedValue(response); - createComponent({ handler }); - await waitForPromises(); - - expect(datesSubscriptionHandler).not.toHaveBeenCalled(); - }); - }); - }); - }); - - describe('assignees widget', () => { - it('renders assignees component when widget is returned from the API', async () => { - createComponent(); - await waitForPromises(); - - expect(findWorkItemAssignees().exists()).toBe(true); - }); - - it('does not render assignees component when widget is not returned from the API', async () => { - createComponent({ - handler: jest - .fn() - .mockResolvedValue(workItemByIidResponseFactory({ assigneesWidgetPresent: false })), - }); - await waitForPromises(); - - expect(findWorkItemAssignees().exists()).toBe(false); - }); - }); - - describe('labels widget', () => { - it.each` - description | labelsWidgetPresent | exists - ${'renders when widget is returned from API'} | ${true} | ${true} - ${'does not render when widget is not returned from API'} | ${false} | ${false} - `('$description', async ({ labelsWidgetPresent, exists }) => { - const response = workItemByIidResponseFactory({ labelsWidgetPresent }); - const handler = jest.fn().mockResolvedValue(response); - createComponent({ handler }); - await waitForPromises(); - - expect(findWorkItemLabels().exists()).toBe(exists); - }); - }); - - describe('dates widget', () => { - describe.each` - description | datesWidgetPresent | exists - ${'when widget is returned from API'} | ${true} | ${true} - ${'when widget is not returned from API'} | ${false} | ${false} - `('$description', ({ datesWidgetPresent, exists }) => { - it(`${datesWidgetPresent ? 'renders' : 'does not render'} due date component`, async () => { - const response = workItemByIidResponseFactory({ datesWidgetPresent }); - const handler = jest.fn().mockResolvedValue(response); - createComponent({ handler }); - await waitForPromises(); - - expect(findWorkItemDueDate().exists()).toBe(exists); - }); - }); - - it('shows an error message when it emits an `error` event', async () => { - createComponent(); - await waitForPromises(); - const updateError = 'Failed to update'; - - findWorkItemDueDate().vm.$emit('error', updateError); - await waitForPromises(); - - expect(findAlert().text()).toBe(updateError); - }); - }); - - describe('milestone widget', () => { - it.each` - description | milestoneWidgetPresent | exists - ${'renders when widget is returned from API'} | ${true} | ${true} - ${'does not render when widget is not returned from API'} | ${false} | ${false} - `('$description', async ({ milestoneWidgetPresent, exists }) => { - const response = workItemByIidResponseFactory({ milestoneWidgetPresent }); - const handler = jest.fn().mockResolvedValue(response); - createComponent({ handler }); - await waitForPromises(); - - expect(findWorkItemMilestone().exists()).toBe(exists); - }); - - describe('milestone subscription', () => { - describe('when the milestone widget exists', () => { - it('calls the milestone subscription', async () => { - createComponent(); - await waitForPromises(); - - expect(milestoneSubscriptionHandler).toHaveBeenCalledWith({ issuableId: id }); - }); - }); - - describe('when the assignees widget does not exist', () => { - it('does not call the milestone subscription', async () => { - const response = workItemByIidResponseFactory({ milestoneWidgetPresent: false }); - const handler = jest.fn().mockResolvedValue(response); - createComponent({ handler }); - await waitForPromises(); - - expect(milestoneSubscriptionHandler).not.toHaveBeenCalled(); - }); - }); - }); - }); - it('calls the work item query', async () => { createComponent(); await waitForPromises(); @@ -796,4 +632,76 @@ describe('WorkItemDetail component', () => { expect(findWorkItemTodos().exists()).toBe(false); }); }); + + describe('work item attributes wrapper', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('renders the work item attributes wrapper', () => { + expect(findWorkItemAttributesWrapper().exists()).toBe(true); + }); + + it('shows an error message when it emits an `error` event', async () => { + const updateError = 'Failed to update'; + + findWorkItemAttributesWrapper().vm.$emit('error', updateError); + await waitForPromises(); + + expect(findAlert().text()).toBe(updateError); + }); + }); + + describe('work item two column view', () => { + describe('when `workItemsMvc2Enabled` is false', () => { + beforeEach(async () => { + createComponent({ workItemsMvc2Enabled: false }); + await waitForPromises(); + }); + + it('does not have the `work-item-overview` class', () => { + expect(findWorkItemTwoColumnViewContainer().classes()).not.toContain('work-item-overview'); + }); + + it('does not have sticky header', () => { + expect(findIntersectionObserver().exists()).toBe(false); + expect(findStickyHeader().exists()).toBe(false); + }); + + it('does not have right sidebar', () => { + expect(findRightSidebar().exists()).toBe(false); + }); + }); + + describe('when `workItemsMvc2Enabled` is true', () => { + beforeEach(async () => { + createComponent({ workItemsMvc2Enabled: true }); + await waitForPromises(); + }); + + it('has the `work-item-overview` class', () => { + expect(findWorkItemTwoColumnViewContainer().classes()).toContain('work-item-overview'); + }); + + it('does not show sticky header by default', () => { + expect(findStickyHeader().exists()).toBe(false); + }); + + it('has the sticky header when the page is scrolled', async () => { + expect(findIntersectionObserver().exists()).toBe(true); + + global.pageYOffset = 100; + triggerPageScroll(); + + await nextTick(); + + expect(findStickyHeader().exists()).toBe(true); + }); + + it('has the right sidebar', () => { + expect(findRightSidebar().exists()).toBe(true); + }); + }); + }); }); diff --git a/spec/frontend/work_items/components/work_item_labels_spec.js b/spec/frontend/work_items/components/work_item_labels_spec.js index 6894aa236e3..4a20e654060 100644 --- a/spec/frontend/work_items/components/work_item_labels_spec.js +++ b/spec/frontend/work_items/components/work_item_labels_spec.js @@ -6,7 +6,6 @@ import waitForPromises from 'helpers/wait_for_promises'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import labelSearchQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql'; -import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; @@ -16,7 +15,6 @@ import { mockLabels, workItemByIidResponseFactory, updateWorkItemMutationResponse, - workItemLabelsSubscriptionResponse, } from '../mock_data'; Vue.use(VueApollo); @@ -38,7 +36,6 @@ describe('WorkItemLabels component', () => { const successUpdateWorkItemMutationHandler = jest .fn() .mockResolvedValue(updateWorkItemMutationResponse); - const subscriptionHandler = jest.fn().mockResolvedValue(workItemLabelsSubscriptionResponse); const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); const createComponent = ({ @@ -53,7 +50,6 @@ describe('WorkItemLabels component', () => { [workItemByIidQuery, workItemQueryHandler], [labelSearchQuery, searchQueryHandler], [updateWorkItemMutation, updateWorkItemMutationHandler], - [workItemLabelsSubscription, subscriptionHandler], ]), provide: { fullPath: 'test-project-path', @@ -246,16 +242,6 @@ describe('WorkItemLabels component', () => { expect(updateWorkItemMutationHandler).not.toHaveBeenCalled(); }); - - it('has a subscription', async () => { - createComponent(); - - await waitForPromises(); - - expect(subscriptionHandler).toHaveBeenCalledWith({ - issuableId: workItemId, - }); - }); }); it('calls the work item query', async () => { diff --git a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js index f3aa347f389..e90775a5240 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js @@ -1,12 +1,10 @@ import { nextTick } from 'vue'; - import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import WidgetWrapper from '~/work_items/components/widget_wrapper.vue'; import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue'; import WorkItemChildrenWrapper from '~/work_items/components/work_item_links/work_item_children_wrapper.vue'; import WorkItemLinksForm from '~/work_items/components/work_item_links/work_item_links_form.vue'; import OkrActionsSplitButton from '~/work_items/components/work_item_links/okr_actions_split_button.vue'; - import { FORM_TYPES, WORK_ITEM_TYPE_ENUM_OBJECTIVE, @@ -42,9 +40,8 @@ describe('WorkItemTree', () => { children, canUpdate, }, + stubs: { WidgetWrapper }, }); - - wrapper.vm.$refs.wrapper.show = jest.fn(); }; it('displays Add button', () => { diff --git a/spec/frontend/work_items/components/work_item_todos_spec.js b/spec/frontend/work_items/components/work_item_todos_spec.js index 83b61a04298..454bd97bbee 100644 --- a/spec/frontend/work_items/components/work_item_todos_spec.js +++ b/spec/frontend/work_items/components/work_item_todos_spec.js @@ -1,14 +1,24 @@ import { GlButton, GlIcon } from '@gitlab/ui'; + import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + import WorkItemTodos from '~/work_items/components/work_item_todos.vue'; -import { ADD, TODO_DONE_ICON, TODO_ADD_ICON } from '~/work_items/constants'; -import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import { + TODO_DONE_ICON, + TODO_ADD_ICON, + TODO_PENDING_STATE, + TODO_DONE_STATE, +} from '~/work_items/constants'; import { updateGlobalTodoCount } from '~/sidebar/utils'; -import { workItemResponseFactory, updateWorkItemMutationResponseFactory } from '../mock_data'; +import createWorkItemTodosMutation from '~/work_items/graphql/create_work_item_todos.mutation.graphql'; +import markDoneWorkItemTodosMutation from '~/work_items/graphql/mark_done_work_item_todos.mutation.graphql'; +import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; + +import { workItemResponseFactory, getTodosMutationResponse } from '../mock_data'; jest.mock('~/sidebar/utils'); @@ -22,27 +32,58 @@ describe('WorkItemTodo component', () => { const errorMessage = 'Failed to add item'; const workItemQueryResponse = workItemResponseFactory({ canUpdate: true }); - const successHandler = jest + const mockWorkItemId = workItemQueryResponse.data.workItem.id; + const mockWorkItemIid = workItemQueryResponse.data.workItem.iid; + const mockWorkItemFullpath = workItemQueryResponse.data.workItem.project.fullPath; + + const createTodoSuccessHandler = jest .fn() - .mockResolvedValue(updateWorkItemMutationResponseFactory({ canUpdate: true })); + .mockResolvedValue(getTodosMutationResponse(TODO_PENDING_STATE)); + const markDoneTodoSuccessHandler = jest + .fn() + .mockResolvedValue(getTodosMutationResponse(TODO_DONE_STATE)); const failureHandler = jest.fn().mockRejectedValue(new Error(errorMessage)); - const inputVariables = { - id: 'gid://gitlab/WorkItem/1', - currentUserTodosWidget: { - action: ADD, - }, + const inputVariablesCreateTodos = { + targetId: 'gid://gitlab/WorkItem/1', + }; + + const inputVariablesMarkDoneTodos = { + id: 'gid://gitlab/Todo/1', + }; + + const mockCurrentUserTodos = { + id: 'gid://gitlab/Todo/1', }; const createComponent = ({ - currentUserTodosMock = [updateWorkItemMutation, successHandler], + mutation = createWorkItemTodosMutation, + currentUserTodosHandler = createTodoSuccessHandler, currentUserTodos = [], } = {}) => { - const handlers = [currentUserTodosMock]; + const mockApolloProvider = createMockApollo([[mutation, currentUserTodosHandler]]); + + mockApolloProvider.clients.defaultClient.cache.writeQuery({ + query: workItemByIidQuery, + variables: { fullPath: mockWorkItemFullpath, iid: mockWorkItemIid }, + data: { + ...workItemQueryResponse.data, + workspace: { + __typename: 'Project', + id: 'gid://gitlab/Project/1', + workItems: { + nodes: [workItemQueryResponse.data.workItem], + }, + }, + }, + }); + wrapper = shallowMountExtended(WorkItemTodos, { - apolloProvider: createMockApollo(handlers), + apolloProvider: mockApolloProvider, propsData: { - workItem: workItemQueryResponse.data.workItem, + workItemId: mockWorkItemId, + workItemIid: mockWorkItemIid, + workItemFullpath: mockWorkItemFullpath, currentUserTodos, }, }); @@ -58,35 +99,41 @@ describe('WorkItemTodo component', () => { it('renders mark as done button when there is pending item', () => { createComponent({ - currentUserTodos: [ - { - node: { - id: 'gid://gitlab/Todo/1', - state: 'pending', - }, - }, - ], + currentUserTodos: [mockCurrentUserTodos], }); expect(findTodoIcon().props('name')).toEqual(TODO_DONE_ICON); expect(findTodoIcon().classes('gl-fill-blue-500')).toBe(true); }); - it('calls update mutation when to do button is clicked', async () => { - createComponent(); + it.each` + assertionName | mutation | currentUserTodosHandler | currentUserTodos | inputVariables + ${'create'} | ${createWorkItemTodosMutation} | ${createTodoSuccessHandler} | ${[]} | ${inputVariablesCreateTodos} + ${'mark done'} | ${markDoneWorkItemTodosMutation} | ${markDoneTodoSuccessHandler} | ${[mockCurrentUserTodos]} | ${inputVariablesMarkDoneTodos} + `( + 'calls $assertionName todos mutation when to do button is toggled', + async ({ mutation, currentUserTodosHandler, currentUserTodos, inputVariables }) => { + createComponent({ + mutation, + currentUserTodosHandler, + currentUserTodos, + }); - findTodoWidget().vm.$emit('click'); + findTodoWidget().vm.$emit('click'); - await waitForPromises(); + await waitForPromises(); - expect(successHandler).toHaveBeenCalledWith({ - input: inputVariables, - }); - expect(updateGlobalTodoCount).toHaveBeenCalled(); - }); + expect(currentUserTodosHandler).toHaveBeenCalledWith({ + input: inputVariables, + }); + expect(updateGlobalTodoCount).toHaveBeenCalled(); + }, + ); it('emits error when the update mutation fails', async () => { - createComponent({ currentUserTodosMock: [updateWorkItemMutation, failureHandler] }); + createComponent({ + currentUserTodosHandler: failureHandler, + }); findTodoWidget().vm.$emit('click'); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index a873462ea63..f88e69a7ffe 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -68,6 +68,38 @@ export const mockAwardEmojiThumbsDown = { export const mockAwardsWidget = { nodes: [mockAwardEmojiThumbsUp, mockAwardEmojiThumbsDown], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + __typename: 'PageInfo', + }, + __typename: 'AwardEmojiConnection', +}; + +export const mockMoreThanDefaultAwardEmojisWidget = { + nodes: [ + mockAwardEmojiThumbsUp, + mockAwardEmojiThumbsDown, + { ...mockAwardEmojiThumbsUp, name: 'one' }, + { ...mockAwardEmojiThumbsUp, name: 'two' }, + { ...mockAwardEmojiThumbsUp, name: 'three' }, + { ...mockAwardEmojiThumbsUp, name: 'four' }, + { ...mockAwardEmojiThumbsUp, name: 'five' }, + { ...mockAwardEmojiThumbsUp, name: 'six' }, + { ...mockAwardEmojiThumbsUp, name: 'seven' }, + { ...mockAwardEmojiThumbsUp, name: 'eight' }, + { ...mockAwardEmojiThumbsUp, name: 'nine' }, + { ...mockAwardEmojiThumbsUp, name: 'ten' }, + ], + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + startCursor: null, + endCursor: 'endCursor', + __typename: 'PageInfo', + }, __typename: 'AwardEmojiConnection', }; @@ -629,14 +661,10 @@ export const workItemResponseFactory = ({ ? { type: 'CURRENT_USER_TODOS', currentUserTodos: { - edges: [ + nodes: [ { - node: { - id: 'gid://gitlab/Todo/1', - state: 'pending', - __typename: 'Todo', - }, - __typename: 'TodoEdge', + id: 'gid://gitlab/Todo/1', + __typename: 'Todo', }, ], __typename: 'TodoConnection', @@ -803,154 +831,6 @@ export const deleteWorkItemMutationErrorResponse = { }, }; -export const workItemDatesSubscriptionResponse = { - data: { - issuableDatesUpdated: { - id: 'gid://gitlab/WorkItem/1', - widgets: [ - { - __typename: 'WorkItemWidgetStartAndDueDate', - dueDate: '2022-12-31', - startDate: '2022-01-01', - }, - ], - }, - }, -}; - -export const workItemTitleSubscriptionResponse = { - data: { - issuableTitleUpdated: { - id: 'gid://gitlab/WorkItem/1', - title: 'new title', - }, - }, -}; - -export const workItemDescriptionSubscriptionResponse = { - data: { - issuableDescriptionUpdated: { - id: 'gid://gitlab/WorkItem/1', - widgets: [ - { - __typename: 'WorkItemWidgetDescription', - type: 'DESCRIPTION', - description: 'New description', - descriptionHtml: '<p>New description</p>', - lastEditedAt: '2022-09-21T06:18:42Z', - lastEditedBy: { - id: 'gid://gitlab/User/2', - name: 'Someone else', - webPath: '/not-you', - }, - }, - ], - }, - }, -}; - -export const workItemWeightSubscriptionResponse = { - data: { - issuableWeightUpdated: { - id: 'gid://gitlab/WorkItem/1', - widgets: [ - { - __typename: 'WorkItemWidgetWeight', - weight: 1, - }, - ], - }, - }, -}; - -export const workItemAssigneesSubscriptionResponse = { - data: { - issuableAssigneesUpdated: { - id: 'gid://gitlab/WorkItem/1', - widgets: [ - { - __typename: 'WorkItemAssigneesWeight', - assignees: { - nodes: [mockAssignees[0]], - }, - }, - ], - }, - }, -}; - -export const workItemLabelsSubscriptionResponse = { - data: { - issuableLabelsUpdated: { - id: 'gid://gitlab/WorkItem/1', - widgets: [ - { - __typename: 'WorkItemWidgetLabels', - type: 'LABELS', - allowsScopedLabels: false, - labels: { - nodes: mockLabels, - }, - }, - ], - }, - }, -}; - -export const workItemIterationSubscriptionResponse = { - data: { - issuableIterationUpdated: { - id: 'gid://gitlab/WorkItem/1', - widgets: [ - { - __typename: 'WorkItemWidgetIteration', - iteration: { - description: 'Iteration description', - dueDate: '2022-07-29', - id: 'gid://gitlab/Iteration/1125', - iid: '95', - startDate: '2022-06-22', - title: 'Iteration subcription title', - }, - }, - ], - }, - }, -}; - -export const workItemHealthStatusSubscriptionResponse = { - data: { - issuableHealthStatusUpdated: { - id: 'gid://gitlab/WorkItem/1', - widgets: [ - { - __typename: 'WorkItemWidgetHealthStatus', - healthStatus: 'needsAttention', - }, - ], - }, - }, -}; - -export const workItemMilestoneSubscriptionResponse = { - data: { - issuableMilestoneUpdated: { - id: 'gid://gitlab/WorkItem/1', - widgets: [ - { - __typename: 'WorkItemWidgetMilestone', - type: 'MILESTONE', - milestone: { - id: 'gid://gitlab/Milestone/1125', - expired: false, - title: 'Milestone title', - }, - }, - ], - }, - }, -}; - export const workItemHierarchyEmptyResponse = { data: { workspace: { @@ -2130,6 +2010,9 @@ export const mockWorkItemNotesResponse = { webUrl: 'http://127.0.0.1:3000/root', __typename: 'UserCore', }, + awardEmoji: { + nodes: [], + }, __typename: 'Note', }, ], @@ -2241,6 +2124,9 @@ export const mockWorkItemNotesByIidResponse = { webUrl: 'http://127.0.0.1:3000/root', __typename: 'UserCore', }, + awardEmoji: { + nodes: [], + }, __typename: 'Note', }, ], @@ -2294,6 +2180,9 @@ export const mockWorkItemNotesByIidResponse = { webUrl: 'http://127.0.0.1:3000/root', __typename: 'UserCore', }, + awardEmoji: { + nodes: [], + }, __typename: 'Note', }, ], @@ -2348,6 +2237,9 @@ export const mockWorkItemNotesByIidResponse = { webUrl: 'http://127.0.0.1:3000/root', __typename: 'UserCore', }, + awardEmoji: { + nodes: [], + }, __typename: 'Note', }, ], @@ -2460,6 +2352,9 @@ export const mockMoreWorkItemNotesResponse = { webUrl: 'http://127.0.0.1:3000/root', __typename: 'UserCore', }, + awardEmoji: { + nodes: [], + }, __typename: 'Note', }, ], @@ -2513,6 +2408,9 @@ export const mockMoreWorkItemNotesResponse = { webUrl: 'http://127.0.0.1:3000/root', __typename: 'UserCore', }, + awardEmoji: { + nodes: [], + }, __typename: 'Note', }, ], @@ -2564,6 +2462,9 @@ export const mockMoreWorkItemNotesResponse = { webUrl: 'http://127.0.0.1:3000/root', __typename: 'UserCore', }, + awardEmoji: { + nodes: [], + }, __typename: 'Note', }, ], @@ -2631,6 +2532,9 @@ export const createWorkItemNoteResponse = { repositionNote: true, __typename: 'NotePermissions', }, + awardEmoji: { + nodes: [], + }, __typename: 'Note', }, ], @@ -2682,6 +2586,9 @@ export const mockWorkItemCommentNote = { webUrl: 'http://127.0.0.1:3000/root', __typename: 'UserCore', }, + awardEmoji: { + nodes: [mockAwardEmojiThumbsDown], + }, }; export const mockWorkItemCommentNoteByContributor = { @@ -2781,6 +2688,9 @@ export const mockWorkItemNotesResponseWithComments = { repositionNote: true, __typename: 'NotePermissions', }, + awardEmoji: { + nodes: [mockAwardEmojiThumbsDown], + }, __typename: 'Note', }, { @@ -2821,6 +2731,9 @@ export const mockWorkItemNotesResponseWithComments = { repositionNote: true, __typename: 'NotePermissions', }, + awardEmoji: { + nodes: [], + }, __typename: 'Note', }, ], @@ -2869,6 +2782,9 @@ export const mockWorkItemNotesResponseWithComments = { webUrl: 'http://127.0.0.1:3000/root', __typename: 'UserCore', }, + awardEmoji: { + nodes: [], + }, __typename: 'Note', }, ], @@ -2945,6 +2861,9 @@ export const workItemNotesCreateSubscriptionResponse = { webUrl: 'http://127.0.0.1:3000/root', __typename: 'UserCore', }, + awardEmoji: { + nodes: [], + }, __typename: 'Note', }, ], @@ -2972,6 +2891,9 @@ export const workItemNotesCreateSubscriptionResponse = { webUrl: 'http://127.0.0.1:3000/root', __typename: 'UserCore', }, + awardEmoji: { + nodes: [], + }, __typename: 'Note', }, }, @@ -3017,6 +2939,9 @@ export const workItemNotesUpdateSubscriptionResponse = { webUrl: 'http://127.0.0.1:3000/root', __typename: 'UserCore', }, + awardEmoji: { + nodes: [], + }, __typename: 'Note', }, }, @@ -3176,6 +3101,9 @@ export const workItemNotesWithSystemNotesWithChangedDescription = { }, __typename: 'SystemNoteMetadata', }, + awardEmoji: { + nodes: [], + }, __typename: 'Note', }, ], @@ -3239,6 +3167,9 @@ export const workItemNotesWithSystemNotesWithChangedDescription = { }, __typename: 'SystemNoteMetadata', }, + awardEmoji: { + nodes: [], + }, __typename: 'Note', }, ], @@ -3302,6 +3233,9 @@ export const workItemNotesWithSystemNotesWithChangedDescription = { }, __typename: 'SystemNoteMetadata', }, + awardEmoji: { + nodes: [], + }, __typename: 'Note', }, ], @@ -3350,3 +3284,17 @@ export const getAwardEmojiResponse = (toggledOn) => { }, }; }; + +export const getTodosMutationResponse = (state) => { + return { + data: { + todoMutation: { + todo: { + id: 'gid://gitlab/Todo/1', + state, + }, + errors: [], + }, + }, + }; +}; diff --git a/spec/frontend/work_items/notes/award_utils_spec.js b/spec/frontend/work_items/notes/award_utils_spec.js new file mode 100644 index 00000000000..8ae32ce5f40 --- /dev/null +++ b/spec/frontend/work_items/notes/award_utils_spec.js @@ -0,0 +1,109 @@ +import { getMutation, optimisticAwardUpdate } from '~/work_items/notes/award_utils'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import mockApollo from 'helpers/mock_apollo_helper'; +import { __ } from '~/locale'; +import workItemNotesByIidQuery from '~/work_items/graphql/notes/work_item_notes_by_iid.query.graphql'; +import addAwardEmojiMutation from '~/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql'; +import removeAwardEmojiMutation from '~/work_items/graphql/notes/work_item_note_remove_award_emoji.mutation.graphql'; +import { + mockWorkItemNotesResponseWithComments, + mockAwardEmojiThumbsUp, + mockAwardEmojiThumbsDown, +} from '../mock_data'; + +function getWorkItem(data) { + return data.workspace.workItems.nodes[0]; +} +function getFirstNote(workItem) { + return workItem.widgets.find((w) => w.type === 'NOTES').discussions.nodes[0].notes.nodes[0]; +} + +describe('Work item note award utils', () => { + const workItem = getWorkItem(mockWorkItemNotesResponseWithComments.data); + const firstNote = getFirstNote(workItem); + const fullPath = 'test-project-path'; + const workItemIid = workItem.iid; + const currentUserId = getIdFromGraphQLId(mockAwardEmojiThumbsDown.user.id); + + beforeEach(() => { + window.gon = { current_user_id: currentUserId }; + }); + + describe('getMutation', () => { + it('returns remove mutation when user has already awarded award', () => { + const note = firstNote; + const { name } = mockAwardEmojiThumbsDown; + + expect(getMutation({ note, name })).toEqual({ + mutation: removeAwardEmojiMutation, + mutationName: 'awardEmojiRemove', + errorMessage: __('Failed to remove emoji. Please try again'), + }); + }); + + it('returns remove mutation when user has not already awarded award', () => { + const note = {}; + const { name } = mockAwardEmojiThumbsUp; + + expect(getMutation({ note, name })).toEqual({ + mutation: addAwardEmojiMutation, + mutationName: 'awardEmojiAdd', + errorMessage: __('Failed to add emoji. Please try again'), + }); + }); + }); + + describe('optimisticAwardUpdate', () => { + let apolloProvider; + beforeEach(() => { + apolloProvider = mockApollo(); + + apolloProvider.clients.defaultClient.writeQuery({ + query: workItemNotesByIidQuery, + variables: { fullPath, iid: workItemIid }, + ...mockWorkItemNotesResponseWithComments, + }); + }); + + it('adds new emoji to cache', () => { + const note = firstNote; + const { name } = mockAwardEmojiThumbsUp; + + const updateFn = optimisticAwardUpdate({ note, name, fullPath, workItemIid }); + + updateFn(apolloProvider.clients.defaultClient.cache); + + const updatedResult = apolloProvider.clients.defaultClient.readQuery({ + query: workItemNotesByIidQuery, + variables: { fullPath, iid: workItemIid }, + }); + + const updatedWorkItem = getWorkItem(updatedResult); + const updatedNote = getFirstNote(updatedWorkItem); + + expect(updatedNote.awardEmoji.nodes).toEqual([ + mockAwardEmojiThumbsDown, + mockAwardEmojiThumbsUp, + ]); + }); + + it('removes existing emoji from cache', () => { + const note = firstNote; + const { name } = mockAwardEmojiThumbsDown; + + const updateFn = optimisticAwardUpdate({ note, name, fullPath, workItemIid }); + + updateFn(apolloProvider.clients.defaultClient.cache); + + const updatedResult = apolloProvider.clients.defaultClient.readQuery({ + query: workItemNotesByIidQuery, + variables: { fullPath, iid: workItemIid }, + }); + + const updatedWorkItem = getWorkItem(updatedResult); + const updatedNote = getFirstNote(updatedWorkItem); + + expect(updatedNote.awardEmoji.nodes).toEqual([]); + }); + }); +}); diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js index b5d54a7c319..79ba31e7012 100644 --- a/spec/frontend/work_items/router_spec.js +++ b/spec/frontend/work_items/router_spec.js @@ -2,28 +2,14 @@ import { mount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; -import { - currentUserResponse, - workItemAssigneesSubscriptionResponse, - workItemDatesSubscriptionResponse, - workItemByIidResponseFactory, - workItemTitleSubscriptionResponse, - workItemLabelsSubscriptionResponse, - workItemMilestoneSubscriptionResponse, - workItemDescriptionSubscriptionResponse, -} from 'jest/work_items/mock_data'; +import { currentUserResponse, workItemByIidResponseFactory } from 'jest/work_items/mock_data'; import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql'; import App from '~/work_items/components/app.vue'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; -import workItemDatesSubscription from '~/graphql_shared/subscriptions/work_item_dates.subscription.graphql'; -import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql'; -import workItemAssigneesSubscription from '~/work_items/graphql/work_item_assignees.subscription.graphql'; -import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql'; -import workItemMilestoneSubscription from '~/work_items/graphql/work_item_milestone.subscription.graphql'; -import workItemDescriptionSubscription from '~/work_items/graphql/work_item_description.subscription.graphql'; import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; import WorkItemsRoot from '~/work_items/pages/work_item_root.vue'; import { createRouter } from '~/work_items/router'; +import workItemUpdatedSubscription from '~/work_items/graphql/work_item_updated.subscription.graphql'; jest.mock('~/behaviors/markdown/render_gfm'); @@ -34,18 +20,9 @@ describe('Work items router', () => { const workItemQueryHandler = jest.fn().mockResolvedValue(workItemByIidResponseFactory()); const currentUserQueryHandler = jest.fn().mockResolvedValue(currentUserResponse); - const datesSubscriptionHandler = jest.fn().mockResolvedValue(workItemDatesSubscriptionResponse); - const titleSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse); - const assigneesSubscriptionHandler = jest + const workItemUpdatedSubscriptionHandler = jest .fn() - .mockResolvedValue(workItemAssigneesSubscriptionResponse); - const labelsSubscriptionHandler = jest.fn().mockResolvedValue(workItemLabelsSubscriptionResponse); - const milestoneSubscriptionHandler = jest - .fn() - .mockResolvedValue(workItemMilestoneSubscriptionResponse); - const descriptionSubscriptionHandler = jest - .fn() - .mockResolvedValue(workItemDescriptionSubscriptionResponse); + .mockResolvedValue({ data: { workItemUpdated: null } }); const createComponent = async (routeArg) => { const router = createRouter('/work_item'); @@ -56,12 +33,7 @@ describe('Work items router', () => { const handlers = [ [workItemByIidQuery, workItemQueryHandler], [currentUserQuery, currentUserQueryHandler], - [workItemDatesSubscription, datesSubscriptionHandler], - [workItemTitleSubscription, titleSubscriptionHandler], - [workItemAssigneesSubscription, assigneesSubscriptionHandler], - [workItemLabelsSubscription, labelsSubscriptionHandler], - [workItemMilestoneSubscription, milestoneSubscriptionHandler], - [workItemDescriptionSubscription, descriptionSubscriptionHandler], + [workItemUpdatedSubscription, workItemUpdatedSubscriptionHandler], ]; wrapper = mount(App, { @@ -81,6 +53,7 @@ describe('Work items router', () => { WorkItemIteration: true, WorkItemHealthStatus: true, WorkItemNotes: true, + WorkItemAwardEmoji: true, }, }); }; diff --git a/spec/frontend/work_items/utils_spec.js b/spec/frontend/work_items/utils_spec.js index b8af5f10a5a..aa24b80cf08 100644 --- a/spec/frontend/work_items/utils_spec.js +++ b/spec/frontend/work_items/utils_spec.js @@ -1,9 +1,4 @@ -import { - autocompleteDataSources, - markdownPreviewPath, - getWorkItemTodoOptimisticResponse, -} from '~/work_items/utils'; -import { workItemResponseFactory } from './mock_data'; +import { autocompleteDataSources, markdownPreviewPath } from '~/work_items/utils'; describe('autocompleteDataSources', () => { beforeEach(() => { @@ -30,17 +25,3 @@ describe('markdownPreviewPath', () => { ); }); }); - -describe('getWorkItemTodoOptimisticResponse', () => { - it.each` - scenario | pendingTodo | result - ${'empty'} | ${false} | ${0} - ${'present'} | ${true} | ${1} - `('returns correct response when pending item list is $scenario', ({ pendingTodo, result }) => { - const workItem = workItemResponseFactory({ canUpdate: true }); - expect( - getWorkItemTodoOptimisticResponse({ workItem, pendingTodo }).workItemUpdate.workItem - .widgets[0].currentUserTodos.edges.length, - ).toBe(result); - }); -}); |