diff options
Diffstat (limited to 'spec/frontend')
377 files changed, 15098 insertions, 7434 deletions
diff --git a/spec/frontend/.eslintrc.yml b/spec/frontend/.eslintrc.yml index 8e6faa90c58..d0e585e844a 100644 --- a/spec/frontend/.eslintrc.yml +++ b/spec/frontend/.eslintrc.yml @@ -25,3 +25,6 @@ rules: - 'testAction' jest/no-test-callback: - off + "@gitlab/no-global-event-off": + - off + diff --git a/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js b/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js index 9fee8e18d26..2acd8111c77 100644 --- a/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js +++ b/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js @@ -1,3 +1,12 @@ +export const mockEditorApi = { + eventManager: { + addEventType: jest.fn(), + listen: jest.fn(), + removeEventHandler: jest.fn(), + }, + getMarkdown: jest.fn(), +}; + export const Editor = { props: { initialValue: { @@ -18,14 +27,6 @@ export const Editor = { }, }, created() { - const mockEditorApi = { - eventManager: { - addEventType: jest.fn(), - listen: jest.fn(), - removeEventHandler: jest.fn(), - }, - }; - this.$emit('load', mockEditorApi); }, render(h) { diff --git a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap index 5fad0d07f97..8948a9926bb 100644 --- a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap +++ b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap @@ -18,7 +18,9 @@ exports[`AddContextCommitsModal renders modal with 2 tabs 1`] = ` theme="indigo" value="0" > - <gl-tab-stub> + <gl-tab-stub + titlelinkclass="" + > <div class="mt-2" @@ -37,7 +39,9 @@ exports[`AddContextCommitsModal renders modal with 2 tabs 1`] = ` </div> </gl-tab-stub> - <gl-tab-stub> + <gl-tab-stub + titlelinkclass="" + > <review-tab-container-stub commits="" diff --git a/spec/frontend/admin/users/components/app_spec.js b/spec/frontend/admin/users/components/app_spec.js new file mode 100644 index 00000000000..65b13e3a40d --- /dev/null +++ b/spec/frontend/admin/users/components/app_spec.js @@ -0,0 +1,37 @@ +import { shallowMount } from '@vue/test-utils'; + +import AdminUsersApp from '~/admin/users/components/app.vue'; +import AdminUsersTable from '~/admin/users/components/users_table.vue'; +import { users, paths } from '../mock_data'; + +describe('AdminUsersApp component', () => { + let wrapper; + + const initComponent = (props = {}) => { + wrapper = shallowMount(AdminUsersApp, { + propsData: { + users, + paths, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when initialized', () => { + beforeEach(() => { + initComponent(); + }); + + it('renders the admin users table with props', () => { + expect(wrapper.find(AdminUsersTable).props()).toEqual({ + users, + paths, + }); + }); + }); +}); diff --git a/spec/frontend/admin/users/components/users_table_spec.js b/spec/frontend/admin/users/components/users_table_spec.js new file mode 100644 index 00000000000..ba36e1e32ef --- /dev/null +++ b/spec/frontend/admin/users/components/users_table_spec.js @@ -0,0 +1,61 @@ +import { GlTable } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; + +import AdminUsersTable from '~/admin/users/components/users_table.vue'; +import { users, paths } from '../mock_data'; + +describe('AdminUsersTable component', () => { + let wrapper; + + const getCellByLabel = (trIdx, label) => { + return wrapper + .find(GlTable) + .find('tbody') + .findAll('tr') + .at(trIdx) + .find(`[data-label="${label}"][role="cell"]`); + }; + + const initComponent = (props = {}) => { + wrapper = mount(AdminUsersTable, { + propsData: { + users, + paths, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when there are users', () => { + const user = users[0]; + + beforeEach(() => { + initComponent(); + }); + + it.each` + key | label + ${'name'} | ${'Name'} + ${'projectsCount'} | ${'Projects'} + ${'createdAt'} | ${'Created on'} + ${'lastActivityOn'} | ${'Last activity'} + `('renders users.$key for $label', ({ key, label }) => { + expect(getCellByLabel(0, label).text()).toBe(`${user[key]}`); + }); + }); + + describe('when users is an empty array', () => { + beforeEach(() => { + initComponent({ users: [] }); + }); + + it('renders a "No users found" message', () => { + expect(wrapper.text()).toContain('No users found'); + }); + }); +}); diff --git a/spec/frontend/admin/users/index_spec.js b/spec/frontend/admin/users/index_spec.js new file mode 100644 index 00000000000..171d54c8f4f --- /dev/null +++ b/spec/frontend/admin/users/index_spec.js @@ -0,0 +1,35 @@ +import { createWrapper } from '@vue/test-utils'; +import initAdminUsers from '~/admin/users'; +import AdminUsersApp from '~/admin/users/components/app.vue'; +import { users, paths } from './mock_data'; + +describe('initAdminUsersApp', () => { + let wrapper; + let el; + + const findApp = () => wrapper.find(AdminUsersApp); + + beforeEach(() => { + el = document.createElement('div'); + el.setAttribute('data-users', JSON.stringify(users)); + el.setAttribute('data-paths', JSON.stringify(paths)); + + document.body.appendChild(el); + + wrapper = createWrapper(initAdminUsers(el)); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + el.remove(); + el = null; + }); + + it('parses and passes props', () => { + expect(findApp().props()).toMatchObject({ + users, + paths, + }); + }); +}); diff --git a/spec/frontend/admin/users/mock_data.js b/spec/frontend/admin/users/mock_data.js new file mode 100644 index 00000000000..62fa9469638 --- /dev/null +++ b/spec/frontend/admin/users/mock_data.js @@ -0,0 +1,29 @@ +export const users = [ + { + id: 2177, + name: 'Nikki', + createdAt: '2020-11-13T12:26:54.177Z', + email: 'nikki@example.com', + username: 'nikki', + lastActivityOn: '2020-12-09', + avatarUrl: + 'https://secure.gravatar.com/avatar/054f062d8b1a42b123f17e13a173cda8?s=80\\u0026d=identicon', + badges: [], + projectsCount: 0, + actions: [], + }, +]; + +export const paths = { + edit: '/admin/users/id/edit', + approve: '/admin/users/id/approve', + reject: '/admin/users/id/reject', + unblock: '/admin/users/id/unblock', + block: '/admin/users/id/block', + deactivate: '/admin/users/id/deactivate', + activate: '/admin/users/id/activate', + unlock: '/admin/users/id/unlock', + delete: '/admin/users/id', + deleteWithContributions: '/admin/users/id', + adminUser: '/admin/users/id', +}; diff --git a/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js b/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js index 1d87301aac9..6430273ec59 100644 --- a/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js +++ b/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js @@ -179,7 +179,7 @@ describe('Alert Details Sidebar Assignees', () => { findAssigned() .find('.dropdown-menu-user-username') .text(), - ).toBe('root'); + ).toBe('@root'); }); }); }); diff --git a/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js b/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js index 5574c83eb76..ed78a593944 100644 --- a/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js +++ b/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js @@ -42,8 +42,8 @@ describe('AlertsServiceForm', () => { mockAxios = new MockAdapter(axios); setFixtures(` <div> - <span class="js-service-active-status fa fa-circle" data-value="true"></span> - <span class="js-service-active-status fa fa-power-off" data-value="false"></span> + <span class="js-service-active-status" data-value="true"><svg class="s16 cgreen" data-testid="check-icon"><use xlink:href="icons.svg#check" /></svg></span> + <span class="js-service-active-status" data-value="false"><svg class="s16 clgray" data-testid="power-icon"><use xlink:href="icons.svg#power" /></svg></span> </div>`); }); diff --git a/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_old_spec.js.snap b/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_old_spec.js.snap deleted file mode 100644 index 9306bf24baf..00000000000 --- a/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_old_spec.js.snap +++ /dev/null @@ -1,47 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AlertsSettingsFormOld with default values renders the initial template 1`] = ` -"<gl-form-stub> - <h5 class=\\"gl-font-lg gl-my-5\\"></h5> - <!----> - <div data-testid=\\"alert-settings-description\\"> - <p> - <gl-sprintf-stub message=\\"You must provide this URL and authorization key to authorize an external service to send alerts to GitLab. You can provide this URL and key to multiple services. After configuring an external service, alerts from your service will display on the GitLab %{linkStart}Alerts%{linkEnd} page.\\"></gl-sprintf-stub> - </p> - <p> - <gl-sprintf-stub message=\\"Review your external service's documentation to learn where to provide this information to your external service, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.\\"></gl-sprintf-stub> - </p> - </div> - <gl-form-group-stub label-for=\\"integration-type\\" label=\\"Integration\\"> - <gl-form-select-stub id=\\"integration-type\\" options=\\"[object Object],[object Object],[object Object]\\" data-testid=\\"alert-settings-select\\" value=\\"HTTP\\"></gl-form-select-stub> <span class=\\"gl-text-gray-500\\"><gl-sprintf-stub message=\\"Learn more about our our upcoming %{linkStart}integrations%{linkEnd}\\"></gl-sprintf-stub></span> - </gl-form-group-stub> - <gl-form-group-stub label=\\"Active\\" label-for=\\"active\\"> - <toggle-button-stub id=\\"active\\"></toggle-button-stub> - </gl-form-group-stub> - <!----> - <gl-form-group-stub label=\\"Webhook URL\\" label-for=\\"url\\"> - <gl-form-input-group-stub value=\\"/alerts/notify.json\\" predefinedoptions=\\"[object Object]\\" id=\\"url\\" readonly=\\"\\"></gl-form-input-group-stub> <span class=\\"gl-text-gray-500\\"> - - </span> - </gl-form-group-stub> - <gl-form-group-stub label-for=\\"authorization-key\\"> - <gl-form-input-group-stub value=\\"\\" predefinedoptions=\\"[object Object]\\" id=\\"authorization-key\\" readonly=\\"\\" class=\\"gl-mb-2\\"></gl-form-input-group-stub> - <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\" class=\\"gl-mt-3\\" role=\\"button\\" tabindex=\\"0\\">Reset key</gl-button-stub> - <gl-modal-stub modalid=\\"tokenModal\\" titletag=\\"h4\\" modalclass=\\"\\" size=\\"md\\" title=\\"Reset key\\" ok-title=\\"Reset key\\" ok-variant=\\"danger\\"> - Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in. - </gl-modal-stub> - </gl-form-group-stub> - <gl-form-group-stub label=\\"Alert test payload\\" label-for=\\"alert-json\\"> - <gl-form-textarea-stub noresize=\\"true\\" id=\\"alert-json\\" disabled=\\"true\\" state=\\"true\\" placeholder=\\"Enter test alert JSON....\\" rows=\\"6\\" max-rows=\\"10\\"></gl-form-textarea-stub> - </gl-form-group-stub> - <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\">Test alert payload</gl-button-stub> - <div class=\\"footer-block row-content-block gl-display-flex gl-justify-content-space-between\\"> - <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\"> - Save changes - </gl-button-stub> - <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\"> - Cancel - </gl-button-stub> - </div> -</gl-form-stub>" -`; diff --git a/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_new_spec.js.snap b/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_spec.js.snap index e2ef7483316..a1ced8910b3 100644 --- a/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_new_spec.js.snap +++ b/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_spec.js.snap @@ -28,7 +28,7 @@ exports[`AlertsSettingsFormNew with default values renders the initial template <div id=\\"integration-webhook\\" role=\\"group\\" class=\\"form-group gl-form-group\\"><label id=\\"integration-webhook__BV_label_\\" for=\\"integration-webhook\\" class=\\"d-block col-form-label\\">3. Set up webhook</label> <div class=\\"bv-no-focus-ring\\"><span>Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the <a rel=\\"noopener noreferrer\\" target=\\"_blank\\" href=\\"https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html\\" class=\\"gl-link gl-display-inline-block\\">GitLab documentation</a> to learn more about configuring your endpoint.</span> <label class=\\"gl-display-flex gl-flex-direction-column gl-mb-0 gl-w-max-content gl-my-4 gl-font-weight-normal\\"> <div class=\\"gl-toggle-wrapper\\"><span class=\\"gl-toggle-label\\">Active</span> - <!----> <button aria-label=\\"Active\\" type=\\"button\\" class=\\"gl-toggle\\"><span class=\\"toggle-icon\\"><svg data-testid=\\"close-icon\\" class=\\"gl-icon s16\\"><use href=\\"#close\\"></use></svg></span></button></div> + <!----> <button aria-label=\\"Active\\" type=\\"button\\" class=\\"gl-toggle\\"><span class=\\"toggle-icon\\"><svg data-testid=\\"close-icon\\" aria-hidden=\\"true\\" class=\\"gl-icon s16\\"><use href=\\"#close\\"></use></svg></span></button></div> <!----> </label> <!----> @@ -40,7 +40,7 @@ exports[`AlertsSettingsFormNew with default values renders the initial template <!----> <!----> <input id=\\"url\\" type=\\"text\\" readonly=\\"readonly\\" class=\\"gl-form-input form-control\\"> <div class=\\"input-group-append\\"><button title=\\"Copy\\" data-clipboard-text=\\"\\" aria-label=\\"Copy this value\\" type=\\"button\\" class=\\"btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon\\"> - <!----> <svg data-testid=\\"copy-to-clipboard-icon\\" class=\\"gl-button-icon gl-icon s16\\"> + <!----> <svg data-testid=\\"copy-to-clipboard-icon\\" aria-hidden=\\"true\\" class=\\"gl-button-icon gl-icon s16\\"> <use href=\\"#copy-to-clipboard\\"></use> </svg> <!----></button></div> @@ -56,7 +56,7 @@ exports[`AlertsSettingsFormNew with default values renders the initial template <!----> <!----> <input id=\\"authorization-key\\" type=\\"text\\" readonly=\\"readonly\\" class=\\"gl-form-input form-control\\"> <div class=\\"input-group-append\\"><button title=\\"Copy\\" data-clipboard-text=\\"\\" aria-label=\\"Copy this value\\" type=\\"button\\" class=\\"btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon\\"> - <!----> <svg data-testid=\\"copy-to-clipboard-icon\\" class=\\"gl-button-icon gl-icon s16\\"> + <!----> <svg data-testid=\\"copy-to-clipboard-icon\\" aria-hidden=\\"true\\" class=\\"gl-button-icon gl-icon s16\\"> <use href=\\"#copy-to-clipboard\\"></use> </svg> <!----></button></div> @@ -87,7 +87,7 @@ exports[`AlertsSettingsFormNew with default values renders the initial template <div class=\\"gl-display-flex gl-justify-content-start gl-py-3\\"><button data-testid=\\"integration-form-submit\\" type=\\"submit\\" class=\\"btn js-no-auto-disable btn-success btn-md gl-button\\"> <!----> <!----> <span class=\\"gl-button-text\\">Save integration - </span></button> <button data-testid=\\"integration-test-and-submit\\" type=\\"button\\" class=\\"btn gl-mx-3 js-no-auto-disable btn-success btn-md gl-button btn-success-secondary\\"> + </span></button> <button data-testid=\\"integration-test-and-submit\\" type=\\"button\\" disabled=\\"disabled\\" class=\\"btn gl-mx-3 js-no-auto-disable btn-success btn-md disabled gl-button btn-success-secondary\\"> <!----> <!----> <span class=\\"gl-button-text\\">Save and test payload</span></button> <button type=\\"reset\\" class=\\"btn js-no-auto-disable btn-default btn-md gl-button\\"> <!----> diff --git a/spec/frontend/alerts_settings/alerts_integrations_list_spec.js b/spec/frontend/alerts_settings/alerts_integrations_list_spec.js index 90bb38f0c2b..3a7392f64f7 100644 --- a/spec/frontend/alerts_settings/alerts_integrations_list_spec.js +++ b/spec/frontend/alerts_settings/alerts_integrations_list_spec.js @@ -35,9 +35,6 @@ describe('AlertIntegrationsList', () => { integrations: mockIntegrations, ...props, }, - provide: { - glFeatures: { httpIntegrationsList: true }, - }, stubs: { GlIcon: true, GlButton: true, diff --git a/spec/frontend/alerts_settings/alerts_settings_form_old_spec.js b/spec/frontend/alerts_settings/alerts_settings_form_old_spec.js deleted file mode 100644 index 3d0dfb44d63..00000000000 --- a/spec/frontend/alerts_settings/alerts_settings_form_old_spec.js +++ /dev/null @@ -1,204 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlModal, GlAlert } from '@gitlab/ui'; -import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form_old.vue'; -import ToggleButton from '~/vue_shared/components/toggle_button.vue'; -import { i18n } from '~/alerts_settings/constants'; -import service from '~/alerts_settings/services'; -import { defaultAlertSettingsConfig } from './util'; - -jest.mock('~/alerts_settings/services'); - -describe('AlertsSettingsFormOld', () => { - let wrapper; - - const createComponent = ({ methods } = {}, data) => { - wrapper = shallowMount(AlertsSettingsForm, { - data() { - return { ...data }; - }, - provide: { - ...defaultAlertSettingsConfig, - }, - methods, - }); - }; - - const findSelect = () => wrapper.find('[data-testid="alert-settings-select"]'); - const findJsonInput = () => wrapper.find('#alert-json'); - const findUrl = () => wrapper.find('#url'); - const findAuthorizationKey = () => wrapper.find('#authorization-key'); - const findApiUrl = () => wrapper.find('#api-url'); - - beforeEach(() => { - setFixtures(` - <div> - <span class="js-service-active-status fa fa-circle" data-value="true"></span> - <span class="js-service-active-status fa fa-power-off" data-value="false"></span> - </div>`); - }); - - afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } - }); - - describe('with default values', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders the initial template', () => { - expect(wrapper.html()).toMatchSnapshot(); - }); - }); - - describe('reset key', () => { - it('triggers resetKey method', () => { - const resetKey = jest.fn(); - const methods = { resetKey }; - createComponent({ methods }); - - wrapper.find(GlModal).vm.$emit('ok'); - - expect(resetKey).toHaveBeenCalled(); - }); - - it('updates the authorization key on success', () => { - createComponent( - {}, - { - token: 'newToken', - }, - ); - - expect(findAuthorizationKey().attributes('value')).toBe('newToken'); - }); - - it('shows a alert message on error', () => { - service.updateGenericKey.mockRejectedValueOnce({}); - - createComponent(); - - return wrapper.vm.resetKey().then(() => { - expect(wrapper.find(GlAlert).exists()).toBe(true); - }); - }); - }); - - describe('activate toggle', () => { - it('triggers toggleActivated method', () => { - const toggleService = jest.fn(); - const methods = { toggleService }; - createComponent({ methods }); - - wrapper.find(ToggleButton).vm.$emit('change', true); - expect(toggleService).toHaveBeenCalled(); - }); - - describe('error is encountered', () => { - it('restores previous value', () => { - service.updateGenericKey.mockRejectedValueOnce({}); - createComponent(); - return wrapper.vm.resetKey().then(() => { - expect(wrapper.find(ToggleButton).props('value')).toBe(false); - }); - }); - }); - }); - - describe('prometheus is active', () => { - beforeEach(() => { - createComponent( - {}, - { - selectedIntegration: 'PROMETHEUS', - }, - ); - }); - - it('renders a valid "select"', () => { - expect(findSelect().exists()).toBe(true); - }); - - it('shows the API URL input', () => { - expect(findApiUrl().exists()).toBe(true); - }); - - it('shows the correct default API URL', () => { - expect(findUrl().attributes('value')).toBe(defaultAlertSettingsConfig.prometheus.url); - }); - }); - - describe('Opsgenie is active', () => { - beforeEach(() => { - createComponent( - {}, - { - selectedIntegration: 'OPSGENIE', - }, - ); - }); - - it('shows a input for the Opsgenie target URL', () => { - expect(findApiUrl().exists()).toBe(true); - }); - }); - - describe('trigger test alert', () => { - beforeEach(() => { - createComponent({}); - }); - - it('should enable the JSON input', () => { - expect(findJsonInput().exists()).toBe(true); - expect(findJsonInput().props('value')).toBe(null); - }); - - it('should validate JSON input', async () => { - createComponent(true, { - testAlertJson: '{ "value": "test" }', - }); - - findJsonInput().vm.$emit('change'); - - await wrapper.vm.$nextTick(); - - expect(findJsonInput().attributes('state')).toBe('true'); - }); - - describe('alert service is toggled', () => { - describe('error handling', () => { - const toggleService = true; - - it('should show generic error', async () => { - service.updateGenericActive.mockRejectedValueOnce({}); - - createComponent(); - - await wrapper.vm.toggleActivated(toggleService); - expect(wrapper.vm.active).toBe(false); - expect(wrapper.find(GlAlert).attributes('variant')).toBe('danger'); - expect(wrapper.find(GlAlert).text()).toBe(i18n.errorMsg); - }); - - it('should show first field specific error when available', async () => { - const err1 = "can't be blank"; - const err2 = 'is not a valid URL'; - const key = 'api_url'; - service.updateGenericActive.mockRejectedValueOnce({ - response: { data: { errors: { [key]: [err1, err2] } } }, - }); - - createComponent(); - - await wrapper.vm.toggleActivated(toggleService); - - expect(wrapper.find(GlAlert).text()).toContain(i18n.errorMsg); - expect(wrapper.find(GlAlert).text()).toContain(`${key} ${err1}`); - }); - }); - }); - }); -}); diff --git a/spec/frontend/alerts_settings/alerts_settings_form_new_spec.js b/spec/frontend/alerts_settings/alerts_settings_form_spec.js index fbd482b1906..428c6f93444 100644 --- a/spec/frontend/alerts_settings/alerts_settings_form_new_spec.js +++ b/spec/frontend/alerts_settings/alerts_settings_form_spec.js @@ -8,7 +8,7 @@ import { GlFormTextarea, } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; -import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form_new.vue'; +import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue'; import { defaultAlertSettingsConfig } from './util'; import { typeSet } from '~/alerts_settings/constants'; @@ -93,16 +93,28 @@ describe('AlertsSettingsFormNew', () => { ).toBe(true); }); - it('disabled the dropdown and shows help text when multi integrations are not supported', async () => { + it('disables the dropdown and shows help text when multi integrations are not supported', async () => { createComponent({ props: { canAddIntegration: false } }); expect(findSelect().attributes('disabled')).toBe('disabled'); expect(findMultiSupportText().exists()).toBe(true); }); + + it('disabled the name input when the selected value is prometheus', async () => { + createComponent(); + const options = findSelect().findAll('option'); + await options.at(2).setSelected(); + + expect( + findFormFields() + .at(0) + .attributes('disabled'), + ).toBe('disabled'); + }); }); describe('submitting integration form', () => { it('allows for create-new-integration with the correct form values for HTTP', async () => { - createComponent({}); + createComponent(); const options = findSelect().findAll('option'); await options.at(1).setSelected(); @@ -128,7 +140,7 @@ describe('AlertsSettingsFormNew', () => { }); it('allows for create-new-integration with the correct form values for PROMETHEUS', async () => { - createComponent({}); + createComponent(); const options = findSelect().findAll('option'); await options.at(2).setSelected(); diff --git a/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js index 7384cf9a095..fe187d9e8f9 100644 --- a/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js +++ b/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js @@ -1,12 +1,13 @@ import VueApollo from 'vue-apollo'; import { mount, createLocalVue } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; import createMockApollo from 'jest/helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; +import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; import AlertsSettingsWrapper from '~/alerts_settings/components/alerts_settings_wrapper.vue'; -import AlertsSettingsFormOld from '~/alerts_settings/components/alerts_settings_form_old.vue'; -import AlertsSettingsFormNew from '~/alerts_settings/components/alerts_settings_form_new.vue'; +import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue'; import IntegrationsList from '~/alerts_settings/components/alerts_integrations_list.vue'; import getIntegrationsQuery from '~/alerts_settings/graphql/queries/get_integrations.query.graphql'; import createHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql'; @@ -75,7 +76,6 @@ describe('AlertsSettingsWrapper', () => { }, provide: { ...defaultAlertSettingsConfig, - glFeatures: { httpIntegrationsList: false }, ...provide, }, mocks: { @@ -110,39 +110,25 @@ describe('AlertsSettingsWrapper', () => { apolloProvider: fakeApollo, provide: { ...defaultAlertSettingsConfig, - glFeatures: { httpIntegrationsList: true }, }, }); } afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); + wrapper = null; }); - describe('with httpIntegrationsList feature flag disabled', () => { - it('renders data driven alerts integrations list and old form by default', () => { - createComponent(); - expect(wrapper.find(IntegrationsList).exists()).toBe(true); - expect(wrapper.find(AlertsSettingsFormOld).exists()).toBe(true); - expect(wrapper.find(AlertsSettingsFormNew).exists()).toBe(false); - }); - }); - - describe('with httpIntegrationsList feature flag enabled', () => { + describe('rendered via default permissions', () => { it('renders the GraphQL alerts integrations list and new form', () => { - createComponent({ provide: { glFeatures: { httpIntegrationsList: true } } }); + createComponent(); expect(wrapper.find(IntegrationsList).exists()).toBe(true); - expect(wrapper.find(AlertsSettingsFormOld).exists()).toBe(false); - expect(wrapper.find(AlertsSettingsFormNew).exists()).toBe(true); + expect(wrapper.find(AlertsSettingsForm).exists()).toBe(true); }); it('uses a loading state inside the IntegrationsList table', () => { createComponent({ data: { integrations: {} }, - provide: { glFeatures: { httpIntegrationsList: true } }, loading: true, }); expect(wrapper.find(IntegrationsList).exists()).toBe(true); @@ -152,7 +138,6 @@ describe('AlertsSettingsWrapper', () => { it('renders the IntegrationsList table using the API data', () => { createComponent({ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - provide: { glFeatures: { httpIntegrationsList: true } }, loading: false, }); expect(findLoader().exists()).toBe(false); @@ -162,14 +147,13 @@ describe('AlertsSettingsWrapper', () => { it('calls `$apollo.mutate` with `createHttpIntegrationMutation`', () => { createComponent({ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - provide: { glFeatures: { httpIntegrationsList: true } }, loading: false, }); jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: { createHttpIntegrationMutation: { integration: { id: '1' } } }, }); - wrapper.find(AlertsSettingsFormNew).vm.$emit('create-new-integration', { + wrapper.find(AlertsSettingsForm).vm.$emit('create-new-integration', { type: typeSet.http, variables: createHttpVariables, }); @@ -185,14 +169,13 @@ describe('AlertsSettingsWrapper', () => { it('calls `$apollo.mutate` with `updateHttpIntegrationMutation`', () => { createComponent({ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - provide: { glFeatures: { httpIntegrationsList: true } }, loading: false, }); jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: { updateHttpIntegrationMutation: { integration: { id: '1' } } }, }); - wrapper.find(AlertsSettingsFormNew).vm.$emit('update-integration', { + wrapper.find(AlertsSettingsForm).vm.$emit('update-integration', { type: typeSet.http, variables: updateHttpVariables, }); @@ -206,14 +189,13 @@ describe('AlertsSettingsWrapper', () => { it('calls `$apollo.mutate` with `resetHttpTokenMutation`', () => { createComponent({ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - provide: { glFeatures: { httpIntegrationsList: true } }, loading: false, }); jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: { resetHttpTokenMutation: { integration: { id: '1' } } }, }); - wrapper.find(AlertsSettingsFormNew).vm.$emit('reset-token', { + wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', { type: typeSet.http, variables: { id: ID }, }); @@ -229,14 +211,13 @@ describe('AlertsSettingsWrapper', () => { it('calls `$apollo.mutate` with `createPrometheusIntegrationMutation`', () => { createComponent({ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - provide: { glFeatures: { httpIntegrationsList: true } }, loading: false, }); jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: { createPrometheusIntegrationMutation: { integration: { id: '2' } } }, }); - wrapper.find(AlertsSettingsFormNew).vm.$emit('create-new-integration', { + wrapper.find(AlertsSettingsForm).vm.$emit('create-new-integration', { type: typeSet.prometheus, variables: createPrometheusVariables, }); @@ -252,14 +233,13 @@ describe('AlertsSettingsWrapper', () => { it('calls `$apollo.mutate` with `updatePrometheusIntegrationMutation`', () => { createComponent({ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - provide: { glFeatures: { httpIntegrationsList: true } }, loading: false, }); jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: { updatePrometheusIntegrationMutation: { integration: { id: '2' } } }, }); - wrapper.find(AlertsSettingsFormNew).vm.$emit('update-integration', { + wrapper.find(AlertsSettingsForm).vm.$emit('update-integration', { type: typeSet.prometheus, variables: updatePrometheusVariables, }); @@ -273,14 +253,13 @@ describe('AlertsSettingsWrapper', () => { it('calls `$apollo.mutate` with `resetPrometheusTokenMutation`', () => { createComponent({ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - provide: { glFeatures: { httpIntegrationsList: true } }, loading: false, }); jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: { resetPrometheusTokenMutation: { integration: { id: '1' } } }, }); - wrapper.find(AlertsSettingsFormNew).vm.$emit('reset-token', { + wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', { type: typeSet.prometheus, variables: { id: ID }, }); @@ -296,12 +275,11 @@ describe('AlertsSettingsWrapper', () => { it('shows an error alert when integration creation fails ', async () => { createComponent({ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - provide: { glFeatures: { httpIntegrationsList: true } }, loading: false, }); jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(ADD_INTEGRATION_ERROR); - wrapper.find(AlertsSettingsFormNew).vm.$emit('create-new-integration', {}); + wrapper.find(AlertsSettingsForm).vm.$emit('create-new-integration', {}); await waitForPromises(); @@ -311,13 +289,12 @@ describe('AlertsSettingsWrapper', () => { it('shows an error alert when integration token reset fails ', async () => { createComponent({ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - provide: { glFeatures: { httpIntegrationsList: true } }, loading: false, }); jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(RESET_INTEGRATION_TOKEN_ERROR); - wrapper.find(AlertsSettingsFormNew).vm.$emit('reset-token', {}); + wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', {}); await waitForPromises(); expect(createFlash).toHaveBeenCalledWith({ message: RESET_INTEGRATION_TOKEN_ERROR }); @@ -326,30 +303,30 @@ describe('AlertsSettingsWrapper', () => { it('shows an error alert when integration update fails ', async () => { createComponent({ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - provide: { glFeatures: { httpIntegrationsList: true } }, loading: false, }); jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg); - wrapper.find(AlertsSettingsFormNew).vm.$emit('update-integration', {}); + wrapper.find(AlertsSettingsForm).vm.$emit('update-integration', {}); await waitForPromises(); expect(createFlash).toHaveBeenCalledWith({ message: UPDATE_INTEGRATION_ERROR }); }); it('shows an error alert when integration test payload fails ', async () => { + const mock = new AxiosMockAdapter(axios); + mock.onPost(/(.*)/).replyOnce(403); createComponent({ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - provide: { glFeatures: { httpIntegrationsList: true } }, loading: false, }); - wrapper.find(AlertsSettingsFormNew).vm.$emit('test-payload-failure'); - - await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); - expect(createFlash).toHaveBeenCalledTimes(1); + return wrapper.vm.validateAlertPayload({ endpoint: '', data: '', token: '' }).then(() => { + expect(createFlash).toHaveBeenCalledWith({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); + expect(createFlash).toHaveBeenCalledTimes(1); + mock.restore(); + }); }); }); @@ -405,7 +382,7 @@ describe('AlertsSettingsWrapper', () => { it.each([true, false])('it shows/hides the alert when opsgenie is %s', active => { createComponent({ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - provide: { glFeatures: { httpIntegrationsList: true }, opsgenie: { active } }, + provide: { opsgenie: { active } }, loading: false, }); diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index 724d33922a1..37630c15b89 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -1254,6 +1254,46 @@ describe('Api', () => { }); }); + describe('trackRedisCounterEvent', () => { + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/usage_data/increment_counter`; + + const event = 'dummy_event'; + const postData = { event }; + const headers = { + 'Content-Type': 'application/json', + }; + + describe('when usage data increment counter is called with feature flag disabled', () => { + beforeEach(() => { + gon.features = { ...gon.features, usageDataApi: false }; + }); + + it('returns null', () => { + jest.spyOn(axios, 'post'); + mock.onPost(expectedUrl).replyOnce(httpStatus.OK, true); + + expect(axios.post).toHaveBeenCalledTimes(0); + expect(Api.trackRedisCounterEvent(event)).toEqual(null); + }); + }); + + describe('when usage data increment counter is called', () => { + beforeEach(() => { + gon.features = { ...gon.features, usageDataApi: true }; + }); + + it('resolves the Promise', () => { + jest.spyOn(axios, 'post'); + mock.onPost(expectedUrl, { event }).replyOnce(httpStatus.OK, true); + + return Api.trackRedisCounterEvent(event).then(({ data }) => { + expect(data).toEqual(true); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, postData, { headers }); + }); + }); + }); + }); + describe('trackRedisHllUserEvent', () => { const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/usage_data/increment_unique_users`; diff --git a/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js b/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js new file mode 100644 index 00000000000..025e605b920 --- /dev/null +++ b/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js @@ -0,0 +1,242 @@ +import { mount } from '@vue/test-utils'; +import { GlAlert, GlButton } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { within } from '@testing-library/dom'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import Tracking from '~/tracking'; +import RecoveryCodes, { + i18n, +} from '~/authentication/two_factor_auth/components/recovery_codes.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { + RECOVERY_CODE_DOWNLOAD_FILENAME, + COPY_KEYBOARD_SHORTCUT, +} from '~/authentication/two_factor_auth/constants'; +import { codes, codesFormattedString, codesDownloadHref, profileAccountPath } from '../mock_data'; + +describe('RecoveryCodes', () => { + let wrapper; + + const createComponent = (options = {}) => { + wrapper = extendedWrapper( + mount(RecoveryCodes, { + propsData: { + codes, + profileAccountPath, + ...(options?.propsData || {}), + }, + ...options, + }), + ); + }; + + const queryByText = (text, options) => within(wrapper.element).queryByText(text, options); + const findAlert = () => wrapper.find(GlAlert); + const findRecoveryCodes = () => wrapper.findByTestId('recovery-codes'); + const findCopyButton = () => wrapper.find(ClipboardButton); + const findButtonByText = text => + wrapper.findAll(GlButton).wrappers.find(buttonWrapper => buttonWrapper.text() === text); + const findDownloadButton = () => findButtonByText('Download codes'); + const findPrintButton = () => findButtonByText('Print codes'); + const findProceedButton = () => findButtonByText('Proceed'); + const manuallyCopyRecoveryCodes = () => + wrapper.vm.$options.mousetrap.trigger(COPY_KEYBOARD_SHORTCUT); + + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + createComponent(); + }); + + it('renders title', () => { + expect(queryByText(i18n.pageTitle)).toEqual(expect.any(HTMLElement)); + }); + + it('renders alert', () => { + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(i18n.alertTitle); + }); + + it('renders codes', () => { + const recoveryCodes = findRecoveryCodes().text(); + + codes.forEach(code => { + expect(recoveryCodes).toContain(code); + }); + }); + + describe('"Proceed" button', () => { + it('renders button as disabled', () => { + const proceedButton = findProceedButton(); + + expect(proceedButton.exists()).toBe(true); + expect(proceedButton.props('disabled')).toBe(true); + expect(proceedButton.attributes()).toMatchObject({ + title: i18n.proceedButton, + href: profileAccountPath, + }); + }); + + it('fires Snowplow event', () => { + expect(findProceedButton().attributes()).toMatchObject({ + 'data-track-event': 'click_button', + 'data-track-label': '2fa_recovery_codes_proceed_button', + }); + }); + }); + + describe('"Copy codes" button', () => { + it('renders button', () => { + const copyButton = findCopyButton(); + + expect(copyButton.exists()).toBe(true); + expect(copyButton.text()).toBe(i18n.copyButton); + expect(copyButton.props()).toMatchObject({ + title: i18n.copyButton, + text: codesFormattedString, + }); + }); + + describe('when button is clicked', () => { + beforeEach(async () => { + findCopyButton().trigger('click'); + + await nextTick(); + }); + + it('enables "Proceed" button', () => { + expect(findProceedButton().props('disabled')).toBe(false); + }); + + it('fires Snowplow event', () => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { + label: '2fa_recovery_codes_copy_button', + }); + }); + }); + }); + + describe('"Download codes" button', () => { + it('renders button', () => { + const downloadButton = findDownloadButton(); + + expect(downloadButton.exists()).toBe(true); + expect(downloadButton.attributes()).toMatchObject({ + title: i18n.downloadButton, + download: RECOVERY_CODE_DOWNLOAD_FILENAME, + href: codesDownloadHref, + }); + }); + + describe('when button is clicked', () => { + beforeEach(async () => { + const downloadButton = findDownloadButton(); + // jsdom does not support navigating. + // Since we are clicking an anchor tag there is no way to mock this + // and we are forced to instead remove the `href` attribute. + // More info: https://github.com/jsdom/jsdom/issues/2112#issuecomment-663672587 + downloadButton.element.removeAttribute('href'); + downloadButton.trigger('click'); + + await nextTick(); + }); + + it('enables "Proceed" button', () => { + expect(findProceedButton().props('disabled')).toBe(false); + }); + + it('fires Snowplow event', () => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { + label: '2fa_recovery_codes_download_button', + }); + }); + }); + }); + + describe('"Print codes" button', () => { + it('renders button', () => { + const printButton = findPrintButton(); + + expect(printButton.exists()).toBe(true); + expect(printButton.attributes()).toMatchObject({ + title: i18n.printButton, + }); + }); + + describe('when button is clicked', () => { + beforeEach(async () => { + window.print = jest.fn(); + + findPrintButton().trigger('click'); + + await nextTick(); + }); + + it('enables "Proceed" button and opens print dialog', () => { + expect(findProceedButton().props('disabled')).toBe(false); + expect(window.print).toHaveBeenCalled(); + }); + + it('fires Snowplow event', () => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { + label: '2fa_recovery_codes_print_button', + }); + }); + }); + }); + + describe('when codes are manually copied', () => { + describe('when selected text is the recovery codes', () => { + beforeEach(async () => { + jest.spyOn(window, 'getSelection').mockImplementation(() => ({ + toString: jest.fn(() => codesFormattedString), + })); + + manuallyCopyRecoveryCodes(); + + await nextTick(); + }); + + it('enables "Proceed" button', () => { + expect(findProceedButton().props('disabled')).toBe(false); + }); + + it('fires Snowplow event', () => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'copy_keyboard_shortcut', { + label: '2fa_recovery_codes_manual_copy', + }); + }); + }); + + describe('when selected text includes the recovery codes', () => { + beforeEach(() => { + jest.spyOn(window, 'getSelection').mockImplementation(() => ({ + toString: jest.fn(() => `foo bar ${codesFormattedString}`), + })); + }); + + it('enables "Proceed" button', async () => { + manuallyCopyRecoveryCodes(); + + await nextTick(); + + expect(findProceedButton().props('disabled')).toBe(false); + }); + }); + + describe('when selected text does not include the recovery codes', () => { + beforeEach(() => { + jest.spyOn(window, 'getSelection').mockImplementation(() => ({ + toString: jest.fn(() => 'foo bar'), + })); + }); + + it('keeps "Proceed" button disabled', async () => { + manuallyCopyRecoveryCodes(); + + await nextTick(); + + expect(findProceedButton().props('disabled')).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/authentication/two_factor_auth/index_spec.js b/spec/frontend/authentication/two_factor_auth/index_spec.js new file mode 100644 index 00000000000..b181170b0a1 --- /dev/null +++ b/spec/frontend/authentication/two_factor_auth/index_spec.js @@ -0,0 +1,80 @@ +import { createWrapper } from '@vue/test-utils'; +import { getByTestId, fireEvent } from '@testing-library/dom'; +import * as urlUtils from '~/lib/utils/url_utility'; +import { initRecoveryCodes, initClose2faSuccessMessage } from '~/authentication/two_factor_auth'; +import RecoveryCodes from '~/authentication/two_factor_auth/components/recovery_codes.vue'; +import { codesJsonString, codes, profileAccountPath } from './mock_data'; + +describe('initRecoveryCodes', () => { + let el; + let wrapper; + + const findRecoveryCodesComponent = () => wrapper.find(RecoveryCodes); + + beforeEach(() => { + el = document.createElement('div'); + el.setAttribute('class', 'js-2fa-recovery-codes'); + el.setAttribute('data-codes', codesJsonString); + el.setAttribute('data-profile-account-path', profileAccountPath); + document.body.appendChild(el); + + wrapper = createWrapper(initRecoveryCodes()); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('parses `data-codes` and passes to `RecoveryCodes` as `codes` prop', () => { + expect(findRecoveryCodesComponent().props('codes')).toEqual(codes); + }); + + it('parses `data-profile-account-path` and passes to `RecoveryCodes` as `profileAccountPath` prop', () => { + expect(findRecoveryCodesComponent().props('profileAccountPath')).toEqual(profileAccountPath); + }); +}); + +describe('initClose2faSuccessMessage', () => { + beforeEach(() => { + document.body.innerHTML = ` + <button + data-testid="close-2fa-enabled-success-alert" + class="js-close-2fa-enabled-success-alert" + > + </button> + `; + + initClose2faSuccessMessage(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + describe('when alert is closed', () => { + beforeEach(() => { + delete window.location; + window.location = new URL( + 'https://localhost/-/profile/account?two_factor_auth_enabled_successfully=true', + ); + + document.title = 'foo bar'; + + urlUtils.updateHistory = jest.fn(); + }); + + afterEach(() => { + document.title = ''; + }); + + it('removes `two_factor_auth_enabled_successfully` query param', () => { + fireEvent.click(getByTestId(document.body, 'close-2fa-enabled-success-alert')); + + expect(urlUtils.updateHistory).toHaveBeenCalledWith({ + url: 'https://localhost/-/profile/account', + title: 'foo bar', + replace: true, + }); + }); + }); +}); diff --git a/spec/frontend/authentication/two_factor_auth/mock_data.js b/spec/frontend/authentication/two_factor_auth/mock_data.js new file mode 100644 index 00000000000..7b2a1764abd --- /dev/null +++ b/spec/frontend/authentication/two_factor_auth/mock_data.js @@ -0,0 +1,31 @@ +export const codes = [ + 'e8471c403a6a84c0', + 'b1b92de21c68f08e', + 'd7689f332cd8cd73', + '05b706accfa95cfa', + 'b0a2b45ea956c1d2', + '599dc672d18d5161', + 'e14e9f4adf4b8bf2', + '1013007a75efeeec', + '26bd057c4c696a4f', + '1c46fba5a4275ef4', +]; + +export const codesJsonString = + '["e8471c403a6a84c0","b1b92de21c68f08e","d7689f332cd8cd73","05b706accfa95cfa","b0a2b45ea956c1d2","599dc672d18d5161","e14e9f4adf4b8bf2","1013007a75efeeec","26bd057c4c696a4f","1c46fba5a4275ef4"]'; + +export const codesFormattedString = `e8471c403a6a84c0 +b1b92de21c68f08e +d7689f332cd8cd73 +05b706accfa95cfa +b0a2b45ea956c1d2 +599dc672d18d5161 +e14e9f4adf4b8bf2 +1013007a75efeeec +26bd057c4c696a4f +1c46fba5a4275ef4`; + +export const codesDownloadHref = + 'data:text/plain;charset=utf-8,e8471c403a6a84c0%0Ab1b92de21c68f08e%0Ad7689f332cd8cd73%0A05b706accfa95cfa%0Ab0a2b45ea956c1d2%0A599dc672d18d5161%0Ae14e9f4adf4b8bf2%0A1013007a75efeeec%0A26bd057c4c696a4f%0A1c46fba5a4275ef4'; + +export const profileAccountPath = '/-/profile/account'; diff --git a/spec/frontend/blob/components/mock_data.js b/spec/frontend/blob/components/mock_data.js index 8cfcec2693c..95789ca13cb 100644 --- a/spec/frontend/blob/components/mock_data.js +++ b/spec/frontend/blob/components/mock_data.js @@ -55,5 +55,3 @@ export const SimpleBlobContentMock = { path: 'foo.js', plainData: 'Plain', }; - -export default {}; diff --git a/spec/frontend/blob/viewer/index_spec.js b/spec/frontend/blob/viewer/index_spec.js index 69ec22b1f94..a4b4044f5f9 100644 --- a/spec/frontend/blob/viewer/index_spec.js +++ b/spec/frontend/blob/viewer/index_spec.js @@ -154,7 +154,7 @@ describe('Blob viewer', () => { blob.switchToViewer('simple'); - expect(simpleBtn.classList.contains('active')).toBeTruthy(); + expect(simpleBtn.classList.contains('selected')).toBeTruthy(); expect(simpleBtn.blur).toHaveBeenCalled(); }); diff --git a/spec/frontend/blob_edit/edit_blob_spec.js b/spec/frontend/blob_edit/edit_blob_spec.js index ac8b916e448..9637ea09a3a 100644 --- a/spec/frontend/blob_edit/edit_blob_spec.js +++ b/spec/frontend/blob_edit/edit_blob_spec.js @@ -1,25 +1,35 @@ import waitForPromises from 'helpers/wait_for_promises'; import EditBlob from '~/blob_edit/edit_blob'; import EditorLite from '~/editor/editor_lite'; -import MarkdownExtension from '~/editor/editor_markdown_ext'; -import FileTemplateExtension from '~/editor/editor_file_template_ext'; +import { EditorMarkdownExtension } from '~/editor/editor_markdown_ext'; +import { FileTemplateExtension } from '~/editor/editor_file_template_ext'; jest.mock('~/editor/editor_lite'); jest.mock('~/editor/editor_markdown_ext'); +jest.mock('~/editor/editor_file_template_ext'); describe('Blob Editing', () => { const useMock = jest.fn(); const mockInstance = { use: useMock, - getValue: jest.fn(), + setValue: jest.fn(), + getValue: jest.fn().mockReturnValue('test value'), focus: jest.fn(), }; beforeEach(() => { - setFixtures( - `<div class="js-edit-blob-form"><div id="file_path"></div><div id="editor"></div><input id="file-content"></div>`, - ); + setFixtures(` + <form class="js-edit-blob-form"> + <div id="file_path"></div> + <div id="editor"></div> + <textarea id="file-content"></textarea> + </form> + `); jest.spyOn(EditorLite.prototype, 'createInstance').mockReturnValue(mockInstance); }); + afterEach(() => { + EditorMarkdownExtension.mockClear(); + FileTemplateExtension.mockClear(); + }); const editorInst = isMarkdown => { return new EditBlob({ @@ -34,20 +44,31 @@ describe('Blob Editing', () => { it('loads FileTemplateExtension by default', async () => { await initEditor(); - expect(useMock).toHaveBeenCalledWith(FileTemplateExtension); + expect(FileTemplateExtension).toHaveBeenCalledTimes(1); }); describe('Markdown', () => { it('does not load MarkdownExtension by default', async () => { await initEditor(); - expect(useMock).not.toHaveBeenCalledWith(MarkdownExtension); + expect(EditorMarkdownExtension).not.toHaveBeenCalled(); }); it('loads MarkdownExtension only for the markdown files', async () => { await initEditor(true); expect(useMock).toHaveBeenCalledTimes(2); - expect(useMock).toHaveBeenNthCalledWith(1, FileTemplateExtension); - expect(useMock).toHaveBeenNthCalledWith(2, MarkdownExtension); + expect(FileTemplateExtension).toHaveBeenCalledTimes(1); + expect(EditorMarkdownExtension).toHaveBeenCalledTimes(1); }); }); + + it('adds trailing newline to the blob content on submit', async () => { + const form = document.querySelector('.js-edit-blob-form'); + const fileContentEl = document.getElementById('file-content'); + + await initEditor(); + + form.dispatchEvent(new Event('submit')); + + expect(fileContentEl.value).toBe('test value\n'); + }); }); diff --git a/spec/frontend/boards/board_list_new_spec.js b/spec/frontend/boards/board_list_new_spec.js index 55516e3fd56..96b03ed927e 100644 --- a/spec/frontend/boards/board_list_new_spec.js +++ b/spec/frontend/boards/board_list_new_spec.js @@ -1,15 +1,11 @@ -/* global List */ -/* global ListIssue */ - import Vuex from 'vuex'; import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; import { createLocalVue, mount } from '@vue/test-utils'; import eventHub from '~/boards/eventhub'; import BoardList from '~/boards/components/board_list_new.vue'; import BoardCard from '~/boards/components/board_card.vue'; -import '~/boards/models/issue'; import '~/boards/models/list'; -import { listObj, mockIssuesByListId, issues } from './mock_data'; +import { mockList, mockIssuesByListId, issues, mockIssues } from './mock_data'; import defaultState from '~/boards/stores/state'; const localVue = createLocalVue(); @@ -46,13 +42,11 @@ const createComponent = ({ ...state, }); - const list = new List({ - ...listObj, - id: 'gid://gitlab/List/1', + const list = { + ...mockList, ...listProps, - doNotFetchIssues: true, - }); - const issue = new ListIssue({ + }; + const issue = { title: 'Testing', id: 1, iid: 1, @@ -60,9 +54,9 @@ const createComponent = ({ labels: [], assignees: [], ...listIssueProps, - }); - if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) { - list.issuesSize = 1; + }; + if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesCount')) { + list.issuesCount = 1; } const component = mount(BoardList, { @@ -71,6 +65,7 @@ const createComponent = ({ disabled: false, list, issues: [issue], + canAdminList: true, ...componentProps, }, store, @@ -87,17 +82,19 @@ const createComponent = ({ describe('Board list component', () => { let wrapper; + const findByTestId = testId => wrapper.find(`[data-testid="${testId}"]`); useFakeRequestAnimationFrame(); + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + describe('When Expanded', () => { beforeEach(() => { wrapper = createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('renders component', () => { expect(wrapper.find('.board-list-component').exists()).toBe(true); }); @@ -107,7 +104,7 @@ describe('Board list component', () => { state: { listsFlags: { 'gid://gitlab/List/1': { isLoading: true } } }, }); - expect(wrapper.find('[data-testid="board_list_loading"').exists()).toBe(true); + expect(findByTestId('board_list_loading').exists()).toBe(true); }); it('renders issues', () => { @@ -157,7 +154,7 @@ describe('Board list component', () => { it('shows how many more issues to load', async () => { wrapper.vm.showCount = true; - wrapper.setProps({ list: { issuesSize: 20 } }); + wrapper.setProps({ list: { issuesCount: 20 } }); await wrapper.vm.$nextTick(); expect(wrapper.find('.board-list-count').text()).toBe('Showing 1 of 20 issues'); @@ -167,30 +164,30 @@ describe('Board list component', () => { describe('load more issues', () => { beforeEach(() => { wrapper = createComponent({ - listProps: { issuesSize: 25 }, + listProps: { issuesCount: 25 }, }); }); - afterEach(() => { - wrapper.destroy(); - }); - it('loads more issues after scrolling', () => { - wrapper.vm.$refs.list.dispatchEvent(new Event('scroll')); + wrapper.vm.listRef.dispatchEvent(new Event('scroll')); expect(actions.fetchIssuesForList).toHaveBeenCalled(); }); it('does not load issues if already loading', () => { - wrapper.vm.$refs.list.dispatchEvent(new Event('scroll')); - wrapper.vm.$refs.list.dispatchEvent(new Event('scroll')); + wrapper = createComponent({ + state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } }, + }); + wrapper.vm.listRef.dispatchEvent(new Event('scroll')); - expect(actions.fetchIssuesForList).toHaveBeenCalledTimes(1); + expect(actions.fetchIssuesForList).not.toHaveBeenCalled(); }); it('shows loading more spinner', async () => { + wrapper = createComponent({ + state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } }, + }); wrapper.vm.showCount = true; - wrapper.vm.list.loadingMore = true; await wrapper.vm.$nextTick(); expect(wrapper.find('.board-list-count .gl-spinner').exists()).toBe(true); @@ -200,17 +197,13 @@ describe('Board list component', () => { describe('max issue count warning', () => { beforeEach(() => { wrapper = createComponent({ - listProps: { issuesSize: 50 }, + listProps: { issuesCount: 50 }, }); }); - afterEach(() => { - wrapper.destroy(); - }); - describe('when issue count exceeds max issue count', () => { it('sets background to bg-danger-100', async () => { - wrapper.setProps({ list: { issuesSize: 4, maxIssueCount: 3 } }); + wrapper.setProps({ list: { issuesCount: 4, maxIssueCount: 3 } }); await wrapper.vm.$nextTick(); expect(wrapper.find('.bg-danger-100').exists()).toBe(true); @@ -219,7 +212,7 @@ describe('Board list component', () => { describe('when list issue count does NOT exceed list max issue count', () => { it('does not sets background to bg-danger-100', () => { - wrapper.setProps({ list: { issuesSize: 2, maxIssueCount: 3 } }); + wrapper.setProps({ list: { issuesCount: 2, maxIssueCount: 3 } }); expect(wrapper.find('.bg-danger-100').exists()).toBe(false); }); @@ -233,4 +226,43 @@ describe('Board list component', () => { }); }); }); + + describe('drag & drop issue', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + describe('handleDragOnStart', () => { + it('adds a class `is-dragging` to document body', () => { + expect(document.body.classList.contains('is-dragging')).toBe(false); + + findByTestId('tree-root-wrapper').vm.$emit('start'); + + expect(document.body.classList.contains('is-dragging')).toBe(true); + }); + }); + + describe('handleDragOnEnd', () => { + it('removes class `is-dragging` from document body', () => { + jest.spyOn(wrapper.vm, 'moveIssue').mockImplementation(() => {}); + document.body.classList.add('is-dragging'); + + findByTestId('tree-root-wrapper').vm.$emit('end', { + oldIndex: 1, + newIndex: 0, + item: { + dataset: { + issueId: mockIssues[0].id, + issueIid: mockIssues[0].iid, + issuePath: mockIssues[0].referencePath, + }, + }, + to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } }, + from: { dataset: { listId: 'gid://gitlab/List/2' } }, + }); + + expect(document.body.classList.contains('is-dragging')).toBe(false); + }); + }); + }); }); diff --git a/spec/frontend/boards/boards_store_spec.js b/spec/frontend/boards/boards_store_spec.js index e7c1cf79fdc..c89f6d22ef2 100644 --- a/spec/frontend/boards/boards_store_spec.js +++ b/spec/frontend/boards/boards_store_spec.js @@ -1,7 +1,7 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import axios from '~/lib/utils/axios_utils'; -import boardsStore, { gqlClient } from '~/boards/stores/boards_store'; +import boardsStore from '~/boards/stores/boards_store'; import eventHub from '~/boards/eventhub'; import { listObj, listObjDuplicate } from './mock_data'; @@ -66,23 +66,6 @@ describe('boardsStore', () => { }); }); - describe('generateDefaultLists', () => { - const listsEndpointGenerate = `${endpoints.listsEndpoint}/generate.json`; - - it('makes a request to generate default lists', () => { - axiosMock.onPost(listsEndpointGenerate).replyOnce(200, dummyResponse); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); - - return expect(boardsStore.generateDefaultLists()).resolves.toEqual(expectedResponse); - }); - - it('fails for error response', () => { - axiosMock.onPost(listsEndpointGenerate).replyOnce(500); - - return expect(boardsStore.generateDefaultLists()).rejects.toThrow(); - }); - }); - describe('createList', () => { const entityType = 'moorhen'; const entityId = 'quack'; @@ -473,118 +456,6 @@ describe('boardsStore', () => { }); }); - describe('createBoard', () => { - const labelIds = ['first label', 'second label']; - const assigneeId = 'as sign ee'; - const milestoneId = 'vegetable soup'; - const board = { - labels: labelIds.map(id => ({ id })), - assignee: { id: assigneeId }, - milestone: { id: milestoneId }, - }; - - describe('for existing board', () => { - const id = 'skate-board'; - const url = `${endpoints.boardsEndpoint}/${id}.json`; - const expectedRequest = expect.objectContaining({ - data: JSON.stringify({ - board: { - ...board, - id, - label_ids: labelIds, - assignee_id: assigneeId, - milestone_id: milestoneId, - }, - }), - }); - - let requestSpy; - - beforeEach(() => { - requestSpy = jest.fn(); - axiosMock.onPut(url).replyOnce(config => requestSpy(config)); - jest.spyOn(gqlClient, 'mutate').mockReturnValue(Promise.resolve({})); - }); - - it('makes a request to update the board', () => { - requestSpy.mockReturnValue([200, dummyResponse]); - const expectedResponse = [ - expect.objectContaining({ data: dummyResponse }), - expect.objectContaining({}), - ]; - - return expect( - boardsStore.createBoard({ - ...board, - id, - }), - ) - .resolves.toEqual(expectedResponse) - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - - it('fails for error response', () => { - requestSpy.mockReturnValue([500]); - - return expect( - boardsStore.createBoard({ - ...board, - id, - }), - ) - .rejects.toThrow() - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - }); - - describe('for new board', () => { - const url = `${endpoints.boardsEndpoint}.json`; - const expectedRequest = expect.objectContaining({ - data: JSON.stringify({ - board: { - ...board, - label_ids: labelIds, - assignee_id: assigneeId, - milestone_id: milestoneId, - }, - }), - }); - - let requestSpy; - - beforeEach(() => { - requestSpy = jest.fn(); - axiosMock.onPost(url).replyOnce(config => requestSpy(config)); - jest.spyOn(gqlClient, 'mutate').mockReturnValue(Promise.resolve({})); - }); - - it('makes a request to create a new board', () => { - requestSpy.mockReturnValue([200, dummyResponse]); - const expectedResponse = dummyResponse; - - return expect(boardsStore.createBoard(board)) - .resolves.toEqual(expectedResponse) - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - - it('fails for error response', () => { - requestSpy.mockReturnValue([500]); - - return expect(boardsStore.createBoard(board)) - .rejects.toThrow() - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - }); - }); - describe('deleteBoard', () => { const id = 'capsized'; const url = `${endpoints.boardsEndpoint}/${id}.json`; @@ -727,24 +598,6 @@ describe('boardsStore', () => { }); }); - it('check for blank state adding', () => { - expect(boardsStore.shouldAddBlankState()).toBe(true); - }); - - it('check for blank state not adding', () => { - boardsStore.addList(listObj); - - expect(boardsStore.shouldAddBlankState()).toBe(false); - }); - - it('check for blank state adding when closed list exist', () => { - boardsStore.addList({ - list_type: 'closed', - }); - - expect(boardsStore.shouldAddBlankState()).toBe(true); - }); - it('removes list from state', () => { boardsStore.addList(listObj); diff --git a/spec/frontend/boards/components/board_assignee_dropdown_spec.js b/spec/frontend/boards/components/board_assignee_dropdown_spec.js index e185a6d5419..bbdcc707f09 100644 --- a/spec/frontend/boards/components/board_assignee_dropdown_spec.js +++ b/spec/frontend/boards/components/board_assignee_dropdown_spec.js @@ -1,5 +1,11 @@ import { mount, createLocalVue } from '@vue/test-utils'; -import { GlDropdownItem, GlAvatarLink, GlAvatarLabeled, GlSearchBoxByType } from '@gitlab/ui'; +import { + GlDropdownItem, + GlAvatarLink, + GlAvatarLabeled, + GlSearchBoxByType, + GlLoadingIcon, +} from '@gitlab/ui'; import createMockApollo from 'jest/helpers/mock_apollo_helper'; import VueApollo from 'vue-apollo'; import BoardAssigneeDropdown from '~/boards/components/board_assignee_dropdown.vue'; @@ -8,7 +14,7 @@ import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dro import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; import store from '~/boards/stores'; import getIssueParticipants from '~/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql'; -import searchUsers from '~/boards/queries/users_search.query.graphql'; +import searchUsers from '~/boards/graphql/users_search.query.graphql'; import { participants } from '../mock_data'; const localVue = createLocalVue(); @@ -20,17 +26,18 @@ describe('BoardCardAssigneeDropdown', () => { let fakeApollo; let getIssueParticipantsSpy; let getSearchUsersSpy; + let dispatchSpy; const iid = '111'; const activeIssueName = 'test'; const anotherIssueName = 'hello'; - const createComponent = (search = '') => { + const createComponent = (search = '', loading = false) => { wrapper = mount(BoardAssigneeDropdown, { data() { return { search, - selected: store.getters.activeIssue.assignees, + selected: [], participants, }; }, @@ -39,6 +46,15 @@ describe('BoardCardAssigneeDropdown', () => { canUpdate: true, rootPath: '', }, + mocks: { + $apollo: { + queries: { + participants: { + loading, + }, + }, + }, + }, }); }; @@ -47,14 +63,13 @@ describe('BoardCardAssigneeDropdown', () => { [getIssueParticipants, getIssueParticipantsSpy], [searchUsers, getSearchUsersSpy], ]); - wrapper = mount(BoardAssigneeDropdown, { localVue, apolloProvider: fakeApollo, data() { return { search, - selected: store.getters.activeIssue.assignees, + selected: [], participants, }; }, @@ -82,6 +97,8 @@ describe('BoardCardAssigneeDropdown', () => { return wrapper.findAll(GlDropdownItem).wrappers.find(node => node.text().indexOf(text) === 0); }; + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + beforeEach(() => { store.state.activeId = '1'; store.state.issues = { @@ -91,10 +108,11 @@ describe('BoardCardAssigneeDropdown', () => { }, }; - jest.spyOn(store, 'dispatch').mockResolvedValue(); + dispatchSpy = jest.spyOn(store, 'dispatch').mockResolvedValue(); }); afterEach(() => { + window.gon = {}; jest.restoreAllMocks(); }); @@ -243,6 +261,30 @@ describe('BoardCardAssigneeDropdown', () => { }, ); + describe('when participants is loading', () => { + beforeEach(() => { + createComponent('', true); + }); + + it('finds a loading icon in the dropdown', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('when participants is loading is false', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not find GlLoading icon in the dropdown', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('finds at least 1 GlDropdownItem', () => { + expect(wrapper.findAll(GlDropdownItem).length).toBeGreaterThan(0); + }); + }); + describe('Apollo', () => { beforeEach(() => { getIssueParticipantsSpy = jest.fn().mockResolvedValue({ @@ -305,4 +347,39 @@ describe('BoardCardAssigneeDropdown', () => { expect(wrapper.find(GlSearchBoxByType).exists()).toBe(true); }); + + describe('when assign-self is emitted from IssuableAssignees', () => { + const currentUser = { username: 'self', name: '', id: '' }; + + beforeEach(() => { + window.gon = { current_username: currentUser.username }; + + dispatchSpy.mockResolvedValue([currentUser]); + createComponent(); + + wrapper.find(IssuableAssignees).vm.$emit('assign-self'); + }); + + it('calls setAssignees with currentUser', () => { + expect(store.dispatch).toHaveBeenCalledWith('setAssignees', currentUser.username); + }); + + it('adds the user to the selected list', async () => { + expect(findByText(currentUser.username).exists()).toBe(true); + }); + }); + + describe('when setting an assignee', () => { + beforeEach(() => { + createComponent(); + }); + + it('passes loading state from Vuex to BoardEditableItem', async () => { + store.state.isSettingAssignees = true; + + await wrapper.vm.$nextTick(); + + expect(wrapper.find(BoardEditableItem).props('loading')).toBe(true); + }); + }); }); diff --git a/spec/frontend/boards/components/board_column_new_spec.js b/spec/frontend/boards/components/board_column_new_spec.js index 4aafc3a867a..81c0e60f931 100644 --- a/spec/frontend/boards/components/board_column_new_spec.js +++ b/spec/frontend/boards/components/board_column_new_spec.js @@ -2,7 +2,6 @@ import { shallowMount } from '@vue/test-utils'; import { listObj } from 'jest/boards/mock_data'; import BoardColumn from '~/boards/components/board_column_new.vue'; -import List from '~/boards/models/list'; import { ListType } from '~/boards/constants'; import { createStore } from '~/boards/stores'; @@ -20,24 +19,22 @@ describe('Board Column Component', () => { const listMock = { ...listObj, - list_type: listType, + listType, collapsed, }; if (listType === ListType.assignee) { delete listMock.label; - listMock.user = {}; + listMock.assignee = {}; } - const list = new List({ ...listMock, doNotFetchIssues: true }); - store = createStore(); wrapper = shallowMount(BoardColumn, { store, propsData: { disabled: false, - list, + list: listMock, }, provide: { boardId, @@ -60,7 +57,7 @@ describe('Board Column Component', () => { it('has class is-collapsed when list is collapsed', () => { createComponent({ collapsed: false }); - expect(wrapper.vm.list.isExpanded).toBe(true); + expect(isCollapsed()).toBe(false); }); it('does not have class is-collapsed when list is expanded', () => { diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js index 09e38001e2e..291013c561e 100644 --- a/spec/frontend/boards/components/board_content_spec.js +++ b/spec/frontend/boards/components/board_content_spec.js @@ -1,32 +1,38 @@ import Vuex from 'vuex'; import { createLocalVue, shallowMount } from '@vue/test-utils'; import { GlAlert } from '@gitlab/ui'; +import Draggable from 'vuedraggable'; import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue'; -import BoardColumn from 'ee_else_ce/boards/components/board_column.vue'; import getters from 'ee_else_ce/boards/stores/getters'; -import { mockListsWithModel } from '../mock_data'; +import BoardColumn from '~/boards/components/board_column.vue'; +import { mockLists, mockListsWithModel } from '../mock_data'; import BoardContent from '~/boards/components/board_content.vue'; const localVue = createLocalVue(); localVue.use(Vuex); +const actions = { + moveList: jest.fn(), +}; + describe('BoardContent', () => { let wrapper; const defaultState = { isShowingEpicsSwimlanes: false, - boardLists: mockListsWithModel, + boardLists: mockLists, error: undefined, }; const createStore = (state = defaultState) => { return new Vuex.Store({ + actions, getters, state, }); }; - const createComponent = state => { + const createComponent = ({ state, props = {}, graphqlBoardListsEnabled = false } = {}) => { const store = createStore({ ...defaultState, ...state, @@ -37,25 +43,61 @@ describe('BoardContent', () => { lists: mockListsWithModel, canAdminList: true, disabled: false, + ...props, + }, + provide: { + glFeatures: { graphqlBoardLists: graphqlBoardListsEnabled }, }, store, }); }; - beforeEach(() => { - createComponent(); - }); - afterEach(() => { wrapper.destroy(); }); it('renders a BoardColumn component per list', () => { - expect(wrapper.findAll(BoardColumn)).toHaveLength(mockListsWithModel.length); + createComponent(); + + expect(wrapper.findAll(BoardColumn)).toHaveLength(mockLists.length); }); it('does not display EpicsSwimlanes component', () => { + createComponent(); + expect(wrapper.find(EpicsSwimlanes).exists()).toBe(false); expect(wrapper.find(GlAlert).exists()).toBe(false); }); + + describe('graphqlBoardLists feature flag enabled', () => { + describe('can admin list', () => { + beforeEach(() => { + createComponent({ graphqlBoardListsEnabled: true, props: { canAdminList: true } }); + }); + + it('renders draggable component', () => { + expect(wrapper.find(Draggable).exists()).toBe(true); + }); + }); + + describe('can not admin list', () => { + beforeEach(() => { + createComponent({ graphqlBoardListsEnabled: true, props: { canAdminList: false } }); + }); + + it('renders draggable component', () => { + expect(wrapper.find(Draggable).exists()).toBe(false); + }); + }); + }); + + describe('graphqlBoardLists feature flag disabled', () => { + beforeEach(() => { + createComponent({ graphqlBoardListsEnabled: false }); + }); + + it('does not render draggable component', () => { + expect(wrapper.find(Draggable).exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index 65d8070192c..3b15cbb6b7e 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -1,47 +1,275 @@ -import { mount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'jest/helpers/test_constants'; +import { GlModal } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; + +import axios from '~/lib/utils/axios_utils'; +import { visitUrl } from '~/lib/utils/url_utility'; import boardsStore from '~/boards/stores/boards_store'; -import boardForm from '~/boards/components/board_form.vue'; -import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; +import BoardForm from '~/boards/components/board_form.vue'; +import BoardConfigurationOptions from '~/boards/components/board_configuration_options.vue'; +import createBoardMutation from '~/boards/graphql/board.mutation.graphql'; + +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn().mockName('visitUrlMock'), +})); + +const currentBoard = { + id: 1, + name: 'test', + labels: [], + milestone_id: undefined, + assignee: {}, + assignee_id: undefined, + weight: null, + hide_backlog_list: false, + hide_closed_list: false, +}; + +const boardDefaults = { + id: false, + name: '', + labels: [], + milestone_id: undefined, + assignee: {}, + assignee_id: undefined, + weight: null, + hide_backlog_list: false, + hide_closed_list: false, +}; + +const defaultProps = { + canAdminBoard: false, + labelsPath: `${TEST_HOST}/labels/path`, + labelsWebUrl: `${TEST_HOST}/-/labels`, + currentBoard, +}; -describe('board_form.vue', () => { +const endpoints = { + boardsEndpoint: 'test-endpoint', +}; + +const mutate = jest.fn().mockResolvedValue({}); + +describe('BoardForm', () => { let wrapper; + let axiosMock; - const propsData = { - canAdminBoard: false, - labelsPath: `${TEST_HOST}/labels/path`, - labelsWebUrl: `${TEST_HOST}/-/labels`, - }; + const findModal = () => wrapper.find(GlModal); + const findModalActionPrimary = () => findModal().props('actionPrimary'); + const findForm = () => wrapper.find('[data-testid="board-form"]'); + const findFormWrapper = () => wrapper.find('[data-testid="board-form-wrapper"]'); + const findDeleteConfirmation = () => wrapper.find('[data-testid="delete-confirmation-message"]'); + const findConfigurationOptions = () => wrapper.find(BoardConfigurationOptions); + const findInput = () => wrapper.find('#board-new-name'); - const findModal = () => wrapper.find(DeprecatedModal); + const createComponent = (props, data) => { + wrapper = shallowMount(BoardForm, { + propsData: { ...defaultProps, ...props }, + data() { + return { + ...data, + }; + }, + provide: { + endpoints, + }, + mocks: { + $apollo: { + mutate, + }, + }, + attachToDocument: true, + }); + }; beforeEach(() => { - boardsStore.state.currentPage = 'edit'; - wrapper = mount(boardForm, { propsData }); + axiosMock = new AxiosMockAdapter(axios); }); afterEach(() => { wrapper.destroy(); wrapper = null; + axiosMock.restore(); + boardsStore.state.currentPage = null; }); - describe('methods', () => { - describe('cancel', () => { - it('resets currentPage', () => { - wrapper.vm.cancel(); - expect(boardsStore.state.currentPage).toBe(''); + describe('when user can not admin the board', () => { + beforeEach(() => { + boardsStore.state.currentPage = 'new'; + createComponent(); + }); + + it('hides modal footer when user is not a board admin', () => { + expect(findModal().attributes('hide-footer')).toBeDefined(); + }); + + it('displays board scope title', () => { + expect(findModal().attributes('title')).toBe('Board scope'); + }); + + it('does not display a form', () => { + expect(findForm().exists()).toBe(false); + }); + }); + + describe('when user can admin the board', () => { + beforeEach(() => { + boardsStore.state.currentPage = 'new'; + createComponent({ canAdminBoard: true }); + }); + + it('shows modal footer when user is a board admin', () => { + expect(findModal().attributes('hide-footer')).toBeUndefined(); + }); + + it('displays a form', () => { + expect(findForm().exists()).toBe(true); + }); + + it('focuses an input field', async () => { + expect(document.activeElement).toBe(wrapper.vm.$refs.name); + }); + }); + + describe('when creating a new board', () => { + beforeEach(() => { + boardsStore.state.currentPage = 'new'; + }); + + describe('on non-scoped-board', () => { + beforeEach(() => { + createComponent({ canAdminBoard: true }); + }); + + it('clears the form', () => { + expect(findConfigurationOptions().props('board')).toEqual(boardDefaults); + }); + + it('shows a correct title about creating a board', () => { + expect(findModal().attributes('title')).toBe('Create new board'); + }); + + it('passes correct primary action text and variant', () => { + expect(findModalActionPrimary().text).toBe('Create board'); + expect(findModalActionPrimary().attributes[0].variant).toBe('success'); + }); + + it('does not render delete confirmation message', () => { + expect(findDeleteConfirmation().exists()).toBe(false); + }); + + it('renders form wrapper', () => { + expect(findFormWrapper().exists()).toBe(true); + }); + + it('passes a true isNewForm prop to BoardConfigurationOptions component', () => { + expect(findConfigurationOptions().props('isNewForm')).toBe(true); + }); + }); + + describe('when submitting a create event', () => { + beforeEach(() => { + const url = `${endpoints.boardsEndpoint}.json`; + axiosMock.onPost(url).reply(200, { id: '2', board_path: 'new path' }); + }); + + it('does not call API if board name is empty', async () => { + createComponent({ canAdminBoard: true }); + findInput().trigger('keyup.enter', { metaKey: true }); + + await waitForPromises(); + + expect(mutate).not.toHaveBeenCalled(); + }); + + it('calls REST and GraphQL API and redirects to correct page', async () => { + createComponent({ canAdminBoard: true }); + + findInput().value = 'Test name'; + findInput().trigger('input'); + findInput().trigger('keyup.enter', { metaKey: true }); + + await waitForPromises(); + + expect(axiosMock.history.post[0].data).toBe( + JSON.stringify({ board: { ...boardDefaults, name: 'test', label_ids: [''] } }), + ); + + expect(mutate).toHaveBeenCalledWith({ + mutation: createBoardMutation, + variables: { + id: 'gid://gitlab/Board/2', + }, + }); + + await waitForPromises(); + expect(visitUrl).toHaveBeenCalledWith('new path'); }); }); }); - describe('buttons', () => { - it('cancel button triggers cancel()', () => { - wrapper.setMethods({ cancel: jest.fn() }); - findModal().vm.$emit('cancel'); + describe('when editing a board', () => { + beforeEach(() => { + boardsStore.state.currentPage = 'edit'; + }); + + describe('on non-scoped-board', () => { + beforeEach(() => { + createComponent({ canAdminBoard: true }); + }); + + it('clears the form', () => { + expect(findConfigurationOptions().props('board')).toEqual(currentBoard); + }); + + it('shows a correct title about creating a board', () => { + expect(findModal().attributes('title')).toBe('Edit board'); + }); + + it('passes correct primary action text and variant', () => { + expect(findModalActionPrimary().text).toBe('Save changes'); + expect(findModalActionPrimary().attributes[0].variant).toBe('info'); + }); + + it('does not render delete confirmation message', () => { + expect(findDeleteConfirmation().exists()).toBe(false); + }); + + it('renders form wrapper', () => { + expect(findFormWrapper().exists()).toBe(true); + }); + + it('passes a false isNewForm prop to BoardConfigurationOptions component', () => { + expect(findConfigurationOptions().props('isNewForm')).toBe(false); + }); + }); + + describe('when submitting an update event', () => { + beforeEach(() => { + const url = endpoints.boardsEndpoint; + axiosMock.onPut(url).reply(200, { board_path: 'new path' }); + }); + + it('calls REST and GraphQL API with correct parameters', async () => { + createComponent({ canAdminBoard: true }); + + findInput().trigger('keyup.enter', { metaKey: true }); + + await waitForPromises(); + + expect(axiosMock.history.put[0].data).toBe( + JSON.stringify({ board: { ...currentBoard, label_ids: [''] } }), + ); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.cancel).toHaveBeenCalled(); + expect(mutate).toHaveBeenCalledWith({ + mutation: createBoardMutation, + variables: { + id: `gid://gitlab/Board/${currentBoard.id}`, + }, + }); }); }); }); diff --git a/spec/frontend/boards/components/board_list_header_new_spec.js b/spec/frontend/boards/components/board_list_header_new_spec.js index 80786d82620..7428dfae83f 100644 --- a/spec/frontend/boards/components/board_list_header_new_spec.js +++ b/spec/frontend/boards/components/board_list_header_new_spec.js @@ -1,9 +1,8 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { listObj } from 'jest/boards/mock_data'; +import { mockLabelList } from 'jest/boards/mock_data'; import BoardListHeader from '~/boards/components/board_list_header_new.vue'; -import List from '~/boards/models/list'; import { ListType } from '~/boards/constants'; const localVue = createLocalVue(); @@ -32,21 +31,19 @@ describe('Board List Header Component', () => { const boardId = '1'; const listMock = { - ...listObj, - list_type: listType, + ...mockLabelList, + listType, collapsed, }; if (listType === ListType.assignee) { delete listMock.label; - listMock.user = {}; + listMock.assignee = {}; } - const list = new List({ ...listMock, doNotFetchIssues: true }); - if (withLocalStorage) { localStorage.setItem( - `boards.${boardId}.${list.type}.${list.id}.expanded`, + `boards.${boardId}.${listMock.listType}.${listMock.id}.expanded`, (!collapsed).toString(), ); } @@ -62,7 +59,7 @@ describe('Board List Header Component', () => { localVue, propsData: { disabled: false, - list, + list: listMock, }, provide: { boardId, @@ -72,14 +69,15 @@ describe('Board List Header Component', () => { }); }; - const isExpanded = () => wrapper.vm.list.isExpanded; - const isCollapsed = () => !isExpanded(); + const isCollapsed = () => wrapper.vm.list.collapsed; + const isExpanded = () => !isCollapsed; const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' }); + const findTitle = () => wrapper.find('.board-title'); const findCaret = () => wrapper.find('.board-title-caret'); describe('Add issue button', () => { - const hasNoAddButton = [ListType.promotion, ListType.blank, ListType.closed]; + const hasNoAddButton = [ListType.closed]; const hasAddButton = [ListType.backlog, ListType.label, ListType.milestone, ListType.assignee]; it.each(hasNoAddButton)('does not render when List Type is `%s`', listType => { @@ -125,7 +123,7 @@ describe('Board List Header Component', () => { it('collapses expanded Column when clicking the collapse icon', async () => { createComponent(); - expect(isExpanded()).toBe(true); + expect(isCollapsed()).toBe(false); findCaret().vm.$emit('click'); @@ -166,4 +164,24 @@ describe('Board List Header Component', () => { expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded())); }); }); + + describe('user can drag', () => { + const cannotDragList = [ListType.backlog, ListType.closed]; + const canDragList = [ListType.label, ListType.milestone, ListType.assignee]; + + it.each(cannotDragList)( + 'does not have user-can-drag-class so user cannot drag list', + listType => { + createComponent({ listType }); + + expect(findTitle().classes()).not.toContain('user-can-drag'); + }, + ); + + it.each(canDragList)('has user-can-drag-class so user can drag list', listType => { + createComponent({ listType }); + + expect(findTitle().classes()).toContain('user-can-drag'); + }); + }); }); diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js index 2439c347bf0..656a503bb86 100644 --- a/spec/frontend/boards/components/board_list_header_spec.js +++ b/spec/frontend/boards/components/board_list_header_spec.js @@ -73,7 +73,7 @@ describe('Board List Header Component', () => { const findCaret = () => wrapper.find('.board-title-caret'); describe('Add issue button', () => { - const hasNoAddButton = [ListType.promotion, ListType.blank, ListType.closed]; + const hasNoAddButton = [ListType.closed]; const hasAddButton = [ListType.backlog, ListType.label, ListType.milestone, ListType.assignee]; it.each(hasNoAddButton)('does not render when List Type is `%s`', listType => { diff --git a/spec/frontend/boards/components/board_new_issue_new_spec.js b/spec/frontend/boards/components/board_new_issue_new_spec.js index af4bad65121..ee1c4f31cf0 100644 --- a/spec/frontend/boards/components/board_new_issue_new_spec.js +++ b/spec/frontend/boards/components/board_new_issue_new_spec.js @@ -3,7 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import BoardNewIssue from '~/boards/components/board_new_issue_new.vue'; import '~/boards/models/list'; -import { mockListsWithModel } from '../mock_data'; +import { mockList } from '../mock_data'; const localVue = createLocalVue(); @@ -37,7 +37,7 @@ describe('Issue boards new issue form', () => { wrapper = shallowMount(BoardNewIssue, { propsData: { disabled: false, - list: mockListsWithModel[0], + list: mockList, }, store, localVue, diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js index 2b7605a3f7c..db3c8c22950 100644 --- a/spec/frontend/boards/components/boards_selector_spec.js +++ b/spec/frontend/boards/components/boards_selector_spec.js @@ -1,6 +1,6 @@ import { nextTick } from 'vue'; import { mount } from '@vue/test-utils'; -import { GlDeprecatedDropdown, GlLoadingIcon } from '@gitlab/ui'; +import { GlDropdown, GlLoadingIcon, GlDropdownSectionHeader } from '@gitlab/ui'; import { TEST_HOST } from 'spec/test_constants'; import BoardsSelector from '~/boards/components/boards_selector.vue'; import boardsStore from '~/boards/stores/boards_store'; @@ -34,8 +34,9 @@ describe('BoardsSelector', () => { }; const getDropdownItems = () => wrapper.findAll('.js-dropdown-item'); - const getDropdownHeaders = () => wrapper.findAll('.dropdown-bold-header'); + const getDropdownHeaders = () => wrapper.findAll(GlDropdownSectionHeader); const getLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findDropdown = () => wrapper.find(GlDropdown); beforeEach(() => { const $apollo = { @@ -103,7 +104,7 @@ describe('BoardsSelector', () => { }); // Emits gl-dropdown show event to simulate the dropdown is opened at initialization time - wrapper.find(GlDeprecatedDropdown).vm.$emit('show'); + findDropdown().vm.$emit('show'); }); afterEach(() => { @@ -125,7 +126,10 @@ describe('BoardsSelector', () => { }); describe('loaded', () => { - beforeEach(() => { + beforeEach(async () => { + await wrapper.setData({ + loadingBoards: false, + }); return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick()); }); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js new file mode 100644 index 00000000000..74d88d9f34c --- /dev/null +++ b/spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js @@ -0,0 +1,152 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { mockMilestone as TEST_MILESTONE } from 'jest/boards/mock_data'; +import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue'; +import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; +import { createStore } from '~/boards/stores'; +import createFlash from '~/flash'; + +const TEST_ISSUE = { id: 'gid://gitlab/Issue/1', iid: 9, referencePath: 'h/b#2' }; + +jest.mock('~/flash'); + +describe('~/boards/components/sidebar/board_sidebar_milestone_select.vue', () => { + let wrapper; + let store; + + afterEach(() => { + wrapper.destroy(); + store = null; + wrapper = null; + }); + + const createWrapper = ({ milestone = null } = {}) => { + store = createStore(); + store.state.issues = { [TEST_ISSUE.id]: { ...TEST_ISSUE, milestone } }; + store.state.activeId = TEST_ISSUE.id; + + wrapper = shallowMount(BoardSidebarMilestoneSelect, { + store, + provide: { + canUpdate: true, + }, + data: () => ({ + milestones: [TEST_MILESTONE], + }), + stubs: { + 'board-editable-item': BoardEditableItem, + }, + mocks: { + $apollo: { + loading: false, + }, + }, + }); + }; + + const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]'); + const findLoader = () => wrapper.find(GlLoadingIcon); + const findDropdownItem = () => wrapper.find('[data-testid="milestone-item"]'); + const findUnsetMilestoneItem = () => wrapper.find('[data-testid="no-milestone-item"]'); + const findNoMilestonesFoundItem = () => wrapper.find('[data-testid="no-milestones-found"]'); + + it('renders "None" when no milestone is selected', () => { + createWrapper(); + + expect(findCollapsed().text()).toBe('None'); + }); + + it('renders milestone title when set', () => { + createWrapper({ milestone: TEST_MILESTONE }); + + expect(findCollapsed().text()).toContain(TEST_MILESTONE.title); + }); + + it('shows loader while Apollo is loading', async () => { + createWrapper({ milestone: TEST_MILESTONE }); + + expect(findLoader().exists()).toBe(false); + + wrapper.vm.$apollo.loading = true; + await wrapper.vm.$nextTick(); + + expect(findLoader().exists()).toBe(true); + }); + + it('shows message when error or no milestones found', async () => { + createWrapper(); + + wrapper.setData({ milestones: [] }); + await wrapper.vm.$nextTick(); + + expect(findNoMilestonesFoundItem().text()).toBe('No milestones found'); + }); + + describe('when milestone is selected', () => { + beforeEach(async () => { + createWrapper(); + + jest.spyOn(wrapper.vm, 'setActiveIssueMilestone').mockImplementation(() => { + store.state.issues[TEST_ISSUE.id].milestone = TEST_MILESTONE; + }); + findDropdownItem().vm.$emit('click'); + await wrapper.vm.$nextTick(); + }); + + it('collapses sidebar and renders selected milestone', () => { + expect(findCollapsed().isVisible()).toBe(true); + expect(findCollapsed().text()).toContain(TEST_MILESTONE.title); + }); + + it('commits change to the server', () => { + expect(wrapper.vm.setActiveIssueMilestone).toHaveBeenCalledWith({ + milestoneId: TEST_MILESTONE.id, + projectPath: 'h/b', + }); + }); + }); + + describe('when milestone is set to "None"', () => { + beforeEach(async () => { + createWrapper({ milestone: TEST_MILESTONE }); + + jest.spyOn(wrapper.vm, 'setActiveIssueMilestone').mockImplementation(() => { + store.state.issues[TEST_ISSUE.id].milestone = null; + }); + findUnsetMilestoneItem().vm.$emit('click'); + await wrapper.vm.$nextTick(); + }); + + it('collapses sidebar and renders "None"', () => { + expect(findCollapsed().isVisible()).toBe(true); + expect(findCollapsed().text()).toBe('None'); + }); + + it('commits change to the server', () => { + expect(wrapper.vm.setActiveIssueMilestone).toHaveBeenCalledWith({ + milestoneId: null, + projectPath: 'h/b', + }); + }); + }); + + describe('when the mutation fails', () => { + const testMilestone = { id: '1', title: 'Former milestone' }; + + beforeEach(async () => { + createWrapper({ milestone: testMilestone }); + + jest.spyOn(wrapper.vm, 'setActiveIssueMilestone').mockImplementation(() => { + throw new Error(['failed mutation']); + }); + findDropdownItem().vm.$emit('click'); + await wrapper.vm.$nextTick(); + }); + + it('collapses sidebar and renders former milestone', () => { + expect(findCollapsed().isVisible()).toBe(true); + expect(findCollapsed().text()).toContain(testMilestone.title); + expect(createFlash).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/boards/list_spec.js b/spec/frontend/boards/list_spec.js index 9c3a6e66ef4..b731bb6e474 100644 --- a/spec/frontend/boards/list_spec.js +++ b/spec/frontend/boards/list_spec.js @@ -184,7 +184,6 @@ describe('List model', () => { }), ); list.issues = []; - global.gon.features = { boardsWithSwimlanes: false }; }); it('adds new issue to top of list', done => { diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 58f67231d55..ea6c52c6830 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -1,10 +1,8 @@ -/* global ListIssue */ /* global List */ import Vue from 'vue'; import { keyBy } from 'lodash'; import '~/boards/models/list'; -import '~/boards/models/issue'; import boardsStore from '~/boards/stores/boards_store'; export const boardObj = { @@ -99,7 +97,7 @@ export const mockMilestone = { due_date: '2019-12-31', }; -const assignees = [ +export const assignees = [ { id: 'gid://gitlab/User/2', username: 'angelina.herman', @@ -184,8 +182,6 @@ export const mockActiveIssue = { emailsDisabled: false, }; -export const mockIssueWithModel = new ListIssue(mockIssue); - export const mockIssue2 = { id: 'gid://gitlab/Issue/437', iid: 28, @@ -203,8 +199,6 @@ export const mockIssue2 = { }, }; -export const mockIssue2WithModel = new ListIssue(mockIssue2); - export const mockIssue3 = { id: 'gid://gitlab/Issue/438', iid: 29, @@ -288,38 +282,39 @@ export const setMockEndpoints = (opts = {}) => { }); }; -export const mockLists = [ - { - id: 'gid://gitlab/List/1', - title: 'Backlog', - position: null, - listType: 'backlog', - collapsed: false, - label: null, - assignee: null, - milestone: null, - loading: false, - issuesSize: 1, - }, - { - id: 'gid://gitlab/List/2', +export const mockList = { + id: 'gid://gitlab/List/1', + title: 'Backlog', + position: null, + listType: 'backlog', + collapsed: false, + label: null, + assignee: null, + milestone: null, + loading: false, + issuesCount: 1, +}; + +export const mockLabelList = { + id: 'gid://gitlab/List/2', + title: 'To Do', + position: 0, + listType: 'label', + collapsed: false, + label: { + id: 'gid://gitlab/GroupLabel/121', title: 'To Do', - position: 0, - listType: 'label', - collapsed: false, - label: { - id: 'gid://gitlab/GroupLabel/121', - title: 'To Do', - color: '#F0AD4E', - textColor: '#FFFFFF', - description: null, - }, - assignee: null, - milestone: null, - loading: false, - issuesSize: 0, + color: '#F0AD4E', + textColor: '#FFFFFF', + description: null, }, -]; + assignee: null, + milestone: null, + loading: false, + issuesCount: 0, +}; + +export const mockLists = [mockList, mockLabelList]; export const mockListsById = keyBy(mockLists, 'id'); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 4d529580a7a..0cae6456887 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -1,23 +1,25 @@ import testAction from 'helpers/vuex_action_helper'; import { - mockListsWithModel, mockLists, mockListsById, mockIssue, - mockIssueWithModel, - mockIssue2WithModel, + mockIssue2, rawIssue, mockIssues, + mockMilestone, labels, mockActiveIssue, } from '../mock_data'; import actions, { gqlClient } from '~/boards/stores/actions'; import * as types from '~/boards/stores/mutation_types'; import { inactiveId } from '~/boards/constants'; -import issueMoveListMutation from '~/boards/queries/issue_move_list.mutation.graphql'; -import destroyBoardListMutation from '~/boards/queries/board_list_destroy.mutation.graphql'; +import issueMoveListMutation from '~/boards/graphql/issue_move_list.mutation.graphql'; +import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql'; import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql'; import { fullBoardId, formatListIssues, formatBoardLists } from '~/boards/boards_util'; +import createFlash from '~/flash'; + +jest.mock('~/flash'); const expectNotImplemented = action => { it('is not implemented', () => { @@ -29,6 +31,10 @@ const expectNotImplemented = action => { // subgroups when the movIssue action is called. const getProjectPath = path => path.split('#')[0]; +beforeEach(() => { + window.gon = { features: {} }; +}); + describe('setInitialBoardData', () => { it('sets data object', () => { const mockData = { @@ -65,6 +71,24 @@ describe('setFilters', () => { }); }); +describe('performSearch', () => { + it('should dispatch setFilters action', done => { + testAction(actions.performSearch, {}, {}, [], [{ type: 'setFilters', payload: {} }], done); + }); + + it('should dispatch setFilters, fetchLists and resetIssues action when graphqlBoardLists FF is on', done => { + window.gon = { features: { graphqlBoardLists: true } }; + testAction( + actions.performSearch, + {}, + {}, + [], + [{ type: 'setFilters', payload: {} }, { type: 'fetchLists' }, { type: 'resetIssues' }], + done, + ); + }); +}); + describe('setActiveId', () => { it('should commit mutation SET_ACTIVE_ID', done => { const state = { @@ -120,7 +144,7 @@ describe('fetchLists', () => { payload: formattedLists, }, ], - [{ type: 'generateDefaultLists' }], + [], done, ); }); @@ -150,37 +174,12 @@ describe('fetchLists', () => { payload: formattedLists, }, ], - [{ type: 'createList', payload: { backlog: true } }, { type: 'generateDefaultLists' }], + [{ type: 'createList', payload: { backlog: true } }], done, ); }); }); -describe('generateDefaultLists', () => { - let store; - beforeEach(() => { - const state = { - endpoints: { fullPath: 'gitlab-org', boardId: '1' }, - boardType: 'group', - disabled: false, - boardLists: [{ type: 'backlog' }, { type: 'closed' }], - }; - - store = { - commit: jest.fn(), - dispatch: jest.fn(() => Promise.resolve()), - state, - }; - }); - - it('should dispatch fetchLabels', () => { - return actions.generateDefaultLists(store).then(() => { - expect(store.dispatch.mock.calls[0]).toEqual(['fetchLabels', 'to do']); - expect(store.dispatch.mock.calls[1]).toEqual(['fetchLabels', 'doing']); - }); - }); -}); - describe('createList', () => { it('should dispatch addList action when creating backlog list', done => { const backlogList = { @@ -251,8 +250,8 @@ describe('createList', () => { describe('moveList', () => { it('should commit MOVE_LIST mutation and dispatch updateList action', done => { const initialBoardListsState = { - 'gid://gitlab/List/1': mockListsWithModel[0], - 'gid://gitlab/List/2': mockListsWithModel[1], + 'gid://gitlab/List/1': mockLists[0], + 'gid://gitlab/List/2': mockLists[1], }; const state = { @@ -274,7 +273,7 @@ describe('moveList', () => { [ { type: types.MOVE_LIST, - payload: { movedList: mockListsWithModel[0], listAtNewIndex: mockListsWithModel[1] }, + payload: { movedList: mockLists[0], listAtNewIndex: mockLists[1] }, }, ], [ @@ -290,6 +289,33 @@ describe('moveList', () => { done, ); }); + + it('should not commit MOVE_LIST or dispatch updateList if listId and replacedListId are the same', () => { + const initialBoardListsState = { + 'gid://gitlab/List/1': mockLists[0], + 'gid://gitlab/List/2': mockLists[1], + }; + + const state = { + endpoints: { fullPath: 'gitlab-org', boardId: '1' }, + boardType: 'group', + disabled: false, + boardLists: initialBoardListsState, + }; + + testAction( + actions.moveList, + { + listId: 'gid://gitlab/List/1', + replacedListId: 'gid://gitlab/List/1', + newIndex: 1, + adjustmentValue: 1, + }, + state, + [], + [], + ); + }); }); describe('updateList', () => { @@ -499,15 +525,15 @@ describe('moveIssue', () => { }; const issues = { - '436': mockIssueWithModel, - '437': mockIssue2WithModel, + '436': mockIssue, + '437': mockIssue2, }; const state = { endpoints: { fullPath: 'gitlab-org', boardId: '1' }, boardType: 'group', disabled: false, - boardLists: mockListsWithModel, + boardLists: mockLists, issuesByListId: listIssues, issues, }; @@ -536,7 +562,7 @@ describe('moveIssue', () => { { type: types.MOVE_ISSUE, payload: { - originalIssue: mockIssueWithModel, + originalIssue: mockIssue, fromListId: 'gid://gitlab/List/1', toListId: 'gid://gitlab/List/2', }, @@ -611,7 +637,7 @@ describe('moveIssue', () => { { type: types.MOVE_ISSUE, payload: { - originalIssue: mockIssueWithModel, + originalIssue: mockIssue, fromListId: 'gid://gitlab/List/1', toListId: 'gid://gitlab/List/2', }, @@ -619,7 +645,7 @@ describe('moveIssue', () => { { type: types.MOVE_ISSUE_FAILURE, payload: { - originalIssue: mockIssueWithModel, + originalIssue: mockIssue, fromListId: 'gid://gitlab/List/1', toListId: 'gid://gitlab/List/2', originalIndex: 0, @@ -639,38 +665,59 @@ describe('setAssignees', () => { const refPath = `${projectPath}#3`; const iid = '1'; - beforeEach(() => { - jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ - data: { issueSetAssignees: { issue: { assignees: { nodes: [{ ...node }] } } } }, + describe('when succeeds', () => { + beforeEach(() => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { issueSetAssignees: { issue: { assignees: { nodes: [{ ...node }] } } } }, + }); }); - }); - it('calls mutate with the correct values', async () => { - await actions.setAssignees( - { commit: () => {}, getters: { activeIssue: { iid, referencePath: refPath } } }, - [name], - ); + it('calls mutate with the correct values', async () => { + await actions.setAssignees( + { commit: () => {}, getters: { activeIssue: { iid, referencePath: refPath } } }, + [name], + ); + + expect(gqlClient.mutate).toHaveBeenCalledWith({ + mutation: updateAssignees, + variables: { iid, assigneeUsernames: [name], projectPath }, + }); + }); - expect(gqlClient.mutate).toHaveBeenCalledWith({ - mutation: updateAssignees, - variables: { iid, assigneeUsernames: [name], projectPath }, + it('calls the correct mutation with the correct values', done => { + testAction( + actions.setAssignees, + {}, + { activeIssue: { iid, referencePath: refPath }, commit: () => {} }, + [ + { type: types.SET_ASSIGNEE_LOADING, payload: true }, + { + type: 'UPDATE_ISSUE_BY_ID', + payload: { prop: 'assignees', issueId: undefined, value: [node] }, + }, + { type: types.SET_ASSIGNEE_LOADING, payload: false }, + ], + [], + done, + ); }); }); - it('calls the correct mutation with the correct values', done => { - testAction( - actions.setAssignees, - {}, - { activeIssue: { iid, referencePath: refPath }, commit: () => {} }, - [ - { - type: 'UPDATE_ISSUE_BY_ID', - payload: { prop: 'assignees', issueId: undefined, value: [node] }, - }, - ], - [], - done, - ); + describe('when fails', () => { + beforeEach(() => { + jest.spyOn(gqlClient, 'mutate').mockRejectedValue(); + }); + + it('calls createFlash', async () => { + await actions.setAssignees({ + commit: () => {}, + getters: { activeIssue: { iid, referencePath: refPath } }, + }); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'An error occurred while updating assignees.', + }); + }); }); }); @@ -885,6 +932,60 @@ describe('setActiveIssueSubscribed', () => { }); }); +describe('setActiveIssueMilestone', () => { + const state = { issues: { [mockIssue.id]: mockIssue } }; + const getters = { activeIssue: mockIssue }; + const testMilestone = { + ...mockMilestone, + id: 'gid://gitlab/Milestone/1', + }; + const input = { + milestoneId: testMilestone.id, + projectPath: 'h/b', + }; + + it('should commit milestone after setting the issue', done => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + updateIssue: { + issue: { + milestone: testMilestone, + }, + errors: [], + }, + }, + }); + + const payload = { + issueId: getters.activeIssue.id, + prop: 'milestone', + value: testMilestone, + }; + + testAction( + actions.setActiveIssueMilestone, + input, + { ...state, ...getters }, + [ + { + type: types.UPDATE_ISSUE_BY_ID, + payload, + }, + ], + [], + done, + ); + }); + + it('throws error if fails', async () => { + jest + .spyOn(gqlClient, 'mutate') + .mockResolvedValue({ data: { updateIssue: { errors: ['failed mutation'] } } }); + + await expect(actions.setActiveIssueMilestone({ getters }, input)).rejects.toThrow(Error); + }); +}); + describe('fetchBacklog', () => { expectNotImplemented(actions.fetchBacklog); }); diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js index 64025726dd1..6ceb8867d1f 100644 --- a/spec/frontend/boards/stores/getters_spec.js +++ b/spec/frontend/boards/stores/getters_spec.js @@ -6,28 +6,10 @@ import { mockIssues, mockIssuesByListId, issues, - mockListsWithModel, + mockLists, } from '../mock_data'; describe('Boards - Getters', () => { - describe('labelToggleState', () => { - it('should return "on" when isShowingLabels is true', () => { - const state = { - isShowingLabels: true, - }; - - expect(getters.labelToggleState(state)).toBe('on'); - }); - - it('should return "off" when isShowingLabels is false', () => { - const state = { - isShowingLabels: false, - }; - - expect(getters.labelToggleState(state)).toBe('off'); - }); - }); - describe('isSidebarOpen', () => { it('returns true when activeId is not equal to 0', () => { const state = { @@ -51,52 +33,8 @@ describe('Boards - Getters', () => { window.gon = { features: {} }; }); - describe('when boardsWithSwimlanes is true', () => { - beforeEach(() => { - window.gon = { features: { boardsWithSwimlanes: true } }; - }); - - describe('when isShowingEpicsSwimlanes is true', () => { - it('returns true', () => { - const state = { - isShowingEpicsSwimlanes: true, - }; - - expect(getters.isSwimlanesOn(state)).toBe(true); - }); - }); - - describe('when isShowingEpicsSwimlanes is false', () => { - it('returns false', () => { - const state = { - isShowingEpicsSwimlanes: false, - }; - - expect(getters.isSwimlanesOn(state)).toBe(false); - }); - }); - }); - - describe('when boardsWithSwimlanes is false', () => { - describe('when isShowingEpicsSwimlanes is true', () => { - it('returns false', () => { - const state = { - isShowingEpicsSwimlanes: true, - }; - - expect(getters.isSwimlanesOn(state)).toBe(false); - }); - }); - - describe('when isShowingEpicsSwimlanes is false', () => { - it('returns false', () => { - const state = { - isShowingEpicsSwimlanes: false, - }; - - expect(getters.isSwimlanesOn(state)).toBe(false); - }); - }); + it('returns false', () => { + expect(getters.isSwimlanesOn()).toBe(false); }); }); @@ -156,22 +94,22 @@ describe('Boards - Getters', () => { const boardsState = { boardLists: { - 'gid://gitlab/List/1': mockListsWithModel[0], - 'gid://gitlab/List/2': mockListsWithModel[1], + 'gid://gitlab/List/1': mockLists[0], + 'gid://gitlab/List/2': mockLists[1], }, }; describe('getListByLabelId', () => { it('returns list for a given label id', () => { expect(getters.getListByLabelId(boardsState)('gid://gitlab/GroupLabel/121')).toEqual( - mockListsWithModel[1], + mockLists[1], ); }); }); describe('getListByTitle', () => { it('returns list for a given list title', () => { - expect(getters.getListByTitle(boardsState)('To Do')).toEqual(mockListsWithModel[1]); + expect(getters.getListByTitle(boardsState)('To Do')).toEqual(mockLists[1]); }); }); }); diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index e1e57a8fd43..d93119ede3d 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -1,15 +1,7 @@ import mutations from '~/boards/stores/mutations'; import * as types from '~/boards/stores/mutation_types'; import defaultState from '~/boards/stores/state'; -import { - mockListsWithModel, - mockLists, - rawIssue, - mockIssue, - mockIssue2, - mockIssueWithModel, - mockIssue2WithModel, -} from '../mock_data'; +import { mockLists, rawIssue, mockIssue, mockIssue2 } from '../mock_data'; const expectNotImplemented = action => { it('is not implemented', () => { @@ -21,8 +13,8 @@ describe('Board Store Mutations', () => { let state; const initialBoardListsState = { - 'gid://gitlab/List/1': mockListsWithModel[0], - 'gid://gitlab/List/2': mockListsWithModel[1], + 'gid://gitlab/List/1': mockLists[0], + 'gid://gitlab/List/2': mockLists[1], }; beforeEach(() => { @@ -41,19 +33,21 @@ describe('Board Store Mutations', () => { }; const boardType = 'group'; const disabled = false; - const showPromotion = false; + const boardConfig = { + milestoneTitle: 'Milestone 1', + }; mutations[types.SET_INITIAL_BOARD_DATA](state, { ...endpoints, boardType, disabled, - showPromotion, + boardConfig, }); expect(state.endpoints).toEqual(endpoints); expect(state.boardType).toEqual(boardType); expect(state.disabled).toEqual(disabled); - expect(state.showPromotion).toEqual(showPromotion); + expect(state.boardConfig).toEqual(boardConfig); }); }); @@ -135,10 +129,10 @@ describe('Board Store Mutations', () => { describe('RECEIVE_ADD_LIST_SUCCESS', () => { it('adds list to boardLists state', () => { - mutations.RECEIVE_ADD_LIST_SUCCESS(state, mockListsWithModel[0]); + mutations.RECEIVE_ADD_LIST_SUCCESS(state, mockLists[0]); expect(state.boardLists).toEqual({ - [mockListsWithModel[0].id]: mockListsWithModel[0], + [mockLists[0].id]: mockLists[0], }); }); }); @@ -155,13 +149,13 @@ describe('Board Store Mutations', () => { }; mutations.MOVE_LIST(state, { - movedList: mockListsWithModel[0], - listAtNewIndex: mockListsWithModel[1], + movedList: mockLists[0], + listAtNewIndex: mockLists[1], }); expect(state.boardLists).toEqual({ - 'gid://gitlab/List/2': mockListsWithModel[1], - 'gid://gitlab/List/1': mockListsWithModel[0], + 'gid://gitlab/List/2': mockLists[1], + 'gid://gitlab/List/1': mockLists[0], }); }); }); @@ -171,8 +165,8 @@ describe('Board Store Mutations', () => { state = { ...state, boardLists: { - 'gid://gitlab/List/2': mockListsWithModel[1], - 'gid://gitlab/List/1': mockListsWithModel[0], + 'gid://gitlab/List/2': mockLists[1], + 'gid://gitlab/List/1': mockLists[0], }, error: undefined, }; @@ -186,7 +180,7 @@ describe('Board Store Mutations', () => { describe('REMOVE_LIST', () => { it('removes list from boardLists', () => { - const [list, secondList] = mockListsWithModel; + const [list, secondList] = mockLists; const expected = { [secondList.id]: secondList, }; @@ -355,8 +349,8 @@ describe('Board Store Mutations', () => { }; const issues = { - '1': mockIssueWithModel, - '2': mockIssue2WithModel, + '1': mockIssue, + '2': mockIssue2, }; state = { @@ -367,7 +361,7 @@ describe('Board Store Mutations', () => { }; mutations.MOVE_ISSUE(state, { - originalIssue: mockIssue2WithModel, + originalIssue: mockIssue2, fromListId: 'gid://gitlab/List/1', toListId: 'gid://gitlab/List/2', }); @@ -396,7 +390,7 @@ describe('Board Store Mutations', () => { issue: rawIssue, }); - expect(state.issues).toEqual({ '436': { ...mockIssueWithModel, id: 436 } }); + expect(state.issues).toEqual({ '436': { ...mockIssue, id: 436 } }); }); }); @@ -466,13 +460,13 @@ describe('Board Store Mutations', () => { boardLists: initialBoardListsState, }; - expect(state.boardLists['gid://gitlab/List/1'].issuesSize).toBe(1); + expect(state.boardLists['gid://gitlab/List/1'].issuesCount).toBe(1); - mutations.ADD_ISSUE_TO_LIST(state, { list: mockListsWithModel[0], issue: mockIssue2 }); + mutations.ADD_ISSUE_TO_LIST(state, { list: mockLists[0], issue: mockIssue2 }); expect(state.issuesByListId['gid://gitlab/List/1']).toContain(mockIssue2.id); expect(state.issues[mockIssue2.id]).toEqual(mockIssue2); - expect(state.boardLists['gid://gitlab/List/1'].issuesSize).toBe(2); + expect(state.boardLists['gid://gitlab/List/1'].issuesCount).toBe(2); }); }); @@ -524,6 +518,14 @@ describe('Board Store Mutations', () => { }); }); + describe('SET_ASSIGNEE_LOADING', () => { + it('sets isSettingAssignees to the value passed', () => { + mutations.SET_ASSIGNEE_LOADING(state, true); + + expect(state.isSettingAssignees).toBe(true); + }); + }); + describe('SET_CURRENT_PAGE', () => { expectNotImplemented(mutations.SET_CURRENT_PAGE); }); diff --git a/spec/frontend/branches/ajax_loading_spinner_spec.js b/spec/frontend/branches/ajax_loading_spinner_spec.js index a6404faa445..31cc7b99e42 100644 --- a/spec/frontend/branches/ajax_loading_spinner_spec.js +++ b/spec/frontend/branches/ajax_loading_spinner_spec.js @@ -9,7 +9,7 @@ describe('Ajax Loading Spinner', () => { <a class="js-ajax-loading-spinner" data-remote href="http://goesnowhere.nothing/whereami"> - <i class="fa fa-trash-o"></i> + Remove me </a></div>`; AjaxLoadingSpinner.init(); ajaxLoadingSpinnerElement = document.querySelector('.js-ajax-loading-spinner'); diff --git a/spec/frontend/ci_lint/components/ci_lint_spec.js b/spec/frontend/ci_lint/components/ci_lint_spec.js index b353da5910d..1c99fdb3505 100644 --- a/spec/frontend/ci_lint/components/ci_lint_spec.js +++ b/spec/frontend/ci_lint/components/ci_lint_spec.js @@ -3,8 +3,8 @@ import { shallowMount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; import EditorLite from '~/vue_shared/components/editor_lite.vue'; import CiLint from '~/ci_lint/components/ci_lint.vue'; -import CiLintResults from '~/ci_lint/components/ci_lint_results.vue'; -import lintCIMutation from '~/ci_lint/graphql/mutations/lint_ci.mutation.graphql'; +import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue'; +import lintCIMutation from '~/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql'; import { mockLintDataValid } from '../mock_data'; describe('CI Lint', () => { diff --git a/spec/frontend/ci_lint/graphql/resolvers_spec.js b/spec/frontend/ci_lint/graphql/resolvers_spec.js deleted file mode 100644 index 437c52cf6b4..00000000000 --- a/spec/frontend/ci_lint/graphql/resolvers_spec.js +++ /dev/null @@ -1,38 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; -import httpStatus from '~/lib/utils/http_status'; - -import resolvers from '~/ci_lint/graphql/resolvers'; -import { mockLintResponse } from '../mock_data'; - -describe('~/ci_lint/graphql/resolvers', () => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - describe('Mutation', () => { - describe('lintCI', () => { - const endpoint = '/ci/lint'; - - beforeEach(() => { - mock.onPost(endpoint).reply(httpStatus.OK, mockLintResponse); - }); - - it('resolves lint data with type names', async () => { - const result = resolvers.Mutation.lintCI(null, { - endpoint, - content: 'content', - dry_run: true, - }); - - await expect(result).resolves.toMatchSnapshot(); - }); - }); - }); -}); diff --git a/spec/frontend/ci_lint/mock_data.js b/spec/frontend/ci_lint/mock_data.js index b87c9f8413b..28ea0f55bf8 100644 --- a/spec/frontend/ci_lint/mock_data.js +++ b/spec/frontend/ci_lint/mock_data.js @@ -1,86 +1,4 @@ -export const mockLintResponse = { - valid: true, - errors: [], - warnings: [], - jobs: [ - { - name: 'job_1', - stage: 'test', - before_script: ["echo 'before script 1'"], - script: ["echo 'script 1'"], - after_script: ["echo 'after script 1"], - tag_list: ['tag 1'], - environment: 'prd', - when: 'on_success', - allow_failure: false, - only: null, - except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, - }, - { - name: 'job_2', - stage: 'test', - before_script: ["echo 'before script 2'"], - script: ["echo 'script 2'"], - after_script: ["echo 'after script 2"], - tag_list: ['tag 2'], - environment: 'stg', - when: 'on_success', - allow_failure: true, - only: { refs: ['web', 'chat', 'pushes'] }, - except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, - }, - ], -}; - -export const mockJobs = [ - { - name: 'job_1', - stage: 'build', - beforeScript: [], - script: ["echo 'Building'"], - afterScript: [], - tagList: [], - environment: null, - when: 'on_success', - allowFailure: true, - only: { refs: ['web', 'chat', 'pushes'] }, - except: null, - }, - { - name: 'multi_project_job', - stage: 'test', - beforeScript: [], - script: [], - afterScript: [], - tagList: [], - environment: null, - when: 'on_success', - allowFailure: false, - only: { refs: ['branches', 'tags'] }, - except: null, - }, - { - name: 'job_2', - stage: 'test', - beforeScript: ["echo 'before script'"], - script: ["echo 'script'"], - afterScript: ["echo 'after script"], - tagList: [], - environment: null, - when: 'on_success', - allowFailure: false, - only: { refs: ['branches@gitlab-org/gitlab'] }, - except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, - }, -]; - -export const mockErrors = [ - '"job_1 job: chosen stage does not exist; available stages are .pre, build, test, deploy, .post"', -]; - -export const mockWarnings = [ - '"jobs:multi_project_job may allow multiple pipelines to run for a single action due to `rules:when` clause with no `workflow:rules` - read more: https://docs.gitlab.com/ee/ci/troubleshooting.html#pipeline-warnings"', -]; +import { mockJobs } from 'jest/pipeline_editor/mock_data'; export const mockLintDataValid = { data: { diff --git a/spec/frontend/close_reopen_report_toggle_spec.js b/spec/frontend/close_reopen_report_toggle_spec.js deleted file mode 100644 index d2ce6298c5c..00000000000 --- a/spec/frontend/close_reopen_report_toggle_spec.js +++ /dev/null @@ -1,283 +0,0 @@ -import CloseReopenReportToggle from '~/close_reopen_report_toggle'; -import DropLab from '~/droplab/drop_lab'; - -describe('CloseReopenReportToggle', () => { - describe('class constructor', () => { - const dropdownTrigger = {}; - const dropdownList = {}; - const button = {}; - let commentTypeToggle; - - beforeEach(() => { - commentTypeToggle = new CloseReopenReportToggle({ - dropdownTrigger, - dropdownList, - button, - }); - }); - - it('sets .dropdownTrigger', () => { - expect(commentTypeToggle.dropdownTrigger).toBe(dropdownTrigger); - }); - - it('sets .dropdownList', () => { - expect(commentTypeToggle.dropdownList).toBe(dropdownList); - }); - - it('sets .button', () => { - expect(commentTypeToggle.button).toBe(button); - }); - }); - - describe('initDroplab', () => { - let closeReopenReportToggle; - const dropdownList = { - querySelector: jest.fn(), - }; - const dropdownTrigger = {}; - const button = {}; - const reopenItem = {}; - const closeItem = {}; - const config = {}; - - beforeEach(() => { - jest.spyOn(DropLab.prototype, 'init').mockImplementation(() => {}); - dropdownList.querySelector.mockReturnValueOnce(reopenItem).mockReturnValueOnce(closeItem); - - closeReopenReportToggle = new CloseReopenReportToggle({ - dropdownTrigger, - dropdownList, - button, - }); - - jest.spyOn(closeReopenReportToggle, 'setConfig').mockReturnValue(config); - - closeReopenReportToggle.initDroplab(); - }); - - it('sets .reopenItem and .closeItem', () => { - expect(dropdownList.querySelector).toHaveBeenCalledWith('.reopen-item'); - expect(dropdownList.querySelector).toHaveBeenCalledWith('.close-item'); - expect(closeReopenReportToggle.reopenItem).toBe(reopenItem); - expect(closeReopenReportToggle.closeItem).toBe(closeItem); - }); - - it('sets .droplab', () => { - expect(closeReopenReportToggle.droplab).toEqual(expect.any(Object)); - }); - - it('calls .setConfig', () => { - expect(closeReopenReportToggle.setConfig).toHaveBeenCalled(); - }); - - it('calls droplab.init', () => { - expect(DropLab.prototype.init).toHaveBeenCalledWith( - dropdownTrigger, - dropdownList, - expect.any(Array), - config, - ); - }); - }); - - describe('updateButton', () => { - let closeReopenReportToggle; - const dropdownList = {}; - const dropdownTrigger = {}; - const button = { - blur: jest.fn(), - }; - const isClosed = true; - - beforeEach(() => { - closeReopenReportToggle = new CloseReopenReportToggle({ - dropdownTrigger, - dropdownList, - button, - }); - - jest.spyOn(closeReopenReportToggle, 'toggleButtonType').mockImplementation(() => {}); - - closeReopenReportToggle.updateButton(isClosed); - }); - - it('calls .toggleButtonType', () => { - expect(closeReopenReportToggle.toggleButtonType).toHaveBeenCalledWith(isClosed); - }); - - it('calls .button.blur', () => { - expect(closeReopenReportToggle.button.blur).toHaveBeenCalled(); - }); - }); - - describe('toggleButtonType', () => { - let closeReopenReportToggle; - const dropdownList = {}; - const dropdownTrigger = {}; - const button = {}; - const isClosed = true; - const showItem = { - click: jest.fn(), - }; - const hideItem = {}; - showItem.classList = { - add: jest.fn(), - remove: jest.fn(), - }; - hideItem.classList = { - add: jest.fn(), - remove: jest.fn(), - }; - - beforeEach(() => { - closeReopenReportToggle = new CloseReopenReportToggle({ - dropdownTrigger, - dropdownList, - button, - }); - - jest.spyOn(closeReopenReportToggle, 'getButtonTypes').mockReturnValue([showItem, hideItem]); - - closeReopenReportToggle.toggleButtonType(isClosed); - }); - - it('calls .getButtonTypes', () => { - expect(closeReopenReportToggle.getButtonTypes).toHaveBeenCalledWith(isClosed); - }); - - it('removes hide class and add selected class to showItem, opposite for hideItem', () => { - expect(showItem.classList.remove).toHaveBeenCalledWith('hidden'); - expect(showItem.classList.add).toHaveBeenCalledWith('droplab-item-selected'); - expect(hideItem.classList.add).toHaveBeenCalledWith('hidden'); - expect(hideItem.classList.remove).toHaveBeenCalledWith('droplab-item-selected'); - }); - - it('clicks the showItem', () => { - expect(showItem.click).toHaveBeenCalled(); - }); - }); - - describe('getButtonTypes', () => { - let closeReopenReportToggle; - const dropdownList = {}; - const dropdownTrigger = {}; - const button = {}; - const reopenItem = {}; - const closeItem = {}; - - beforeEach(() => { - closeReopenReportToggle = new CloseReopenReportToggle({ - dropdownTrigger, - dropdownList, - button, - }); - - closeReopenReportToggle.reopenItem = reopenItem; - closeReopenReportToggle.closeItem = closeItem; - }); - - it('returns reopenItem, closeItem if isClosed is true', () => { - const buttonTypes = closeReopenReportToggle.getButtonTypes(true); - - expect(buttonTypes).toEqual([reopenItem, closeItem]); - }); - - it('returns closeItem, reopenItem if isClosed is false', () => { - const buttonTypes = closeReopenReportToggle.getButtonTypes(false); - - expect(buttonTypes).toEqual([closeItem, reopenItem]); - }); - }); - - describe('setDisable', () => { - let closeReopenReportToggle; - const dropdownList = {}; - const dropdownTrigger = { - setAttribute: jest.fn(), - removeAttribute: jest.fn(), - }; - const button = { - setAttribute: jest.fn(), - removeAttribute: jest.fn(), - }; - - beforeEach(() => { - closeReopenReportToggle = new CloseReopenReportToggle({ - dropdownTrigger, - dropdownList, - button, - }); - }); - - it('disable .button and .dropdownTrigger if shouldDisable is true', () => { - closeReopenReportToggle.setDisable(true); - - expect(button.setAttribute).toHaveBeenCalledWith('disabled', 'true'); - expect(dropdownTrigger.setAttribute).toHaveBeenCalledWith('disabled', 'true'); - }); - - it('disable .button and .dropdownTrigger if shouldDisable is undefined', () => { - closeReopenReportToggle.setDisable(); - - expect(button.setAttribute).toHaveBeenCalledWith('disabled', 'true'); - expect(dropdownTrigger.setAttribute).toHaveBeenCalledWith('disabled', 'true'); - }); - - it('enable .button and .dropdownTrigger if shouldDisable is false', () => { - closeReopenReportToggle.setDisable(false); - - expect(button.removeAttribute).toHaveBeenCalledWith('disabled'); - expect(dropdownTrigger.removeAttribute).toHaveBeenCalledWith('disabled'); - }); - }); - - describe('setConfig', () => { - let closeReopenReportToggle; - const dropdownList = {}; - const dropdownTrigger = {}; - const button = {}; - let config; - - beforeEach(() => { - closeReopenReportToggle = new CloseReopenReportToggle({ - dropdownTrigger, - dropdownList, - button, - }); - - config = closeReopenReportToggle.setConfig(); - }); - - it('returns a config object', () => { - expect(config).toEqual({ - InputSetter: [ - { - input: button, - valueAttribute: 'data-text', - inputAttribute: 'data-value', - }, - { - input: button, - valueAttribute: 'data-text', - inputAttribute: 'title', - }, - { - input: button, - valueAttribute: 'data-button-class', - inputAttribute: 'class', - }, - { - input: dropdownTrigger, - valueAttribute: 'data-toggle-class', - inputAttribute: 'class', - }, - { - input: button, - valueAttribute: 'data-url', - inputAttribute: 'data-endpoint', - }, - ], - }); - }); - }); -}); diff --git a/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap index 744ef318260..c2ace1b4e30 100644 --- a/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap +++ b/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap @@ -55,7 +55,7 @@ exports[`Applications Crossplane application shows the correct description 1`] = `; exports[`Applications Ingress application shows the correct warning message 1`] = ` -<strong +<span data-testid="ingressCostWarning" > Installing Ingress may incur additional costs. Learn more about @@ -68,7 +68,7 @@ exports[`Applications Ingress application shows the correct warning message 1`] pricing </a> . -</strong> +</span> `; exports[`Applications Knative application shows the correct description 1`] = ` diff --git a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap index de40e03b598..6f28573c808 100644 --- a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap +++ b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap @@ -53,6 +53,7 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ type="button" > <svg + aria-hidden="true" class="gl-icon s16 gl-new-dropdown-item-check-icon" data-testid="mobile-issue-close-icon" > @@ -107,6 +108,7 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ type="button" > <svg + aria-hidden="true" class="gl-icon s16 gl-new-dropdown-item-check-icon gl-visibility-hidden" data-testid="mobile-issue-close-icon" > diff --git a/spec/frontend/clusters/stores/clusters_store_spec.js b/spec/frontend/clusters/stores/clusters_store_spec.js index ed862818c7b..381a4717127 100644 --- a/spec/frontend/clusters/stores/clusters_store_spec.js +++ b/spec/frontend/clusters/stores/clusters_store_spec.js @@ -50,6 +50,7 @@ describe('Clusters Store', () => { expect(store.state).toEqual({ helpPath: null, + helmHelpPath: null, ingressHelpPath: null, environmentsHelpPath: null, clustersHelpPath: null, @@ -62,7 +63,7 @@ describe('Clusters Store', () => { rbac: false, applications: { helm: { - title: 'Helm Tiller', + title: 'Legacy Helm Tiller server', status: mockResponseData.applications[0].status, statusReason: mockResponseData.applications[0].status_reason, requestReason: null, diff --git a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap index 62e527a2c5f..b59d1597a12 100644 --- a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap +++ b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap @@ -17,6 +17,7 @@ exports[`Code navigation popover component renders popover 1`] = ` > <gl-tab-stub title="Definition" + titlelinkclass="" > <div class="overflow-auto code-navigation-popover-container" @@ -76,6 +77,7 @@ exports[`Code navigation popover component renders popover 1`] = ` <gl-tab-stub class="py-2" data-testid="references-tab" + titlelinkclass="" > <p diff --git a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js index f12f300872a..f14a555f357 100644 --- a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js @@ -186,7 +186,7 @@ describe('EKS Cluster Store Actions', () => { role_external_id: payload.externalId, region: DEFAULT_REGION, }) - .reply(400, error); + .reply(400, null); }); it('dispatches createRoleError action', () => @@ -198,6 +198,32 @@ describe('EKS Cluster Store Actions', () => { [{ type: 'requestCreateRole' }, { type: 'createRoleError', payload: { error } }], )); }); + + describe('when request fails with a message', () => { + beforeEach(() => { + const errResp = { message: 'Something failed' }; + + mock + .onPost(state.createRolePath, { + role_arn: payload.roleArn, + role_external_id: payload.externalId, + region: DEFAULT_REGION, + }) + .reply(4, errResp); + }); + + it('dispatches createRoleError action', () => + testAction( + actions.createRole, + payload, + state, + [], + [ + { type: 'requestCreateRole' }, + { type: 'createRoleError', payload: { error: 'Something failed' } }, + ], + )); + }); }); describe('requestCreateRole', () => { diff --git a/spec/frontend/cycle_analytics/banner_spec.js b/spec/frontend/cycle_analytics/banner_spec.js index f0b8cb18a90..0cae0298cee 100644 --- a/spec/frontend/cycle_analytics/banner_spec.js +++ b/spec/frontend/cycle_analytics/banner_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; import banner from '~/cycle_analytics/components/banner.vue'; -describe('Cycle analytics banner', () => { +describe('Value Stream Analytics banner', () => { let vm; beforeEach(() => { diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap index 1e94e90c3b0..8c6b446794f 100644 --- a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap +++ b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap @@ -2,7 +2,7 @@ exports[`Design management design version dropdown component renders design version dropdown button 1`] = ` <gl-dropdown-stub - category="tertiary" + category="primary" headertext="" issueiid="" projectpath="" @@ -14,6 +14,7 @@ exports[`Design management design version dropdown component renders design vers avatarurl="" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischecked="true" ischeckitem="true" @@ -27,6 +28,7 @@ exports[`Design management design version dropdown component renders design vers avatarurl="" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischeckitem="true" secondarytext="" @@ -40,7 +42,7 @@ exports[`Design management design version dropdown component renders design vers exports[`Design management design version dropdown component renders design version list 1`] = ` <gl-dropdown-stub - category="tertiary" + category="primary" headertext="" issueiid="" projectpath="" @@ -52,6 +54,7 @@ exports[`Design management design version dropdown component renders design vers avatarurl="" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischecked="true" ischeckitem="true" @@ -65,6 +68,7 @@ exports[`Design management design version dropdown component renders design vers avatarurl="" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischeckitem="true" secondarytext="" diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js index 88892bb1878..9c11af28cf0 100644 --- a/spec/frontend/design_management/pages/design/index_spec.js +++ b/spec/frontend/design_management/pages/design/index_spec.js @@ -2,7 +2,9 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueRouter from 'vue-router'; import { GlAlert } from '@gitlab/ui'; import { ApolloMutation } from 'vue-apollo'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import createFlash from '~/flash'; +import Api from '~/api'; import DesignIndex from '~/design_management/pages/design/index.vue'; import DesignSidebar from '~/design_management/components/design_sidebar.vue'; import DesignPresentation from '~/design_management/components/design_presentation.vue'; @@ -20,8 +22,14 @@ import design from '../../mock_data/design'; import mockResponseWithDesigns from '../../mock_data/designs'; import mockResponseNoDesigns from '../../mock_data/no_designs'; import mockAllVersions from '../../mock_data/all_versions'; +import { + DESIGN_TRACKING_PAGE_NAME, + DESIGN_SNOWPLOW_EVENT_TYPES, + DESIGN_USAGE_PING_EVENT_TYPES, +} from '~/design_management/utils/tracking'; jest.mock('~/flash'); +jest.mock('~/api.js'); const focusInput = jest.fn(); const mutate = jest.fn().mockResolvedValue(); @@ -81,7 +89,10 @@ describe('Design management design index page', () => { const findSidebar = () => wrapper.find(DesignSidebar); const findDesignPresentation = () => wrapper.find(DesignPresentation); - function createComponent({ loading = false } = {}, { data = {}, intialRouteOptions = {} } = {}) { + function createComponent( + { loading = false } = {}, + { data = {}, intialRouteOptions = {}, provide = {} } = {}, + ) { const $apollo = { queries: { design: { @@ -106,6 +117,7 @@ describe('Design management design index page', () => { provide: { issueIid: '1', projectPath: 'project-path', + ...provide, }, data() { return { @@ -343,4 +355,64 @@ describe('Design management design index page', () => { }); }); }); + + describe('tracking', () => { + let trackingSpy; + + beforeEach(() => { + trackingSpy = mockTracking('_category_', undefined, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + describe('on mount', () => { + it('tracks design view in snowplow', () => { + createComponent({ loading: true }); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith( + DESIGN_TRACKING_PAGE_NAME, + DESIGN_SNOWPLOW_EVENT_TYPES.VIEW_DESIGN, + { + context: { + data: { + 'design-collection-owner': 'issue', + 'design-is-current-version': true, + 'design-version-number': 1, + 'internal-object-referrer': 'issue-design-collection', + }, + schema: 'iglu:com.gitlab/design_management_context/jsonschema/1-0-0', + }, + label: DESIGN_SNOWPLOW_EVENT_TYPES.VIEW_DESIGN, + }, + ); + }); + + describe('with usage_data_design_action enabled', () => { + it('tracks design view usage ping', () => { + createComponent( + { loading: true }, + { + provide: { + glFeatures: { usageDataDesignAction: true }, + }, + }, + ); + expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); + expect(Api.trackRedisHllUserEvent).toHaveBeenCalledWith( + DESIGN_USAGE_PING_EVENT_TYPES.DESIGN_ACTION, + ); + }); + }); + + describe('with usage_data_design_action disabled', () => { + it("doesn't track design view usage ping", () => { + createComponent({ loading: true }); + expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(0); + }); + }); + }); + }); }); diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index 05238efd761..147169dd9aa 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -31,7 +31,10 @@ import { moveDesignMutationResponseWithErrors, } from '../mock_data/apollo_mock'; import moveDesignMutation from '~/design_management/graphql/mutations/move_design.mutation.graphql'; -import { DESIGN_TRACKING_PAGE_NAME } from '~/design_management/utils/tracking'; +import { + DESIGN_TRACKING_PAGE_NAME, + DESIGN_SNOWPLOW_EVENT_TYPES, +} from '~/design_management/utils/tracking'; jest.mock('~/flash.js'); const mockPageEl = { @@ -509,14 +512,20 @@ describe('Design management index page', () => { wrapper.vm.onUploadDesignDone(designUploadMutationCreatedResponse); expect(trackingSpy).toHaveBeenCalledTimes(1); - expect(trackingSpy).toHaveBeenCalledWith(DESIGN_TRACKING_PAGE_NAME, 'create_design'); + expect(trackingSpy).toHaveBeenCalledWith( + DESIGN_TRACKING_PAGE_NAME, + DESIGN_SNOWPLOW_EVENT_TYPES.CREATE_DESIGN, + ); }); it('tracks design modification', () => { wrapper.vm.onUploadDesignDone(designUploadMutationUpdatedResponse); expect(trackingSpy).toHaveBeenCalledTimes(1); - expect(trackingSpy).toHaveBeenCalledWith(DESIGN_TRACKING_PAGE_NAME, 'update_design'); + expect(trackingSpy).toHaveBeenCalledWith( + DESIGN_TRACKING_PAGE_NAME, + DESIGN_SNOWPLOW_EVENT_TYPES.UPDATE_DESIGN, + ); }); }); }); diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index 225710eab63..416564b72c3 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -12,16 +12,19 @@ import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue'; import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue'; import CommitWidget from '~/diffs/components/commit_widget.vue'; import TreeList from '~/diffs/components/tree_list.vue'; -import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '~/diffs/constants'; import createDiffsStore from '../create_diffs_store'; import axios from '~/lib/utils/axios_utils'; import * as urlUtils from '~/lib/utils/url_utility'; import diffsMockData from '../mock_data/merge_request_diffs'; +import { EVT_VIEW_FILE_BY_FILE } from '~/diffs/constants'; + +import eventHub from '~/diffs/event_hub'; + const mergeRequestDiff = { version_index: 1 }; const TEST_ENDPOINT = `${TEST_HOST}/diff/endpoint`; -const COMMIT_URL = '[BASE URL]/OLD'; -const UPDATED_COMMIT_URL = '[BASE URL]/NEW'; +const COMMIT_URL = `${TEST_HOST}/COMMIT/OLD`; +const UPDATED_COMMIT_URL = `${TEST_HOST}/COMMIT/NEW`; function getCollapsedFilesWarning(wrapper) { return wrapper.find(CollapsedFilesWarning); @@ -62,7 +65,7 @@ describe('diffs/components/app', () => { changesEmptyStateIllustration: '', dismissEndpoint: '', showSuggestPopover: true, - viewDiffsFileByFile: false, + fileByFileUserPreference: false, ...props, }, provide, @@ -75,12 +78,6 @@ describe('diffs/components/app', () => { }); } - function getOppositeViewType(currentViewType) { - return currentViewType === INLINE_DIFF_VIEW_TYPE - ? PARALLEL_DIFF_VIEW_TYPE - : INLINE_DIFF_VIEW_TYPE; - } - beforeEach(() => { // setup globals (needed for component to mount :/) window.mrTabs = { @@ -125,104 +122,6 @@ describe('diffs/components/app', () => { wrapper.vm.$nextTick(done); }); - describe('when the diff view type changes and it should load a single diff view style', () => { - const noLinesDiff = { - highlighted_diff_lines: [], - parallel_diff_lines: [], - }; - const parallelLinesDiff = { - highlighted_diff_lines: [], - parallel_diff_lines: ['line'], - }; - const inlineLinesDiff = { - highlighted_diff_lines: ['line'], - parallel_diff_lines: [], - }; - const fullDiff = { - highlighted_diff_lines: ['line'], - parallel_diff_lines: ['line'], - }; - - function expectFetchToOccur({ vueInstance, done = () => {}, existingFiles = 1 } = {}) { - vueInstance.$nextTick(() => { - expect(vueInstance.diffFiles.length).toEqual(existingFiles); - expect(vueInstance.fetchDiffFilesBatch).toHaveBeenCalled(); - - done(); - }); - } - - it('fetches diffs if it has none', done => { - wrapper.vm.isLatestVersion = () => false; - - store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); - - expectFetchToOccur({ vueInstance: wrapper.vm, existingFiles: 0, done }); - }); - - it('fetches diffs if it has both view styles, but no lines in either', done => { - wrapper.vm.isLatestVersion = () => false; - - store.state.diffs.diffFiles.push(noLinesDiff); - store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); - - expectFetchToOccur({ vueInstance: wrapper.vm, done }); - }); - - it('fetches diffs if it only has inline view style', done => { - wrapper.vm.isLatestVersion = () => false; - - store.state.diffs.diffFiles.push(inlineLinesDiff); - store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); - - expectFetchToOccur({ vueInstance: wrapper.vm, done }); - }); - - it('fetches diffs if it only has parallel view style', done => { - wrapper.vm.isLatestVersion = () => false; - - store.state.diffs.diffFiles.push(parallelLinesDiff); - store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); - - expectFetchToOccur({ vueInstance: wrapper.vm, done }); - }); - - it('fetches batch diffs if it has none', done => { - store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); - - expectFetchToOccur({ vueInstance: wrapper.vm, existingFiles: 0, done }); - }); - - it('fetches batch diffs if it has both view styles, but no lines in either', done => { - store.state.diffs.diffFiles.push(noLinesDiff); - store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); - - expectFetchToOccur({ vueInstance: wrapper.vm, done }); - }); - - it('fetches batch diffs if it only has inline view style', done => { - store.state.diffs.diffFiles.push(inlineLinesDiff); - store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); - - expectFetchToOccur({ vueInstance: wrapper.vm, done }); - }); - - it('fetches batch diffs if it only has parallel view style', done => { - store.state.diffs.diffFiles.push(parallelLinesDiff); - store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); - - expectFetchToOccur({ vueInstance: wrapper.vm, done }); - }); - - it('does not fetch batch diffs if it has already fetched both styles of diff', () => { - store.state.diffs.diffFiles.push(fullDiff); - store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); - - expect(wrapper.vm.diffFiles.length).toEqual(1); - expect(wrapper.vm.fetchDiffFilesBatch).not.toHaveBeenCalled(); - }); - }); - it('calls batch methods if diffsBatchLoad is enabled, and not latest version', done => { expect(wrapper.vm.diffFilesLength).toEqual(0); wrapper.vm.isLatestVersion = () => false; @@ -743,70 +642,76 @@ describe('diffs/components/app', () => { }); }); - describe('hideTreeListIfJustOneFile', () => { - let toggleShowTreeList; + describe('setTreeDisplay', () => { + let setShowTreeList; beforeEach(() => { - toggleShowTreeList = jest.fn(); + setShowTreeList = jest.fn(); }); afterEach(() => { localStorage.removeItem('mr_tree_show'); }); - it('calls toggleShowTreeList when only 1 file', () => { + it('calls setShowTreeList when only 1 file', () => { createComponent({}, ({ state }) => { state.diffs.diffFiles.push({ sha: '123' }); }); wrapper.setMethods({ - toggleShowTreeList, + setShowTreeList, }); - wrapper.vm.hideTreeListIfJustOneFile(); + wrapper.vm.setTreeDisplay(); - expect(toggleShowTreeList).toHaveBeenCalledWith(false); + expect(setShowTreeList).toHaveBeenCalledWith({ showTreeList: false, saving: false }); }); - it('does not call toggleShowTreeList when more than 1 file', () => { + it('calls setShowTreeList with true when more than 1 file is in diffs array', () => { createComponent({}, ({ state }) => { state.diffs.diffFiles.push({ sha: '123' }); state.diffs.diffFiles.push({ sha: '124' }); }); wrapper.setMethods({ - toggleShowTreeList, + setShowTreeList, }); - wrapper.vm.hideTreeListIfJustOneFile(); + wrapper.vm.setTreeDisplay(); - expect(toggleShowTreeList).not.toHaveBeenCalled(); + expect(setShowTreeList).toHaveBeenCalledWith({ showTreeList: true, saving: false }); }); - it('does not call toggleShowTreeList when localStorage is set', () => { - localStorage.setItem('mr_tree_show', 'true'); + it.each` + showTreeList + ${true} + ${false} + `('calls setShowTreeList with localstorage $showTreeList', ({ showTreeList }) => { + localStorage.setItem('mr_tree_show', showTreeList); createComponent({}, ({ state }) => { state.diffs.diffFiles.push({ sha: '123' }); }); wrapper.setMethods({ - toggleShowTreeList, + setShowTreeList, }); - wrapper.vm.hideTreeListIfJustOneFile(); + wrapper.vm.setTreeDisplay(); - expect(toggleShowTreeList).not.toHaveBeenCalled(); + expect(setShowTreeList).toHaveBeenCalledWith({ showTreeList, saving: false }); }); }); describe('file-by-file', () => { - it('renders a single diff', () => { - createComponent({ viewDiffsFileByFile: true }, ({ state }) => { + it('renders a single diff', async () => { + createComponent({ fileByFileUserPreference: true }, ({ state }) => { state.diffs.diffFiles.push({ file_hash: '123' }); state.diffs.diffFiles.push({ file_hash: '312' }); }); + await wrapper.vm.$nextTick(); + expect(wrapper.findAll(DiffFile).length).toBe(1); }); @@ -814,31 +719,37 @@ describe('diffs/components/app', () => { const fileByFileNav = () => wrapper.find('[data-testid="file-by-file-navigation"]'); const paginator = () => fileByFileNav().find(GlPagination); - it('sets previous button as disabled', () => { - createComponent({ viewDiffsFileByFile: true }, ({ state }) => { + it('sets previous button as disabled', async () => { + createComponent({ fileByFileUserPreference: true }, ({ state }) => { state.diffs.diffFiles.push({ file_hash: '123' }, { file_hash: '312' }); }); + await wrapper.vm.$nextTick(); + expect(paginator().attributes('prevpage')).toBe(undefined); expect(paginator().attributes('nextpage')).toBe('2'); }); - it('sets next button as disabled', () => { - createComponent({ viewDiffsFileByFile: true }, ({ state }) => { + it('sets next button as disabled', async () => { + createComponent({ fileByFileUserPreference: true }, ({ state }) => { state.diffs.diffFiles.push({ file_hash: '123' }, { file_hash: '312' }); state.diffs.currentDiffFileId = '312'; }); + await wrapper.vm.$nextTick(); + expect(paginator().attributes('prevpage')).toBe('1'); expect(paginator().attributes('nextpage')).toBe(undefined); }); - it("doesn't display when there's fewer than 2 files", () => { - createComponent({ viewDiffsFileByFile: true }, ({ state }) => { + it("doesn't display when there's fewer than 2 files", async () => { + createComponent({ fileByFileUserPreference: true }, ({ state }) => { state.diffs.diffFiles.push({ file_hash: '123' }); state.diffs.currentDiffFileId = '123'; }); + await wrapper.vm.$nextTick(); + expect(fileByFileNav().exists()).toBe(false); }); @@ -849,11 +760,13 @@ describe('diffs/components/app', () => { `( 'it calls navigateToDiffFileIndex with $index when $link is clicked', async ({ currentDiffFileId, targetFile }) => { - createComponent({ viewDiffsFileByFile: true }, ({ state }) => { + createComponent({ fileByFileUserPreference: true }, ({ state }) => { state.diffs.diffFiles.push({ file_hash: '123' }, { file_hash: '312' }); state.diffs.currentDiffFileId = currentDiffFileId; }); + await wrapper.vm.$nextTick(); + jest.spyOn(wrapper.vm, 'navigateToDiffFileIndex'); paginator().vm.$emit('input', targetFile); @@ -864,5 +777,24 @@ describe('diffs/components/app', () => { }, ); }); + + describe('control via event stream', () => { + it.each` + setting + ${true} + ${false} + `( + 'triggers the action with the new fileByFile setting - $setting - when the event with that setting is received', + async ({ setting }) => { + createComponent(); + await wrapper.vm.$nextTick(); + + eventHub.$emit(EVT_VIEW_FILE_BY_FILE, { setting }); + await wrapper.vm.$nextTick(); + + expect(store.state.diffs.viewDiffsFileByFile).toBe(setting); + }, + ); + }); }); }); diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js index 9e4fcddd1b4..8a7eb6aaca6 100644 --- a/spec/frontend/diffs/components/commit_item_spec.js +++ b/spec/frontend/diffs/components/commit_item_spec.js @@ -84,7 +84,7 @@ describe('diffs/components/commit_item', () => { it('renders commit sha', () => { const shaElement = getShaElement(); - const labelElement = shaElement.find('[data-testid="commit-sha-group"] button'); + const labelElement = shaElement.find('[data-testid="commit-sha-short-id"]'); const buttonElement = shaElement.find('button.input-group-text'); expect(labelElement.text()).toEqual(commit.short_id); diff --git a/spec/frontend/diffs/components/compare_dropdown_layout_spec.js b/spec/frontend/diffs/components/compare_dropdown_layout_spec.js index a163a43daf1..92e4a2d9c62 100644 --- a/spec/frontend/diffs/components/compare_dropdown_layout_spec.js +++ b/spec/frontend/diffs/components/compare_dropdown_layout_spec.js @@ -1,4 +1,4 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import { trimText } from 'helpers/text_helper'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import CompareDropdownLayout from '~/diffs/components/compare_dropdown_layout.vue'; @@ -22,7 +22,7 @@ describe('CompareDropdownLayout', () => { }); const createComponent = (propsData = {}) => { - wrapper = shallowMount(CompareDropdownLayout, { + wrapper = mount(CompareDropdownLayout, { propsData: { ...propsData, }, @@ -35,7 +35,7 @@ describe('CompareDropdownLayout', () => { href: listItem.find('a').attributes('href'), text: trimText(listItem.text()), createdAt: listItem.findAll(TimeAgo).wrappers[0]?.props('time'), - isActive: listItem.find('a.is-active').exists(), + isActive: listItem.classes().includes('is-active'), })); afterEach(() => { @@ -69,7 +69,7 @@ describe('CompareDropdownLayout', () => { expect(findListItemsData()).toEqual([ { href: 'version/1', - text: 'version 1 (base) abcdef1 1 commit', + text: 'version 1 (base) abcdef1 1 commit 2 years ago', createdAt: TEST_CREATED_AT, isActive: true, }, diff --git a/spec/frontend/diffs/components/compare_versions_spec.js b/spec/frontend/diffs/components/compare_versions_spec.js index b3dfc71260c..09e9669c474 100644 --- a/spec/frontend/diffs/components/compare_versions_spec.js +++ b/spec/frontend/diffs/components/compare_versions_spec.js @@ -59,8 +59,8 @@ describe('CompareVersions', () => { expect(sourceDropdown.exists()).toBe(true); expect(targetDropdown.exists()).toBe(true); - expect(sourceDropdown.find('a span').html()).toContain('latest version'); - expect(targetDropdown.find('a span').html()).toContain(targetBranchName); + expect(sourceDropdown.find('a p').html()).toContain('latest version'); + expect(targetDropdown.find('button').html()).toContain(targetBranchName); }); it('should not render comparison dropdowns if no mergeRequestDiffs are specified', () => { diff --git a/spec/frontend/diffs/components/diff_content_spec.js b/spec/frontend/diffs/components/diff_content_spec.js index e3a6f7f16a9..43d295ff1b3 100644 --- a/spec/frontend/diffs/components/diff_content_spec.js +++ b/spec/frontend/diffs/components/diff_content_spec.js @@ -6,13 +6,11 @@ import InlineDiffView from '~/diffs/components/inline_diff_view.vue'; import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_diffable.vue'; import NoPreviewViewer from '~/vue_shared/components/diff_viewer/viewers/no_preview.vue'; import ParallelDiffView from '~/diffs/components/parallel_diff_view.vue'; -import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; import NoteForm from '~/notes/components/note_form.vue'; import DiffDiscussions from '~/diffs/components/diff_discussions.vue'; import { IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants'; import diffFileMockData from '../mock_data/diff_file'; import { diffViewerModes } from '~/ide/constants'; -import { diffLines } from '~/diffs/store/getters'; import DiffView from '~/diffs/components/diff_view.vue'; const localVue = createLocalVue(); @@ -74,7 +72,7 @@ describe('DiffContent', () => { isInlineView: isInlineViewGetterMock, isParallelView: isParallelViewGetterMock, getCommentFormForDiffFile: getCommentFormForDiffFileGetterMock, - diffLines, + diffLines: () => () => [...diffFileMockData.parallel_diff_lines], }, actions: { saveDiffDiscussion: saveDiffDiscussionMock, @@ -122,11 +120,11 @@ describe('DiffContent', () => { expect(wrapper.find(ParallelDiffView).exists()).toBe(true); }); - it('should render diff view if `unifiedDiffLines` & `unifiedDiffComponents` are true', () => { + it('should render diff view if `unifiedDiffComponents` are true', () => { isParallelViewGetterMock.mockReturnValue(true); createComponent({ props: { diffFile: textDiffFile }, - provide: { glFeatures: { unifiedDiffLines: true, unifiedDiffComponents: true } }, + provide: { glFeatures: { unifiedDiffComponents: true } }, }); expect(wrapper.find(DiffView).exists()).toBe(true); @@ -167,14 +165,6 @@ describe('DiffContent', () => { describe('with image files', () => { const imageDiffFile = { ...defaultProps.diffFile, viewer: { name: diffViewerModes.image } }; - it('should have image diff view in place', () => { - getCommentFormForDiffFileGetterMock.mockReturnValue(() => true); - createComponent({ props: { diffFile: imageDiffFile } }); - - expect(wrapper.find(InlineDiffView).exists()).toBe(false); - expect(wrapper.find(ImageDiffOverlay).exists()).toBe(true); - }); - it('renders diff file discussions', () => { getCommentFormForDiffFileGetterMock.mockReturnValue(() => true); createComponent({ diff --git a/spec/frontend/diffs/components/diff_expansion_cell_spec.js b/spec/frontend/diffs/components/diff_expansion_cell_spec.js index 81e08f09f62..a3b4b5c3abb 100644 --- a/spec/frontend/diffs/components/diff_expansion_cell_spec.js +++ b/spec/frontend/diffs/components/diff_expansion_cell_spec.js @@ -5,18 +5,16 @@ import { getByText } from '@testing-library/dom'; import { createStore } from '~/mr_notes/stores'; import DiffExpansionCell from '~/diffs/components/diff_expansion_cell.vue'; import { getPreviousLineIndex } from '~/diffs/store/utils'; -import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '~/diffs/constants'; +import { INLINE_DIFF_VIEW_TYPE } from '~/diffs/constants'; import diffFileMockData from '../mock_data/diff_file'; const EXPAND_UP_CLASS = '.js-unfold'; const EXPAND_DOWN_CLASS = '.js-unfold-down'; const lineSources = { [INLINE_DIFF_VIEW_TYPE]: 'highlighted_diff_lines', - [PARALLEL_DIFF_VIEW_TYPE]: 'parallel_diff_lines', }; const lineHandlers = { [INLINE_DIFF_VIEW_TYPE]: line => line, - [PARALLEL_DIFF_VIEW_TYPE]: line => line.right || line.left, }; function makeLoadMoreLinesPayload({ @@ -126,7 +124,6 @@ describe('DiffExpansionCell', () => { describe('any row', () => { [ { diffViewType: INLINE_DIFF_VIEW_TYPE, lineIndex: 8, file: { parallel_diff_lines: [] } }, - { diffViewType: PARALLEL_DIFF_VIEW_TYPE, lineIndex: 7, file: { highlighted_diff_lines: [] } }, ].forEach(({ diffViewType, file, lineIndex }) => { describe(`with diffViewType (${diffViewType})`, () => { beforeEach(() => { diff --git a/spec/frontend/diffs/components/diff_file_row_spec.js b/spec/frontend/diffs/components/diff_file_row_spec.js index 23adc8f9da4..7403a7918a9 100644 --- a/spec/frontend/diffs/components/diff_file_row_spec.js +++ b/spec/frontend/diffs/components/diff_file_row_spec.js @@ -7,12 +7,9 @@ import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; describe('Diff File Row component', () => { let wrapper; - const createComponent = (props = {}, highlightCurrentDiffRow = false) => { + const createComponent = (props = {}) => { wrapper = shallowMount(DiffFileRow, { propsData: { ...props }, - provide: { - glFeatures: { highlightCurrentDiffRow }, - }, }); }; @@ -60,26 +57,23 @@ describe('Diff File Row component', () => { }); it.each` - features | fileType | isViewed | expected - ${{ highlightCurrentDiffRow: true }} | ${'blob'} | ${false} | ${'gl-font-weight-bold'} - ${{}} | ${'blob'} | ${true} | ${''} - ${{}} | ${'tree'} | ${false} | ${''} - ${{}} | ${'tree'} | ${true} | ${''} + fileType | isViewed | expected + ${'blob'} | ${false} | ${'gl-font-weight-bold'} + ${'blob'} | ${true} | ${''} + ${'tree'} | ${false} | ${''} + ${'tree'} | ${true} | ${''} `( - 'with (features="$features", fileType="$fileType", isViewed=$isViewed), sets fileClasses="$expected"', - ({ features, fileType, isViewed, expected }) => { - createComponent( - { - file: { - type: fileType, - fileHash: '#123456789', - }, - level: 0, - hideFileStats: false, - viewedFiles: isViewed ? { '#123456789': true } : {}, + 'with (fileType="$fileType", isViewed=$isViewed), sets fileClasses="$expected"', + ({ fileType, isViewed, expected }) => { + createComponent({ + file: { + type: fileType, + fileHash: '#123456789', }, - features.highlightCurrentDiffRow, - ); + level: 0, + hideFileStats: false, + viewedFiles: isViewed ? { '#123456789': true } : {}, + }); expect(wrapper.find(FileRow).props('fileClasses')).toBe(expected); }, ); diff --git a/spec/frontend/diffs/components/diff_row_spec.js b/spec/frontend/diffs/components/diff_row_spec.js index f9e76cf8107..0ec075c8ad8 100644 --- a/spec/frontend/diffs/components/diff_row_spec.js +++ b/spec/frontend/diffs/components/diff_row_spec.js @@ -97,18 +97,18 @@ describe('DiffRow', () => { ${'right'} `('$side side', ({ side }) => { it(`renders empty cells if ${side} is unavailable`, () => { - const wrapper = createWrapper({ props: { line: testLines[2] } }); + const wrapper = createWrapper({ props: { line: testLines[2], inline: false } }); expect(wrapper.find(`[data-testid="${side}LineNumber"]`).exists()).toBe(false); expect(wrapper.find(`[data-testid="${side}EmptyCell"]`).exists()).toBe(true); }); it('renders comment button', () => { - const wrapper = createWrapper({ props: { line: testLines[3] } }); + const wrapper = createWrapper({ props: { line: testLines[3], inline: false } }); expect(wrapper.find(`[data-testid="${side}CommentButton"]`).exists()).toBe(true); }); it('renders avatars', () => { - const wrapper = createWrapper({ props: { line: testLines[0] } }); + const wrapper = createWrapper({ props: { line: testLines[0], inline: false } }); expect(wrapper.find(`[data-testid="${side}Discussions"]`).exists()).toBe(true); }); }); diff --git a/spec/frontend/diffs/components/image_diff_overlay_spec.js b/spec/frontend/diffs/components/image_diff_overlay_spec.js index 5a88a3cabd1..087715111b4 100644 --- a/spec/frontend/diffs/components/image_diff_overlay_spec.js +++ b/spec/frontend/diffs/components/image_diff_overlay_spec.js @@ -24,6 +24,8 @@ describe('Diffs image diff overlay component', () => { propsData: { discussions: [...imageDiffDiscussions], fileHash: 'ABC', + renderedWidth: 200, + renderedHeight: 200, ...props, }, methods: { @@ -71,8 +73,8 @@ describe('Diffs image diff overlay component', () => { createComponent(); const imageBadges = getAllImageBadges(); - expect(imageBadges.at(0).attributes('style')).toBe('left: 10px; top: 10px;'); - expect(imageBadges.at(1).attributes('style')).toBe('left: 5px; top: 5px;'); + expect(imageBadges.at(0).attributes('style')).toBe('left: 10%; top: 5%;'); + expect(imageBadges.at(1).attributes('style')).toBe('left: 5%; top: 2.5%;'); }); it('renders single badge for discussion object', () => { @@ -95,6 +97,8 @@ describe('Diffs image diff overlay component', () => { y: 0, width: 100, height: 200, + xPercent: 0, + yPercent: 0, }); }); @@ -120,11 +124,13 @@ describe('Diffs image diff overlay component', () => { describe('comment form', () => { const getCommentIndicator = () => wrapper.find('.comment-indicator'); beforeEach(() => { - createComponent({}, store => { + createComponent({ canComment: true }, store => { store.state.diffs.commentForms.push({ fileHash: 'ABC', x: 20, y: 10, + xPercent: 10, + yPercent: 10, }); }); }); @@ -134,7 +140,7 @@ describe('Diffs image diff overlay component', () => { }); it('sets comment form badge position', () => { - expect(getCommentIndicator().attributes('style')).toBe('left: 20px; top: 10px;'); + expect(getCommentIndicator().attributes('style')).toBe('left: 10%; top: 10%;'); }); }); }); diff --git a/spec/frontend/diffs/components/settings_dropdown_spec.js b/spec/frontend/diffs/components/settings_dropdown_spec.js index 72330d8efba..eb9f9b4db73 100644 --- a/spec/frontend/diffs/components/settings_dropdown_spec.js +++ b/spec/frontend/diffs/components/settings_dropdown_spec.js @@ -2,12 +2,18 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import diffModule from '~/diffs/store/modules'; import SettingsDropdown from '~/diffs/components/settings_dropdown.vue'; -import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '~/diffs/constants'; +import { + EVT_VIEW_FILE_BY_FILE, + PARALLEL_DIFF_VIEW_TYPE, + INLINE_DIFF_VIEW_TYPE, +} from '~/diffs/constants'; +import eventHub from '~/diffs/event_hub'; const localVue = createLocalVue(); localVue.use(Vuex); describe('Diff settings dropdown component', () => { + let wrapper; let vm; let actions; @@ -25,10 +31,15 @@ describe('Diff settings dropdown component', () => { extendStore(store); - vm = mount(SettingsDropdown, { + wrapper = mount(SettingsDropdown, { localVue, store, }); + vm = wrapper.vm; + } + + function getFileByFileCheckbox(vueWrapper) { + return vueWrapper.find('[data-testid="file-by-file"]'); } beforeEach(() => { @@ -41,14 +52,14 @@ describe('Diff settings dropdown component', () => { }); afterEach(() => { - vm.destroy(); + wrapper.destroy(); }); describe('tree view buttons', () => { it('list view button dispatches setRenderTreeList with false', () => { createComponent(); - vm.find('.js-list-view').trigger('click'); + wrapper.find('.js-list-view').trigger('click'); expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), false); }); @@ -56,7 +67,7 @@ describe('Diff settings dropdown component', () => { it('tree view button dispatches setRenderTreeList with true', () => { createComponent(); - vm.find('.js-tree-view').trigger('click'); + wrapper.find('.js-tree-view').trigger('click'); expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), true); }); @@ -68,8 +79,8 @@ describe('Diff settings dropdown component', () => { }); }); - expect(vm.find('.js-list-view').classes('selected')).toBe(true); - expect(vm.find('.js-tree-view').classes('selected')).toBe(false); + expect(wrapper.find('.js-list-view').classes('selected')).toBe(true); + expect(wrapper.find('.js-tree-view').classes('selected')).toBe(false); }); it('sets tree button as selected when renderTreeList is true', () => { @@ -79,8 +90,8 @@ describe('Diff settings dropdown component', () => { }); }); - expect(vm.find('.js-list-view').classes('selected')).toBe(false); - expect(vm.find('.js-tree-view').classes('selected')).toBe(true); + expect(wrapper.find('.js-list-view').classes('selected')).toBe(false); + expect(wrapper.find('.js-tree-view').classes('selected')).toBe(true); }); }); @@ -92,8 +103,8 @@ describe('Diff settings dropdown component', () => { }); }); - expect(vm.find('.js-inline-diff-button').classes('selected')).toBe(true); - expect(vm.find('.js-parallel-diff-button').classes('selected')).toBe(false); + expect(wrapper.find('.js-inline-diff-button').classes('selected')).toBe(true); + expect(wrapper.find('.js-parallel-diff-button').classes('selected')).toBe(false); }); it('sets parallel button as selected', () => { @@ -103,14 +114,14 @@ describe('Diff settings dropdown component', () => { }); }); - expect(vm.find('.js-inline-diff-button').classes('selected')).toBe(false); - expect(vm.find('.js-parallel-diff-button').classes('selected')).toBe(true); + expect(wrapper.find('.js-inline-diff-button').classes('selected')).toBe(false); + expect(wrapper.find('.js-parallel-diff-button').classes('selected')).toBe(true); }); it('calls setInlineDiffViewType when clicking inline button', () => { createComponent(); - vm.find('.js-inline-diff-button').trigger('click'); + wrapper.find('.js-inline-diff-button').trigger('click'); expect(actions.setInlineDiffViewType).toHaveBeenCalled(); }); @@ -118,7 +129,7 @@ describe('Diff settings dropdown component', () => { it('calls setParallelDiffViewType when clicking parallel button', () => { createComponent(); - vm.find('.js-parallel-diff-button').trigger('click'); + wrapper.find('.js-parallel-diff-button').trigger('click'); expect(actions.setParallelDiffViewType).toHaveBeenCalled(); }); @@ -132,7 +143,7 @@ describe('Diff settings dropdown component', () => { }); }); - expect(vm.find('#show-whitespace').element.checked).toBe(false); + expect(wrapper.find('#show-whitespace').element.checked).toBe(false); }); it('sets as checked when showWhitespace is true', () => { @@ -142,13 +153,13 @@ describe('Diff settings dropdown component', () => { }); }); - expect(vm.find('#show-whitespace').element.checked).toBe(true); + expect(wrapper.find('#show-whitespace').element.checked).toBe(true); }); it('calls setShowWhitespace on change', () => { createComponent(); - const checkbox = vm.find('#show-whitespace'); + const checkbox = wrapper.find('#show-whitespace'); checkbox.element.checked = true; checkbox.trigger('change'); @@ -159,4 +170,52 @@ describe('Diff settings dropdown component', () => { }); }); }); + + describe('file-by-file toggle', () => { + beforeEach(() => { + jest.spyOn(eventHub, '$emit'); + }); + + it.each` + fileByFile | checked + ${true} | ${true} + ${false} | ${false} + `( + 'sets the checkbox to { checked: $checked } if the fileByFile setting is $fileByFile', + async ({ fileByFile, checked }) => { + createComponent(store => { + Object.assign(store.state.diffs, { + viewDiffsFileByFile: fileByFile, + }); + }); + + await vm.$nextTick(); + + expect(getFileByFileCheckbox(wrapper).element.checked).toBe(checked); + }, + ); + + it.each` + start | emit + ${true} | ${false} + ${false} | ${true} + `( + 'when the file by file setting starts as $start, toggling the checkbox should emit an event set to $emit', + async ({ start, emit }) => { + createComponent(store => { + Object.assign(store.state.diffs, { + viewDiffsFileByFile: start, + }); + }); + + await vm.$nextTick(); + + getFileByFileCheckbox(wrapper).trigger('click'); + + await vm.$nextTick(); + + expect(eventHub.$emit).toHaveBeenCalledWith(EVT_VIEW_FILE_BY_FILE, { setting: emit }); + }, + ); + }); }); diff --git a/spec/frontend/diffs/diff_file_spec.js b/spec/frontend/diffs/diff_file_spec.js deleted file mode 100644 index 5d74760ef66..00000000000 --- a/spec/frontend/diffs/diff_file_spec.js +++ /dev/null @@ -1,60 +0,0 @@ -import { prepareRawDiffFile } from '~/diffs/diff_file'; - -const DIFF_FILES = [ - { - file_hash: 'ABC', // This file is just a normal file - }, - { - file_hash: 'DEF', // This file replaces a symlink - a_mode: '0', - b_mode: '0755', - }, - { - file_hash: 'DEF', // This symlink is replaced by a file - a_mode: '120000', - b_mode: '0', - }, - { - file_hash: 'GHI', // This symlink replaces a file - a_mode: '0', - b_mode: '120000', - }, - { - file_hash: 'GHI', // This file is replaced by a symlink - a_mode: '0755', - b_mode: '0', - }, -]; - -function makeBrokenSymlinkObject(replaced, wasSymbolic, isSymbolic, wasReal, isReal) { - return { - replaced, - wasSymbolic, - isSymbolic, - wasReal, - isReal, - }; -} - -describe('diff_file utilities', () => { - describe('prepareRawDiffFile', () => { - it.each` - fileIndex | description | brokenSymlink - ${0} | ${'a file that is not symlink-adjacent'} | ${false} - ${1} | ${'a file that replaces a symlink'} | ${makeBrokenSymlinkObject(false, false, false, false, true)} - ${2} | ${'a symlink that is replaced by a file'} | ${makeBrokenSymlinkObject(true, true, false, false, false)} - ${3} | ${'a symlink that replaces a file'} | ${makeBrokenSymlinkObject(false, false, true, false, false)} - ${4} | ${'a file that is replaced by a symlink'} | ${makeBrokenSymlinkObject(true, false, false, true, false)} - `( - 'properly marks $description with the correct .brokenSymlink value', - ({ fileIndex, brokenSymlink }) => { - const preppedRaw = prepareRawDiffFile({ - file: DIFF_FILES[fileIndex], - allFiles: DIFF_FILES, - }); - - expect(preppedRaw.brokenSymlink).toStrictEqual(brokenSymlink); - }, - ); - }); -}); diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index 0af5ddd9764..fef7676e795 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -32,7 +32,7 @@ import { setHighlightedRow, toggleTreeOpen, scrollToFile, - toggleShowTreeList, + setShowTreeList, renderFileForDiscussionId, setRenderTreeList, setShowWhitespace, @@ -48,6 +48,7 @@ import { moveToNeighboringCommit, setCurrentDiffFileIdFromNote, navigateToDiffFileIndex, + setFileByFile, } from '~/diffs/store/actions'; import eventHub from '~/notes/event_hub'; import * as types from '~/diffs/store/mutation_types'; @@ -159,10 +160,10 @@ describe('DiffsStoreActions', () => { .onGet( mergeUrlParams( { - per_page: DIFFS_PER_PAGE, w: '1', view: 'inline', page: 1, + per_page: DIFFS_PER_PAGE, }, endpointBatch, ), @@ -171,10 +172,10 @@ describe('DiffsStoreActions', () => { .onGet( mergeUrlParams( { - per_page: DIFFS_PER_PAGE, w: '1', view: 'inline', page: 2, + per_page: DIFFS_PER_PAGE, }, endpointBatch, ), @@ -248,7 +249,7 @@ describe('DiffsStoreActions', () => { { type: types.SET_LOADING, payload: true }, { type: types.SET_LOADING, payload: false }, { type: types.SET_MERGE_REQUEST_DIFFS, payload: diffMetadata.merge_request_diffs }, - { type: types.SET_DIFF_DATA, payload: noFilesData }, + { type: types.SET_DIFF_METADATA, payload: noFilesData }, ], [], () => { @@ -901,15 +902,22 @@ describe('DiffsStoreActions', () => { }); }); - describe('toggleShowTreeList', () => { + describe('setShowTreeList', () => { it('commits toggle', done => { - testAction(toggleShowTreeList, null, {}, [{ type: types.TOGGLE_SHOW_TREE_LIST }], [], done); + testAction( + setShowTreeList, + { showTreeList: true }, + {}, + [{ type: types.SET_SHOW_TREE_LIST, payload: true }], + [], + done, + ); }); it('updates localStorage', () => { jest.spyOn(localStorage, 'setItem').mockImplementation(() => {}); - toggleShowTreeList({ commit() {}, state: { showTreeList: true } }); + setShowTreeList({ commit() {} }, { showTreeList: true }); expect(localStorage.setItem).toHaveBeenCalledWith('mr_tree_show', true); }); @@ -917,7 +925,7 @@ describe('DiffsStoreActions', () => { it('does not update localStorage', () => { jest.spyOn(localStorage, 'setItem').mockImplementation(() => {}); - toggleShowTreeList({ commit() {}, state: { showTreeList: true } }, false); + setShowTreeList({ commit() {} }, { showTreeList: true, saving: false }); expect(localStorage.setItem).not.toHaveBeenCalled(); }); @@ -1236,10 +1244,6 @@ describe('DiffsStoreActions', () => { { diffViewType: 'inline' }, [ { - type: 'SET_HIDDEN_VIEW_DIFF_FILE_LINES', - payload: { filePath: 'path', lines: ['test'] }, - }, - { type: 'SET_CURRENT_VIEW_DIFF_FILE_LINES', payload: { filePath: 'path', lines: ['test'] }, }, @@ -1259,10 +1263,6 @@ describe('DiffsStoreActions', () => { { diffViewType: 'inline' }, [ { - type: 'SET_HIDDEN_VIEW_DIFF_FILE_LINES', - payload: { filePath: 'path', lines }, - }, - { type: 'SET_CURRENT_VIEW_DIFF_FILE_LINES', payload: { filePath: 'path', lines: lines.slice(0, 200) }, }, @@ -1456,4 +1456,20 @@ describe('DiffsStoreActions', () => { ); }); }); + + describe('setFileByFile', () => { + it.each` + value + ${true} + ${false} + `('commits SET_FILE_BY_FILE with the new value $value', ({ value }) => { + return testAction( + setFileByFile, + { fileByFile: value }, + { viewDiffsFileByFile: null }, + [{ type: types.SET_FILE_BY_FILE, payload: value }], + [], + ); + }); + }); }); diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js index c0645faf89e..13e7cad835d 100644 --- a/spec/frontend/diffs/store/mutations_spec.js +++ b/spec/frontend/diffs/store/mutations_spec.js @@ -1,7 +1,7 @@ import createState from '~/diffs/store/modules/diff_state'; import mutations from '~/diffs/store/mutations'; import * as types from '~/diffs/store/mutation_types'; -import { INLINE_DIFF_VIEW_TYPE } from '~/diffs/constants'; +import { INLINE_DIFF_VIEW_TYPE, INLINE_DIFF_LINES_KEY } from '~/diffs/constants'; import diffFileMockData from '../mock_data/diff_file'; import * as utils from '~/diffs/store/utils'; @@ -67,28 +67,28 @@ describe('DiffsStoreMutations', () => { }); }); - describe('SET_DIFF_DATA', () => { - it('should not modify the existing state', () => { + describe('SET_DIFF_METADATA', () => { + it('should overwrite state with the camelCased data that is passed in', () => { const state = { - diffFiles: [ - { - content_sha: diffFileMockData.content_sha, - file_hash: diffFileMockData.file_hash, - highlighted_diff_lines: [], - }, - ], + diffFiles: [], }; const diffMock = { diff_files: [diffFileMockData], }; + const metaMock = { + other_key: 'value', + }; - mutations[types.SET_DIFF_DATA](state, diffMock); + mutations[types.SET_DIFF_METADATA](state, diffMock); + expect(state.diffFiles[0]).toEqual(diffFileMockData); - expect(state.diffFiles[0].parallel_diff_lines).toBeUndefined(); + mutations[types.SET_DIFF_METADATA](state, metaMock); + expect(state.diffFiles[0]).toEqual(diffFileMockData); + expect(state.otherKey).toEqual('value'); }); }); - describe('SET_DIFFSET_DIFF_DATA_BATCH_DATA', () => { + describe('SET_DIFF_DATA_BATCH_DATA', () => { it('should set diff data batch type properly', () => { const state = { diffFiles: [] }; const diffMock = { @@ -97,9 +97,6 @@ describe('DiffsStoreMutations', () => { mutations[types.SET_DIFF_DATA_BATCH](state, diffMock); - const firstLine = state.diffFiles[0].parallel_diff_lines[0]; - - expect(firstLine.right.text).toBeUndefined(); expect(state.diffFiles[0].renderIt).toEqual(true); expect(state.diffFiles[0].collapsed).toEqual(false); }); @@ -142,8 +139,7 @@ describe('DiffsStoreMutations', () => { }; const diffFile = { file_hash: options.fileHash, - highlighted_diff_lines: [], - parallel_diff_lines: [], + [INLINE_DIFF_LINES_KEY]: [], }; const state = { diffFiles: [diffFile], diffViewType: 'viewType' }; const lines = [{ old_line: 1, new_line: 1 }]; @@ -171,9 +167,7 @@ describe('DiffsStoreMutations', () => { ); expect(utils.addContextLines).toHaveBeenCalledWith({ - inlineLines: diffFile.highlighted_diff_lines, - parallelLines: diffFile.parallel_diff_lines, - diffViewType: 'viewType', + inlineLines: diffFile[INLINE_DIFF_LINES_KEY], contextLines: options.contextLines, bottom: options.params.bottom, lineNumbers: options.lineNumbers, @@ -225,19 +219,7 @@ describe('DiffsStoreMutations', () => { diffFiles: [ { file_hash: 'ABC', - parallel_diff_lines: [ - { - left: { - line_code: 'ABC_1', - discussions: [], - }, - right: { - line_code: 'ABC_2', - discussions: [], - }, - }, - ], - highlighted_diff_lines: [ + [INLINE_DIFF_LINES_KEY]: [ { line_code: 'ABC_1', discussions: [], @@ -267,12 +249,8 @@ describe('DiffsStoreMutations', () => { diffPositionByLineCode, }); - expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions.length).toEqual(1); - expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions[0].id).toEqual(1); - expect(state.diffFiles[0].parallel_diff_lines[0].right.discussions).toEqual([]); - - expect(state.diffFiles[0].highlighted_diff_lines[0].discussions.length).toEqual(1); - expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].id).toEqual(1); + expect(state.diffFiles[0][INLINE_DIFF_LINES_KEY][0].discussions.length).toEqual(1); + expect(state.diffFiles[0][INLINE_DIFF_LINES_KEY][0].discussions[0].id).toEqual(1); }); it('should not duplicate discussions on line', () => { @@ -291,19 +269,7 @@ describe('DiffsStoreMutations', () => { diffFiles: [ { file_hash: 'ABC', - parallel_diff_lines: [ - { - left: { - line_code: 'ABC_1', - discussions: [], - }, - right: { - line_code: 'ABC_2', - discussions: [], - }, - }, - ], - highlighted_diff_lines: [ + [INLINE_DIFF_LINES_KEY]: [ { line_code: 'ABC_1', discussions: [], @@ -333,24 +299,16 @@ describe('DiffsStoreMutations', () => { diffPositionByLineCode, }); - expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions.length).toEqual(1); - expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions[0].id).toEqual(1); - expect(state.diffFiles[0].parallel_diff_lines[0].right.discussions).toEqual([]); - - expect(state.diffFiles[0].highlighted_diff_lines[0].discussions.length).toEqual(1); - expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].id).toEqual(1); + expect(state.diffFiles[0][INLINE_DIFF_LINES_KEY][0].discussions.length).toEqual(1); + expect(state.diffFiles[0][INLINE_DIFF_LINES_KEY][0].discussions[0].id).toEqual(1); mutations[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, }); - expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions.length).toEqual(1); - expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions[0].id).toEqual(1); - expect(state.diffFiles[0].parallel_diff_lines[0].right.discussions).toEqual([]); - - expect(state.diffFiles[0].highlighted_diff_lines[0].discussions.length).toEqual(1); - expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].id).toEqual(1); + expect(state.diffFiles[0][INLINE_DIFF_LINES_KEY][0].discussions.length).toEqual(1); + expect(state.diffFiles[0][INLINE_DIFF_LINES_KEY][0].discussions[0].id).toEqual(1); }); it('updates existing discussion', () => { @@ -369,19 +327,7 @@ describe('DiffsStoreMutations', () => { diffFiles: [ { file_hash: 'ABC', - parallel_diff_lines: [ - { - left: { - line_code: 'ABC_1', - discussions: [], - }, - right: { - line_code: 'ABC_2', - discussions: [], - }, - }, - ], - highlighted_diff_lines: [ + [INLINE_DIFF_LINES_KEY]: [ { line_code: 'ABC_1', discussions: [], @@ -411,12 +357,8 @@ describe('DiffsStoreMutations', () => { diffPositionByLineCode, }); - expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions.length).toEqual(1); - expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions[0].id).toEqual(1); - expect(state.diffFiles[0].parallel_diff_lines[0].right.discussions).toEqual([]); - - expect(state.diffFiles[0].highlighted_diff_lines[0].discussions.length).toEqual(1); - expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].id).toEqual(1); + expect(state.diffFiles[0][INLINE_DIFF_LINES_KEY][0].discussions.length).toEqual(1); + expect(state.diffFiles[0][INLINE_DIFF_LINES_KEY][0].discussions[0].id).toEqual(1); mutations[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion: { @@ -427,11 +369,8 @@ describe('DiffsStoreMutations', () => { diffPositionByLineCode, }); - expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions[0].notes.length).toBe(1); - expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].notes.length).toBe(1); - - expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions[0].resolved).toBe(true); - expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].resolved).toBe(true); + expect(state.diffFiles[0][INLINE_DIFF_LINES_KEY][0].discussions[0].notes.length).toBe(1); + expect(state.diffFiles[0][INLINE_DIFF_LINES_KEY][0].discussions[0].resolved).toBe(true); }); it('should not duplicate inline diff discussions', () => { @@ -450,7 +389,7 @@ describe('DiffsStoreMutations', () => { diffFiles: [ { file_hash: 'ABC', - highlighted_diff_lines: [ + [INLINE_DIFF_LINES_KEY]: [ { line_code: 'ABC_1', discussions: [ @@ -472,7 +411,6 @@ describe('DiffsStoreMutations', () => { discussions: [], }, ], - parallel_diff_lines: [], }, ], }; @@ -497,7 +435,7 @@ describe('DiffsStoreMutations', () => { diffPositionByLineCode, }); - expect(state.diffFiles[0].highlighted_diff_lines[0].discussions.length).toBe(1); + expect(state.diffFiles[0][INLINE_DIFF_LINES_KEY][0].discussions.length).toBe(1); }); it('should add legacy discussions to the given line', () => { @@ -517,19 +455,7 @@ describe('DiffsStoreMutations', () => { diffFiles: [ { file_hash: 'ABC', - parallel_diff_lines: [ - { - left: { - line_code: 'ABC_1', - discussions: [], - }, - right: { - line_code: 'ABC_1', - discussions: [], - }, - }, - ], - highlighted_diff_lines: [ + [INLINE_DIFF_LINES_KEY]: [ { line_code: 'ABC_1', discussions: [], @@ -557,11 +483,8 @@ describe('DiffsStoreMutations', () => { diffPositionByLineCode, }); - expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions.length).toEqual(1); - expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions[0].id).toEqual(1); - - expect(state.diffFiles[0].highlighted_diff_lines[0].discussions.length).toEqual(1); - expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].id).toEqual(1); + expect(state.diffFiles[0][INLINE_DIFF_LINES_KEY][0].discussions.length).toEqual(1); + expect(state.diffFiles[0][INLINE_DIFF_LINES_KEY][0].discussions[0].id).toEqual(1); }); it('should add discussions by line_codes and positions attributes', () => { @@ -580,19 +503,7 @@ describe('DiffsStoreMutations', () => { diffFiles: [ { file_hash: 'ABC', - parallel_diff_lines: [ - { - left: { - line_code: 'ABC_1', - discussions: [], - }, - right: { - line_code: 'ABC_1', - discussions: [], - }, - }, - ], - highlighted_diff_lines: [ + [INLINE_DIFF_LINES_KEY]: [ { line_code: 'ABC_1', discussions: [], @@ -624,11 +535,8 @@ describe('DiffsStoreMutations', () => { diffPositionByLineCode, }); - expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions).toHaveLength(1); - expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions[0].id).toBe(1); - - expect(state.diffFiles[0].highlighted_diff_lines[0].discussions).toHaveLength(1); - expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].id).toBe(1); + expect(state.diffFiles[0][INLINE_DIFF_LINES_KEY][0].discussions).toHaveLength(1); + expect(state.diffFiles[0][INLINE_DIFF_LINES_KEY][0].discussions[0].id).toBe(1); }); it('should add discussion to file', () => { @@ -638,8 +546,7 @@ describe('DiffsStoreMutations', () => { { file_hash: 'ABC', discussions: [], - parallel_diff_lines: [], - highlighted_diff_lines: [], + [INLINE_DIFF_LINES_KEY]: [], }, ], }; @@ -668,30 +575,7 @@ describe('DiffsStoreMutations', () => { diffFiles: [ { file_hash: 'ABC', - parallel_diff_lines: [ - { - left: { - line_code: 'ABC_1', - discussions: [ - { - id: 1, - line_code: 'ABC_1', - notes: [], - }, - { - id: 2, - line_code: 'ABC_1', - notes: [], - }, - ], - }, - right: { - line_code: 'ABC_1', - discussions: [], - }, - }, - ], - highlighted_diff_lines: [ + [INLINE_DIFF_LINES_KEY]: [ { line_code: 'ABC_1', discussions: [ @@ -717,8 +601,7 @@ describe('DiffsStoreMutations', () => { lineCode: 'ABC_1', }); - expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions.length).toEqual(0); - expect(state.diffFiles[0].highlighted_diff_lines[0].discussions.length).toEqual(0); + expect(state.diffFiles[0][INLINE_DIFF_LINES_KEY][0].discussions.length).toEqual(0); }); }); @@ -738,15 +621,11 @@ describe('DiffsStoreMutations', () => { }); }); - describe('TOGGLE_SHOW_TREE_LIST', () => { - it('toggles showTreeList', () => { + describe('SET_SHOW_TREE_LIST', () => { + it('sets showTreeList', () => { const state = createState(); - mutations[types.TOGGLE_SHOW_TREE_LIST](state); - - expect(state.showTreeList).toBe(false, 'Failed to toggle showTreeList to false'); - - mutations[types.TOGGLE_SHOW_TREE_LIST](state); + mutations[types.SET_SHOW_TREE_LIST](state, true); expect(state.showTreeList).toBe(true, 'Failed to toggle showTreeList to true'); }); @@ -776,11 +655,7 @@ describe('DiffsStoreMutations', () => { it('sets hasForm on lines', () => { const file = { file_hash: 'hash', - parallel_diff_lines: [ - { left: { line_code: '123', hasForm: false }, right: {} }, - { left: {}, right: { line_code: '124', hasForm: false } }, - ], - highlighted_diff_lines: [ + [INLINE_DIFF_LINES_KEY]: [ { line_code: '123', hasForm: false }, { line_code: '124', hasForm: false }, ], @@ -795,11 +670,8 @@ describe('DiffsStoreMutations', () => { fileHash: 'hash', }); - expect(file.highlighted_diff_lines[0].hasForm).toBe(true); - expect(file.highlighted_diff_lines[1].hasForm).toBe(false); - - expect(file.parallel_diff_lines[0].left.hasForm).toBe(true); - expect(file.parallel_diff_lines[1].right.hasForm).toBe(false); + expect(file[INLINE_DIFF_LINES_KEY][0].hasForm).toBe(true); + expect(file[INLINE_DIFF_LINES_KEY][1].hasForm).toBe(false); }); }); @@ -885,8 +757,7 @@ describe('DiffsStoreMutations', () => { file_path: 'test', isLoadingFullFile: true, isShowingFullFile: false, - highlighted_diff_lines: [], - parallel_diff_lines: [], + [INLINE_DIFF_LINES_KEY]: [], }, ], }; @@ -903,8 +774,7 @@ describe('DiffsStoreMutations', () => { file_path: 'test', isLoadingFullFile: true, isShowingFullFile: false, - highlighted_diff_lines: [], - parallel_diff_lines: [], + [INLINE_DIFF_LINES_KEY]: [], }, ], }; @@ -927,80 +797,42 @@ describe('DiffsStoreMutations', () => { }); }); - describe('SET_HIDDEN_VIEW_DIFF_FILE_LINES', () => { - [ - { current: 'highlighted', hidden: 'parallel', diffViewType: 'inline' }, - { current: 'parallel', hidden: 'highlighted', diffViewType: 'parallel' }, - ].forEach(({ current, hidden, diffViewType }) => { - it(`sets the ${hidden} lines when diff view is ${diffViewType}`, () => { - const file = { file_path: 'test', parallel_diff_lines: [], highlighted_diff_lines: [] }; - const state = { - diffFiles: [file], - diffViewType, - }; - - mutations[types.SET_HIDDEN_VIEW_DIFF_FILE_LINES](state, { - filePath: 'test', - lines: ['test'], - }); - - expect(file[`${current}_diff_lines`]).toEqual([]); - expect(file[`${hidden}_diff_lines`]).toEqual(['test']); - }); - }); - }); - describe('SET_CURRENT_VIEW_DIFF_FILE_LINES', () => { - [ - { current: 'highlighted', hidden: 'parallel', diffViewType: 'inline' }, - { current: 'parallel', hidden: 'highlighted', diffViewType: 'parallel' }, - ].forEach(({ current, hidden, diffViewType }) => { - it(`sets the ${current} lines when diff view is ${diffViewType}`, () => { - const file = { file_path: 'test', parallel_diff_lines: [], highlighted_diff_lines: [] }; - const state = { - diffFiles: [file], - diffViewType, - }; - - mutations[types.SET_CURRENT_VIEW_DIFF_FILE_LINES](state, { - filePath: 'test', - lines: ['test'], - }); - - expect(file[`${current}_diff_lines`]).toEqual(['test']); - expect(file[`${hidden}_diff_lines`]).toEqual([]); + it(`sets the highlighted lines`, () => { + const file = { file_path: 'test', [INLINE_DIFF_LINES_KEY]: [] }; + const state = { + diffFiles: [file], + }; + + mutations[types.SET_CURRENT_VIEW_DIFF_FILE_LINES](state, { + filePath: 'test', + lines: ['test'], }); + + expect(file[INLINE_DIFF_LINES_KEY]).toEqual(['test']); }); }); describe('ADD_CURRENT_VIEW_DIFF_FILE_LINES', () => { - [ - { current: 'highlighted', hidden: 'parallel', diffViewType: 'inline' }, - { current: 'parallel', hidden: 'highlighted', diffViewType: 'parallel' }, - ].forEach(({ current, hidden, diffViewType }) => { - it(`pushes to ${current} lines when diff view is ${diffViewType}`, () => { - const file = { file_path: 'test', parallel_diff_lines: [], highlighted_diff_lines: [] }; - const state = { - diffFiles: [file], - diffViewType, - }; - - mutations[types.ADD_CURRENT_VIEW_DIFF_FILE_LINES](state, { - filePath: 'test', - line: 'test', - }); - - expect(file[`${current}_diff_lines`]).toEqual(['test']); - expect(file[`${hidden}_diff_lines`]).toEqual([]); - - mutations[types.ADD_CURRENT_VIEW_DIFF_FILE_LINES](state, { - filePath: 'test', - line: 'test2', - }); - - expect(file[`${current}_diff_lines`]).toEqual(['test', 'test2']); - expect(file[`${hidden}_diff_lines`]).toEqual([]); + it('pushes to inline lines', () => { + const file = { file_path: 'test', [INLINE_DIFF_LINES_KEY]: [] }; + const state = { + diffFiles: [file], + }; + + mutations[types.ADD_CURRENT_VIEW_DIFF_FILE_LINES](state, { + filePath: 'test', + line: 'test', + }); + + expect(file[INLINE_DIFF_LINES_KEY]).toEqual(['test']); + + mutations[types.ADD_CURRENT_VIEW_DIFF_FILE_LINES](state, { + filePath: 'test', + line: 'test2', }); + + expect(file[INLINE_DIFF_LINES_KEY]).toEqual(['test', 'test2']); }); }); @@ -1060,4 +892,18 @@ describe('DiffsStoreMutations', () => { expect(state.showSuggestPopover).toBe(false); }); }); + + describe('SET_FILE_BY_FILE', () => { + it.each` + value | opposite + ${true} | ${false} + ${false} | ${true} + `('sets viewDiffsFileByFile to $value', ({ value, opposite }) => { + const state = { viewDiffsFileByFile: opposite }; + + mutations[types.SET_FILE_BY_FILE](state, value); + + expect(state.viewDiffsFileByFile).toBe(value); + }); + }); }); diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index 866be0abd22..7ee97224707 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -10,7 +10,7 @@ import { OLD_LINE_TYPE, MATCH_LINE_TYPE, INLINE_DIFF_VIEW_TYPE, - PARALLEL_DIFF_VIEW_TYPE, + INLINE_DIFF_LINES_KEY, } from '~/diffs/constants'; import { MERGE_REQUEST_NOTEABLE_TYPE } from '~/notes/constants'; import diffFileMockData from '../mock_data/diff_file'; @@ -20,14 +20,6 @@ import { noteableDataMock } from '../../notes/mock_data'; const getDiffFileMock = () => JSON.parse(JSON.stringify(diffFileMockData)); const getDiffMetadataMock = () => JSON.parse(JSON.stringify(diffMetadata)); -function extractLinesFromFile(file) { - const unpackedParallel = file.parallel_diff_lines - .flatMap(({ left, right }) => [left, right]) - .filter(Boolean); - - return [...file.highlighted_diff_lines, ...unpackedParallel]; -} - describe('DiffsStoreUtils', () => { describe('findDiffFile', () => { const files = [{ file_hash: 1, name: 'one' }]; @@ -45,7 +37,7 @@ describe('DiffsStoreUtils', () => { }); }); - describe('findIndexInInlineLines and findIndexInParallelLines', () => { + describe('findIndexInInlineLines', () => { const expectSet = (method, lines, invalidLines) => { expect(method(lines, { oldLineNumber: 3, newLineNumber: 5 })).toEqual(4); expect(method(invalidLines || lines, { oldLineNumber: 32, newLineNumber: 53 })).toEqual(-1); @@ -53,44 +45,26 @@ describe('DiffsStoreUtils', () => { describe('findIndexInInlineLines', () => { it('should return correct index for given line numbers', () => { - expectSet(utils.findIndexInInlineLines, getDiffFileMock().highlighted_diff_lines); - }); - }); - - describe('findIndexInParallelLines', () => { - it('should return correct index for given line numbers', () => { - expectSet(utils.findIndexInParallelLines, getDiffFileMock().parallel_diff_lines, []); + expectSet(utils.findIndexInInlineLines, getDiffFileMock()[INLINE_DIFF_LINES_KEY]); }); }); }); describe('getPreviousLineIndex', () => { - [ - { diffViewType: INLINE_DIFF_VIEW_TYPE, file: { parallel_diff_lines: [] } }, - { diffViewType: PARALLEL_DIFF_VIEW_TYPE, file: { highlighted_diff_lines: [] } }, - ].forEach(({ diffViewType, file }) => { - describe(`with diffViewType (${diffViewType}) in split diffs`, () => { - let diffFile; - - beforeEach(() => { - diffFile = { ...clone(diffFileMockData), ...file }; - }); + describe(`with diffViewType (inline) in split diffs`, () => { + let diffFile; - it('should return the correct previous line number', () => { - const emptyLines = - diffViewType === INLINE_DIFF_VIEW_TYPE - ? diffFile.parallel_diff_lines - : diffFile.highlighted_diff_lines; - - // This expectation asserts that we cannot possibly be using the opposite view type lines in the next expectation - expect(emptyLines.length).toBe(0); - expect( - utils.getPreviousLineIndex(diffViewType, diffFile, { - oldLineNumber: 3, - newLineNumber: 5, - }), - ).toBe(4); - }); + beforeEach(() => { + diffFile = { ...clone(diffFileMockData) }; + }); + + it('should return the correct previous line number', () => { + expect( + utils.getPreviousLineIndex(INLINE_DIFF_VIEW_TYPE, diffFile, { + oldLineNumber: 3, + newLineNumber: 5, + }), + ).toBe(4); }); }); }); @@ -100,82 +74,50 @@ describe('DiffsStoreUtils', () => { const diffFile = getDiffFileMock(); const lineNumbers = { oldLineNumber: 3, newLineNumber: 5 }; const inlineIndex = utils.findIndexInInlineLines( - diffFile.highlighted_diff_lines, + diffFile[INLINE_DIFF_LINES_KEY], lineNumbers, ); - const parallelIndex = utils.findIndexInParallelLines( - diffFile.parallel_diff_lines, - lineNumbers, - ); - const atInlineIndex = diffFile.highlighted_diff_lines[inlineIndex]; - const atParallelIndex = diffFile.parallel_diff_lines[parallelIndex]; + const atInlineIndex = diffFile[INLINE_DIFF_LINES_KEY][inlineIndex]; utils.removeMatchLine(diffFile, lineNumbers, false); - expect(diffFile.highlighted_diff_lines[inlineIndex]).not.toEqual(atInlineIndex); - expect(diffFile.parallel_diff_lines[parallelIndex]).not.toEqual(atParallelIndex); + expect(diffFile[INLINE_DIFF_LINES_KEY][inlineIndex]).not.toEqual(atInlineIndex); utils.removeMatchLine(diffFile, lineNumbers, true); - expect(diffFile.highlighted_diff_lines[inlineIndex + 1]).not.toEqual(atInlineIndex); - expect(diffFile.parallel_diff_lines[parallelIndex + 1]).not.toEqual(atParallelIndex); + expect(diffFile[INLINE_DIFF_LINES_KEY][inlineIndex + 1]).not.toEqual(atInlineIndex); }); }); describe('addContextLines', () => { - [INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE].forEach(diffViewType => { - it(`should add context lines for ${diffViewType}`, () => { - const diffFile = getDiffFileMock(); - const inlineLines = diffFile.highlighted_diff_lines; - const parallelLines = diffFile.parallel_diff_lines; - const lineNumbers = { oldLineNumber: 3, newLineNumber: 5 }; - const contextLines = [{ lineNumber: 42, line_code: '123' }]; - const options = { inlineLines, parallelLines, contextLines, lineNumbers, diffViewType }; - const inlineIndex = utils.findIndexInInlineLines(inlineLines, lineNumbers); - const parallelIndex = utils.findIndexInParallelLines(parallelLines, lineNumbers); - const normalizedParallelLine = { - left: options.contextLines[0], - right: options.contextLines[0], - line_code: '123', - }; + it(`should add context lines`, () => { + const diffFile = getDiffFileMock(); + const inlineLines = diffFile[INLINE_DIFF_LINES_KEY]; + const lineNumbers = { oldLineNumber: 3, newLineNumber: 5 }; + const contextLines = [{ lineNumber: 42, line_code: '123' }]; + const options = { inlineLines, contextLines, lineNumbers }; + const inlineIndex = utils.findIndexInInlineLines(inlineLines, lineNumbers); - utils.addContextLines(options); + utils.addContextLines(options); - if (diffViewType === INLINE_DIFF_VIEW_TYPE) { - expect(inlineLines[inlineIndex]).toEqual(contextLines[0]); - } else { - expect(parallelLines[parallelIndex]).toEqual(normalizedParallelLine); - } - }); + expect(inlineLines[inlineIndex]).toEqual(contextLines[0]); + }); - it(`should add context lines properly with bottom parameter for ${diffViewType}`, () => { - const diffFile = getDiffFileMock(); - const inlineLines = diffFile.highlighted_diff_lines; - const parallelLines = diffFile.parallel_diff_lines; - const lineNumbers = { oldLineNumber: 3, newLineNumber: 5 }; - const contextLines = [{ lineNumber: 42, line_code: '123' }]; - const options = { - inlineLines, - parallelLines, - contextLines, - lineNumbers, - bottom: true, - diffViewType, - }; - const normalizedParallelLine = { - left: options.contextLines[0], - right: options.contextLines[0], - line_code: '123', - }; + it(`should add context lines properly with bottom parameter`, () => { + const diffFile = getDiffFileMock(); + const inlineLines = diffFile[INLINE_DIFF_LINES_KEY]; + const lineNumbers = { oldLineNumber: 3, newLineNumber: 5 }; + const contextLines = [{ lineNumber: 42, line_code: '123' }]; + const options = { + inlineLines, + contextLines, + lineNumbers, + bottom: true, + }; - utils.addContextLines(options); + utils.addContextLines(options); - if (diffViewType === INLINE_DIFF_VIEW_TYPE) { - expect(inlineLines[inlineLines.length - 1]).toEqual(contextLines[0]); - } else { - expect(parallelLines[parallelLines.length - 1]).toEqual(normalizedParallelLine); - } - }); + expect(inlineLines[inlineLines.length - 1]).toEqual(contextLines[0]); }); }); @@ -195,7 +137,6 @@ describe('DiffsStoreUtils', () => { new_line: 3, old_line: 1, }, - diffViewType: PARALLEL_DIFF_VIEW_TYPE, linePosition: LINE_POSITION_LEFT, lineRange: { start_line_code: 'abc_1_1', end_line_code: 'abc_2_2' }, }; @@ -256,7 +197,6 @@ describe('DiffsStoreUtils', () => { new_line: 3, old_line: 1, }, - diffViewType: PARALLEL_DIFF_VIEW_TYPE, linePosition: LINE_POSITION_LEFT, }; @@ -424,20 +364,6 @@ describe('DiffsStoreUtils', () => { expect(preppedLine).toEqual(correctLine); }); - it('returns a nested object with "left" and "right" lines + the line code for `parallel` lines', () => { - preppedLine = utils.prepareLineForRenamedFile({ - diffViewType: PARALLEL_DIFF_VIEW_TYPE, - line: sourceLine, - index: lineIndex, - diffFile, - }); - - expect(Object.keys(preppedLine)).toEqual(['left', 'right', 'line_code']); - expect(preppedLine.left).toEqual(correctLine); - expect(preppedLine.right).toEqual(correctLine); - expect(preppedLine.line_code).toEqual(correctLine.line_code); - }); - it.each` brokenSymlink ${false} @@ -474,35 +400,26 @@ describe('DiffsStoreUtils', () => { preparedDiff = { diff_files: [mock] }; splitInlineDiff = { - diff_files: [{ ...mock, parallel_diff_lines: undefined }], + diff_files: [{ ...mock }], }; splitParallelDiff = { - diff_files: [{ ...mock, highlighted_diff_lines: undefined }], + diff_files: [{ ...mock, [INLINE_DIFF_LINES_KEY]: undefined }], }; completedDiff = { - diff_files: [{ ...mock, highlighted_diff_lines: undefined }], + diff_files: [{ ...mock, [INLINE_DIFF_LINES_KEY]: undefined }], }; - preparedDiff.diff_files = utils.prepareDiffData(preparedDiff); - splitInlineDiff.diff_files = utils.prepareDiffData(splitInlineDiff); - splitParallelDiff.diff_files = utils.prepareDiffData(splitParallelDiff); - completedDiff.diff_files = utils.prepareDiffData(completedDiff, [mock]); + preparedDiff.diff_files = utils.prepareDiffData({ diff: preparedDiff }); + splitInlineDiff.diff_files = utils.prepareDiffData({ diff: splitInlineDiff }); + splitParallelDiff.diff_files = utils.prepareDiffData({ diff: splitParallelDiff }); + completedDiff.diff_files = utils.prepareDiffData({ + diff: completedDiff, + priorFiles: [mock], + }); }); it('sets the renderIt and collapsed attribute on files', () => { - const firstParallelDiffLine = preparedDiff.diff_files[0].parallel_diff_lines[2]; - - expect(firstParallelDiffLine.left.discussions.length).toBe(0); - expect(firstParallelDiffLine.left).not.toHaveAttr('text'); - expect(firstParallelDiffLine.right.discussions.length).toBe(0); - expect(firstParallelDiffLine.right).not.toHaveAttr('text'); - const firstParallelChar = firstParallelDiffLine.right.rich_text.charAt(0); - - expect(firstParallelChar).not.toBe(' '); - expect(firstParallelChar).not.toBe('+'); - expect(firstParallelChar).not.toBe('-'); - - const checkLine = preparedDiff.diff_files[0].highlighted_diff_lines[0]; + const checkLine = preparedDiff.diff_files[0][INLINE_DIFF_LINES_KEY][0]; expect(checkLine.discussions.length).toBe(0); expect(checkLine).not.toHaveAttr('text'); @@ -516,29 +433,14 @@ describe('DiffsStoreUtils', () => { expect(preparedDiff.diff_files[0].collapsed).toBeFalsy(); }); - it('adds line_code to all lines', () => { - expect( - preparedDiff.diff_files[0].parallel_diff_lines.filter(line => !line.line_code), - ).toHaveLength(0); - }); - - it('uses right line code if left has none', () => { - const firstLine = preparedDiff.diff_files[0].parallel_diff_lines[0]; - - expect(firstLine.line_code).toEqual(firstLine.right.line_code); - }); - it('guarantees an empty array for both diff styles', () => { - expect(splitInlineDiff.diff_files[0].parallel_diff_lines.length).toEqual(0); - expect(splitInlineDiff.diff_files[0].highlighted_diff_lines.length).toBeGreaterThan(0); - expect(splitParallelDiff.diff_files[0].parallel_diff_lines.length).toBeGreaterThan(0); - expect(splitParallelDiff.diff_files[0].highlighted_diff_lines.length).toEqual(0); + expect(splitInlineDiff.diff_files[0][INLINE_DIFF_LINES_KEY].length).toBeGreaterThan(0); + expect(splitParallelDiff.diff_files[0][INLINE_DIFF_LINES_KEY].length).toEqual(0); }); it('merges existing diff files with newly loaded diff files to ensure split diffs are eventually completed', () => { expect(completedDiff.diff_files.length).toEqual(1); - expect(completedDiff.diff_files[0].parallel_diff_lines.length).toBeGreaterThan(0); - expect(completedDiff.diff_files[0].highlighted_diff_lines.length).toBeGreaterThan(0); + expect(completedDiff.diff_files[0][INLINE_DIFF_LINES_KEY].length).toBeGreaterThan(0); }); it('leaves files in the existing state', () => { @@ -548,20 +450,26 @@ describe('DiffsStoreUtils', () => { content_sha: 'ABC', file_hash: 'DEF', }; - const updatedFilesList = utils.prepareDiffData({ diff_files: [fakeNewFile] }, priorFiles); + const updatedFilesList = utils.prepareDiffData({ + diff: { diff_files: [fakeNewFile] }, + priorFiles, + }); expect(updatedFilesList).toEqual([mock, fakeNewFile]); }); it('completes an existing split diff without overwriting existing diffs', () => { // The current state has a file that has only loaded inline lines - const priorFiles = [{ ...mock, parallel_diff_lines: [] }]; + const priorFiles = [{ ...mock }]; // The next (batch) load loads two files: the other half of that file, and a new file const fakeBatch = [ - { ...mock, highlighted_diff_lines: undefined }, - { ...mock, highlighted_diff_lines: undefined, content_sha: 'ABC', file_hash: 'DEF' }, + { ...mock, [INLINE_DIFF_LINES_KEY]: undefined }, + { ...mock, [INLINE_DIFF_LINES_KEY]: undefined, content_sha: 'ABC', file_hash: 'DEF' }, ]; - const updatedFilesList = utils.prepareDiffData({ diff_files: fakeBatch }, priorFiles); + const updatedFilesList = utils.prepareDiffData({ + diff: { diff_files: fakeBatch }, + priorFiles, + }); expect(updatedFilesList).toEqual([ mock, @@ -584,7 +492,7 @@ describe('DiffsStoreUtils', () => { ...splitInlineDiff.diff_files, ...splitParallelDiff.diff_files, ...completedDiff.diff_files, - ].flatMap(file => extractLinesFromFile(file)); + ].flatMap(file => [...file[INLINE_DIFF_LINES_KEY]]); lines.forEach(line => { expect(line.commentsDisabled).toBe(false); @@ -599,7 +507,7 @@ describe('DiffsStoreUtils', () => { beforeEach(() => { mock = getDiffMetadataMock(); - preparedDiffFiles = utils.prepareDiffData(mock); + preparedDiffFiles = utils.prepareDiffData({ diff: mock, meta: true }); }); it('sets the renderIt and collapsed attribute on files', () => { @@ -608,15 +516,14 @@ describe('DiffsStoreUtils', () => { }); it('guarantees an empty array of lines for both diff styles', () => { - expect(preparedDiffFiles[0].parallel_diff_lines.length).toEqual(0); - expect(preparedDiffFiles[0].highlighted_diff_lines.length).toEqual(0); + expect(preparedDiffFiles[0][INLINE_DIFF_LINES_KEY].length).toEqual(0); }); it('leaves files in the existing state', () => { const fileMock = getDiffFileMock(); const metaData = getDiffMetadataMock(); const priorFiles = [fileMock]; - const updatedFilesList = utils.prepareDiffData(metaData, priorFiles); + const updatedFilesList = utils.prepareDiffData({ diff: metaData, priorFiles, meta: true }); expect(updatedFilesList.length).toEqual(2); expect(updatedFilesList[0]).toEqual(fileMock); @@ -641,14 +548,13 @@ describe('DiffsStoreUtils', () => { const fileMock = getDiffFileMock(); const metaMock = getDiffMetadataMock(); const priorFiles = [{ ...fileMock }]; - const updatedFilesList = utils.prepareDiffData(metaMock, priorFiles); + const updatedFilesList = utils.prepareDiffData({ diff: metaMock, priorFiles, meta: true }); expect(updatedFilesList).toEqual([ fileMock, { ...metaMock.diff_files[0], - highlighted_diff_lines: [], - parallel_diff_lines: [], + [INLINE_DIFF_LINES_KEY]: [], }, ]); }); @@ -1217,30 +1123,19 @@ describe('DiffsStoreUtils', () => { it('converts inline diff lines to parallel diff lines', () => { const file = getDiffFileMock(); - expect(utils.parallelizeDiffLines(file.highlighted_diff_lines)).toEqual( + expect(utils.parallelizeDiffLines(file[INLINE_DIFF_LINES_KEY])).toEqual( file.parallel_diff_lines, ); }); - /** - * What's going on here? - * - * The inline version of parallelizeDiffLines simply keeps the difflines - * in the same order they are received as opposed to shuffling them - * to be "side by side". - * - * This keeps the underlying data structure the same which simplifies - * the components, but keeps the changes grouped together as users - * expect when viewing changes inline. - */ - it('converts inline diff lines to inline diff lines with a parallel structure', () => { + it('converts inline diff lines', () => { const file = getDiffFileMock(); const files = utils.parallelizeDiffLines(file.highlighted_diff_lines, true); expect(files[5].left).toEqual(file.parallel_diff_lines[5].left); expect(files[5].right).toBeNull(); - expect(files[6].left).toBeNull(); - expect(files[6].right).toEqual(file.parallel_diff_lines[5].right); + expect(files[6].left).toEqual(file.parallel_diff_lines[5].right); + expect(files[6].right).toBeNull(); }); }); }); diff --git a/spec/frontend/diffs/utils/diff_file_spec.js b/spec/frontend/diffs/utils/diff_file_spec.js new file mode 100644 index 00000000000..2e6247b8c07 --- /dev/null +++ b/spec/frontend/diffs/utils/diff_file_spec.js @@ -0,0 +1,126 @@ +import { prepareRawDiffFile } from '~/diffs/utils/diff_file'; + +function getDiffFiles() { + return [ + { + blob: { + id: 'C0473471', + }, + file_hash: 'ABC', // This file is just a normal file + file_identifier_hash: 'ABC1', + }, + { + blob: { + id: 'C0473472', + }, + file_hash: 'DEF', // This file replaces a symlink + file_identifier_hash: 'DEF1', + a_mode: '0', + b_mode: '0755', + }, + { + blob: { + id: 'C0473473', + }, + file_hash: 'DEF', // This symlink is replaced by a file + file_identifier_hash: 'DEF2', + a_mode: '120000', + b_mode: '0', + }, + { + blob: { + id: 'C0473474', + }, + file_hash: 'GHI', // This symlink replaces a file + file_identifier_hash: 'GHI1', + a_mode: '0', + b_mode: '120000', + }, + { + blob: { + id: 'C0473475', + }, + file_hash: 'GHI', // This file is replaced by a symlink + file_identifier_hash: 'GHI2', + a_mode: '0755', + b_mode: '0', + }, + ]; +} +function makeBrokenSymlinkObject(replaced, wasSymbolic, isSymbolic, wasReal, isReal) { + return { + replaced, + wasSymbolic, + isSymbolic, + wasReal, + isReal, + }; +} + +describe('diff_file utilities', () => { + describe('prepareRawDiffFile', () => { + let files; + + beforeEach(() => { + files = getDiffFiles(); + }); + + it.each` + fileIndex | description | brokenSymlink + ${0} | ${'a file that is not symlink-adjacent'} | ${false} + ${1} | ${'a file that replaces a symlink'} | ${makeBrokenSymlinkObject(false, false, false, false, true)} + ${2} | ${'a symlink that is replaced by a file'} | ${makeBrokenSymlinkObject(true, true, false, false, false)} + ${3} | ${'a symlink that replaces a file'} | ${makeBrokenSymlinkObject(false, false, true, false, false)} + ${4} | ${'a file that is replaced by a symlink'} | ${makeBrokenSymlinkObject(true, false, false, true, false)} + `( + 'properly marks $description with the correct .brokenSymlink value', + ({ fileIndex, brokenSymlink }) => { + const preppedRaw = prepareRawDiffFile({ + file: files[fileIndex], + allFiles: files, + }); + + expect(preppedRaw.brokenSymlink).toStrictEqual(brokenSymlink); + }, + ); + + it.each` + fileIndex | id + ${0} | ${'8dcd585e-a421-4dab-a04e-6f88c81b7b4c'} + ${1} | ${'3f178b78-392b-44a4-bd7d-5d6192208a97'} + ${2} | ${'3d9e1354-cddf-4a11-8234-f0413521b2e5'} + ${3} | ${'460f005b-d29d-43c1-9a08-099a7c7f08de'} + ${4} | ${'d8c89733-6ce1-4455-ae3d-f8aad6ee99f9'} + `('sets the file id properly { id: $id } on normal diff files', ({ fileIndex, id }) => { + const preppedFile = prepareRawDiffFile({ + file: files[fileIndex], + allFiles: files, + }); + + expect(preppedFile.id).toBe(id); + }); + + it('does not set the `id` property for metadata diff files', () => { + const preppedFile = prepareRawDiffFile({ + file: files[0], + allFiles: files, + meta: true, + }); + + expect(preppedFile).not.toHaveProp('id'); + }); + + it('does not set the id property if the file is missing a `blob.id`', () => { + const fileMissingContentSha = { ...files[0] }; + + delete fileMissingContentSha.blob.id; + + const preppedFile = prepareRawDiffFile({ + file: fileMissingContentSha, + allFiles: files, + }); + + expect(preppedFile).not.toHaveProp('id'); + }); + }); +}); diff --git a/spec/frontend/diffs/utils/preferences_spec.js b/spec/frontend/diffs/utils/preferences_spec.js new file mode 100644 index 00000000000..a48db1d7512 --- /dev/null +++ b/spec/frontend/diffs/utils/preferences_spec.js @@ -0,0 +1,40 @@ +import Cookies from 'js-cookie'; +import { getParameterValues } from '~/lib/utils/url_utility'; + +import { fileByFile } from '~/diffs/utils/preferences'; +import { + DIFF_FILE_BY_FILE_COOKIE_NAME, + DIFF_VIEW_FILE_BY_FILE, + DIFF_VIEW_ALL_FILES, +} from '~/diffs/constants'; + +jest.mock('~/lib/utils/url_utility'); + +describe('diffs preferences', () => { + describe('fileByFile', () => { + it.each` + result | preference | cookie | searchParam + ${false} | ${false} | ${undefined} | ${undefined} + ${true} | ${true} | ${undefined} | ${undefined} + ${true} | ${false} | ${DIFF_VIEW_FILE_BY_FILE} | ${undefined} + ${false} | ${true} | ${DIFF_VIEW_ALL_FILES} | ${undefined} + ${true} | ${false} | ${undefined} | ${[DIFF_VIEW_FILE_BY_FILE]} + ${false} | ${true} | ${undefined} | ${[DIFF_VIEW_ALL_FILES]} + ${true} | ${false} | ${DIFF_VIEW_FILE_BY_FILE} | ${[DIFF_VIEW_FILE_BY_FILE]} + ${true} | ${true} | ${DIFF_VIEW_ALL_FILES} | ${[DIFF_VIEW_FILE_BY_FILE]} + ${false} | ${false} | ${DIFF_VIEW_ALL_FILES} | ${[DIFF_VIEW_ALL_FILES]} + ${false} | ${true} | ${DIFF_VIEW_FILE_BY_FILE} | ${[DIFF_VIEW_ALL_FILES]} + `( + 'should return $result when { preference: $preference, cookie: $cookie, search: $searchParam }', + ({ result, preference, cookie, searchParam }) => { + if (cookie) { + Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, cookie); + } + + getParameterValues.mockReturnValue(searchParam); + + expect(fileByFile(preference)).toBe(result); + }, + ); + }); +}); diff --git a/spec/frontend/editor/editor_lite_extension_base_spec.js b/spec/frontend/editor/editor_lite_extension_base_spec.js new file mode 100644 index 00000000000..ff53640b096 --- /dev/null +++ b/spec/frontend/editor/editor_lite_extension_base_spec.js @@ -0,0 +1,44 @@ +import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION } from '~/editor/constants'; +import { EditorLiteExtension } from '~/editor/editor_lite_extension_base'; + +describe('The basis for an Editor Lite extension', () => { + let ext; + const defaultOptions = { foo: 'bar' }; + + it.each` + description | instance | options + ${'accepts configuration options and instance'} | ${{}} | ${defaultOptions} + ${'leaves instance intact if no options are passed'} | ${{}} | ${undefined} + ${'does not fail if both instance and the options are omitted'} | ${undefined} | ${undefined} + ${'throws if only options are passed'} | ${undefined} | ${defaultOptions} + `('$description', ({ instance, options } = {}) => { + const originalInstance = { ...instance }; + + if (instance) { + if (options) { + Object.entries(options).forEach(prop => { + expect(instance[prop]).toBeUndefined(); + }); + // Both instance and options are passed + ext = new EditorLiteExtension({ instance, ...options }); + Object.entries(options).forEach(([prop, value]) => { + expect(ext[prop]).toBeUndefined(); + expect(instance[prop]).toBe(value); + }); + } else { + ext = new EditorLiteExtension({ instance }); + expect(instance).toEqual(originalInstance); + } + } else if (options) { + // Options are passed without instance + expect(() => { + ext = new EditorLiteExtension({ ...options }); + }).toThrow(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION); + } else { + // Neither options nor instance are passed + expect(() => { + ext = new EditorLiteExtension(); + }).not.toThrow(); + } + }); +}); diff --git a/spec/frontend/editor/editor_lite_spec.js b/spec/frontend/editor/editor_lite_spec.js index 2968984df01..3a7680f6d17 100644 --- a/spec/frontend/editor/editor_lite_spec.js +++ b/spec/frontend/editor/editor_lite_spec.js @@ -1,6 +1,8 @@ +/* eslint-disable max-classes-per-file */ import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor'; import waitForPromises from 'helpers/wait_for_promises'; import Editor from '~/editor/editor_lite'; +import { EditorLiteExtension } from '~/editor/editor_lite_extension_base'; import { DEFAULT_THEME, themes } from '~/ide/lib/themes'; import { EDITOR_LITE_INSTANCE_ERROR_NO_EL, URI_PREFIX } from '~/editor/constants'; @@ -242,17 +244,53 @@ describe('Base editor', () => { describe('extensions', () => { let instance; - const foo1 = jest.fn(); - const foo2 = jest.fn(); - const bar = jest.fn(); - const MyExt1 = { - foo: foo1, + const alphaRes = jest.fn(); + const betaRes = jest.fn(); + const fooRes = jest.fn(); + const barRes = jest.fn(); + class AlphaClass { + constructor() { + this.res = alphaRes; + } + alpha() { + return this?.nonExistentProp || alphaRes; + } + } + class BetaClass { + beta() { + return this?.nonExistentProp || betaRes; + } + } + class WithStaticMethod { + constructor({ instance: inst, ...options } = {}) { + Object.assign(inst, options); + } + static computeBoo(a) { + return a + 1; + } + boo() { + return WithStaticMethod.computeBoo(this.base); + } + } + class WithStaticMethodExtended extends EditorLiteExtension { + static computeBoo(a) { + return a + 1; + } + boo() { + return WithStaticMethodExtended.computeBoo(this.base); + } + } + const AlphaExt = new AlphaClass(); + const BetaExt = new BetaClass(); + const FooObjExt = { + foo() { + return fooRes; + }, }; - const MyExt2 = { - bar, - }; - const MyExt3 = { - foo: foo2, + const BarObjExt = { + bar() { + return barRes; + }, }; describe('basic functionality', () => { @@ -260,13 +298,6 @@ describe('Base editor', () => { instance = editor.createInstance({ el: editorEl, blobPath, blobContent }); }); - it('is extensible with the extensions', () => { - expect(instance.foo).toBeUndefined(); - - instance.use(MyExt1); - expect(instance.foo).toEqual(foo1); - }); - it('does not fail if no extensions supplied', () => { const spy = jest.spyOn(global.console, 'error'); instance.use(); @@ -274,24 +305,80 @@ describe('Base editor', () => { expect(spy).not.toHaveBeenCalled(); }); - it('is extensible with multiple extensions', () => { - expect(instance.foo).toBeUndefined(); - expect(instance.bar).toBeUndefined(); + it("does not extend instance with extension's constructor", () => { + expect(instance.constructor).toBeDefined(); + const { constructor } = instance; + + expect(AlphaExt.constructor).toBeDefined(); + expect(AlphaExt.constructor).not.toEqual(constructor); + + instance.use(AlphaExt); + expect(instance.constructor).toBe(constructor); + }); + + it.each` + type | extensions | methods | expectations + ${'ES6 classes'} | ${AlphaExt} | ${['alpha']} | ${[alphaRes]} + ${'multiple ES6 classes'} | ${[AlphaExt, BetaExt]} | ${['alpha', 'beta']} | ${[alphaRes, betaRes]} + ${'simple objects'} | ${FooObjExt} | ${['foo']} | ${[fooRes]} + ${'multiple simple objects'} | ${[FooObjExt, BarObjExt]} | ${['foo', 'bar']} | ${[fooRes, barRes]} + ${'combination of ES6 classes and objects'} | ${[AlphaExt, BarObjExt]} | ${['alpha', 'bar']} | ${[alphaRes, barRes]} + `('is extensible with $type', ({ extensions, methods, expectations } = {}) => { + methods.forEach(method => { + expect(instance[method]).toBeUndefined(); + }); - instance.use([MyExt1, MyExt2]); + instance.use(extensions); - expect(instance.foo).toEqual(foo1); - expect(instance.bar).toEqual(bar); + methods.forEach(method => { + expect(instance[method]).toBeDefined(); + }); + + expectations.forEach((expectation, i) => { + expect(instance[methods[i]].call()).toEqual(expectation); + }); }); + it('does not extend instance with private data of an extension', () => { + const ext = new WithStaticMethod({ instance }); + ext.staticMethod = () => { + return 'foo'; + }; + ext.staticProp = 'bar'; + + expect(instance.boo).toBeUndefined(); + expect(instance.staticMethod).toBeUndefined(); + expect(instance.staticProp).toBeUndefined(); + + instance.use(ext); + + expect(instance.boo).toBeDefined(); + expect(instance.staticMethod).toBeUndefined(); + expect(instance.staticProp).toBeUndefined(); + }); + + it.each([WithStaticMethod, WithStaticMethodExtended])( + 'properly resolves data for an extension with private data', + ExtClass => { + const base = 1; + expect(instance.base).toBeUndefined(); + expect(instance.boo).toBeUndefined(); + + const ext = new ExtClass({ instance, base }); + + instance.use(ext); + expect(instance.base).toBe(1); + expect(instance.boo()).toBe(2); + }, + ); + it('uses the last definition of a method in case of an overlap', () => { - instance.use([MyExt1, MyExt2, MyExt3]); - expect(instance).toEqual( - expect.objectContaining({ - foo: foo2, - bar, - }), - ); + const FooObjExt2 = { foo: 'foo2' }; + instance.use([FooObjExt, BarObjExt, FooObjExt2]); + expect(instance).toMatchObject({ + foo: 'foo2', + ...BarObjExt, + }); }); it('correctly resolves references withing extensions', () => { @@ -396,15 +483,15 @@ describe('Base editor', () => { }); it('extends all instances if no specific instance is passed', () => { - editor.use(MyExt1); - expect(inst1.foo).toEqual(foo1); - expect(inst2.foo).toEqual(foo1); + editor.use(AlphaExt); + expect(inst1.alpha()).toEqual(alphaRes); + expect(inst2.alpha()).toEqual(alphaRes); }); it('extends specific instance if it has been passed', () => { - editor.use(MyExt1, inst2); - expect(inst1.foo).toBeUndefined(); - expect(inst2.foo).toEqual(foo1); + editor.use(AlphaExt, inst2); + expect(inst1.alpha).toBeUndefined(); + expect(inst2.alpha()).toEqual(alphaRes); }); }); }); diff --git a/spec/frontend/editor/editor_markdown_ext_spec.js b/spec/frontend/editor/editor_markdown_ext_spec.js index 30ab29aad35..b432d4d66ad 100644 --- a/spec/frontend/editor/editor_markdown_ext_spec.js +++ b/spec/frontend/editor/editor_markdown_ext_spec.js @@ -1,6 +1,6 @@ import { Range, Position } from 'monaco-editor'; import EditorLite from '~/editor/editor_lite'; -import EditorMarkdownExtension from '~/editor/editor_markdown_ext'; +import { EditorMarkdownExtension } from '~/editor/editor_markdown_ext'; describe('Markdown Extension for Editor Lite', () => { let editor; @@ -31,7 +31,7 @@ describe('Markdown Extension for Editor Lite', () => { blobPath: filePath, blobContent: text, }); - editor.use(EditorMarkdownExtension); + editor.use(new EditorMarkdownExtension()); }); afterEach(() => { diff --git a/spec/frontend/environments/environment_actions_spec.js b/spec/frontend/environments/environment_actions_spec.js index d305f5e90bd..cc5153d6eba 100644 --- a/spec/frontend/environments/environment_actions_spec.js +++ b/spec/frontend/environments/environment_actions_spec.js @@ -1,51 +1,69 @@ -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, mount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; -import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import eventHub from '~/environments/event_hub'; import EnvironmentActions from '~/environments/components/environment_actions.vue'; +const scheduledJobAction = { + name: 'scheduled action', + playPath: `${TEST_HOST}/scheduled/job/action`, + playable: true, + scheduledAt: '2063-04-05T00:42:00Z', +}; + +const expiredJobAction = { + name: 'expired action', + playPath: `${TEST_HOST}/expired/job/action`, + playable: true, + scheduledAt: '2018-10-05T08:23:00Z', +}; + describe('EnvironmentActions Component', () => { - let vm; + let wrapper; - const findEnvironmentActionsButton = () => vm.find('[data-testid="environment-actions-button"]'); + const findEnvironmentActionsButton = () => + wrapper.find('[data-testid="environment-actions-button"]'); - beforeEach(() => { - vm = shallowMount(EnvironmentActions, { - propsData: { actions: [] }, + function createComponent(props, { mountFn = shallowMount } = {}) { + wrapper = mountFn(EnvironmentActions, { + propsData: { actions: [], ...props }, directives: { GlTooltip: createMockDirective(), }, }); - }); + } + + function createComponentWithScheduledJobs(opts = {}) { + return createComponent({ actions: [scheduledJobAction, expiredJobAction] }, opts); + } + + const findDropdownItem = action => { + const buttons = wrapper.findAll(GlDropdownItem); + return buttons.filter(button => button.text().startsWith(action.name)).at(0); + }; afterEach(() => { - vm.destroy(); + wrapper.destroy(); + wrapper = null; }); it('should render a dropdown button with 2 icons', () => { - expect(vm.find('.dropdown-new').findAll(GlIcon).length).toBe(2); + createComponent({}, { mountFn: mount }); + expect(wrapper.find(GlDropdown).findAll(GlIcon).length).toBe(2); }); it('should render a dropdown button with aria-label description', () => { - expect(vm.find('.dropdown-new').attributes('aria-label')).toEqual('Deploy to...'); + createComponent(); + expect(wrapper.find(GlDropdown).attributes('aria-label')).toBe('Deploy to...'); }); it('should render a tooltip', () => { + createComponent(); const tooltip = getBinding(findEnvironmentActionsButton().element, 'gl-tooltip'); expect(tooltip).toBeDefined(); }); - describe('is loading', () => { - beforeEach(() => { - vm.setData({ isLoading: true }); - }); - - it('should render a dropdown button with a loading icon', () => { - expect(vm.findAll(GlLoadingIcon).length).toBe(1); - }); - }); - describe('manual actions', () => { const actions = [ { @@ -64,68 +82,71 @@ describe('EnvironmentActions Component', () => { ]; beforeEach(() => { - vm.setProps({ actions }); + createComponent({ actions }); }); it('should render a dropdown with the provided list of actions', () => { - expect(vm.findAll('.dropdown-menu li').length).toEqual(actions.length); + expect(wrapper.findAll(GlDropdownItem)).toHaveLength(actions.length); }); it("should render a disabled action when it's not playable", () => { - expect(vm.find('.dropdown-menu li:last-child gl-button-stub').props('disabled')).toBe(true); + const dropdownItems = wrapper.findAll(GlDropdownItem); + const lastDropdownItem = dropdownItems.at(dropdownItems.length - 1); + expect(lastDropdownItem.attributes('disabled')).toBe('true'); }); }); describe('scheduled jobs', () => { - const scheduledJobAction = { - name: 'scheduled action', - playPath: `${TEST_HOST}/scheduled/job/action`, - playable: true, - scheduledAt: '2063-04-05T00:42:00Z', - }; - const expiredJobAction = { - name: 'expired action', - playPath: `${TEST_HOST}/expired/job/action`, - playable: true, - scheduledAt: '2018-10-05T08:23:00Z', - }; - const findDropdownItem = action => { - const buttons = vm.findAll('.dropdown-menu li gl-button-stub'); - return buttons.filter(button => button.text().startsWith(action.name)).at(0); + let emitSpy; + + const clickAndConfirm = async ({ confirm = true } = {}) => { + jest.spyOn(window, 'confirm').mockImplementation(() => confirm); + + findDropdownItem(scheduledJobAction).vm.$emit('click'); + await wrapper.vm.$nextTick(); }; beforeEach(() => { + emitSpy = jest.fn(); + eventHub.$on('postAction', emitSpy); jest.spyOn(Date, 'now').mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime()); - vm.setProps({ actions: [scheduledJobAction, expiredJobAction] }); }); - it('emits postAction event after confirming', () => { - const emitSpy = jest.fn(); - eventHub.$on('postAction', emitSpy); - jest.spyOn(window, 'confirm').mockImplementation(() => true); + describe('when postAction event is confirmed', () => { + beforeEach(async () => { + createComponentWithScheduledJobs({ mountFn: mount }); + clickAndConfirm(); + }); - findDropdownItem(scheduledJobAction).vm.$emit('click'); + it('emits postAction event', () => { + expect(window.confirm).toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledWith({ endpoint: scheduledJobAction.playPath }); + }); - expect(window.confirm).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith({ endpoint: scheduledJobAction.playPath }); + it('should render a dropdown button with a loading icon', () => { + expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true); + }); }); - it('does not emit postAction event if confirmation is cancelled', () => { - const emitSpy = jest.fn(); - eventHub.$on('postAction', emitSpy); - jest.spyOn(window, 'confirm').mockImplementation(() => false); - - findDropdownItem(scheduledJobAction).vm.$emit('click'); + describe('when postAction event is denied', () => { + beforeEach(() => { + createComponentWithScheduledJobs({ mountFn: mount }); + clickAndConfirm({ confirm: false }); + }); - expect(window.confirm).toHaveBeenCalled(); - expect(emitSpy).not.toHaveBeenCalled(); + it('does not emit postAction event if confirmation is cancelled', () => { + expect(window.confirm).toHaveBeenCalled(); + expect(emitSpy).not.toHaveBeenCalled(); + }); }); it('displays the remaining time in the dropdown', () => { + createComponentWithScheduledJobs(); expect(findDropdownItem(scheduledJobAction).text()).toContain('24:00:00'); }); it('displays 00:00:00 for expired jobs in the dropdown', () => { + createComponentWithScheduledJobs(); expect(findDropdownItem(expiredJobAction).text()).toContain('00:00:00'); }); }); diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js index 1b429783821..bc692352103 100644 --- a/spec/frontend/environments/environment_item_spec.js +++ b/spec/frontend/environments/environment_item_spec.js @@ -1,3 +1,4 @@ +import { cloneDeep } from 'lodash'; import { mount } from '@vue/test-utils'; import { format } from 'timeago.js'; import EnvironmentItem from '~/environments/components/environment_item.vue'; @@ -30,6 +31,11 @@ describe('Environment item', () => { }); const findAutoStop = () => wrapper.find('.js-auto-stop'); + const findUpcomingDeployment = () => wrapper.find('[data-testid="upcoming-deployment"]'); + const findUpcomingDeploymentContent = () => + wrapper.find('[data-testid="upcoming-deployment-content"]'); + const findUpcomingDeploymentStatusLink = () => + wrapper.find('[data-testid="upcoming-deployment-status-link"]'); afterEach(() => { wrapper.destroy(); @@ -87,6 +93,72 @@ describe('Environment item', () => { }); }); + describe('When the envionment has an upcoming deployment', () => { + describe('When the upcoming deployment has a deployable', () => { + it('should render the build ID and user', () => { + expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText( + '#27 by upcoming-username', + ); + }); + + it('should render a status icon with a link and tooltip', () => { + expect(findUpcomingDeploymentStatusLink().exists()).toBe(true); + + expect(findUpcomingDeploymentStatusLink().attributes().href).toBe( + '/root/environment-test/-/jobs/892', + ); + + expect(findUpcomingDeploymentStatusLink().attributes().title).toBe( + 'Deployment running', + ); + }); + }); + + describe('When the deployment does not have a deployable', () => { + beforeEach(() => { + const environmentWithoutDeployable = cloneDeep(environment); + delete environmentWithoutDeployable.upcoming_deployment.deployable; + + factory({ + propsData: { + model: environmentWithoutDeployable, + canReadEnvironment: true, + tableData, + }, + }); + }); + + it('should still renders the build ID and user', () => { + expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText( + '#27 by upcoming-username', + ); + }); + + it('should not render the status icon', () => { + expect(findUpcomingDeploymentStatusLink().exists()).toBe(false); + }); + }); + }); + + describe('Without upcoming deployment', () => { + beforeEach(() => { + const environmentWithoutUpcomingDeployment = cloneDeep(environment); + delete environmentWithoutUpcomingDeployment.upcoming_deployment; + + factory({ + propsData: { + model: environmentWithoutUpcomingDeployment, + canReadEnvironment: true, + tableData, + }, + }); + }); + + it('should not render anything in the upcoming deployment column', () => { + expect(findUpcomingDeploymentContent().exists()).toBe(false); + }); + }); + describe('Without auto-stop date', () => { beforeEach(() => { factory({ @@ -209,6 +281,10 @@ describe('Environment item', () => { it('should render the number of children in a badge', () => { expect(wrapper.find('.folder-name .badge').text()).toContain(folder.size); }); + + it('should not render the "Upcoming deployment" column', () => { + expect(findUpcomingDeployment().exists()).toBe(false); + }); }); describe('When environment can be deleted', () => { diff --git a/spec/frontend/environments/mock_data.js b/spec/frontend/environments/mock_data.js index 77c5dad0bbf..e7b99c8688c 100644 --- a/spec/frontend/environments/mock_data.js +++ b/spec/frontend/environments/mock_data.js @@ -86,6 +86,98 @@ const environment = { ], deployed_at: '2016-11-29T18:11:58.430Z', }, + upcoming_deployment: { + id: 82, + iid: 27, + sha: '1132df044b73943943c949e7ac2c2f120a89bf59', + ref: { + name: 'master', + ref_path: '/root/environment-test/-/tree/master', + }, + status: 'running', + created_at: '2020-12-04T19:57:49.514Z', + deployed_at: null, + tag: false, + 'last?': false, + user: { + id: 1, + name: 'Upcoming Name', + username: 'upcoming-username', + state: 'active', + avatar_url: 'http://0.0.0.0:3000/uploads/-/system/user/avatar/2/avatar.png', + web_url: 'http://0.0.0.0:3000/upcoming-username', + show_status: false, + path: '/upcoming-username', + }, + deployable: { + id: 1310, + name: 'deploy_to_development', + started: '2020-12-04T19:58:10.806Z', + archived: false, + build_path: '/root/environment-test/-/jobs/892', + cancel_path: + '/root/environment-test/-/jobs/892/cancel?continue%5Bto%5D=%2Froot%2Fenvironment-test%2F-%2Fjobs%2F892', + playable: false, + scheduled: false, + created_at: '2020-12-04T19:57:49.455Z', + updated_at: '2020-12-04T19:58:10.809Z', + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + tooltip: 'running', + has_details: true, + details_path: '/root/environment-test/-/jobs/892', + illustration: { + image: + '/assets/illustrations/skipped-job_empty-29a8a37d8a61d1b6f68cf3484f9024e53cd6eb95e28eae3554f8011a1146bf27.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_running-9c635b2419a8e1ec991c993061b89cc5aefc0743bb238ecd0c381e7741a70e8c.png', + action: { + icon: 'cancel', + title: 'Cancel', + path: '/root/environment-test/-/jobs/892/cancel', + method: 'post', + button_title: 'Cancel this job', + }, + }, + }, + commit: { + id: '1132df044b73943943c949e7ac2c2f120a89bf59', + short_id: '1132df04', + created_at: '2020-12-01T15:46:26.000-05:00', + parent_ids: ['e0808dee2a5877563ec140e65d8b41908f90098c'], + title: 'Update .gitlab-ci.yml', + message: 'Update .gitlab-ci.yml', + author_name: 'Upcoming Name', + author_email: 'admin@example.com', + authored_date: '2020-12-01T15:46:26.000-05:00', + committer_name: 'Upcoming Name', + committer_email: 'admin@example.com', + committed_date: '2020-12-01T15:46:26.000-05:00', + web_url: + 'http://0.0.0.0:3000/root/environment-test/-/commit/1132df044b73943943c949e7ac2c2f120a89bf59', + author: { + id: 1, + name: 'Upcoming Name', + username: 'upcoming-username', + state: 'active', + avatar_url: 'http://0.0.0.0:3000/uploads/-/system/user/avatar/2/avatar.png', + web_url: 'http://0.0.0.0:3000/upcoming-username', + show_status: false, + path: '/upcoming-username', + }, + author_gravatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + commit_url: + 'http://0.0.0.0:3000/root/environment-test/-/commit/1132df044b73943943c949e7ac2c2f120a89bf59', + commit_path: '/root/environment-test/-/commit/1132df044b73943943c949e7ac2c2f120a89bf59', + }, + }, has_stop_action: true, environment_path: 'root/ci-folders/environments/31', log_path: 'root/ci-folders/environments/31/logs', @@ -156,6 +248,11 @@ const tableData = { title: 'Updated', spacing: 'section-10', }, + upcoming: { + title: 'Upcoming', + mobileTitle: 'Upcoming deployment', + spacing: 'section-10', + }, autoStop: { title: 'Auto stop in', spacing: 'section-5', diff --git a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js index 67f4bee766b..06b9385b112 100644 --- a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js +++ b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js @@ -1,7 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import { GlModal, GlSprintf } from '@gitlab/ui'; +import { GlModal, GlSprintf, GlAlert } from '@gitlab/ui'; import Component from '~/feature_flags/components/configure_feature_flags_modal.vue'; -import Callout from '~/vue_shared/components/callout.vue'; describe('Configure Feature Flags Modal', () => { const mockEvent = { preventDefault: jest.fn() }; @@ -36,8 +35,8 @@ describe('Configure Feature Flags Modal', () => { const findGlModal = () => wrapper.find(GlModal); const findPrimaryAction = () => findGlModal().props('actionPrimary'); const findProjectNameInput = () => wrapper.find('#project_name_verification'); - const findDangerCallout = () => - wrapper.findAll(Callout).filter(c => c.props('category') === 'danger'); + const findDangerGlAlert = () => + wrapper.findAll(GlAlert).filter(c => c.props('variant') === 'danger'); describe('idle', () => { afterEach(() => wrapper.destroy()); @@ -86,10 +85,10 @@ describe('Configure Feature Flags Modal', () => { ); }); - it('should display one and only one danger callout', () => { - const dangerCallout = findDangerCallout(); - expect(dangerCallout.length).toBe(1); - expect(dangerCallout.at(0).props('message')).toMatch(/Regenerating the instance ID/); + it('should display one and only one danger alert', () => { + const dangerGlAlert = findDangerGlAlert(); + expect(dangerGlAlert.length).toBe(1); + expect(dangerGlAlert.at(0).text()).toMatch(/Regenerating the instance ID/); }); it('should display a message asking to fill the project name', () => { @@ -130,7 +129,7 @@ describe('Configure Feature Flags Modal', () => { }); it('should not display regenerating instance ID', async () => { - expect(findDangerCallout().exists()).toBe(false); + expect(findDangerGlAlert().exists()).toBe(false); }); it('should disable the project name input', async () => { diff --git a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js index 6a394251060..f8e25925774 100644 --- a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js +++ b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js @@ -4,7 +4,7 @@ import MockAdapter from 'axios-mock-adapter'; import { GlToggle, GlAlert } from '@gitlab/ui'; import { TEST_HOST } from 'spec/test_constants'; import { mockTracking } from 'helpers/tracking_helper'; -import { LEGACY_FLAG, NEW_VERSION_FLAG, NEW_FLAG_ALERT } from '~/feature_flags/constants'; +import { LEGACY_FLAG, NEW_VERSION_FLAG } from '~/feature_flags/constants'; import Form from '~/feature_flags/components/form.vue'; import createStore from '~/feature_flags/store/edit'; import EditFeatureFlag from '~/feature_flags/components/edit_feature_flag.vue'; @@ -37,9 +37,6 @@ describe('Edit feature flag form', () => { showUserCallout: true, userCalloutId, userCalloutsPath, - glFeatures: { - featureFlagsNewVersion: true, - }, ...opts, }, }); @@ -151,33 +148,4 @@ describe('Edit feature flag form', () => { }); }); }); - - describe('without new version flags', () => { - beforeEach(() => factory({ glFeatures: { featureFlagsNewVersion: false } })); - - it('should alert users that feature flags are changing soon', () => { - expect(findAlert().text()).toBe(NEW_FLAG_ALERT); - }); - }); - - describe('dismissing new version alert', () => { - beforeEach(() => { - factory({ glFeatures: { featureFlagsNewVersion: false } }); - mock.onPost(userCalloutsPath, { feature_name: userCalloutId }).reply(200); - findAlert().vm.$emit('dismiss'); - return wrapper.vm.$nextTick(); - }); - - afterEach(() => { - mock.restore(); - }); - - it('should hide the alert', () => { - expect(findAlert().exists()).toBe(false); - }); - - it('should send the dismissal event', () => { - expect(mock.history.post.length).toBe(1); - }); - }); }); diff --git a/spec/frontend/feature_flags/components/feature_flags_tab_spec.js b/spec/frontend/feature_flags/components/feature_flags_tab_spec.js index bc90c5ceb2d..23cc7045d1f 100644 --- a/spec/frontend/feature_flags/components/feature_flags_tab_spec.js +++ b/spec/frontend/feature_flags/components/feature_flags_tab_spec.js @@ -12,6 +12,7 @@ const DEFAULT_PROPS = { errorTitle: 'test title', emptyState: true, emptyTitle: 'test empty', + emptyDescription: 'empty description', }; const DEFAULT_PROVIDE = { @@ -115,9 +116,7 @@ describe('feature_flags/components/feature_flags_tab.vue', () => { it('should show an empty state if it is empty', () => { expect(emptyState.text()).toContain(DEFAULT_PROPS.emptyTitle); - expect(emptyState.text()).toContain( - 'Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.', - ); + expect(emptyState.text()).toContain(DEFAULT_PROPS.emptyDescription); expect(emptyState.props('svgPath')).toBe(DEFAULT_PROVIDE.errorStateSvgPath); expect(emptyStateLink.attributes('href')).toBe(DEFAULT_PROVIDE.featureFlagsHelpPagePath); expect(emptyStateLink.text()).toBe('More information'); diff --git a/spec/frontend/feature_flags/components/new_feature_flag_spec.js b/spec/frontend/feature_flags/components/new_feature_flag_spec.js index dbc6e03d922..e317ac4b092 100644 --- a/spec/frontend/feature_flags/components/new_feature_flag_spec.js +++ b/spec/frontend/feature_flags/components/new_feature_flag_spec.js @@ -1,17 +1,11 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; -import MockAdapter from 'axios-mock-adapter'; import { GlAlert } from '@gitlab/ui'; import { TEST_HOST } from 'spec/test_constants'; import Form from '~/feature_flags/components/form.vue'; import createStore from '~/feature_flags/store/new'; import NewFeatureFlag from '~/feature_flags/components/new_feature_flag.vue'; -import { - ROLLOUT_STRATEGY_ALL_USERS, - DEFAULT_PERCENT_ROLLOUT, - NEW_FLAG_ALERT, -} from '~/feature_flags/constants'; -import axios from '~/lib/utils/axios_utils'; +import { ROLLOUT_STRATEGY_ALL_USERS, DEFAULT_PERCENT_ROLLOUT } from '~/feature_flags/constants'; import { allUsersStrategy } from '../mock_data'; const userCalloutId = 'feature_flags_new_version'; @@ -42,9 +36,6 @@ describe('New feature flag form', () => { userCalloutsPath, environmentsEndpoint: 'environments.json', projectId: '8', - glFeatures: { - featureFlagsNewVersion: true, - }, ...opts, }, }); @@ -58,8 +49,6 @@ describe('New feature flag form', () => { wrapper.destroy(); }); - const findAlert = () => wrapper.find(GlAlert); - describe('with error', () => { it('should render the error', () => { store.dispatch('receiveCreateFeatureFlagError', { message: ['The name is required'] }); @@ -101,36 +90,4 @@ describe('New feature flag form', () => { expect(strategies).toEqual([allUsersStrategy]); }); - - describe('without new version flags', () => { - beforeEach(() => factory({ glFeatures: { featureFlagsNewVersion: false } })); - - it('should alert users that feature flags are changing soon', () => { - expect(findAlert().text()).toBe(NEW_FLAG_ALERT); - }); - }); - - describe('dismissing new version alert', () => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onPost(userCalloutsPath, { feature_name: userCalloutId }).reply(200); - factory({ glFeatures: { featureFlagsNewVersion: false } }); - findAlert().vm.$emit('dismiss'); - return wrapper.vm.$nextTick(); - }); - - afterEach(() => { - mock.restore(); - }); - - it('should hide the alert', () => { - expect(findAlert().exists()).toBe(false); - }); - - it('should send the dismissal event', () => { - expect(mock.history.post.length).toBe(1); - }); - }); }); diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js index 5c37d986ef1..b1c299ba91f 100644 --- a/spec/frontend/filtered_search/filtered_search_manager_spec.js +++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js @@ -69,7 +69,7 @@ describe('Filtered Search Manager', () => { ${FilteredSearchSpecHelper.createInputHTML(placeholder)} </ul> <button class="clear-search" type="button"> - <i class="fa fa-times"></i> + <svg class="s16 clear-search-icon" data-testid="close-icon"><use xlink:href="icons.svg#close" /></svg> </button> </form> </div> diff --git a/spec/frontend/fixtures/abuse_reports.rb b/spec/frontend/fixtures/abuse_reports.rb index 48b055fcda5..f5524a10033 100644 --- a/spec/frontend/fixtures/abuse_reports.rb +++ b/spec/frontend/fixtures/abuse_reports.rb @@ -4,6 +4,7 @@ require 'spec_helper' RSpec.describe Admin::AbuseReportsController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers + include AdminModeHelper let(:admin) { create(:admin) } let!(:abuse_report) { create(:abuse_report) } @@ -18,6 +19,7 @@ RSpec.describe Admin::AbuseReportsController, '(JavaScript fixtures)', type: :co before do sign_in(admin) + enable_admin_mode!(admin) end it 'abuse_reports/abuse_reports_list.html' do diff --git a/spec/frontend/fixtures/admin_users.rb b/spec/frontend/fixtures/admin_users.rb index f068ada53e1..e0fecbdb1aa 100644 --- a/spec/frontend/fixtures/admin_users.rb +++ b/spec/frontend/fixtures/admin_users.rb @@ -5,12 +5,14 @@ require 'spec_helper' RSpec.describe Admin::UsersController, '(JavaScript fixtures)', type: :controller do include StubENV include JavaScriptFixturesHelpers + include AdminModeHelper let(:admin) { create(:admin) } before do stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') sign_in(admin) + enable_admin_mode!(admin) end render_views diff --git a/spec/frontend/fixtures/application_settings.rb b/spec/frontend/fixtures/application_settings.rb index 6156e6a43bc..ebccecb32ba 100644 --- a/spec/frontend/fixtures/application_settings.rb +++ b/spec/frontend/fixtures/application_settings.rb @@ -5,6 +5,7 @@ require 'spec_helper' RSpec.describe Admin::ApplicationSettingsController, '(JavaScript fixtures)', type: :controller do include StubENV include JavaScriptFixturesHelpers + include AdminModeHelper let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} @@ -13,6 +14,7 @@ RSpec.describe Admin::ApplicationSettingsController, '(JavaScript fixtures)', ty before do stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') sign_in(admin) + enable_admin_mode!(admin) end render_views diff --git a/spec/frontend/fixtures/autocomplete_sources.rb b/spec/frontend/fixtures/autocomplete_sources.rb index 8858d69a939..9ff0f959c11 100644 --- a/spec/frontend/fixtures/autocomplete_sources.rb +++ b/spec/frontend/fixtures/autocomplete_sources.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Projects::AutocompleteSourcesController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers - let_it_be(:admin) { create(:admin) } + let_it_be(:user) { create(:user) } let_it_be(:group) { create(:group, name: 'frontend-fixtures') } let_it_be(:project) { create(:project, namespace: group, path: 'autocomplete-sources-project') } let_it_be(:issue) { create(:issue, project: project) } @@ -15,7 +15,8 @@ RSpec.describe Projects::AutocompleteSourcesController, '(JavaScript fixtures)', end before do - sign_in(admin) + group.add_owner(user) + sign_in(user) end it 'autocomplete_sources/labels.json' do diff --git a/spec/frontend/fixtures/blob.rb b/spec/frontend/fixtures/blob.rb index a365ee805af..b112886b2ca 100644 --- a/spec/frontend/fixtures/blob.rb +++ b/spec/frontend/fixtures/blob.rb @@ -5,9 +5,9 @@ require 'spec_helper' RSpec.describe Projects::BlobController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers - let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project, :repository, namespace: namespace, path: 'branches-project') } + let(:user) { project.owner } render_views @@ -16,7 +16,7 @@ RSpec.describe Projects::BlobController, '(JavaScript fixtures)', type: :control end before do - sign_in(admin) + sign_in(user) allow(SecureRandom).to receive(:hex).and_return('securerandomhex:thereisnospoon') end diff --git a/spec/frontend/fixtures/boards.rb b/spec/frontend/fixtures/boards.rb deleted file mode 100644 index 90e2ca4db63..00000000000 --- a/spec/frontend/fixtures/boards.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Projects::BoardsController, '(JavaScript fixtures)', type: :controller do - include JavaScriptFixturesHelpers - - let(:admin) { create(:admin) } - let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} - let(:project) { create(:project, :repository, namespace: namespace, path: 'boards-project') } - - render_views - - before(:all) do - clean_frontend_fixtures('boards/') - end - - before do - sign_in(admin) - end - - it 'boards/show.html' do - get(:index, params: { - namespace_id: project.namespace, - project_id: project - }) - - expect(response).to be_successful - end -end diff --git a/spec/frontend/fixtures/branches.rb b/spec/frontend/fixtures/branches.rb index df2d1af7ecf..f3b3633347d 100644 --- a/spec/frontend/fixtures/branches.rb +++ b/spec/frontend/fixtures/branches.rb @@ -5,9 +5,9 @@ require 'spec_helper' RSpec.describe 'Branches (JavaScript fixtures)' do include JavaScriptFixturesHelpers - let_it_be(:admin) { create(:admin) } let_it_be(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'branches-project') } + let_it_be(:user) { project.owner } before(:all) do clean_frontend_fixtures('branches/') @@ -22,7 +22,7 @@ RSpec.describe 'Branches (JavaScript fixtures)' do render_views before do - sign_in(admin) + sign_in(user) end it 'branches/new_branch.html' do @@ -44,7 +44,7 @@ RSpec.describe 'Branches (JavaScript fixtures)' do # - "master": default, protected # - "markdown": non-default, protected # - "many_files": non-default, not protected - get api("/projects/#{project.id}/repository/branches?search=ma", admin) + get api("/projects/#{project.id}/repository/branches?search=ma", user) expect(response).to be_successful end diff --git a/spec/frontend/fixtures/clusters.rb b/spec/frontend/fixtures/clusters.rb index d0940c7dc7f..b37aa137504 100644 --- a/spec/frontend/fixtures/clusters.rb +++ b/spec/frontend/fixtures/clusters.rb @@ -5,10 +5,10 @@ require 'spec_helper' RSpec.describe Projects::ClustersController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers - let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project, :repository, namespace: namespace) } let(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) } + let(:user) { project.owner } render_views @@ -17,7 +17,7 @@ RSpec.describe Projects::ClustersController, '(JavaScript fixtures)', type: :con end before do - sign_in(admin) + sign_in(user) end after do diff --git a/spec/frontend/fixtures/commit.rb b/spec/frontend/fixtures/commit.rb index 9175a757b73..ff62a8286fc 100644 --- a/spec/frontend/fixtures/commit.rb +++ b/spec/frontend/fixtures/commit.rb @@ -6,14 +6,12 @@ RSpec.describe 'Commit (JavaScript fixtures)' do include JavaScriptFixturesHelpers let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { create(:user) } + let_it_be(:user) { project.owner } let_it_be(:commit) { project.commit("master") } before(:all) do clean_frontend_fixtures('commit/') clean_frontend_fixtures('api/commits/') - - project.add_maintainer(user) end before do diff --git a/spec/frontend/fixtures/deploy_keys.rb b/spec/frontend/fixtures/deploy_keys.rb index e87600e9d24..5c24c071792 100644 --- a/spec/frontend/fixtures/deploy_keys.rb +++ b/spec/frontend/fixtures/deploy_keys.rb @@ -4,6 +4,7 @@ require 'spec_helper' RSpec.describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers + include AdminModeHelper let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} @@ -17,7 +18,10 @@ RSpec.describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :c end before do + # Using an admin for these fixtures because they are used for verifying a frontend + # component that would normally get its data from `Admin::DeployKeysController` sign_in(admin) + enable_admin_mode!(admin) end after do diff --git a/spec/frontend/fixtures/emojis.rb b/spec/frontend/fixtures/emojis.rb deleted file mode 100644 index b95c7632917..00000000000 --- a/spec/frontend/fixtures/emojis.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Emojis (JavaScript fixtures)', type: :request do - include JavaScriptFixturesHelpers - - before(:all) do - clean_frontend_fixtures('emojis/') - end - - it 'emojis/emojis.json' do |example| - get '/-/emojis/1/emojis.json' - - expect(response).to be_successful - end -end diff --git a/spec/frontend/fixtures/freeze_period.rb b/spec/frontend/fixtures/freeze_period.rb index 193bd0c3ef2..09e4f969e1d 100644 --- a/spec/frontend/fixtures/freeze_period.rb +++ b/spec/frontend/fixtures/freeze_period.rb @@ -4,10 +4,10 @@ require 'spec_helper' RSpec.describe 'Freeze Periods (JavaScript fixtures)' do include JavaScriptFixturesHelpers - include Ci::PipelineSchedulesHelper + include TimeZoneHelper - let_it_be(:admin) { create(:admin) } let_it_be(:project) { create(:project, :repository, path: 'freeze-periods-project') } + let_it_be(:user) { project.owner } before(:all) do clean_frontend_fixtures('api/freeze-periods/') @@ -34,16 +34,18 @@ RSpec.describe 'Freeze Periods (JavaScript fixtures)' do create(:ci_freeze_period, project: project, freeze_start: '0 12 * * 1-5', freeze_end: '0 1 5 * *', cron_timezone: 'Etc/UTC') create(:ci_freeze_period, project: project, freeze_start: '0 12 * * 1-5', freeze_end: '0 16 * * 6', cron_timezone: 'Europe/Berlin') - get api("/projects/#{project.id}/freeze_periods", admin) + get api("/projects/#{project.id}/freeze_periods", user) expect(response).to be_successful end end - describe Ci::PipelineSchedulesHelper, '(JavaScript fixtures)' do + describe TimeZoneHelper, '(JavaScript fixtures)' do let(:response) { timezone_data.to_json } it 'api/freeze-periods/timezone_data.json' do + # Looks empty but does things + # More info: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38525/diffs#note_391048415 end end end diff --git a/spec/frontend/fixtures/groups.rb b/spec/frontend/fixtures/groups.rb index 9f0b2c73c93..42aad9f187e 100644 --- a/spec/frontend/fixtures/groups.rb +++ b/spec/frontend/fixtures/groups.rb @@ -5,20 +5,20 @@ require 'spec_helper' RSpec.describe 'Groups (JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers - let(:admin) { create(:admin) } + let(:user) { create(:user) } let(:group) { create(:group, name: 'frontend-fixtures-group', runners_token: 'runnerstoken:intabulasreferre')} - render_views - before(:all) do clean_frontend_fixtures('groups/') end before do - group.add_maintainer(admin) - sign_in(admin) + group.add_owner(user) + sign_in(user) end + render_views + describe GroupsController, '(JavaScript fixtures)', type: :controller do it 'groups/edit.html' do get :edit, params: { id: group } diff --git a/spec/frontend/fixtures/issues.rb b/spec/frontend/fixtures/issues.rb index baea87be45f..a027247bd0d 100644 --- a/spec/frontend/fixtures/issues.rb +++ b/spec/frontend/fixtures/issues.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers - let(:admin) { create(:admin, feed_token: 'feedtoken:coldfeed') } + let(:user) { create(:user, feed_token: 'feedtoken:coldfeed') } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project_empty_repo, namespace: namespace, path: 'issues-project') } @@ -16,9 +16,8 @@ RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', type: :contr end before do - stub_feature_flags(vue_issue_header: false) - - sign_in(admin) + project.add_maintainer(user) + sign_in(user) end after do @@ -42,17 +41,6 @@ RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', type: :contr render_issue(create(:closed_issue, project: project)) end - it 'issues/issue-with-task-list.html' do - issue = create(:issue, project: project, description: '- [ ] Task List Item') - render_issue(issue) - end - - it 'issues/issue_with_comment.html' do - issue = create(:issue, project: project) - create(:note, project: project, noteable: issue, note: '- [ ] Task List Item').save - render_issue(issue) - end - it 'issues/issue_list.html' do create(:issue, project: project) diff --git a/spec/frontend/fixtures/jobs.rb b/spec/frontend/fixtures/jobs.rb index 64197a62301..22179c790bd 100644 --- a/spec/frontend/fixtures/jobs.rb +++ b/spec/frontend/fixtures/jobs.rb @@ -5,9 +5,9 @@ require 'spec_helper' RSpec.describe Projects::JobsController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers - let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project, :repository, namespace: namespace, path: 'builds-project') } + let(:user) { project.owner } let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.id) } let!(:build_with_artifacts) { create(:ci_build, :success, :artifacts, :trace_artifact, pipeline: pipeline, stage: 'test', artifacts_expire_at: Time.now + 18.months) } let!(:failed_build) { create(:ci_build, :failed, pipeline: pipeline, stage: 'build') } @@ -22,28 +22,17 @@ RSpec.describe Projects::JobsController, '(JavaScript fixtures)', type: :control render_views before(:all) do - clean_frontend_fixtures('builds/') clean_frontend_fixtures('jobs/') end before do - sign_in(admin) + sign_in(user) end after do remove_repository(project) end - it 'builds/build-with-artifacts.html' do - get :show, params: { - namespace_id: project.namespace.to_param, - project_id: project, - id: build_with_artifacts.to_param - } - - expect(response).to be_successful - end - it 'jobs/delayed.json' do get :show, params: { namespace_id: project.namespace.to_param, diff --git a/spec/frontend/fixtures/labels.rb b/spec/frontend/fixtures/labels.rb index 2b7babb2e52..d7ca2aff18c 100644 --- a/spec/frontend/fixtures/labels.rb +++ b/spec/frontend/fixtures/labels.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe 'Labels (JavaScript fixtures)' do include JavaScriptFixturesHelpers - let(:admin) { create(:admin) } + let(:user) { create(:user) } let(:group) { create(:group, name: 'frontend-fixtures-group' )} let(:project) { create(:project_empty_repo, namespace: group, path: 'labels-project') } @@ -25,28 +25,10 @@ RSpec.describe 'Labels (JavaScript fixtures)' do remove_repository(project) end - describe Groups::LabelsController, '(JavaScript fixtures)', type: :controller do - render_views - - before do - sign_in(admin) - end - - it 'labels/group_labels.json' do - get :index, params: { - group_id: group - }, format: 'json' - - expect(response).to be_successful - end - end - describe API::Helpers::LabelHelpers, type: :request do include JavaScriptFixturesHelpers include ApiHelpers - let(:user) { create(:user) } - before do group.add_owner(user) end @@ -62,7 +44,8 @@ RSpec.describe 'Labels (JavaScript fixtures)' do render_views before do - sign_in(admin) + group.add_owner(user) + sign_in(user) end it 'labels/project_labels.json' do diff --git a/spec/frontend/fixtures/merge_requests.rb b/spec/frontend/fixtures/merge_requests.rb index 6f281b26e6d..acce3891ada 100644 --- a/spec/frontend/fixtures/merge_requests.rb +++ b/spec/frontend/fixtures/merge_requests.rb @@ -5,15 +5,15 @@ require 'spec_helper' RSpec.describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers - let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project, :repository, namespace: namespace, path: 'merge-requests-project') } + let(:user) { project.owner } # rubocop: disable Layout/TrailingWhitespace let(:description) do <<~MARKDOWN.strip_heredoc - [ ] Task List Item - - [ ] + - [ ] - [ ] Task List Item 2 MARKDOWN end @@ -55,7 +55,7 @@ RSpec.describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: end before do - sign_in(admin) + sign_in(user) allow(Discussion).to receive(:build_discussion_id).and_return(['discussionid:ceterumcenseo']) end @@ -64,7 +64,7 @@ RSpec.describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: end it 'merge_requests/merge_request_of_current_user.html' do - merge_request.update(author: admin) + merge_request.update(author: user) render_merge_request(merge_request) end @@ -75,38 +75,20 @@ RSpec.describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: render_merge_request(merge_request) end - it 'merge_requests/merged_merge_request.html' do - expect_next_instance_of(MergeRequest) do |merge_request| - allow(merge_request).to receive(:source_branch_exists?).and_return(true) - allow(merge_request).to receive(:can_remove_source_branch?).and_return(true) - end - render_merge_request(merged_merge_request) - end - it 'merge_requests/diff_comment.html' do - create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request) - create(:note_on_merge_request, author: admin, project: project, noteable: merge_request) + create(:diff_note_on_merge_request, project: project, author: user, position: position, noteable: merge_request) + create(:note_on_merge_request, author: user, project: project, noteable: merge_request) render_merge_request(merge_request) end - it 'merge_requests/merge_request_with_comment.html' do - create(:note_on_merge_request, author: admin, project: project, noteable: merge_request, note: '- [ ] Task List Item') - render_merge_request(merge_request) - end - - it 'merge_requests/discussions.json' do - create(:discussion_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request) - render_discussions_json(merge_request) - end - it 'merge_requests/diff_discussion.json' do - create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request) + create(:diff_note_on_merge_request, project: project, author: user, position: position, noteable: merge_request) render_discussions_json(merge_request) end it 'merge_requests/resolved_diff_discussion.json' do - note = create(:discussion_note_on_merge_request, :resolved, project: project, author: admin, position: position, noteable: merge_request) - create(:system_note, project: project, author: admin, noteable: merge_request, discussion_id: note.discussion.id) + note = create(:discussion_note_on_merge_request, :resolved, project: project, author: user, position: position, noteable: merge_request) + create(:system_note, project: project, author: user, noteable: merge_request, discussion_id: note.discussion.id) render_discussions_json(merge_request) end @@ -129,7 +111,7 @@ RSpec.describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: context 'with mentions' do let(:group) { create(:group) } - let(:description) { "@#{group.full_path} @all @#{admin.username}" } + let(:description) { "@#{group.full_path} @all @#{user.username}" } it 'merge_requests/merge_request_with_mentions.html' do render_merge_request(merge_request) diff --git a/spec/frontend/fixtures/merge_requests_diffs.rb b/spec/frontend/fixtures/merge_requests_diffs.rb index 63bd02d0fbd..6e07ef679f5 100644 --- a/spec/frontend/fixtures/merge_requests_diffs.rb +++ b/spec/frontend/fixtures/merge_requests_diffs.rb @@ -5,9 +5,9 @@ require 'spec_helper' RSpec.describe Projects::MergeRequests::DiffsController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers - let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project, :repository, namespace: namespace, path: 'merge-requests-project') } + let(:user) { project.owner } let(:merge_request) { create(:merge_request, :with_diffs, source_project: project, target_project: project, description: '- [ ] Task List Item') } let(:path) { "files/ruby/popen.rb" } let(:position) do @@ -25,7 +25,7 @@ RSpec.describe Projects::MergeRequests::DiffsController, '(JavaScript fixtures)' end before do - sign_in(admin) + sign_in(user) end after do @@ -40,18 +40,6 @@ RSpec.describe Projects::MergeRequests::DiffsController, '(JavaScript fixtures)' render_merge_request(merge_request, commit_id: project.commit.sha) end - it 'merge_request_diffs/inline_changes_tab_with_comments.json' do - create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request) - create(:note_on_merge_request, author: admin, project: project, noteable: merge_request) - render_merge_request(merge_request) - end - - it 'merge_request_diffs/parallel_changes_tab_with_comments.json' do - create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request) - create(:note_on_merge_request, author: admin, project: project, noteable: merge_request) - render_merge_request(merge_request, view: 'parallel') - end - private def render_merge_request(merge_request, view: 'inline', **extra_params) diff --git a/spec/frontend/fixtures/pipeline_schedules.rb b/spec/frontend/fixtures/pipeline_schedules.rb index e47bb25ec0a..a7d43fdbe62 100644 --- a/spec/frontend/fixtures/pipeline_schedules.rb +++ b/spec/frontend/fixtures/pipeline_schedules.rb @@ -5,11 +5,11 @@ require 'spec_helper' RSpec.describe Projects::PipelineSchedulesController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers - let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project, :public, :repository) } - let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: admin) } - let!(:pipeline_schedule_populated) { create(:ci_pipeline_schedule, project: project, owner: admin) } + let(:user) { project.owner } + let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: user) } + let!(:pipeline_schedule_populated) { create(:ci_pipeline_schedule, project: project, owner: user) } let!(:pipeline_schedule_variable1) { create(:ci_pipeline_schedule_variable, key: 'foo', value: 'foovalue', pipeline_schedule: pipeline_schedule_populated) } let!(:pipeline_schedule_variable2) { create(:ci_pipeline_schedule_variable, key: 'bar', value: 'barvalue', pipeline_schedule: pipeline_schedule_populated) } @@ -20,7 +20,7 @@ RSpec.describe Projects::PipelineSchedulesController, '(JavaScript fixtures)', t end before do - sign_in(admin) + sign_in(user) end it 'pipeline_schedules/edit.html' do diff --git a/spec/frontend/fixtures/pipelines.rb b/spec/frontend/fixtures/pipelines.rb index 93e2c19fc27..4270e38afcb 100644 --- a/spec/frontend/fixtures/pipelines.rb +++ b/spec/frontend/fixtures/pipelines.rb @@ -5,7 +5,6 @@ require 'spec_helper' RSpec.describe Projects::PipelinesController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers - let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project, :repository, namespace: namespace, path: 'pipelines-project') } let(:commit) { create(:commit, project: project) } @@ -22,7 +21,7 @@ RSpec.describe Projects::PipelinesController, '(JavaScript fixtures)', type: :co end before do - sign_in(admin) + sign_in(user) end it 'pipelines/pipelines.json' do diff --git a/spec/frontend/fixtures/projects.rb b/spec/frontend/fixtures/projects.rb index d0cedb0ef86..aa2f7dbed36 100644 --- a/spec/frontend/fixtures/projects.rb +++ b/spec/frontend/fixtures/projects.rb @@ -7,11 +7,11 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do runners_token = 'runnerstoken:intabulasreferre' - let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project, namespace: namespace, path: 'builds-project', runners_token: runners_token) } let(:project_with_repo) { create(:project, :repository, description: 'Code and stuff') } let(:project_variable_populated) { create(:project, namespace: namespace, path: 'builds-project2', runners_token: runners_token) } + let(:user) { project.owner } render_views @@ -20,8 +20,8 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do end before do - project.add_maintainer(admin) - sign_in(admin) + project_with_repo.add_maintainer(user) + sign_in(user) allow(SecureRandom).to receive(:hex).and_return('securerandomhex:thereisnospoon') end @@ -30,15 +30,6 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do end describe ProjectsController, '(JavaScript fixtures)', type: :controller do - it 'projects/dashboard.html' do - get :show, params: { - namespace_id: project.namespace.to_param, - id: project - } - - expect(response).to be_successful - end - it 'projects/overview.html' do get :show, params: { namespace_id: project_with_repo.namespace.to_param, diff --git a/spec/frontend/fixtures/prometheus_service.rb b/spec/frontend/fixtures/prometheus_service.rb index 8c923d91d08..3a59ecf3868 100644 --- a/spec/frontend/fixtures/prometheus_service.rb +++ b/spec/frontend/fixtures/prometheus_service.rb @@ -5,10 +5,10 @@ require 'spec_helper' RSpec.describe Projects::ServicesController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers - let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project_empty_repo, namespace: namespace, path: 'services-project') } let!(:service) { create(:prometheus_service, project: project) } + let(:user) { project.owner } render_views @@ -17,7 +17,7 @@ RSpec.describe Projects::ServicesController, '(JavaScript fixtures)', type: :con end before do - sign_in(admin) + sign_in(user) end after do diff --git a/spec/frontend/fixtures/raw.rb b/spec/frontend/fixtures/raw.rb index 337067121d0..cf51f2389bc 100644 --- a/spec/frontend/fixtures/raw.rb +++ b/spec/frontend/fixtures/raw.rb @@ -10,19 +10,17 @@ RSpec.describe 'Raw files', '(JavaScript fixtures)' do let(:response) { @blob.data.force_encoding('UTF-8') } before(:all) do - clean_frontend_fixtures('blob/balsamiq/') clean_frontend_fixtures('blob/notebook/') clean_frontend_fixtures('blob/pdf/') + clean_frontend_fixtures('blob/text/') + clean_frontend_fixtures('blob/binary/') + clean_frontend_fixtures('blob/images/') end after do remove_repository(project) end - it 'blob/balsamiq/test.bmpr' do - @blob = project.repository.blob_at('b89b56d79', 'files/images/balsamiq.bmpr') - end - it 'blob/notebook/basic.json' do @blob = project.repository.blob_at('6d85bb69', 'files/ipython/basic.ipynb') end @@ -38,4 +36,16 @@ RSpec.describe 'Raw files', '(JavaScript fixtures)' do it 'blob/pdf/test.pdf' do @blob = project.repository.blob_at('e774ebd33', 'files/pdf/test.pdf') end + + it 'blob/text/README.md' do + @blob = project.repository.blob_at('e774ebd33', 'README.md') + end + + it 'blob/images/logo-white.png' do + @blob = project.repository.blob_at('e774ebd33', 'files/images/logo-white.png') + end + + it 'blob/binary/Gemfile.zip' do + @blob = project.repository.blob_at('e774ebd33', 'Gemfile.zip') + end end diff --git a/spec/frontend/fixtures/search.rb b/spec/frontend/fixtures/search.rb index 7819d0774a7..264ce7d010c 100644 --- a/spec/frontend/fixtures/search.rb +++ b/spec/frontend/fixtures/search.rb @@ -7,7 +7,7 @@ RSpec.describe SearchController, '(JavaScript fixtures)', type: :controller do render_views - let_it_be(:user) { create(:admin) } + let_it_be(:user) { create(:user) } before(:all) do clean_frontend_fixtures('search/') @@ -66,9 +66,13 @@ RSpec.describe SearchController, '(JavaScript fixtures)', type: :controller do offset: 0) end + before do + project.add_developer(user) + end + it 'search/blob_search_result.html' do - expect_next_instance_of(SearchService) do |search_service| - expect(search_service).to receive(:search_objects).and_return(blobs) + allow_next_instance_of(SearchServicePresenter) do |search_service| + allow(search_service).to receive(:search_objects).and_return(blobs) end get :show, params: { diff --git a/spec/frontend/fixtures/services.rb b/spec/frontend/fixtures/services.rb index 43230301296..7472af802f3 100644 --- a/spec/frontend/fixtures/services.rb +++ b/spec/frontend/fixtures/services.rb @@ -5,10 +5,10 @@ require 'spec_helper' RSpec.describe Projects::ServicesController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers - let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project_empty_repo, namespace: namespace, path: 'services-project') } let!(:service) { create(:custom_issue_tracker_service, project: project) } + let(:user) { project.owner } render_views @@ -17,7 +17,7 @@ RSpec.describe Projects::ServicesController, '(JavaScript fixtures)', type: :con end before do - sign_in(admin) + sign_in(user) end after do diff --git a/spec/frontend/fixtures/snippet.rb b/spec/frontend/fixtures/snippet.rb index 2e67a2ecfe3..5211d52f374 100644 --- a/spec/frontend/fixtures/snippet.rb +++ b/spec/frontend/fixtures/snippet.rb @@ -5,10 +5,10 @@ require 'spec_helper' RSpec.describe SnippetsController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers - let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project, :repository, namespace: namespace, path: 'branches-project') } - let(:snippet) { create(:personal_snippet, :public, title: 'snippet.md', content: '# snippet', file_name: 'snippet.md', author: admin) } + let(:user) { project.owner } + let(:snippet) { create(:personal_snippet, :public, title: 'snippet.md', content: '# snippet', file_name: 'snippet.md', author: user) } render_views @@ -17,7 +17,7 @@ RSpec.describe SnippetsController, '(JavaScript fixtures)', type: :controller do end before do - sign_in(admin) + sign_in(user) allow(Discussion).to receive(:build_discussion_id).and_return(['discussionid:ceterumcenseo']) end @@ -26,7 +26,7 @@ RSpec.describe SnippetsController, '(JavaScript fixtures)', type: :controller do end it 'snippets/show.html' do - create(:discussion_note_on_project_snippet, noteable: snippet, project: project, author: admin, note: '- [ ] Task List Item') + create(:discussion_note_on_project_snippet, noteable: snippet, project: project, author: user, note: '- [ ] Task List Item') get(:show, params: { id: snippet.to_param }) diff --git a/spec/frontend/fixtures/static/balsamiq_viewer.html b/spec/frontend/fixtures/static/balsamiq_viewer.html deleted file mode 100644 index cdd723d1a84..00000000000 --- a/spec/frontend/fixtures/static/balsamiq_viewer.html +++ /dev/null @@ -1 +0,0 @@ -<div class="file-content balsamiq-viewer" data-endpoint="/test" id="js-balsamiq-viewer"></div> diff --git a/spec/frontend/fixtures/static/create_item_dropdown.html b/spec/frontend/fixtures/static/create_item_dropdown.html index d2d38370092..aac7d3397ce 100644 --- a/spec/frontend/fixtures/static/create_item_dropdown.html +++ b/spec/frontend/fixtures/static/create_item_dropdown.html @@ -1,11 +1,42 @@ <div class="js-create-item-dropdown-fixture-root"> -<input name="variable[environment]" type="hidden"> -<div class="dropdown "><button class="dropdown-menu-toggle js-dropdown-menu-toggle" type="button" data-toggle="dropdown"><span class="dropdown-toggle-text ">some label</span><i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i></button><div class="dropdown-menu dropdown-select dropdown-menu-selectable"><div class="dropdown-input"><input type="search" id="" class="dropdown-input-field" autocomplete="off" /><i aria-hidden="true" data-hidden="true" class="fa fa-search dropdown-input-search"></i><i aria-hidden="true" data-hidden="true" role="button" class="fa fa-times dropdown-input-clear js-dropdown-input-clear"></i></div><div class="dropdown-content js-dropdown-content"></div><div class="dropdown-footer"><ul class="dropdown-footer-list"> -<li> -<button class="dropdown-create-new-item-button js-dropdown-create-new-item"> -Create wildcard -<code></code> -</button> -</li> -</ul> -</div><div class="dropdown-loading"><i aria-hidden="true" data-hidden="true" class="fa fa-spinner fa-spin"></i></div></div></div></div> + <input name="variable[environment]" type="hidden" /> + <div class="dropdown "> + <button + class="dropdown-menu-toggle js-dropdown-menu-toggle" + type="button" + data-toggle="dropdown" + > + <span class="dropdown-toggle-text ">some label</span + ><i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i> + </button> + <div class="dropdown-menu dropdown-select dropdown-menu-selectable"> + <div class="dropdown-input"> + <input type="search" id="" class="dropdown-input-field" autocomplete="off" /><i + aria-hidden="true" + data-hidden="true" + class="fa fa-search dropdown-input-search" + ></i + ><i + aria-hidden="true" + data-hidden="true" + role="button" + class="fa fa-times dropdown-input-clear js-dropdown-input-clear" + ></i> + </div> + <div class="dropdown-content js-dropdown-content"></div> + <div class="dropdown-footer"> + <ul class="dropdown-footer-list"> + <li> + <button class="dropdown-create-new-item-button js-dropdown-create-new-item"> + Create wildcard + <code></code> + </button> + </li> + </ul> + </div> + <div class="dropdown-loading"> + <span aria-hidden="true" data-hidden="true" class="gl-spinner"></span> + </div> + </div> + </div> +</div> diff --git a/spec/frontend/fixtures/static/deprecated_jquery_dropdown.html b/spec/frontend/fixtures/static/deprecated_jquery_dropdown.html index 3db9bafcb9f..41e7170b5c6 100644 --- a/spec/frontend/fixtures/static/deprecated_jquery_dropdown.html +++ b/spec/frontend/fixtures/static/deprecated_jquery_dropdown.html @@ -32,7 +32,7 @@ </div> <div class="dropdown-content"></div> <div class="dropdown-loading"> - <i class="fa fa-spinner fa-spin"></i> + <span class="gl-spinner"></span> </div> </div> </div> diff --git a/spec/frontend/fixtures/static/environments/table.html b/spec/frontend/fixtures/static/environments/table.html deleted file mode 100644 index 417af564ff1..00000000000 --- a/spec/frontend/fixtures/static/environments/table.html +++ /dev/null @@ -1,15 +0,0 @@ -<table> -<thead> -<tr> -<th>Environment</th> -<th>Last deployment</th> -<th>Job</th> -<th>Commit</th> -<th></th> -<th></th> -</tr> -</thead> -<tbody> -<tr id="environment-row"></tr> -</tbody> -</table> diff --git a/spec/frontend/fixtures/static/issuable_filter.html b/spec/frontend/fixtures/static/issuable_filter.html deleted file mode 100644 index 06b70fb43f1..00000000000 --- a/spec/frontend/fixtures/static/issuable_filter.html +++ /dev/null @@ -1,9 +0,0 @@ -<form action="/user/project/issues?scope=all&state=closed" class="js-filter-form"> -<input id="utf8" name="utf8" value="✓"> -<input id="check-all-issues" name="check-all-issues"> -<input id="search" name="search"> -<input id="author_id" name="author_id"> -<input id="assignee_id" name="assignee_id"> -<input id="milestone_title" name="milestone_title"> -<input id="label_name" name="label_name"> -</form> diff --git a/spec/frontend/fixtures/static/issue_with_mermaid_graph.html b/spec/frontend/fixtures/static/issue_with_mermaid_graph.html deleted file mode 100644 index e9fa75c8ba9..00000000000 --- a/spec/frontend/fixtures/static/issue_with_mermaid_graph.html +++ /dev/null @@ -1,82 +0,0 @@ -<div class="description" updated-at=""> - <div class="md issue-realtime-trigger-pulse"> - <svg - id="mermaid-1587752414912" - width="100%" - xmlns="http://www.w3.org/2000/svg" - style="max-width: 185.35000610351562px;" - viewBox="0 0 185.35000610351562 50.5" - class="mermaid" - > - <g transform="translate(0, 0)"> - <g class="output"> - <g class="clusters"></g> - <g class="edgePaths"></g> - <g class="edgeLabels"></g> - <g class="nodes"> - <g - class="node js-issuable-buttons btn-close clickable" - style="opacity: 1;" - id="A" - transform="translate(92.67500305175781,25.25)" - title="click to PUT" - > - <a - class="js-issuable-buttons btn-close clickable" - href="https://invalid" - rel="noopener" - > - <rect - rx="0" - ry="0" - x="-84.67500305175781" - y="-17.25" - width="169.35000610351562" - height="34.5" - class="label-container" - ></rect> - <g class="label" transform="translate(0,0)"> - <g transform="translate(-74.67500305175781,-7.25)"> - <text style=""> - <tspan xml:space="preserve" dy="1em" x="1">Click to send a PUT request</tspan> - </text> - </g> - </g> - </a> - </g> - </g> - </g> - </g> - <text class="source" display="none"> - Click to send a PUT request - </text> - </svg> - </div> - <textarea - data-update-url="/h5bp/html5-boilerplate/-/issues/35.json" - dir="auto" - class="hidden js-task-list-field" - ></textarea> - <div class="modal-open recaptcha-modal js-recaptcha-modal" style="display: none;"> - <div role="dialog" tabindex="-1" class="modal d-block"> - <div role="document" class="modal-dialog"> - <div class="modal-content"> - <div class="modal-header"> - <h4 class="modal-title float-left">Please solve the reCAPTCHA</h4> - <button type="button" data-dismiss="modal" aria-label="Close" class="close float-right"> - <span aria-hidden="true">×</span> - </button> - </div> - <div class="modal-body"> - <div> - <p>We want to be sure it is you, please confirm you are not a robot.</p> - <div></div> - </div> - </div> - <!----> - </div> - </div> - </div> - <div class="modal-backdrop fade show"></div> - </div> -</div> diff --git a/spec/frontend/fixtures/static/merge_requests_show.html b/spec/frontend/fixtures/static/merge_requests_show.html deleted file mode 100644 index 87e36c9f315..00000000000 --- a/spec/frontend/fixtures/static/merge_requests_show.html +++ /dev/null @@ -1,15 +0,0 @@ -<a class="btn-close"></a> -<div class="detail-page-description"> -<div class="description js-task-list-container"> -<div class="md"> -<ul class="task-list"> -<li class="task-list-item"> -<input class="task-list-item-checkbox" type="checkbox"> -Task List Item -</li> -</ul> -<textarea class="js-task-list-field">- [ ] Task List Item</textarea> -</div> -</div> -</div> -<form action="/foo" class="js-issuable-update"></form> diff --git a/spec/frontend/fixtures/static/mini_dropdown_graph.html b/spec/frontend/fixtures/static/mini_dropdown_graph.html index cb55698b709..cde811d4f52 100644 --- a/spec/frontend/fixtures/static/mini_dropdown_graph.html +++ b/spec/frontend/fixtures/static/mini_dropdown_graph.html @@ -7,7 +7,7 @@ <ul></ul> </li> <li class="js-builds-dropdown-loading hidden"> - <span class="fa fa-spinner"></span> + <span class="gl-spinner"></span> </li> </ul> </div> diff --git a/spec/frontend/fixtures/static/pipelines.html b/spec/frontend/fixtures/static/pipelines.html deleted file mode 100644 index 42333f94f2f..00000000000 --- a/spec/frontend/fixtures/static/pipelines.html +++ /dev/null @@ -1,3 +0,0 @@ -<div> -<div data-can-create-pipeline="true" data-ci-lint-path="foo" data-empty-state-svg-path="foo" data-endpoint="foo" data-error-state-svg-path="foo" data-has-ci="foo" data-help-auto-devops-path="foo" data-help-page-path="foo" data-new-pipeline-path="foo" data-reset-cache-path="foo" id="pipelines-list-vue"></div> -</div> diff --git a/spec/frontend/fixtures/static/project_select_combo_button.html b/spec/frontend/fixtures/static/project_select_combo_button.html index 50c826051c0..f13f9075706 100644 --- a/spec/frontend/fixtures/static/project_select_combo_button.html +++ b/spec/frontend/fixtures/static/project_select_combo_button.html @@ -1,9 +1,9 @@ <div class="project-item-select-holder"> -<input class="project-item-select" data-group-id="12345" data-relative-path="issues/new"> -<a class="new-project-item-link" data-label="New issue" data-type="issues" href=""> -<i class="fa fa-spinner spin"></i> -</a> -<a class="new-project-item-select-button"> -<i class="fa fa-caret-down"></i> -</a> + <input class="project-item-select" data-group-id="12345" data-relative-path="issues/new" /> + <a class="new-project-item-link" data-label="New issue" data-type="issues" href=""> + <span class="gl-spinner"></span> + </a> + <a class="new-project-item-select-button"> + <i class="fa fa-caret-down"></i> + </a> </div> diff --git a/spec/frontend/fixtures/static/whats_new_notification.html b/spec/frontend/fixtures/static/whats_new_notification.html new file mode 100644 index 00000000000..30d5eea91cc --- /dev/null +++ b/spec/frontend/fixtures/static/whats_new_notification.html @@ -0,0 +1,6 @@ +<div class='whats-new-notification-fixture-root'> + <div class='app' data-storage-key='storage-key'></div> + <div class='header-help'> + <div class='js-whats-new-notification-count'></div> + </div> +</div> diff --git a/spec/frontend/fixtures/tags.rb b/spec/frontend/fixtures/tags.rb index b2a5429fac8..9483f0a4492 100644 --- a/spec/frontend/fixtures/tags.rb +++ b/spec/frontend/fixtures/tags.rb @@ -5,8 +5,8 @@ require 'spec_helper' RSpec.describe 'Tags (JavaScript fixtures)' do include JavaScriptFixturesHelpers - let_it_be(:admin) { create(:admin) } let_it_be(:project) { create(:project, :repository, path: 'tags-project') } + let_it_be(:user) { project.owner } before(:all) do clean_frontend_fixtures('api/tags/') @@ -20,7 +20,7 @@ RSpec.describe 'Tags (JavaScript fixtures)' do include ApiHelpers it 'api/tags/tags.json' do - get api("/projects/#{project.id}/repository/tags", admin) + get api("/projects/#{project.id}/repository/tags", user) expect(response).to be_successful end diff --git a/spec/frontend/fixtures/todos.rb b/spec/frontend/fixtures/todos.rb index 399be272e9b..985afafe50e 100644 --- a/spec/frontend/fixtures/todos.rb +++ b/spec/frontend/fixtures/todos.rb @@ -5,13 +5,13 @@ require 'spec_helper' RSpec.describe 'Todos (JavaScript fixtures)' do include JavaScriptFixturesHelpers - let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project_empty_repo, namespace: namespace, path: 'todos-project') } + let(:user) { project.owner } let(:issue_1) { create(:issue, title: 'issue_1', project: project) } - let!(:todo_1) { create(:todo, user: admin, project: project, target: issue_1, created_at: 5.hours.ago) } + let!(:todo_1) { create(:todo, user: user, project: project, target: issue_1, created_at: 5.hours.ago) } let(:issue_2) { create(:issue, title: 'issue_2', project: project) } - let!(:todo_2) { create(:todo, :done, user: admin, project: project, target: issue_2, created_at: 50.hours.ago) } + let!(:todo_2) { create(:todo, :done, user: user, project: project, target: issue_2, created_at: 50.hours.ago) } before(:all) do clean_frontend_fixtures('todos/') @@ -25,7 +25,7 @@ RSpec.describe 'Todos (JavaScript fixtures)' do render_views before do - sign_in(admin) + sign_in(user) end it 'todos/todos.html' do @@ -39,7 +39,7 @@ RSpec.describe 'Todos (JavaScript fixtures)' do render_views before do - sign_in(admin) + sign_in(user) end it 'todos/todos.json' do diff --git a/spec/frontend/frequent_items/components/app_spec.js b/spec/frontend/frequent_items/components/app_spec.js index b4f36b82385..439a410eaa1 100644 --- a/spec/frontend/frequent_items/components/app_spec.js +++ b/spec/frontend/frequent_items/components/app_spec.js @@ -6,10 +6,10 @@ import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import appComponent from '~/frequent_items/components/app.vue'; import eventHub from '~/frequent_items/event_hub'; -import store from '~/frequent_items/store'; import { FREQUENT_ITEMS, HOUR_IN_MS } from '~/frequent_items/constants'; import { getTopFrequentItems } from '~/frequent_items/utils'; import { currentSession, mockFrequentProjects, mockSearchedProjects } from '../mock_data'; +import { createStore } from '~/frequent_items/store'; useLocalStorageSpy(); @@ -18,6 +18,7 @@ const createComponentWithStore = (namespace = 'projects') => { session = currentSession[namespace]; gon.api_version = session.apiVersion; const Component = Vue.extend(appComponent); + const store = createStore(); return mountComponentWithStore(Component, { store, diff --git a/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js b/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js index ab5784b8f7a..1160ed5c84b 100644 --- a/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js +++ b/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js @@ -1,10 +1,14 @@ import { shallowMount } from '@vue/test-utils'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { trimText } from 'helpers/text_helper'; import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue'; -import { mockProject } from '../mock_data'; // can also use 'mockGroup', but not useful to test here +import { createStore } from '~/frequent_items/store'; +import { mockProject } from '../mock_data'; describe('FrequentItemsListItemComponent', () => { let wrapper; + let trackingSpy; + let store = createStore(); const findTitle = () => wrapper.find({ ref: 'frequentItemsItemTitle' }); const findAvatar = () => wrapper.find({ ref: 'frequentItemsItemAvatar' }); @@ -18,6 +22,7 @@ describe('FrequentItemsListItemComponent', () => { const createComponent = (props = {}) => { wrapper = shallowMount(frequentItemsListItemComponent, { + store, propsData: { itemId: mockProject.id, itemName: mockProject.name, @@ -29,7 +34,14 @@ describe('FrequentItemsListItemComponent', () => { }); }; + beforeEach(() => { + store = createStore({ dropdownType: 'project' }); + trackingSpy = mockTracking('_category_', document, jest.spyOn); + trackingSpy.mockImplementation(() => {}); + }); + afterEach(() => { + unmockTracking(); wrapper.destroy(); wrapper = null; }); @@ -97,5 +109,18 @@ describe('FrequentItemsListItemComponent', () => { `('should render $expected $name', ({ selector, expected }) => { expect(selector()).toHaveLength(expected); }); + + it('tracks when item link is clicked', () => { + const link = wrapper.find('a'); + // NOTE: this listener is required to prevent the click from going through and causing: + // `Error: Not implemented: navigation ...` + link.element.addEventListener('click', e => { + e.preventDefault(); + }); + link.trigger('click'); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_link', { + label: 'project_dropdown_frequent_items_list_item', + }); + }); }); }); diff --git a/spec/frontend/frequent_items/components/frequent_items_list_spec.js b/spec/frontend/frequent_items/components/frequent_items_list_spec.js index 238fd508053..96f73ab1468 100644 --- a/spec/frontend/frequent_items/components/frequent_items_list_spec.js +++ b/spec/frontend/frequent_items/components/frequent_items_list_spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import { createStore } from '~/frequent_items/store'; import frequentItemsListComponent from '~/frequent_items/components/frequent_items_list.vue'; import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue'; import { mockFrequentProjects } from '../mock_data'; @@ -8,6 +9,7 @@ describe('FrequentItemsListComponent', () => { const createComponent = (props = {}) => { wrapper = mount(frequentItemsListComponent, { + store: createStore(), propsData: { namespace: 'projects', items: mockFrequentProjects, @@ -25,7 +27,7 @@ describe('FrequentItemsListComponent', () => { describe('computed', () => { describe('isListEmpty', () => { - it('should return `true` or `false` representing whether if `items` is empty or not with projects', () => { + it('should return `true` or `false` representing whether if `items` is empty or not with projects', async () => { createComponent({ items: [], }); @@ -35,13 +37,14 @@ describe('FrequentItemsListComponent', () => { wrapper.setProps({ items: mockFrequentProjects, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.isListEmpty).toBe(false); }); }); describe('fetched item messages', () => { - it('should return appropriate empty list message based on value of `localStorageFailed` prop with projects', () => { + it('should return appropriate empty list message based on value of `localStorageFailed` prop with projects', async () => { createComponent({ isFetchFailed: true, }); @@ -53,13 +56,14 @@ describe('FrequentItemsListComponent', () => { wrapper.setProps({ isFetchFailed: false, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.listEmptyMessage).toBe('Projects you visit often will appear here'); }); }); describe('searched item messages', () => { - it('should return appropriate empty list message based on value of `searchFailed` prop with projects', () => { + it('should return appropriate empty list message based on value of `searchFailed` prop with projects', async () => { createComponent({ hasSearchQuery: true, isFetchFailed: true, @@ -70,6 +74,7 @@ describe('FrequentItemsListComponent', () => { wrapper.setProps({ isFetchFailed: false, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.listEmptyMessage).toBe('Sorry, no projects matched your search'); }); diff --git a/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js b/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js index c5155315bb9..f5e654e6bcb 100644 --- a/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js +++ b/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js @@ -1,23 +1,35 @@ import { shallowMount } from '@vue/test-utils'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import searchComponent from '~/frequent_items/components/frequent_items_search_input.vue'; +import { createStore } from '~/frequent_items/store'; import eventHub from '~/frequent_items/event_hub'; -const createComponent = (namespace = 'projects') => - shallowMount(searchComponent, { - propsData: { namespace }, - }); - describe('FrequentItemsSearchInputComponent', () => { let wrapper; + let trackingSpy; let vm; + let store; + + const createComponent = (namespace = 'projects') => + shallowMount(searchComponent, { + store, + propsData: { namespace }, + }); beforeEach(() => { + store = createStore({ dropdownType: 'project' }); + jest.spyOn(store, 'dispatch').mockImplementation(() => {}); + + trackingSpy = mockTracking('_category_', document, jest.spyOn); + trackingSpy.mockImplementation(() => {}); + wrapper = createComponent(); ({ vm } = wrapper); }); afterEach(() => { + unmockTracking(); vm.$destroy(); }); @@ -76,4 +88,24 @@ describe('FrequentItemsSearchInputComponent', () => { ); }); }); + + describe('tracking', () => { + it('tracks when search query is entered', async () => { + expect(trackingSpy).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); + + const value = 'my project'; + + const input = wrapper.find('input'); + input.setValue(value); + input.trigger('input'); + + await wrapper.vm.$nextTick(); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'type_search_query', { + label: 'project_dropdown_frequent_items_search_input', + }); + expect(store.dispatch).toHaveBeenCalledWith('setSearchQuery', value); + }); + }); }); diff --git a/spec/frontend/frequent_items/mock_data.js b/spec/frontend/frequent_items/mock_data.js index 8c3c66f67ff..5e15b4b33e0 100644 --- a/spec/frontend/frequent_items/mock_data.js +++ b/spec/frontend/frequent_items/mock_data.js @@ -30,7 +30,6 @@ export const currentSession = { }; export const mockNamespace = 'projects'; - export const mockStorageKey = 'test-user/frequent-projects'; export const mockGroup = { diff --git a/spec/frontend/graphql_shared/utils_spec.js b/spec/frontend/graphql_shared/utils_spec.js index 6a630195126..d392b0f0575 100644 --- a/spec/frontend/graphql_shared/utils_spec.js +++ b/spec/frontend/graphql_shared/utils_spec.js @@ -1,4 +1,12 @@ -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { + getIdFromGraphQLId, + convertToGraphQLId, + convertToGraphQLIds, +} from '~/graphql_shared/utils'; + +const mockType = 'Group'; +const mockId = 12; +const mockGid = `gid://gitlab/Group/12`; describe('getIdFromGraphQLId', () => { [ @@ -44,3 +52,32 @@ describe('getIdFromGraphQLId', () => { }); }); }); + +describe('convertToGraphQLId', () => { + it('combines $type and $id into $result', () => { + expect(convertToGraphQLId(mockType, mockId)).toBe(mockGid); + }); + + it.each` + type | id | message + ${mockType} | ${null} | ${'id must be a number or string; got object'} + ${null} | ${mockId} | ${'type must be a string; got object'} + `('throws TypeError with "$message" if a param is missing', ({ type, id, message }) => { + expect(() => convertToGraphQLId(type, id)).toThrow(new TypeError(message)); + }); +}); + +describe('convertToGraphQLIds', () => { + it('combines $type and $id into $result', () => { + expect(convertToGraphQLIds(mockType, [mockId])).toStrictEqual([mockGid]); + }); + + it.each` + type | ids | message + ${mockType} | ${null} | ${"Cannot read property 'map' of null"} + ${mockType} | ${[mockId, null]} | ${'id must be a number or string; got object'} + ${null} | ${[mockId]} | ${'type must be a string; got object'} + `('throws TypeError with "$message" if a param is missing', ({ type, ids, message }) => { + expect(() => convertToGraphQLIds(type, ids)).toThrow(new TypeError(message)); + }); +}); diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js index 691f8896d74..72d8e23f28b 100644 --- a/spec/frontend/groups/components/app_spec.js +++ b/spec/frontend/groups/components/app_spec.js @@ -35,7 +35,7 @@ describe('AppComponent', () => { let mock; let getGroupsSpy; - const store = new GroupsStore(false); + const store = new GroupsStore({ hideProjects: false }); const service = new GroupsService(mockEndpoint); const createShallowComponent = (hideProjects = false) => { diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js index 83acbb152b5..32bae812c86 100644 --- a/spec/frontend/groups/components/group_item_spec.js +++ b/spec/frontend/groups/components/group_item_spec.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; import groupItemComponent from '~/groups/components/group_item.vue'; import groupFolderComponent from '~/groups/components/group_folder.vue'; +import { getGroupItemMicrodata } from '~/groups/store/utils'; import eventHub from '~/groups/event_hub'; import * as urlUtilities from '~/lib/utils/url_utility'; import { mockParentGroupItem, mockChildren } from '../mock_data'; @@ -30,6 +31,11 @@ describe('GroupItemComponent', () => { vm.$destroy(); }); + const withMicrodata = group => ({ + ...group, + microdata: getGroupItemMicrodata(group), + }); + describe('computed', () => { describe('groupDomId', () => { it('should return ID string suffixed with group ID', () => { @@ -212,4 +218,47 @@ describe('GroupItemComponent', () => { expect(vm.$el.querySelector('.group-list-tree')).toBeDefined(); }); }); + describe('schema.org props', () => { + describe('when showSchemaMarkup is disabled on the group', () => { + it.each(['itemprop', 'itemtype', 'itemscope'], 'it does not set %s', attr => { + expect(vm.$el.getAttribute(attr)).toBeNull(); + }); + it.each( + ['.js-group-avatar', '.js-group-name', '.js-group-description'], + 'it does not set `itemprop` on sub-nodes', + selector => { + expect(vm.$el.querySelector(selector).getAttribute('itemprop')).toBeNull(); + }, + ); + }); + describe('when group has microdata', () => { + beforeEach(() => { + const group = withMicrodata({ + ...mockParentGroupItem, + avatarUrl: 'http://foo.bar', + description: 'Foo Bar', + }); + + vm = createComponent(group); + }); + + it.each` + attr | value + ${'itemscope'} | ${'itemscope'} + ${'itemtype'} | ${'https://schema.org/Organization'} + ${'itemprop'} | ${'subOrganization'} + `('it does set correct $attr', ({ attr, value } = {}) => { + expect(vm.$el.getAttribute(attr)).toBe(value); + }); + + it.each` + selector | propValue + ${'[data-testid="group-avatar"]'} | ${'logo'} + ${'[data-testid="group-name"]'} | ${'name'} + ${'[data-testid="group-description"]'} | ${'description'} + `('it does set correct $selector', ({ selector, propValue } = {}) => { + expect(vm.$el.querySelector(selector).getAttribute('itemprop')).toBe(propValue); + }); + }); + }); }); diff --git a/spec/frontend/groups/components/visibility_level_dropdown_spec.js b/spec/frontend/groups/components/visibility_level_dropdown_spec.js new file mode 100644 index 00000000000..bf9508dc768 --- /dev/null +++ b/spec/frontend/groups/components/visibility_level_dropdown_spec.js @@ -0,0 +1,73 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Component from '~/groups/components/visibility_level_dropdown.vue'; + +describe('Visibility Level Dropdown', () => { + let wrapper; + + const options = [ + { level: 0, label: 'Private', description: 'Private description' }, + { level: 20, label: 'Public', description: 'Public description' }, + ]; + const defaultLevel = 0; + + const createComponent = propsData => { + wrapper = shallowMount(Component, { + propsData, + }); + }; + + beforeEach(() => { + createComponent({ + visibilityLevelOptions: options, + defaultLevel, + }); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const hiddenInputValue = () => + wrapper.find("input[name='group[visibility_level]']").attributes('value'); + const dropdownText = () => wrapper.find(GlDropdown).props('text'); + const findDropdownItems = () => + wrapper.findAll(GlDropdownItem).wrappers.map(option => ({ + text: option.text(), + secondaryText: option.props('secondaryText'), + })); + + describe('Default values', () => { + it('sets the value of the hidden input to the default value', () => { + expect(hiddenInputValue()).toBe(options[0].level.toString()); + }); + + it('sets the text of the dropdown to the default value', () => { + expect(dropdownText()).toBe(options[0].label); + }); + + it('shows all dropdown options', () => { + expect(findDropdownItems()).toEqual( + options.map(({ label, description }) => ({ text: label, secondaryText: description })), + ); + }); + }); + + describe('Selecting an option', () => { + beforeEach(() => { + wrapper + .findAll(GlDropdownItem) + .at(1) + .vm.$emit('click'); + }); + + it('sets the value of the hidden input to the selected value', () => { + expect(hiddenInputValue()).toBe(options[1].level.toString()); + }); + + it('sets the text of the dropdown to the selected value', () => { + expect(dropdownText()).toBe(options[1].label); + }); + }); +}); diff --git a/spec/frontend/groups/members/components/app_spec.js b/spec/frontend/groups/members/components/app_spec.js index de9f30649e9..208e2fc35b6 100644 --- a/spec/frontend/groups/members/components/app_spec.js +++ b/spec/frontend/groups/members/components/app_spec.js @@ -3,12 +3,10 @@ import { nextTick } from 'vue'; import Vuex from 'vuex'; import { GlAlert } from '@gitlab/ui'; import App from '~/groups/members/components/app.vue'; +import FilterSortContainer from '~/members/components/filter_sort/filter_sort_container.vue'; import * as commonUtils from '~/lib/utils/common_utils'; -import { - RECEIVE_MEMBER_ROLE_ERROR, - HIDE_ERROR, -} from '~/vuex_shared/modules/members/mutation_types'; -import mutations from '~/vuex_shared/modules/members/mutations'; +import { RECEIVE_MEMBER_ROLE_ERROR, HIDE_ERROR } from '~/members/store/mutation_types'; +import mutations from '~/members/store/mutations'; describe('GroupMembersApp', () => { const localVue = createLocalVue(); @@ -17,7 +15,7 @@ describe('GroupMembersApp', () => { let wrapper; let store; - const createComponent = (state = {}) => { + const createComponent = (state = {}, options = {}) => { store = new Vuex.Store({ state: { showError: true, @@ -30,10 +28,12 @@ describe('GroupMembersApp', () => { wrapper = shallowMount(App, { localVue, store, + ...options, }); }; const findAlert = () => wrapper.find(GlAlert); + const findFilterSortContainer = () => wrapper.find(FilterSortContainer); beforeEach(() => { commonUtils.scrollToElement = jest.fn(); @@ -86,4 +86,22 @@ describe('GroupMembersApp', () => { expect(findAlert().exists()).toBe(false); }); }); + + describe.each` + featureFlagValue | exists + ${true} | ${true} + ${false} | ${false} + `( + 'when `group_members_filtered_search` feature flag is $featureFlagValue', + ({ featureFlagValue, exists }) => { + it(`${exists ? 'renders' : 'does not render'} FilterSortContainer`, () => { + createComponent( + {}, + { provide: { glFeatures: { groupMembersFilteredSearch: featureFlagValue } } }, + ); + + expect(findFilterSortContainer().exists()).toBe(exists); + }); + }, + ); }); diff --git a/spec/frontend/groups/members/index_spec.js b/spec/frontend/groups/members/index_spec.js index aaa36665c45..5c717e53229 100644 --- a/spec/frontend/groups/members/index_spec.js +++ b/spec/frontend/groups/members/index_spec.js @@ -9,12 +9,13 @@ describe('initGroupMembersApp', () => { let wrapper; const setup = () => { - vm = initGroupMembersApp( - el, - ['account'], - { table: { 'data-qa-selector': 'members_list' } }, - () => ({}), - ); + vm = initGroupMembersApp(el, { + tableFields: ['account'], + tableAttrs: { table: { 'data-qa-selector': 'members_list' } }, + tableSortableFields: ['account'], + requestFormatter: () => ({}), + filteredSearchBar: { show: false }, + }); wrapper = createWrapper(vm); }; @@ -22,6 +23,7 @@ describe('initGroupMembersApp', () => { el = document.createElement('div'); el.setAttribute('data-members', membersJsonString); el.setAttribute('data-group-id', '234'); + el.setAttribute('data-can-manage-members', 'true'); el.setAttribute('data-member-path', '/groups/foo-bar/-/group_members/:id'); window.gon = { current_user_id: 123 }; @@ -61,6 +63,12 @@ describe('initGroupMembersApp', () => { expect(vm.$store.state.sourceId).toBe(234); }); + it('parses and sets `data-can-manage-members` as `canManageMembers` in Vuex store', () => { + setup(); + + expect(vm.$store.state.canManageMembers).toBe(true); + }); + it('parses and sets `members` in Vuex store', () => { setup(); @@ -79,12 +87,24 @@ describe('initGroupMembersApp', () => { expect(vm.$store.state.tableAttrs).toEqual({ table: { 'data-qa-selector': 'members_list' } }); }); + it('sets `tableSortableFields` in Vuex store', () => { + setup(); + + expect(vm.$store.state.tableSortableFields).toEqual(['account']); + }); + it('sets `requestFormatter` in Vuex store', () => { setup(); expect(vm.$store.state.requestFormatter()).toEqual({}); }); + it('sets `filteredSearchBar` in Vuex store', () => { + setup(); + + expect(vm.$store.state.filteredSearchBar).toEqual({ show: false }); + }); + it('sets `memberPath` in Vuex store', () => { setup(); diff --git a/spec/frontend/groups/members/utils_spec.js b/spec/frontend/groups/members/utils_spec.js index b0921c7642f..68945174e9d 100644 --- a/spec/frontend/groups/members/utils_spec.js +++ b/spec/frontend/groups/members/utils_spec.js @@ -13,6 +13,7 @@ describe('group member utils', () => { el = document.createElement('div'); el.setAttribute('data-members', membersJsonString); el.setAttribute('data-group-id', '234'); + el.setAttribute('data-can-manage-members', 'true'); }); afterEach(() => { @@ -23,6 +24,7 @@ describe('group member utils', () => { expect(parseDataAttributes(el)).toEqual({ members: membersParsed, sourceId: 234, + canManageMembers: true, }); }); }); diff --git a/spec/frontend/groups/store/groups_store_spec.js b/spec/frontend/groups/store/groups_store_spec.js index 7d12f73d270..8ac5d7099f1 100644 --- a/spec/frontend/groups/store/groups_store_spec.js +++ b/spec/frontend/groups/store/groups_store_spec.js @@ -1,4 +1,5 @@ import GroupsStore from '~/groups/store/groups_store'; +import { getGroupItemMicrodata } from '~/groups/store/utils'; import { mockGroups, mockSearchedGroups, @@ -17,9 +18,9 @@ describe('ProjectsStore', () => { expect(Object.keys(store.state).length).toBe(2); expect(Array.isArray(store.state.groups)).toBeTruthy(); expect(Object.keys(store.state.pageInfo).length).toBe(0); - expect(store.hideProjects).not.toBeDefined(); + expect(store.hideProjects).toBeFalsy(); - store = new GroupsStore(true); + store = new GroupsStore({ hideProjects: true }); expect(store.hideProjects).toBeTruthy(); }); @@ -86,22 +87,30 @@ describe('ProjectsStore', () => { describe('formatGroupItem', () => { it('should parse group item object and return updated object', () => { - let store; - let updatedGroupItem; - - store = new GroupsStore(); - updatedGroupItem = store.formatGroupItem(mockRawChildren[0]); + const store = new GroupsStore(); + const updatedGroupItem = store.formatGroupItem(mockRawChildren[0]); expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1); expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].children_count); expect(updatedGroupItem.isChildrenLoading).toBe(false); expect(updatedGroupItem.isBeingRemoved).toBe(false); + expect(updatedGroupItem.microdata).toEqual({}); + }); - store = new GroupsStore(true); - updatedGroupItem = store.formatGroupItem(mockRawChildren[0]); + it('with hideProjects', () => { + const store = new GroupsStore({ hideProjects: true }); + const updatedGroupItem = store.formatGroupItem(mockRawChildren[0]); expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1); expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].subgroup_count); + expect(updatedGroupItem.microdata).toEqual({}); + }); + + it('with showSchemaMarkup', () => { + const store = new GroupsStore({ showSchemaMarkup: true }); + const updatedGroupItem = store.formatGroupItem(mockRawChildren[0]); + + expect(updatedGroupItem.microdata).toEqual(getGroupItemMicrodata(mockRawChildren[0])); }); }); diff --git a/spec/frontend/groups/store/utils_spec.js b/spec/frontend/groups/store/utils_spec.js new file mode 100644 index 00000000000..0961d4c72b4 --- /dev/null +++ b/spec/frontend/groups/store/utils_spec.js @@ -0,0 +1,44 @@ +import { getGroupItemMicrodata } from '~/groups/store/utils'; + +describe('~/groups/store/utils', () => { + describe('getGroupItemMetadata', () => { + it('has default type', () => { + expect(getGroupItemMicrodata({ type: 'silly' })).toMatchInlineSnapshot(` + Object { + "descriptionItemprop": "description", + "imageItemprop": "image", + "itemprop": "owns", + "itemscope": true, + "itemtype": "https://schema.org/Thing", + "nameItemprop": "name", + } + `); + }); + + it('has group props', () => { + expect(getGroupItemMicrodata({ type: 'group' })).toMatchInlineSnapshot(` + Object { + "descriptionItemprop": "description", + "imageItemprop": "logo", + "itemprop": "subOrganization", + "itemscope": true, + "itemtype": "https://schema.org/Organization", + "nameItemprop": "name", + } + `); + }); + + it('has project props', () => { + expect(getGroupItemMicrodata({ type: 'project' })).toMatchInlineSnapshot(` + Object { + "descriptionItemprop": "description", + "imageItemprop": "image", + "itemprop": "owns", + "itemscope": true, + "itemtype": "https://schema.org/SoftwareSourceCode", + "nameItemprop": "name", + } + `); + }); + }); +}); diff --git a/spec/frontend/helpers/stub_component.js b/spec/frontend/helpers/stub_component.js new file mode 100644 index 00000000000..45550450517 --- /dev/null +++ b/spec/frontend/helpers/stub_component.js @@ -0,0 +1,12 @@ +export function stubComponent(Component, options = {}) { + return { + props: Component.props, + model: Component.model, + // Do not render any slots/scoped slots except default + // This differs from VTU behavior which renders all slots + template: '<div><slot></slot></div>', + // allows wrapper.find(Component) to work for stub + $_vueTestUtils_original: Component, + ...options, + }; +} diff --git a/spec/frontend/helpers/vue_test_utils_helper.js b/spec/frontend/helpers/vue_test_utils_helper.js index ead898f04d3..0e9127b5c65 100644 --- a/spec/frontend/helpers/vue_test_utils_helper.js +++ b/spec/frontend/helpers/vue_test_utils_helper.js @@ -1,3 +1,5 @@ +import { isArray } from 'lodash'; + const vNodeContainsText = (vnode, text) => (vnode.text && vnode.text.includes(text)) || (vnode.children && vnode.children.filter(child => vNodeContainsText(child, text)).length); @@ -34,9 +36,18 @@ export const waitForMutation = (store, expectedMutationType) => }); }); -export const extendedWrapper = wrapper => - Object.defineProperty(wrapper, 'findByTestId', { +export const extendedWrapper = wrapper => { + if (isArray(wrapper) || !wrapper?.find) { + // eslint-disable-next-line no-console + console.warn( + '[vue-test-utils-helper]: you are trying to extend an object that is not a VueWrapper.', + ); + return wrapper; + } + + return Object.defineProperty(wrapper, 'findByTestId', { value(id) { return this.find(`[data-testid="${id}"]`); }, }); +}; diff --git a/spec/frontend/helpers/vue_test_utils_helper_spec.js b/spec/frontend/helpers/vue_test_utils_helper_spec.js index 41714066da5..31c4ccd5dbb 100644 --- a/spec/frontend/helpers/vue_test_utils_helper_spec.js +++ b/spec/frontend/helpers/vue_test_utils_helper_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { shallowWrapperContainsSlotText } from './vue_test_utils_helper'; +import { extendedWrapper, shallowWrapperContainsSlotText } from './vue_test_utils_helper'; describe('Vue test utils helpers', () => { describe('shallowWrapperContainsSlotText', () => { @@ -45,4 +45,48 @@ describe('Vue test utils helpers', () => { expect(shallowWrapperContainsSlotText(mockComponent, 'namedSlot', searchText)).toBe(false); }); }); + + describe('extendedWrapper', () => { + describe('when an invalid wrapper is provided', () => { + beforeEach(() => { + // eslint-disable-next-line no-console + console.warn = jest.fn(); + }); + + it.each` + wrapper + ${{}} + ${[]} + ${null} + ${undefined} + ${1} + ${''} + `('should warn with an error when the wrapper is $wrapper', ({ wrapper }) => { + extendedWrapper(wrapper); + /* eslint-disable no-console */ + expect(console.warn).toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith( + '[vue-test-utils-helper]: you are trying to extend an object that is not a VueWrapper.', + ); + /* eslint-enable no-console */ + }); + }); + + describe('findByTestId', () => { + const testId = 'a-component'; + let mockComponent; + + beforeEach(() => { + mockComponent = extendedWrapper( + shallowMount({ + template: `<div data-testid="${testId}"></div>`, + }), + ); + }); + + it('should find the component by test id', () => { + expect(mockComponent.findByTestId(testId).exists()).toBe(true); + }); + }); + }); }); diff --git a/spec/frontend/helpers/vuex_action_helper.js b/spec/frontend/helpers/vuex_action_helper.js index 6c3569a2247..64dd3888d47 100644 --- a/spec/frontend/helpers/vuex_action_helper.js +++ b/spec/frontend/helpers/vuex_action_helper.js @@ -4,7 +4,7 @@ const noop = () => {}; * Helper for testing action with expected mutations inspired in * https://vuex.vuejs.org/en/testing.html * - * @param {Function} action to be tested + * @param {(Function|Object)} action to be tested, or object of named parameters * @param {Object} payload will be provided to the action * @param {Object} state will be provided to the action * @param {Array} [expectedMutations=[]] mutations expected to be committed @@ -39,15 +39,42 @@ const noop = () => {}; * [], // expected actions * ).then(done) * .catch(done.fail); + * + * @example + * await testAction({ + * action: actions.actionName, + * payload: { deleteListId: 1 }, + * state: { lists: [1, 2, 3] }, + * expectedMutations: [ { type: types.MUTATION} ], + * expectedActions: [], + * }) */ export default ( - action, - payload, - state, - expectedMutations = [], - expectedActions = [], - done = noop, + actionArg, + payloadArg, + stateArg, + expectedMutationsArg = [], + expectedActionsArg = [], + doneArg = noop, ) => { + let action = actionArg; + let payload = payloadArg; + let state = stateArg; + let expectedMutations = expectedMutationsArg; + let expectedActions = expectedActionsArg; + let done = doneArg; + + if (typeof actionArg !== 'function') { + ({ + action, + payload, + state, + expectedMutations = [], + expectedActions = [], + done = noop, + } = actionArg); + } + const mutations = []; const actions = []; diff --git a/spec/frontend/helpers/vuex_action_helper_spec.js b/spec/frontend/helpers/vuex_action_helper_spec.js index 61d05762a04..4d7bf21820a 100644 --- a/spec/frontend/helpers/vuex_action_helper_spec.js +++ b/spec/frontend/helpers/vuex_action_helper_spec.js @@ -1,166 +1,174 @@ import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import axios from '~/lib/utils/axios_utils'; -import testAction from './vuex_action_helper'; - -describe('VueX test helper (testAction)', () => { - let originalExpect; - let assertion; - let mock; - const noop = () => {}; - - beforeEach(() => { - mock = new MockAdapter(axios); - /** - * In order to test the helper properly, we need to overwrite the Jest - * `expect` helper. We test that the testAction helper properly passes the - * dispatched actions/committed mutations to the Jest helper. - */ - originalExpect = expect; - assertion = null; - global.expect = actual => ({ - toEqual: () => { - originalExpect(actual).toEqual(assertion); - }, - }); - }); +import testActionFn from './vuex_action_helper'; - afterEach(() => { - mock.restore(); - global.expect = originalExpect; - }); +const testActionFnWithOptionsArg = (...args) => { + const [action, payload, state, expectedMutations, expectedActions, done] = args; + return testActionFn({ action, payload, state, expectedMutations, expectedActions, done }); +}; - it('properly passes state and payload to action', () => { - const exampleState = { FOO: 12, BAR: 3 }; - const examplePayload = { BAZ: 73, BIZ: 55 }; +describe.each([testActionFn, testActionFnWithOptionsArg])( + 'VueX test helper (testAction)', + testAction => { + let originalExpect; + let assertion; + let mock; + const noop = () => {}; - const action = ({ state }, payload) => { - originalExpect(state).toEqual(exampleState); - originalExpect(payload).toEqual(examplePayload); - }; + beforeEach(() => { + mock = new MockAdapter(axios); + /** + * In order to test the helper properly, we need to overwrite the Jest + * `expect` helper. We test that the testAction helper properly passes the + * dispatched actions/committed mutations to the Jest helper. + */ + originalExpect = expect; + assertion = null; + global.expect = actual => ({ + toEqual: () => { + originalExpect(actual).toEqual(assertion); + }, + }); + }); - assertion = { mutations: [], actions: [] }; + afterEach(() => { + mock.restore(); + global.expect = originalExpect; + }); - testAction(action, examplePayload, exampleState); - }); + it('properly passes state and payload to action', () => { + const exampleState = { FOO: 12, BAR: 3 }; + const examplePayload = { BAZ: 73, BIZ: 55 }; - describe('given a sync action', () => { - it('mocks committing mutations', () => { - const action = ({ commit }) => { - commit('MUTATION'); + const action = ({ state }, payload) => { + originalExpect(state).toEqual(exampleState); + originalExpect(payload).toEqual(examplePayload); }; - assertion = { mutations: [{ type: 'MUTATION' }], actions: [] }; + assertion = { mutations: [], actions: [] }; - testAction(action, null, {}, assertion.mutations, assertion.actions, noop); + testAction(action, examplePayload, exampleState); }); - it('mocks dispatching actions', () => { - const action = ({ dispatch }) => { - dispatch('ACTION'); - }; + describe('given a sync action', () => { + it('mocks committing mutations', () => { + const action = ({ commit }) => { + commit('MUTATION'); + }; - assertion = { actions: [{ type: 'ACTION' }], mutations: [] }; + assertion = { mutations: [{ type: 'MUTATION' }], actions: [] }; - testAction(action, null, {}, assertion.mutations, assertion.actions, noop); - }); + testAction(action, null, {}, assertion.mutations, assertion.actions, noop); + }); - it('works with done callback once finished', done => { - assertion = { mutations: [], actions: [] }; + it('mocks dispatching actions', () => { + const action = ({ dispatch }) => { + dispatch('ACTION'); + }; - testAction(noop, null, {}, assertion.mutations, assertion.actions, done); - }); + assertion = { actions: [{ type: 'ACTION' }], mutations: [] }; - it('returns a promise', done => { - assertion = { mutations: [], actions: [] }; + testAction(action, null, {}, assertion.mutations, assertion.actions, noop); + }); - testAction(noop, null, {}, assertion.mutations, assertion.actions) - .then(done) - .catch(done.fail); - }); - }); - - describe('given an async action (returning a promise)', () => { - let lastError; - const data = { FOO: 'BAR' }; - - const asyncAction = ({ commit, dispatch }) => { - dispatch('ACTION'); - - return axios - .get(TEST_HOST) - .catch(error => { - commit('ERROR'); - lastError = error; - throw error; - }) - .then(() => { - commit('SUCCESS'); - return data; - }); - }; + it('works with done callback once finished', done => { + assertion = { mutations: [], actions: [] }; - beforeEach(() => { - lastError = null; + testAction(noop, null, {}, assertion.mutations, assertion.actions, done); + }); + + it('returns a promise', done => { + assertion = { mutations: [], actions: [] }; + + testAction(noop, null, {}, assertion.mutations, assertion.actions) + .then(done) + .catch(done.fail); + }); }); - it('works with done callback once finished', done => { - mock.onGet(TEST_HOST).replyOnce(200, 42); + describe('given an async action (returning a promise)', () => { + let lastError; + const data = { FOO: 'BAR' }; - assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] }; + const asyncAction = ({ commit, dispatch }) => { + dispatch('ACTION'); - testAction(asyncAction, null, {}, assertion.mutations, assertion.actions, done); - }); + return axios + .get(TEST_HOST) + .catch(error => { + commit('ERROR'); + lastError = error; + throw error; + }) + .then(() => { + commit('SUCCESS'); + return data; + }); + }; - it('returns original data of successful promise while checking actions/mutations', done => { - mock.onGet(TEST_HOST).replyOnce(200, 42); + beforeEach(() => { + lastError = null; + }); - assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] }; + it('works with done callback once finished', done => { + mock.onGet(TEST_HOST).replyOnce(200, 42); - testAction(asyncAction, null, {}, assertion.mutations, assertion.actions) - .then(res => { - originalExpect(res).toEqual(data); - done(); - }) - .catch(done.fail); - }); + assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] }; + + testAction(asyncAction, null, {}, assertion.mutations, assertion.actions, done); + }); + + it('returns original data of successful promise while checking actions/mutations', done => { + mock.onGet(TEST_HOST).replyOnce(200, 42); - it('returns original error of rejected promise while checking actions/mutations', done => { - mock.onGet(TEST_HOST).replyOnce(500, ''); + assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] }; - assertion = { mutations: [{ type: 'ERROR' }], actions: [{ type: 'ACTION' }] }; + testAction(asyncAction, null, {}, assertion.mutations, assertion.actions) + .then(res => { + originalExpect(res).toEqual(data); + done(); + }) + .catch(done.fail); + }); - testAction(asyncAction, null, {}, assertion.mutations, assertion.actions) - .then(done.fail) - .catch(error => { - originalExpect(error).toBe(lastError); - done(); - }); + it('returns original error of rejected promise while checking actions/mutations', done => { + mock.onGet(TEST_HOST).replyOnce(500, ''); + + assertion = { mutations: [{ type: 'ERROR' }], actions: [{ type: 'ACTION' }] }; + + testAction(asyncAction, null, {}, assertion.mutations, assertion.actions) + .then(done.fail) + .catch(error => { + originalExpect(error).toBe(lastError); + done(); + }); + }); }); - }); - it('works with async actions not returning promises', done => { - const data = { FOO: 'BAR' }; + it('works with async actions not returning promises', done => { + const data = { FOO: 'BAR' }; - const asyncAction = ({ commit, dispatch }) => { - dispatch('ACTION'); + const asyncAction = ({ commit, dispatch }) => { + dispatch('ACTION'); - axios - .get(TEST_HOST) - .then(() => { - commit('SUCCESS'); - return data; - }) - .catch(error => { - commit('ERROR'); - throw error; - }); - }; + axios + .get(TEST_HOST) + .then(() => { + commit('SUCCESS'); + return data; + }) + .catch(error => { + commit('ERROR'); + throw error; + }); + }; - mock.onGet(TEST_HOST).replyOnce(200, 42); + mock.onGet(TEST_HOST).replyOnce(200, 42); - assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] }; + assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] }; - testAction(asyncAction, null, {}, assertion.mutations, assertion.actions, done); - }); -}); + testAction(asyncAction, null, {}, assertion.mutations, assertion.actions, done); + }); + }, +); diff --git a/spec/frontend/ide/components/ide_spec.js b/spec/frontend/ide/components/ide_spec.js index ff3852b6775..315298eaf26 100644 --- a/spec/frontend/ide/components/ide_spec.js +++ b/spec/frontend/ide/components/ide_spec.js @@ -1,15 +1,8 @@ import Vuex from 'vuex'; import { createLocalVue, shallowMount } from '@vue/test-utils'; -import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; import { createStore } from '~/ide/stores'; import ErrorMessage from '~/ide/components/error_message.vue'; -import FindFile from '~/vue_shared/components/file_finder/index.vue'; -import CommitEditorHeader from '~/ide/components/commit_sidebar/editor_header.vue'; -import RepoTabs from '~/ide/components/repo_tabs.vue'; -import IdeStatusBar from '~/ide/components/ide_status_bar.vue'; -import RightPane from '~/ide/components/panes/right.vue'; -import NewModal from '~/ide/components/new_dropdown/modal.vue'; - import ide from '~/ide/components/ide.vue'; import { file } from '../helpers'; import { projectData } from '../mock_data'; @@ -39,17 +32,6 @@ describe('WebIDE', () => { return shallowMount(ide, { store, localVue, - stubs: { - ErrorMessage, - GlButton, - GlLoadingIcon, - CommitEditorHeader, - RepoTabs, - IdeStatusBar, - FindFile, - RightPane, - NewModal, - }, }); } @@ -74,27 +56,24 @@ describe('WebIDE', () => { describe('ide component, non-empty repo', () => { describe('error message', () => { - it('does not show error message when it is not set', () => { - wrapper = createComponent({ - state: { - errorMessage: null, - }, - }); - - expect(wrapper.find(ErrorMessage).exists()).toBe(false); - }); - - it('shows error message when set', () => { - wrapper = createComponent({ - state: { - errorMessage: { - text: 'error', + it.each` + errorMessage | exists + ${null} | ${false} + ${{ text: 'error' }} | ${true} + `( + 'should error message exists=$exists when errorMessage=$errorMessage', + async ({ errorMessage, exists }) => { + wrapper = createComponent({ + state: { + errorMessage, }, - }, - }); + }); - expect(wrapper.find(ErrorMessage).exists()).toBe(true); - }); + await waitForPromises(); + + expect(wrapper.find(ErrorMessage).exists()).toBe(exists); + }, + ); }); describe('onBeforeUnload', () => { diff --git a/spec/frontend/ide/components/terminal/session_spec.js b/spec/frontend/ide/components/terminal/session_spec.js index ce61a31691a..3ca37166ac4 100644 --- a/spec/frontend/ide/components/terminal/session_spec.js +++ b/spec/frontend/ide/components/terminal/session_spec.js @@ -1,4 +1,5 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; import Vuex from 'vuex'; import TerminalSession from '~/ide/components/terminal/session.vue'; import Terminal from '~/ide/components/terminal/terminal.vue'; @@ -38,6 +39,8 @@ describe('IDE TerminalSession', () => { }); }; + const findButton = () => wrapper.find(GlButton); + beforeEach(() => { state = { session: { status: RUNNING, terminalPath: TEST_TERMINAL_PATH }, @@ -69,8 +72,8 @@ describe('IDE TerminalSession', () => { state.session = { status }; factory(); - const button = wrapper.find('button'); - button.trigger('click'); + const button = findButton(); + button.vm.$emit('click'); return wrapper.vm.$nextTick().then(() => { expect(button.text()).toEqual('Stop Terminal'); @@ -84,8 +87,8 @@ describe('IDE TerminalSession', () => { state.session = { status }; factory(); - const button = wrapper.find('button'); - button.trigger('click'); + const button = findButton(); + button.vm.$emit('click'); return wrapper.vm.$nextTick().then(() => { expect(button.text()).toEqual('Restart Terminal'); diff --git a/spec/frontend/ide/components/terminal/view_spec.js b/spec/frontend/ide/components/terminal/view_spec.js index eff200550da..37f7957c526 100644 --- a/spec/frontend/ide/components/terminal/view_spec.js +++ b/spec/frontend/ide/components/terminal/view_spec.js @@ -1,6 +1,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { TEST_HOST } from 'spec/test_constants'; +import waitForPromises from 'helpers/wait_for_promises'; import TerminalEmptyState from '~/ide/components/terminal/empty_state.vue'; import TerminalView from '~/ide/components/terminal/view.vue'; import TerminalSession from '~/ide/components/terminal/session.vue'; @@ -17,7 +18,7 @@ describe('IDE TerminalView', () => { let getters; let wrapper; - const factory = () => { + const factory = async () => { const store = new Vuex.Store({ modules: { terminal: { @@ -30,6 +31,9 @@ describe('IDE TerminalView', () => { }); wrapper = shallowMount(TerminalView, { localVue, store }); + + // Uses deferred components, so wait for those to load... + await waitForPromises(); }; beforeEach(() => { @@ -59,8 +63,8 @@ describe('IDE TerminalView', () => { wrapper.destroy(); }); - it('renders empty state', () => { - factory(); + it('renders empty state', async () => { + await factory(); expect(wrapper.find(TerminalEmptyState).props()).toEqual({ helpPath: TEST_HELP_PATH, @@ -69,8 +73,8 @@ describe('IDE TerminalView', () => { }); }); - it('hides splash and starts, when started', () => { - factory(); + it('hides splash and starts, when started', async () => { + await factory(); expect(actions.startSession).not.toHaveBeenCalled(); expect(actions.hideSplash).not.toHaveBeenCalled(); @@ -81,9 +85,9 @@ describe('IDE TerminalView', () => { expect(actions.hideSplash).toHaveBeenCalled(); }); - it('shows Web Terminal when started', () => { + it('shows Web Terminal when started', async () => { state.isShowSplash = false; - factory(); + await factory(); expect(wrapper.find(TerminalEmptyState).exists()).toBe(false); expect(wrapper.find(TerminalSession).exists()).toBe(true); diff --git a/spec/frontend/ide/stores/actions/file_spec.js b/spec/frontend/ide/stores/actions/file_spec.js index cc290fc526e..744ac086b5f 100644 --- a/spec/frontend/ide/stores/actions/file_spec.js +++ b/spec/frontend/ide/stores/actions/file_spec.js @@ -510,8 +510,6 @@ describe('IDE store file actions', () => { describe('changeFileContent', () => { let tmpFile; - const callAction = (content = 'content\n') => - store.dispatch('changeFileContent', { path: tmpFile.path, content }); beforeEach(() => { tmpFile = file('tmpFile'); @@ -521,11 +519,23 @@ describe('IDE store file actions', () => { }); it('updates file content', () => { - return callAction().then(() => { + const content = 'content\n'; + + return store.dispatch('changeFileContent', { path: tmpFile.path, content }).then(() => { expect(tmpFile.content).toBe('content\n'); }); }); + it('does nothing if path does not exist', () => { + const content = 'content\n'; + + return store + .dispatch('changeFileContent', { path: 'not/a/real_file.txt', content }) + .then(() => { + expect(tmpFile.content).toBe('\n'); + }); + }); + it('adds file into stagedFiles array', () => { return store .dispatch('changeFileContent', { diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js index 6cd2128d356..3b772c0b259 100644 --- a/spec/frontend/ide/utils_spec.js +++ b/spec/frontend/ide/utils_spec.js @@ -4,7 +4,6 @@ import { registerLanguages, registerSchema, trimPathComponents, - insertFinalNewline, trimTrailingWhitespace, getPathParents, getPathParent, @@ -225,29 +224,6 @@ describe('WebIDE utils', () => { }); }); - describe('addFinalNewline', () => { - it.each` - input | output - ${'some text'} | ${'some text\n'} - ${'some text\n'} | ${'some text\n'} - ${'some text\n\n'} | ${'some text\n\n'} - ${'some\n text'} | ${'some\n text\n'} - `('adds a newline if it doesnt already exist for input: $input', ({ input, output }) => { - expect(insertFinalNewline(input)).toBe(output); - }); - - it.each` - input | output - ${'some text'} | ${'some text\r\n'} - ${'some text\r\n'} | ${'some text\r\n'} - ${'some text\n'} | ${'some text\n\r\n'} - ${'some text\r\n\r\n'} | ${'some text\r\n\r\n'} - ${'some\r\n text'} | ${'some\r\n text\r\n'} - `('works with CRLF newline style; input: $input', ({ input, output }) => { - expect(insertFinalNewline(input, '\r\n')).toBe(output); - }); - }); - describe('getPathParents', () => { it.each` path | parents diff --git a/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js new file mode 100644 index 00000000000..d88a31a0e47 --- /dev/null +++ b/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js @@ -0,0 +1,112 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton, GlLink, GlFormInput } from '@gitlab/ui'; +import Select2Select from '~/vue_shared/components/select2_select.vue'; +import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue'; +import { STATUSES } from '~/import_entities/constants'; +import { availableNamespacesFixture } from '../graphql/fixtures'; + +const getFakeGroup = status => ({ + web_url: 'https://fake.host/', + full_path: 'fake_group_1', + full_name: 'fake_name_1', + import_target: { + target_namespace: 'root', + new_name: 'group1', + }, + id: 1, + status, +}); + +describe('import table row', () => { + let wrapper; + let group; + + const findByText = (cmp, text) => { + return wrapper.findAll(cmp).wrappers.find(node => node.text().indexOf(text) === 0); + }; + const findImportButton = () => findByText(GlButton, 'Import'); + const findNameInput = () => wrapper.find(GlFormInput); + const findNamespaceDropdown = () => wrapper.find(Select2Select); + + const createComponent = props => { + wrapper = shallowMount(ImportTableRow, { + propsData: { + availableNamespaces: availableNamespacesFixture, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('events', () => { + beforeEach(() => { + group = getFakeGroup(STATUSES.NONE); + createComponent({ group }); + }); + + it.each` + selector | sourceEvent | payload | event + ${findNamespaceDropdown} | ${'input'} | ${'demo'} | ${'update-target-namespace'} + ${findNameInput} | ${'input'} | ${'demo'} | ${'update-new-name'} + ${findImportButton} | ${'click'} | ${undefined} | ${'import-group'} + `('invokes $event', ({ selector, sourceEvent, payload, event }) => { + selector().vm.$emit(sourceEvent, payload); + expect(wrapper.emitted(event)).toBeDefined(); + expect(wrapper.emitted(event)[0][0]).toBe(payload); + }); + }); + + describe('when entity status is NONE', () => { + beforeEach(() => { + group = getFakeGroup(STATUSES.NONE); + createComponent({ group }); + }); + + it('renders Import button', () => { + expect(findByText(GlButton, 'Import').exists()).toBe(true); + }); + + it('renders namespace dropdown as not disabled', () => { + expect(findNamespaceDropdown().attributes('disabled')).toBe(undefined); + }); + }); + + describe('when entity status is SCHEDULING', () => { + beforeEach(() => { + group = getFakeGroup(STATUSES.SCHEDULING); + createComponent({ group }); + }); + + it('does not render Import button', () => { + expect(findByText(GlButton, 'Import')).toBe(undefined); + }); + + it('renders namespace dropdown as disabled', () => { + expect(findNamespaceDropdown().attributes('disabled')).toBe('true'); + }); + }); + + describe('when entity status is FINISHED', () => { + beforeEach(() => { + group = getFakeGroup(STATUSES.FINISHED); + createComponent({ group }); + }); + + it('does not render Import button', () => { + expect(findByText(GlButton, 'Import')).toBe(undefined); + }); + + it('does not render namespace dropdown', () => { + expect(findNamespaceDropdown().exists()).toBe(false); + }); + + it('renders target as link', () => { + const TARGET_LINK = `${group.import_target.target_namespace}/${group.import_target.new_name}`; + expect(findByText(GlLink, TARGET_LINK).exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/import_entities/import_groups/components/import_table_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_spec.js new file mode 100644 index 00000000000..0ca721cd951 --- /dev/null +++ b/spec/frontend/import_entities/import_groups/components/import_table_spec.js @@ -0,0 +1,103 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import { GlLoadingIcon } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue'; +import ImportTable from '~/import_entities/import_groups/components/import_table.vue'; +import setTargetNamespaceMutation from '~/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql'; +import setNewNameMutation from '~/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql'; +import importGroupMutation from '~/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql'; + +import { STATUSES } from '~/import_entities/constants'; + +import { availableNamespacesFixture, generateFakeEntry } from '../graphql/fixtures'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('import table', () => { + let wrapper; + let apolloProvider; + + const createComponent = ({ bulkImportSourceGroups }) => { + apolloProvider = createMockApollo([], { + Query: { + availableNamespaces: () => availableNamespacesFixture, + bulkImportSourceGroups, + }, + Mutation: { + setTargetNamespace: jest.fn(), + setNewName: jest.fn(), + importGroup: jest.fn(), + }, + }); + + wrapper = shallowMount(ImportTable, { + localVue, + apolloProvider, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders loading icon while performing request', async () => { + createComponent({ + bulkImportSourceGroups: () => new Promise(() => {}), + }); + await waitForPromises(); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + + it('does not renders loading icon when request is completed', async () => { + createComponent({ + bulkImportSourceGroups: () => [], + }); + await waitForPromises(); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + }); + + it('renders import row for each group in response', async () => { + const FAKE_GROUPS = [ + generateFakeEntry({ id: 1, status: STATUSES.NONE }), + generateFakeEntry({ id: 2, status: STATUSES.FINISHED }), + ]; + createComponent({ + bulkImportSourceGroups: () => FAKE_GROUPS, + }); + await waitForPromises(); + + expect(wrapper.findAll(ImportTableRow)).toHaveLength(FAKE_GROUPS.length); + }); + + describe('converts row events to mutation invocations', () => { + const FAKE_GROUP = generateFakeEntry({ id: 1, status: STATUSES.NONE }); + + beforeEach(() => { + createComponent({ + bulkImportSourceGroups: () => [FAKE_GROUP], + }); + return waitForPromises(); + }); + + it.each` + event | payload | mutation | variables + ${'update-target-namespace'} | ${'new-namespace'} | ${setTargetNamespaceMutation} | ${{ sourceGroupId: FAKE_GROUP.id, targetNamespace: 'new-namespace' }} + ${'update-new-name'} | ${'new-name'} | ${setNewNameMutation} | ${{ sourceGroupId: FAKE_GROUP.id, newName: 'new-name' }} + ${'import-group'} | ${undefined} | ${importGroupMutation} | ${{ sourceGroupId: FAKE_GROUP.id }} + `('correctly maps $event to mutation', async ({ event, payload, mutation, variables }) => { + jest.spyOn(apolloProvider.defaultClient, 'mutate'); + wrapper.find(ImportTableRow).vm.$emit(event, payload); + await waitForPromises(); + expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({ + mutation, + variables, + }); + }); + }); +}); diff --git a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js new file mode 100644 index 00000000000..cacbe358a62 --- /dev/null +++ b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js @@ -0,0 +1,221 @@ +import MockAdapter from 'axios-mock-adapter'; +import { InMemoryCache } from 'apollo-cache-inmemory'; +import { createMockClient } from 'mock-apollo-client'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import { + clientTypenames, + createResolvers, +} from '~/import_entities/import_groups/graphql/client_factory'; +import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller'; +import { STATUSES } from '~/import_entities/constants'; + +import bulkImportSourceGroupsQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql'; +import availableNamespacesQuery from '~/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql'; +import setTargetNamespaceMutation from '~/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql'; +import setNewNameMutation from '~/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql'; +import importGroupMutation from '~/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql'; +import httpStatus from '~/lib/utils/http_status'; +import { statusEndpointFixture, availableNamespacesFixture } from './fixtures'; + +jest.mock('~/import_entities/import_groups/graphql/services/status_poller', () => ({ + StatusPoller: jest.fn().mockImplementation(function mock() { + this.startPolling = jest.fn(); + }), +})); + +const FAKE_ENDPOINTS = { + status: '/fake_status_url', + availableNamespaces: '/fake_available_namespaces', + createBulkImport: '/fake_create_bulk_import', +}; + +describe('Bulk import resolvers', () => { + let axiosMockAdapter; + let client; + + beforeEach(() => { + axiosMockAdapter = new MockAdapter(axios); + client = createMockClient({ + cache: new InMemoryCache({ + fragmentMatcher: { match: () => true }, + addTypename: false, + }), + resolvers: createResolvers({ endpoints: FAKE_ENDPOINTS }), + }); + }); + + afterEach(() => { + axiosMockAdapter.restore(); + }); + + describe('queries', () => { + describe('availableNamespaces', () => { + let results; + + beforeEach(async () => { + axiosMockAdapter + .onGet(FAKE_ENDPOINTS.availableNamespaces) + .reply(httpStatus.OK, availableNamespacesFixture); + + const response = await client.query({ query: availableNamespacesQuery }); + results = response.data.availableNamespaces; + }); + + it('mirrors REST endpoint response fields', () => { + const extractRelevantFields = obj => ({ id: obj.id, full_path: obj.full_path }); + + expect(results.map(extractRelevantFields)).toStrictEqual( + availableNamespacesFixture.map(extractRelevantFields), + ); + }); + }); + + describe('bulkImportSourceGroups', () => { + let results; + + beforeEach(async () => { + axiosMockAdapter.onGet(FAKE_ENDPOINTS.status).reply(httpStatus.OK, statusEndpointFixture); + axiosMockAdapter + .onGet(FAKE_ENDPOINTS.availableNamespaces) + .reply(httpStatus.OK, availableNamespacesFixture); + + const response = await client.query({ query: bulkImportSourceGroupsQuery }); + results = response.data.bulkImportSourceGroups; + }); + + it('mirrors REST endpoint response fields', () => { + const MIRRORED_FIELDS = ['id', 'full_name', 'full_path', 'web_url']; + expect( + results.every((r, idx) => + MIRRORED_FIELDS.every( + field => r[field] === statusEndpointFixture.importable_data[idx][field], + ), + ), + ).toBe(true); + }); + + it('populates each result instance with status field default to none', () => { + expect(results.every(r => r.status === STATUSES.NONE)).toBe(true); + }); + + it('populates each result instance with import_target defaulted to first available namespace', () => { + expect( + results.every( + r => r.import_target.target_namespace === availableNamespacesFixture[0].full_path, + ), + ).toBe(true); + }); + }); + }); + + describe('mutations', () => { + let results; + const GROUP_ID = 1; + + beforeEach(() => { + client.writeQuery({ + query: bulkImportSourceGroupsQuery, + data: { + bulkImportSourceGroups: [ + { + __typename: clientTypenames.BulkImportSourceGroup, + id: GROUP_ID, + status: STATUSES.NONE, + web_url: 'https://fake.host/1', + full_path: 'fake_group_1', + full_name: 'fake_name_1', + import_target: { + target_namespace: 'root', + new_name: 'group1', + }, + }, + ], + }, + }); + + client + .watchQuery({ + query: bulkImportSourceGroupsQuery, + fetchPolicy: 'cache-only', + }) + .subscribe(({ data }) => { + results = data.bulkImportSourceGroups; + }); + }); + + it('setTargetNamespaces updates group target namespace', async () => { + const NEW_TARGET_NAMESPACE = 'target'; + await client.mutate({ + mutation: setTargetNamespaceMutation, + variables: { sourceGroupId: GROUP_ID, targetNamespace: NEW_TARGET_NAMESPACE }, + }); + + expect(results[0].import_target.target_namespace).toBe(NEW_TARGET_NAMESPACE); + }); + + it('setNewName updates group target name', async () => { + const NEW_NAME = 'new'; + await client.mutate({ + mutation: setNewNameMutation, + variables: { sourceGroupId: GROUP_ID, newName: NEW_NAME }, + }); + + expect(results[0].import_target.new_name).toBe(NEW_NAME); + }); + + describe('importGroup', () => { + it('sets status to SCHEDULING when request initiates', async () => { + axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(() => new Promise(() => {})); + + client.mutate({ + mutation: importGroupMutation, + variables: { sourceGroupId: GROUP_ID }, + }); + await waitForPromises(); + + const { bulkImportSourceGroups: intermediateResults } = client.readQuery({ + query: bulkImportSourceGroupsQuery, + }); + + expect(intermediateResults[0].status).toBe(STATUSES.SCHEDULING); + }); + + it('sets group status to STARTED when request completes', async () => { + axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK); + await client.mutate({ + mutation: importGroupMutation, + variables: { sourceGroupId: GROUP_ID }, + }); + + expect(results[0].status).toBe(STATUSES.STARTED); + }); + + it('starts polling when request completes', async () => { + axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK); + await client.mutate({ + mutation: importGroupMutation, + variables: { sourceGroupId: GROUP_ID }, + }); + const [statusPoller] = StatusPoller.mock.instances; + expect(statusPoller.startPolling).toHaveBeenCalled(); + }); + + it('resets status to NONE if request fails', async () => { + axiosMockAdapter + .onPost(FAKE_ENDPOINTS.createBulkImport) + .reply(httpStatus.INTERNAL_SERVER_ERROR); + + client + .mutate({ + mutation: importGroupMutation, + variables: { sourceGroupId: GROUP_ID }, + }) + .catch(() => {}); + await waitForPromises(); + + expect(results[0].status).toBe(STATUSES.NONE); + }); + }); + }); +}); diff --git a/spec/frontend/import_entities/import_groups/graphql/fixtures.js b/spec/frontend/import_entities/import_groups/graphql/fixtures.js new file mode 100644 index 00000000000..62e9581bd2d --- /dev/null +++ b/spec/frontend/import_entities/import_groups/graphql/fixtures.js @@ -0,0 +1,51 @@ +import { clientTypenames } from '~/import_entities/import_groups/graphql/client_factory'; + +export const generateFakeEntry = ({ id, status, ...rest }) => ({ + __typename: clientTypenames.BulkImportSourceGroup, + web_url: `https://fake.host/${id}`, + full_path: `fake_group_${id}`, + full_name: `fake_name_${id}`, + import_target: { + target_namespace: 'root', + new_name: `group${id}`, + }, + id, + status, + ...rest, +}); + +export const statusEndpointFixture = { + importable_data: [ + { + id: 2595438, + full_name: 'AutoBreakfast', + full_path: 'auto-breakfast', + web_url: 'https://gitlab.com/groups/auto-breakfast', + }, + { + id: 4347861, + full_name: 'GitLab Data', + full_path: 'gitlab-data', + web_url: 'https://gitlab.com/groups/gitlab-data', + }, + { + id: 5723700, + full_name: 'GitLab Services', + full_path: 'gitlab-services', + web_url: 'https://gitlab.com/groups/gitlab-services', + }, + { + id: 349181, + full_name: 'GitLab-examples', + full_path: 'gitlab-examples', + web_url: 'https://gitlab.com/groups/gitlab-examples', + }, + ], +}; + +export const availableNamespacesFixture = [ + { id: 24, full_path: 'Commit451' }, + { id: 22, full_path: 'gitlab-org' }, + { id: 23, full_path: 'gnuwget' }, + { id: 25, full_path: 'jashkenas' }, +]; diff --git a/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js b/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js new file mode 100644 index 00000000000..5940ea544ea --- /dev/null +++ b/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js @@ -0,0 +1,82 @@ +import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; +import { SourceGroupsManager } from '~/import_entities/import_groups/graphql/services/source_groups_manager'; +import ImportSourceGroupFragment from '~/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql'; +import { clientTypenames } from '~/import_entities/import_groups/graphql/client_factory'; + +describe('SourceGroupsManager', () => { + let manager; + let client; + + const getFakeGroup = () => ({ + __typename: clientTypenames.BulkImportSourceGroup, + id: 5, + }); + + beforeEach(() => { + client = { + readFragment: jest.fn(), + writeFragment: jest.fn(), + }; + + manager = new SourceGroupsManager({ client }); + }); + + it('finds item by group id', () => { + const ID = 5; + + const FAKE_GROUP = getFakeGroup(); + client.readFragment.mockReturnValue(FAKE_GROUP); + const group = manager.findById(ID); + expect(group).toBe(FAKE_GROUP); + expect(client.readFragment).toHaveBeenCalledWith({ + fragment: ImportSourceGroupFragment, + id: defaultDataIdFromObject(getFakeGroup()), + }); + }); + + it('updates group with provided function', () => { + const UPDATED_GROUP = {}; + const fn = jest.fn().mockReturnValue(UPDATED_GROUP); + manager.update(getFakeGroup(), fn); + + expect(client.writeFragment).toHaveBeenCalledWith({ + fragment: ImportSourceGroupFragment, + id: defaultDataIdFromObject(getFakeGroup()), + data: UPDATED_GROUP, + }); + }); + + it('updates group by id with provided function', () => { + const UPDATED_GROUP = {}; + const fn = jest.fn().mockReturnValue(UPDATED_GROUP); + client.readFragment.mockReturnValue(getFakeGroup()); + manager.updateById(getFakeGroup().id, fn); + + expect(client.readFragment).toHaveBeenCalledWith({ + fragment: ImportSourceGroupFragment, + id: defaultDataIdFromObject(getFakeGroup()), + }); + + expect(client.writeFragment).toHaveBeenCalledWith({ + fragment: ImportSourceGroupFragment, + id: defaultDataIdFromObject(getFakeGroup()), + data: UPDATED_GROUP, + }); + }); + + it('sets import status when group is provided', () => { + client.readFragment.mockReturnValue(getFakeGroup()); + + const NEW_STATUS = 'NEW_STATUS'; + manager.setImportStatus(getFakeGroup(), NEW_STATUS); + + expect(client.writeFragment).toHaveBeenCalledWith({ + fragment: ImportSourceGroupFragment, + id: defaultDataIdFromObject(getFakeGroup()), + data: { + ...getFakeGroup(), + status: NEW_STATUS, + }, + }); + }); +}); diff --git a/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js b/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js new file mode 100644 index 00000000000..8eb1ffb3cd0 --- /dev/null +++ b/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js @@ -0,0 +1,213 @@ +import { createMockClient } from 'mock-apollo-client'; +import { InMemoryCache } from 'apollo-cache-inmemory'; +import waitForPromises from 'helpers/wait_for_promises'; + +import createFlash from '~/flash'; +import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller'; +import bulkImportSourceGroupsQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql'; +import { STATUSES } from '~/import_entities/constants'; +import { SourceGroupsManager } from '~/import_entities/import_groups/graphql/services/source_groups_manager'; +import { generateFakeEntry } from '../fixtures'; + +jest.mock('~/flash'); +jest.mock('~/import_entities/import_groups/graphql/services/source_groups_manager', () => ({ + SourceGroupsManager: jest.fn().mockImplementation(function mock() { + this.setImportStatus = jest.fn(); + }), +})); + +const TEST_POLL_INTERVAL = 1000; + +describe('Bulk import status poller', () => { + let poller; + let clientMock; + + const listQueryCacheCalls = () => + clientMock.readQuery.mock.calls.filter(call => call[0].query === bulkImportSourceGroupsQuery); + + beforeEach(() => { + clientMock = createMockClient({ + cache: new InMemoryCache({ + fragmentMatcher: { match: () => true }, + }), + }); + + jest.spyOn(clientMock, 'readQuery'); + + poller = new StatusPoller({ + client: clientMock, + interval: TEST_POLL_INTERVAL, + }); + }); + + describe('general behavior', () => { + beforeEach(() => { + clientMock.cache.writeQuery({ + query: bulkImportSourceGroupsQuery, + data: { bulkImportSourceGroups: [] }, + }); + }); + + it('does not perform polling when constructed', () => { + jest.runOnlyPendingTimers(); + expect(listQueryCacheCalls()).toHaveLength(0); + }); + + it('immediately start polling when requested', async () => { + await poller.startPolling(); + expect(listQueryCacheCalls()).toHaveLength(1); + }); + + it('constantly polls when started', async () => { + poller.startPolling(); + expect(listQueryCacheCalls()).toHaveLength(1); + + jest.advanceTimersByTime(TEST_POLL_INTERVAL); + expect(listQueryCacheCalls()).toHaveLength(2); + + jest.advanceTimersByTime(TEST_POLL_INTERVAL); + expect(listQueryCacheCalls()).toHaveLength(3); + }); + + it('does not start polling when requested multiple times', async () => { + poller.startPolling(); + expect(listQueryCacheCalls()).toHaveLength(1); + + poller.startPolling(); + expect(listQueryCacheCalls()).toHaveLength(1); + }); + + it('stops polling when requested', async () => { + poller.startPolling(); + expect(listQueryCacheCalls()).toHaveLength(1); + + poller.stopPolling(); + jest.runOnlyPendingTimers(); + expect(listQueryCacheCalls()).toHaveLength(1); + }); + + it('does not query server when list is empty', async () => { + jest.spyOn(clientMock, 'query'); + poller.startPolling(); + expect(clientMock.query).not.toHaveBeenCalled(); + }); + }); + + it('does not query server when no groups have STARTED status', async () => { + clientMock.cache.writeQuery({ + query: bulkImportSourceGroupsQuery, + data: { + bulkImportSourceGroups: [STATUSES.NONE, STATUSES.FINISHED].map((status, idx) => + generateFakeEntry({ status, id: idx }), + ), + }, + }); + + jest.spyOn(clientMock, 'query'); + poller.startPolling(); + expect(clientMock.query).not.toHaveBeenCalled(); + }); + + describe('when there are groups which have STARTED status', () => { + const TARGET_NAMESPACE = 'root'; + + const STARTED_GROUP_1 = { + status: STATUSES.STARTED, + id: 'started1', + import_target: { + target_namespace: TARGET_NAMESPACE, + new_name: 'group1', + }, + }; + + const STARTED_GROUP_2 = { + status: STATUSES.STARTED, + id: 'started2', + import_target: { + target_namespace: TARGET_NAMESPACE, + new_name: 'group2', + }, + }; + + const NOT_STARTED_GROUP = { + status: STATUSES.NONE, + id: 'not_started', + import_target: { + target_namespace: TARGET_NAMESPACE, + new_name: 'group3', + }, + }; + + it('query server only for groups with STATUSES.STARTED', async () => { + clientMock.cache.writeQuery({ + query: bulkImportSourceGroupsQuery, + data: { + bulkImportSourceGroups: [STARTED_GROUP_1, NOT_STARTED_GROUP, STARTED_GROUP_2].map(group => + generateFakeEntry(group), + ), + }, + }); + + clientMock.query = jest.fn().mockResolvedValue({ data: {} }); + poller.startPolling(); + + expect(clientMock.query).toHaveBeenCalledTimes(1); + await waitForPromises(); + const [[doc]] = clientMock.query.mock.calls; + const { selections } = doc.query.definitions[0].selectionSet; + expect(selections.every(field => field.name.value === 'group')).toBeTruthy(); + expect(selections).toHaveLength(2); + expect(selections.map(sel => sel.arguments[0].value.value)).toStrictEqual([ + `${TARGET_NAMESPACE}/${STARTED_GROUP_1.import_target.new_name}`, + `${TARGET_NAMESPACE}/${STARTED_GROUP_2.import_target.new_name}`, + ]); + }); + + it('updates statuses only for groups in response', async () => { + clientMock.cache.writeQuery({ + query: bulkImportSourceGroupsQuery, + data: { + bulkImportSourceGroups: [STARTED_GROUP_1, STARTED_GROUP_2].map(group => + generateFakeEntry(group), + ), + }, + }); + + clientMock.query = jest.fn().mockResolvedValue({ data: { group0: {} } }); + poller.startPolling(); + await waitForPromises(); + const [managerInstance] = SourceGroupsManager.mock.instances; + expect(managerInstance.setImportStatus).toHaveBeenCalledTimes(1); + expect(managerInstance.setImportStatus).toHaveBeenCalledWith( + expect.objectContaining({ id: STARTED_GROUP_1.id }), + STATUSES.FINISHED, + ); + }); + + describe('when error occurs', () => { + beforeEach(() => { + clientMock.cache.writeQuery({ + query: bulkImportSourceGroupsQuery, + data: { + bulkImportSourceGroups: [STARTED_GROUP_1, STARTED_GROUP_2].map(group => + generateFakeEntry(group), + ), + }, + }); + + clientMock.query = jest.fn().mockRejectedValue(new Error('dummy error')); + poller.startPolling(); + return waitForPromises(); + }); + + it('reports an error', () => { + expect(createFlash).toHaveBeenCalled(); + }); + + it('continues polling', async () => { + jest.advanceTimersByTime(TEST_POLL_INTERVAL); + expect(listQueryCacheCalls()).toHaveLength(2); + }); + }); + }); +}); diff --git a/spec/frontend/import_projects/components/bitbucket_status_table_spec.js b/spec/frontend/import_entities/import_projects/components/bitbucket_status_table_spec.js index b65b388fd5f..8f8c01a8b81 100644 --- a/spec/frontend/import_projects/components/bitbucket_status_table_spec.js +++ b/spec/frontend/import_entities/import_projects/components/bitbucket_status_table_spec.js @@ -2,8 +2,8 @@ import { nextTick } from 'vue'; import { shallowMount } from '@vue/test-utils'; import { GlAlert } from '@gitlab/ui'; -import BitbucketStatusTable from '~/import_projects/components/bitbucket_status_table.vue'; -import ImportProjectsTable from '~/import_projects/components/import_projects_table.vue'; +import BitbucketStatusTable from '~/import_entities/import_projects/components/bitbucket_status_table.vue'; +import ImportProjectsTable from '~/import_entities/import_projects/components/import_projects_table.vue'; const ImportProjectsTableStub = { name: 'ImportProjectsTable', diff --git a/spec/frontend/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js index 7322c7c1ae1..b4ac11b4404 100644 --- a/spec/frontend/import_projects/components/import_projects_table_spec.js +++ b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js @@ -2,11 +2,11 @@ import { nextTick } from 'vue'; import Vuex from 'vuex'; import { createLocalVue, shallowMount } from '@vue/test-utils'; import { GlLoadingIcon, GlButton, GlIntersectionObserver } from '@gitlab/ui'; -import state from '~/import_projects/store/state'; -import * as getters from '~/import_projects/store/getters'; -import { STATUSES } from '~/import_projects/constants'; -import ImportProjectsTable from '~/import_projects/components/import_projects_table.vue'; -import ProviderRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue'; +import state from '~/import_entities/import_projects/store/state'; +import * as getters from '~/import_entities/import_projects/store/getters'; +import { STATUSES } from '~/import_entities/constants'; +import ImportProjectsTable from '~/import_entities/import_projects/components/import_projects_table.vue'; +import ProviderRepoTableRow from '~/import_entities/import_projects/components/provider_repo_table_row.vue'; describe('ImportProjectsTable', () => { let wrapper; diff --git a/spec/frontend/import_projects/components/provider_repo_table_row_spec.js b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js index 03e30ef610e..aa003226050 100644 --- a/spec/frontend/import_projects/components/provider_repo_table_row_spec.js +++ b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js @@ -2,9 +2,9 @@ import { nextTick } from 'vue'; import Vuex from 'vuex'; import { createLocalVue, shallowMount } from '@vue/test-utils'; import { GlBadge } from '@gitlab/ui'; -import ProviderRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue'; -import ImportStatus from '~/import_projects/components/import_status.vue'; -import { STATUSES } from '~/import_projects/constants'; +import ProviderRepoTableRow from '~/import_entities/import_projects/components/provider_repo_table_row.vue'; +import ImportStatus from '~/import_entities/components/import_status.vue'; +import { STATUSES } from '~/import_entities//constants'; import Select2Select from '~/vue_shared/components/select2_select.vue'; describe('ProviderRepoTableRow', () => { diff --git a/spec/frontend/import_projects/store/actions_spec.js b/spec/frontend/import_entities/import_projects/store/actions_spec.js index 06afb20c6a2..5d4e73a17a3 100644 --- a/spec/frontend/import_projects/store/actions_spec.js +++ b/spec/frontend/import_entities/import_projects/store/actions_spec.js @@ -17,11 +17,11 @@ import { RECEIVE_NAMESPACES_ERROR, SET_PAGE, SET_FILTER, -} from '~/import_projects/store/mutation_types'; -import actionsFactory from '~/import_projects/store/actions'; -import { getImportTarget } from '~/import_projects/store/getters'; -import state from '~/import_projects/store/state'; -import { STATUSES } from '~/import_projects/constants'; +} from '~/import_entities/import_projects/store/mutation_types'; +import actionsFactory from '~/import_entities/import_projects/store/actions'; +import { getImportTarget } from '~/import_entities/import_projects/store/getters'; +import state from '~/import_entities/import_projects/store/state'; +import { STATUSES } from '~/import_entities/constants'; jest.mock('~/flash'); diff --git a/spec/frontend/import_projects/store/getters_spec.js b/spec/frontend/import_entities/import_projects/store/getters_spec.js index 1ce42e534ea..f0ccffc19f2 100644 --- a/spec/frontend/import_projects/store/getters_spec.js +++ b/spec/frontend/import_entities/import_projects/store/getters_spec.js @@ -5,9 +5,9 @@ import { hasImportableRepos, importAllCount, getImportTarget, -} from '~/import_projects/store/getters'; -import { STATUSES } from '~/import_projects/constants'; -import state from '~/import_projects/store/state'; +} from '~/import_entities/import_projects/store/getters'; +import { STATUSES } from '~/import_entities/constants'; +import state from '~/import_entities/import_projects/store/state'; const IMPORTED_REPO = { importSource: {}, diff --git a/spec/frontend/import_projects/store/mutations_spec.js b/spec/frontend/import_entities/import_projects/store/mutations_spec.js index 5d78a7fa9e7..8b7ddffe6f4 100644 --- a/spec/frontend/import_projects/store/mutations_spec.js +++ b/spec/frontend/import_entities/import_projects/store/mutations_spec.js @@ -1,7 +1,7 @@ -import * as types from '~/import_projects/store/mutation_types'; -import mutations from '~/import_projects/store/mutations'; -import getInitialState from '~/import_projects/store/state'; -import { STATUSES } from '~/import_projects/constants'; +import * as types from '~/import_entities/import_projects/store/mutation_types'; +import mutations from '~/import_entities/import_projects/store/mutations'; +import getInitialState from '~/import_entities/import_projects/store/state'; +import { STATUSES } from '~/import_entities/constants'; describe('import_projects store mutations', () => { let state; diff --git a/spec/frontend/import_projects/utils_spec.js b/spec/frontend/import_entities/import_projects/utils_spec.js index 4e1e16a3184..7d9c4b7137e 100644 --- a/spec/frontend/import_projects/utils_spec.js +++ b/spec/frontend/import_entities/import_projects/utils_spec.js @@ -1,5 +1,9 @@ -import { isProjectImportable, isIncompatible, getImportStatus } from '~/import_projects/utils'; -import { STATUSES } from '~/import_projects/constants'; +import { + isProjectImportable, + isIncompatible, + getImportStatus, +} from '~/import_entities/import_projects/utils'; +import { STATUSES } from '~/import_entities/constants'; describe('import_projects utils', () => { const COMPATIBLE_PROJECT = { diff --git a/spec/frontend/importer_status_spec.js b/spec/frontend/importer_status_spec.js deleted file mode 100644 index 4ef74a2fe84..00000000000 --- a/spec/frontend/importer_status_spec.js +++ /dev/null @@ -1,141 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import { ImporterStatus } from '~/importer_status'; -import axios from '~/lib/utils/axios_utils'; - -describe('Importer Status', () => { - let instance; - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - describe('addToImport', () => { - const importUrl = '/import_url'; - const fixtures = ` - <table> - <tr id="repo_123"> - <td class="import-target"></td> - <td class="import-actions job-status"> - <button name="button" type="submit" class="btn btn-import js-add-to-import"> - </button> - </td> - </tr> - </table> - `; - - beforeEach(() => { - setFixtures(fixtures); - jest.spyOn(ImporterStatus.prototype, 'initStatusPage').mockImplementation(() => {}); - jest.spyOn(ImporterStatus.prototype, 'setAutoUpdate').mockImplementation(() => {}); - instance = new ImporterStatus({ - jobsUrl: '', - importUrl, - }); - }); - - it('sets table row to active after post request', done => { - mock.onPost(importUrl).reply(200, { - id: 1, - full_path: '/full_path', - }); - - instance - .addToImport({ - currentTarget: document.querySelector('.js-add-to-import'), - }) - .then(() => { - expect(document.querySelector('tr').classList.contains('table-active')).toEqual(true); - done(); - }) - .catch(done.fail); - }); - - it('shows error message after failed POST request', done => { - setFixtures(`${fixtures}<div class="flash-container"></div>`); - - mock.onPost(importUrl).reply(422, { - errors: 'You forgot your lunch', - }); - - instance - .addToImport({ - currentTarget: document.querySelector('.js-add-to-import'), - }) - .then(() => { - const flashMessage = document.querySelector('.flash-text'); - - expect(flashMessage.textContent.trim()).toEqual( - 'An error occurred while importing project: You forgot your lunch', - ); - done(); - }) - .catch(done.fail); - }); - }); - - describe('autoUpdate', () => { - const jobsUrl = '/jobs_url'; - - beforeEach(() => { - const div = document.createElement('div'); - div.innerHTML = ` - <div id="project_1"> - <div class="job-status"> - </div> - </div> - `; - - document.body.appendChild(div); - - jest.spyOn(ImporterStatus.prototype, 'initStatusPage').mockImplementation(() => {}); - jest.spyOn(ImporterStatus.prototype, 'setAutoUpdate').mockImplementation(() => {}); - instance = new ImporterStatus({ - jobsUrl, - }); - }); - - function setupMock(importStatus) { - mock.onGet(jobsUrl).reply(200, [ - { - id: 1, - import_status: importStatus, - }, - ]); - } - - function expectJobStatus(done, status) { - instance - .autoUpdate() - .then(() => { - expect(document.querySelector('#project_1').innerText.trim()).toEqual(status); - done(); - }) - .catch(done.fail); - } - - it('sets the job status to done', done => { - setupMock('finished'); - expectJobStatus(done, 'Done'); - }); - - it('sets the job status to scheduled', done => { - setupMock('scheduled'); - expectJobStatus(done, 'Scheduled'); - }); - - it('sets the job status to started', done => { - setupMock('started'); - expectJobStatus(done, 'Started'); - }); - - it('sets the job status to custom status', done => { - setupMock('custom status'); - expectJobStatus(done, 'custom status'); - }); - }); -}); diff --git a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap index e4620590e62..c3fd4a9bab2 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap +++ b/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap @@ -17,7 +17,7 @@ exports[`Alert integration settings form default state should match the default data-qa-selector="create_issue_checkbox" > <span> - Create an issue. Issues are created for each alert triggered. + Create an incident. Incidents are created for each alert triggered. </span> </gl-form-checkbox-stub> </gl-form-group-stub> @@ -32,7 +32,7 @@ exports[`Alert integration settings form default state should match the default for="alert-integration-settings-issue-template" > - Issue template (optional) + Incident template (optional) <gl-link-stub href="/help/user/project/description_templates#creating-issue-templates" @@ -47,7 +47,7 @@ exports[`Alert integration settings form default state should match the default <gl-dropdown-stub block="true" - category="tertiary" + category="primary" data-qa-selector="incident_templates_dropdown" headertext="" id="alert-integration-settings-issue-template" @@ -60,6 +60,7 @@ exports[`Alert integration settings form default state should match the default data-qa-selector="incident_templates_item" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischeckitem="true" secondarytext="" @@ -88,7 +89,7 @@ exports[`Alert integration settings form default state should match the default checked="true" > <span> - Automatically close incident issues when the associated Prometheus alert resolves. + Automatically close incidents when the associated Prometheus alert resolves. </span> </gl-form-checkbox-stub> </gl-form-group-stub> diff --git a/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap index 072e611b9a4..2b3c803be08 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap +++ b/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap @@ -43,6 +43,7 @@ exports[`IncidentsSettingTabs should render the component 1`] = ` > <gl-tab-stub title="Alert integration" + titlelinkclass="" > <alertssettingsform-stub class="gl-pt-3" @@ -51,6 +52,7 @@ exports[`IncidentsSettingTabs should render the component 1`] = ` </gl-tab-stub> <gl-tab-stub title="PagerDuty integration" + titlelinkclass="" > <pagerdutysettingsform-stub class="gl-pt-3" diff --git a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap index 273356151fc..f0eb54c1b3a 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap +++ b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap @@ -5,7 +5,9 @@ exports[`Alert integration settings form should match the default snapshot 1`] = <!----> <p> - Setting up a webhook with PagerDuty will automatically create a GitLab issue for each PagerDuty incident. + <gl-sprintf-stub + message="Create a GitLab incident for each PagerDuty incident by %{linkStart}configuring a webhook in PagerDuty%{linkEnd}" + /> </p> <form> @@ -33,18 +35,10 @@ exports[`Alert integration settings form should match the default snapshot 1`] = value="pagerduty.webhook.com" /> - <div - class="gl-text-gray-200 gl-pt-2" - > - <gl-sprintf-stub - message="Create a GitLab issue for each PagerDuty incident by %{docsLink}" - /> - </div> - <gl-button-stub buttontextclasses="" category="primary" - class="gl-mt-3" + class="gl-mt-5" data-testid="webhook-reset-btn" icon="" role="button" diff --git a/spec/frontend/integrations/edit/store/actions_spec.js b/spec/frontend/integrations/edit/store/actions_spec.js index 5b5c8d6f76e..1ff881c265d 100644 --- a/spec/frontend/integrations/edit/store/actions_spec.js +++ b/spec/frontend/integrations/edit/store/actions_spec.js @@ -1,13 +1,19 @@ import testAction from 'helpers/vuex_action_helper'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; import createState from '~/integrations/edit/store/state'; import { setOverride, setIsSaving, setIsTesting, setIsResetting, + requestResetIntegration, + receiveResetIntegrationSuccess, + receiveResetIntegrationError, } from '~/integrations/edit/store/actions'; import * as types from '~/integrations/edit/store/mutation_types'; +jest.mock('~/lib/utils/url_utility'); + describe('Integration form store actions', () => { let state; @@ -40,4 +46,28 @@ describe('Integration form store actions', () => { ]); }); }); + + describe('requestResetIntegration', () => { + it('should commit REQUEST_RESET_INTEGRATION mutation', () => { + return testAction(requestResetIntegration, null, state, [ + { type: types.REQUEST_RESET_INTEGRATION }, + ]); + }); + }); + + describe('receiveResetIntegrationSuccess', () => { + it('should call refreshCurrentPage()', () => { + return testAction(receiveResetIntegrationSuccess, null, state, [], [], () => { + expect(refreshCurrentPage).toHaveBeenCalled(); + }); + }); + }); + + describe('receiveResetIntegrationError', () => { + it('should commit RECEIVE_RESET_INTEGRATION_ERROR mutation', () => { + return testAction(receiveResetIntegrationError, null, state, [ + { type: types.RECEIVE_RESET_INTEGRATION_ERROR }, + ]); + }); + }); }); diff --git a/spec/frontend/integrations/edit/store/mutations_spec.js b/spec/frontend/integrations/edit/store/mutations_spec.js index 4707b4b3714..81f39adb87f 100644 --- a/spec/frontend/integrations/edit/store/mutations_spec.js +++ b/spec/frontend/integrations/edit/store/mutations_spec.js @@ -40,4 +40,20 @@ describe('Integration form store mutations', () => { expect(state.isResetting).toBe(true); }); }); + + describe(`${types.REQUEST_RESET_INTEGRATION}`, () => { + it('sets isResetting', () => { + mutations[types.REQUEST_RESET_INTEGRATION](state); + + expect(state.isResetting).toBe(true); + }); + }); + + describe(`${types.RECEIVE_RESET_INTEGRATION_ERROR}`, () => { + it('sets isResetting', () => { + mutations[types.RECEIVE_RESET_INTEGRATION_ERROR](state); + + expect(state.isResetting).toBe(false); + }); + }); }); 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 fb0bd6bb195..106a2df783d 100644 --- a/spec/frontend/invite_members/components/members_token_select_spec.js +++ b/spec/frontend/invite_members/components/members_token_select_spec.js @@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { GlTokenSelector } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; +import { stubComponent } from 'helpers/stub_component'; import Api from '~/api'; import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; @@ -17,6 +18,9 @@ const createComponent = () => { ariaLabelledby: label, placeholder, }, + stubs: { + GlTokenSelector: stubComponent(GlTokenSelector), + }, }); }; diff --git a/spec/frontend/issuable/related_issues/components/issue_token_spec.js b/spec/frontend/issuable/related_issues/components/issue_token_spec.js index 1b4c6b548e2..d5181d4a17a 100644 --- a/spec/frontend/issuable/related_issues/components/issue_token_spec.js +++ b/spec/frontend/issuable/related_issues/components/issue_token_spec.js @@ -100,7 +100,7 @@ describe('IssueToken', () => { state, }); - expect(findReferenceIcon().attributes('aria-label')).toBe(state); + expect(findReferenceIcon().props('ariaLabel')).toBe(state); expect(findReference().text()).toBe(displayReference); expect(findTitle().text()).toBe(title); }); 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 2544d0bd030..2c02e1e1de4 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 @@ -280,7 +280,7 @@ describe('RelatedIssuesRoot', () => { const input = 'asdf/qwer#444 #12 '; wrapper.vm.onInput({ untouchedRawReferences: input.trim().split(/\s/), - touchedReference: 2, + touchedReference: '2', }); expect(wrapper.vm.state.pendingReferences).toHaveLength(2); @@ -292,13 +292,37 @@ describe('RelatedIssuesRoot', () => { const input = 'something random '; wrapper.vm.onInput({ untouchedRawReferences: input.trim().split(/\s/), - touchedReference: 2, + touchedReference: '2', }); expect(wrapper.vm.state.pendingReferences).toHaveLength(2); expect(wrapper.vm.state.pendingReferences[0]).toEqual('something'); expect(wrapper.vm.state.pendingReferences[1]).toEqual('random'); }); + + it('prepends # when user enters a numeric value [0-9]', async () => { + const input = '23'; + + wrapper.vm.onInput({ + untouchedRawReferences: input.trim().split(/\s/), + touchedReference: input, + }); + + expect(wrapper.vm.inputValue).toBe(`#${input}`); + }); + + it('prepends # when user enters a number', async () => { + const input = 23; + + wrapper.vm.onInput({ + untouchedRawReferences: String(input) + .trim() + .split(/\s/), + touchedReference: input, + }); + + expect(wrapper.vm.inputValue).toBe(`#${input}`); + }); }); describe('onBlur', () => { diff --git a/spec/frontend/issuable_show/components/issuable_body_spec.js b/spec/frontend/issuable_show/components/issuable_body_spec.js index 0e4475e8103..5708eaf4a31 100644 --- a/spec/frontend/issuable_show/components/issuable_body_spec.js +++ b/spec/frontend/issuable_show/components/issuable_body_spec.js @@ -135,6 +135,33 @@ describe('IssuableBody', () => { expect(wrapper.emitted('edit-issuable')).toBeTruthy(); }); + + it.each(['keydown-title', 'keydown-description'])( + 'component emits `%s` event with event object and issuableMeta params via issuable-edit-form', + async eventName => { + const eventObj = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + const issuableMeta = { + issuableTitle: 'foo', + issuableDescription: 'foobar', + }; + + wrapper.setProps({ + editFormVisible: true, + }); + + await wrapper.vm.$nextTick(); + + const issuableEditForm = wrapper.find(IssuableEditForm); + + issuableEditForm.vm.$emit(eventName, eventObj, issuableMeta); + + expect(wrapper.emitted(eventName)).toBeTruthy(); + expect(wrapper.emitted(eventName)[0]).toMatchObject([eventObj, issuableMeta]); + }, + ); }); }); }); diff --git a/spec/frontend/issuable_show/components/issuable_edit_form_spec.js b/spec/frontend/issuable_show/components/issuable_edit_form_spec.js index 352e66cdffe..a865bdb5608 100644 --- a/spec/frontend/issuable_show/components/issuable_edit_form_spec.js +++ b/spec/frontend/issuable_show/components/issuable_edit_form_spec.js @@ -41,6 +41,40 @@ describe('IssuableEditForm', () => { wrapper.destroy(); }); + describe('watch', () => { + describe('issuable', () => { + it('sets title and description to `issuable.title` and `issuable.description` when those values are available', async () => { + wrapper.setProps({ + issuable: { + ...issuableEditFormProps.issuable, + title: 'Foo', + description: 'Foobar', + }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.title).toBe('Foo'); + expect(wrapper.vm.description).toBe('Foobar'); + }); + + it('sets title and description to empty string when `issuable.title` and `issuable.description` is unavailable', async () => { + wrapper.setProps({ + issuable: { + ...issuableEditFormProps.issuable, + title: null, + description: null, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.title).toBe(''); + expect(wrapper.vm.description).toBe(''); + }); + }); + }); + describe('created', () => { it('binds `update.issuable` and `close.form` event listeners', () => { const eventOnSpy = jest.spyOn(IssuableEventHub, '$on'); @@ -118,5 +152,42 @@ describe('IssuableEditForm', () => { expect(actionsEl.find('button.js-save').exists()).toBe(true); expect(actionsEl.find('button.js-cancel').exists()).toBe(true); }); + + describe('events', () => { + const eventObj = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + + it('component emits `keydown-title` event with event object and issuableMeta params via gl-form-input', async () => { + const titleInputEl = wrapper.find(GlFormInput); + + titleInputEl.vm.$emit('keydown', eventObj, 'title'); + + expect(wrapper.emitted('keydown-title')).toBeTruthy(); + expect(wrapper.emitted('keydown-title')[0]).toMatchObject([ + eventObj, + { + issuableTitle: wrapper.vm.title, + issuableDescription: wrapper.vm.description, + }, + ]); + }); + + it('component emits `keydown-description` event with event object and issuableMeta params via textarea', async () => { + const descriptionInputEl = wrapper.find('[data-testid="description"] textarea'); + + descriptionInputEl.trigger('keydown', eventObj, 'description'); + + expect(wrapper.emitted('keydown-description')).toBeTruthy(); + expect(wrapper.emitted('keydown-description')[0]).toMatchObject([ + eventObj, + { + issuableTitle: wrapper.vm.title, + issuableDescription: wrapper.vm.description, + }, + ]); + }); + }); }); }); diff --git a/spec/frontend/issuable_show/components/issuable_show_root_spec.js b/spec/frontend/issuable_show/components/issuable_show_root_spec.js index 112e4ccd340..ca0aefc1083 100644 --- a/spec/frontend/issuable_show/components/issuable_show_root_spec.js +++ b/spec/frontend/issuable_show/components/issuable_show_root_spec.js @@ -118,6 +118,27 @@ describe('IssuableShowRoot', () => { expect(wrapper.emitted('sidebar-toggle')).toBeTruthy(); }); + + it.each(['keydown-title', 'keydown-description'])( + 'component emits `%s` event with event object and issuableMeta params via issuable-body', + eventName => { + const eventObj = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + const issuableMeta = { + issuableTitle: 'foo', + issuableDescription: 'foobar', + }; + + const issuableBody = wrapper.find(IssuableBody); + + issuableBody.vm.$emit(eventName, eventObj, issuableMeta); + + expect(wrapper.emitted(eventName)).toBeTruthy(); + expect(wrapper.emitted(eventName)[0]).toMatchObject([eventObj, issuableMeta]); + }, + ); }); }); }); diff --git a/spec/frontend/issuable_show/mock_data.js b/spec/frontend/issuable_show/mock_data.js index 14e5febdc6b..af854f420bc 100644 --- a/spec/frontend/issuable_show/mock_data.js +++ b/spec/frontend/issuable_show/mock_data.js @@ -28,7 +28,9 @@ export const mockIssuableShowProps = { descriptionPreviewPath: '/gitlab-org/gitlab-shell/preview_markdown', editFormVisible: false, enableAutocomplete: true, + enableAutosave: true, enableEdit: true, + showFieldTitle: false, statusBadgeClass: 'status-box-open', statusIcon: 'issue-open-m', }; diff --git a/spec/frontend/issue_show/components/header_actions_spec.js b/spec/frontend/issue_show/components/header_actions_spec.js index 67b8665a889..b9836ae7240 100644 --- a/spec/frontend/issue_show/components/header_actions_spec.js +++ b/spec/frontend/issue_show/components/header_actions_spec.js @@ -7,6 +7,7 @@ import HeaderActions from '~/issue_show/components/header_actions.vue'; import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants'; import promoteToEpicMutation from '~/issue_show/queries/promote_to_epic.mutation.graphql'; import * as urlUtility from '~/lib/utils/url_utility'; +import eventHub from '~/notes/event_hub'; import createStore from '~/notes/stores'; jest.mock('~/flash'); @@ -82,8 +83,10 @@ describe('HeaderActions component', () => { } = {}) => { mutateMock = jest.fn().mockResolvedValue(mutateResponse); - store.getters.getNoteableData.state = issueState; - store.getters.getNoteableData.blocked_by_issues = blockedByIssues; + store.dispatch('setNoteableData', { + blocked_by_issues: blockedByIssues, + state: issueState, + }); return shallowMount(HeaderActions, { localVue, @@ -273,6 +276,26 @@ describe('HeaderActions component', () => { }); }); + describe('when `toggle.issuable.state` event is emitted', () => { + it('invokes a method to toggle the issue state', () => { + wrapper = mountComponent({ mutateResponse: updateIssueMutationResponse }); + + eventHub.$emit('toggle.issuable.state'); + + expect(mutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + iid: defaultProps.iid, + projectPath: defaultProps.projectPath, + stateEvent: IssueStateEvent.Close, + }, + }, + }), + ); + }); + }); + describe('modal', () => { const blockedByIssues = [ { iid: 13, web_url: 'gitlab-org/gitlab-test/-/issues/13' }, diff --git a/spec/frontend/issue_show/issue_spec.js b/spec/frontend/issue_show/issue_spec.js index 7a48353af94..cee9969d26a 100644 --- a/spec/frontend/issue_show/issue_spec.js +++ b/spec/frontend/issue_show/issue_spec.js @@ -30,7 +30,8 @@ describe('Issue show index', () => { initialDescriptionHtml: '<svg onload=window.alert(1)>', }); - const issuableData = parseData.parseIssuableData(); + const initialDataEl = document.getElementById('js-issuable-app'); + const issuableData = parseData.parseIssuableData(initialDataEl); initIssuableApp(issuableData, createStore()); await waitForPromises(); diff --git a/spec/frontend/issue_spec.js b/spec/frontend/issue_spec.js index 24020daf728..00595736821 100644 --- a/spec/frontend/issue_spec.js +++ b/spec/frontend/issue_spec.js @@ -1,5 +1,3 @@ -/* eslint-disable one-var, no-use-before-define */ - import $ from 'jquery'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; @@ -7,59 +5,16 @@ import Issue from '~/issue'; import '~/lib/utils/text_utility'; describe('Issue', () => { + let $boxClosed; + let $boxOpen; let testContext; beforeEach(() => { testContext = {}; }); - let $boxClosed, $boxOpen, $btn; - preloadFixtures('issues/closed-issue.html'); - preloadFixtures('issues/issue-with-task-list.html'); preloadFixtures('issues/open-issue.html'); - preloadFixtures('static/issue_with_mermaid_graph.html'); - - function expectErrorMessage() { - const $flashMessage = $('div.flash-alert'); - - expect($flashMessage).toExist(); - expect($flashMessage).toBeVisible(); - expect($flashMessage).toHaveText('Unable to update this issue at this time.'); - } - - function expectIssueState(isIssueOpen) { - expectVisibility($boxClosed, !isIssueOpen); - expectVisibility($boxOpen, isIssueOpen); - - expect($btn).toHaveText(isIssueOpen ? 'Close issue' : 'Reopen issue'); - } - - function expectNewBranchButtonState(isPending, canCreate) { - if (Issue.$btnNewBranch.length === 0) { - return; - } - - const $available = Issue.$btnNewBranch.find('.available'); - - expect($available).toHaveText('New branch'); - - if (!isPending && canCreate) { - expect($available).toBeVisible(); - } else { - expect($available).toBeHidden(); - } - - const $unavailable = Issue.$btnNewBranch.find('.unavailable'); - - expect($unavailable).toHaveText('New branch unavailable'); - - if (!isPending && !canCreate) { - expect($unavailable).toBeVisible(); - } else { - expect($unavailable).toBeHidden(); - } - } function expectVisibility($element, shouldBeVisible) { if (shouldBeVisible) { @@ -69,7 +24,12 @@ describe('Issue', () => { } } - function findElements(isIssueInitiallyOpen) { + function expectIssueState(isIssueOpen) { + expectVisibility($boxClosed, !isIssueOpen); + expectVisibility($boxOpen, isIssueOpen); + } + + function findElements() { $boxClosed = $('div.status-box-issue-closed'); expect($boxClosed).toExist(); @@ -79,11 +39,6 @@ describe('Issue', () => { expect($boxOpen).toExist(); expect($boxOpen).toHaveText('Open'); - - $btn = $('.js-issuable-close-button'); - - expect($btn).toExist(); - expect($btn).toHaveText(isIssueInitiallyOpen ? 'Close issue' : 'Reopen issue'); } [true, false].forEach(isIssueInitiallyOpen => { @@ -99,25 +54,6 @@ describe('Issue', () => { testContext.$projectIssuesCounter.text('1,001'); } - function mockCloseButtonResponseSuccess(url, response) { - mock.onPut(url).reply(() => { - expectNewBranchButtonState(true, false); - - return [200, response]; - }); - } - - function mockCloseButtonResponseError(url) { - mock.onPut(url).networkError(); - } - - function mockCanCreateBranch(canCreateBranch) { - mock.onGet(/(.*)\/can_create_branch$/).reply(200, { - can_create_branch: canCreateBranch, - suggested_branch_name: 'foo-99', - }); - } - beforeEach(() => { if (isIssueInitiallyOpen) { loadFixtures('issues/open-issue.html'); @@ -130,7 +66,6 @@ describe('Issue', () => { jest.spyOn(axios, 'get'); findElements(isIssueInitiallyOpen); - testContext.$triggeredButton = $btn; }); afterEach(() => { @@ -138,120 +73,19 @@ describe('Issue', () => { $('div.flash-alert').remove(); }); - it(`${action}s the issue`, done => { - mockCloseButtonResponseSuccess(testContext.$triggeredButton.attr('href'), { - id: 34, - }); - mockCanCreateBranch(!isIssueInitiallyOpen); - - setup(); - testContext.$triggeredButton.trigger('click'); - - setImmediate(() => { - expectIssueState(!isIssueInitiallyOpen); - - expect(testContext.$triggeredButton.get(0).getAttribute('disabled')).toBeNull(); - expect(testContext.$projectIssuesCounter.text()).toBe( - isIssueInitiallyOpen ? '1,000' : '1,002', - ); - expectNewBranchButtonState(false, !isIssueInitiallyOpen); - - done(); - }); - }); - - it(`fails to ${action} the issue if saved:false`, done => { - mockCloseButtonResponseSuccess(testContext.$triggeredButton.attr('href'), { - saved: false, - }); - mockCanCreateBranch(isIssueInitiallyOpen); - - setup(); - testContext.$triggeredButton.trigger('click'); - - setImmediate(() => { - expectIssueState(isIssueInitiallyOpen); - - expect(testContext.$triggeredButton.get(0).getAttribute('disabled')).toBeNull(); - expectErrorMessage(); - - expect(testContext.$projectIssuesCounter.text()).toBe('1,001'); - expectNewBranchButtonState(false, isIssueInitiallyOpen); - - done(); - }); - }); - - it(`fails to ${action} the issue if HTTP error occurs`, done => { - mockCloseButtonResponseError(testContext.$triggeredButton.attr('href')); - mockCanCreateBranch(isIssueInitiallyOpen); - - setup(); - testContext.$triggeredButton.trigger('click'); - - setImmediate(() => { - expectIssueState(isIssueInitiallyOpen); - - expect(testContext.$triggeredButton.get(0).getAttribute('disabled')).toBeNull(); - expectErrorMessage(); - - expect(testContext.$projectIssuesCounter.text()).toBe('1,001'); - expectNewBranchButtonState(false, isIssueInitiallyOpen); - - done(); - }); - }); - - it('disables the new branch button if Ajax call fails', () => { - mockCloseButtonResponseError(testContext.$triggeredButton.attr('href')); - mock.onGet(/(.*)\/can_create_branch$/).networkError(); - - setup(); - testContext.$triggeredButton.trigger('click'); - - expectNewBranchButtonState(false, false); - }); - - it('does not trigger Ajax call if new branch button is missing', done => { - mockCloseButtonResponseError(testContext.$triggeredButton.attr('href')); - - document.querySelector('#related-branches').remove(); - document.querySelector('.create-mr-dropdown-wrap').remove(); - + it(`${action}s the issue on dispatch of issuable_vue_app:change event`, () => { setup(); - testContext.$triggeredButton.trigger('click'); - - setImmediate(() => { - expect(axios.get).not.toHaveBeenCalled(); - - done(); - }); - }); - }); - }); - - describe('when not displaying blocked warning', () => { - describe('when clicking a mermaid graph inside an issue description', () => { - let mock; - let spy; - - beforeEach(() => { - loadFixtures('static/issue_with_mermaid_graph.html'); - mock = new MockAdapter(axios); - spy = jest.spyOn(axios, 'put'); - }); - - afterEach(() => { - mock.restore(); - jest.clearAllMocks(); - }); - - it('does not make a PUT request', () => { - Issue.prototype.initIssueBtnEventListeners(); - $('svg a.js-issuable-actions').trigger('click'); + document.dispatchEvent( + new CustomEvent('issuable_vue_app:change', { + detail: { + data: { id: 1 }, + isClosed: isIssueInitiallyOpen, + }, + }), + ); - expect(spy).not.toHaveBeenCalled(); + expectIssueState(!isIssueInitiallyOpen); }); }); }); diff --git a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap index fe6d9a34078..c40b7c90c72 100644 --- a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap +++ b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap @@ -120,6 +120,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = class="gl-search-box-by-type" > <svg + aria-hidden="true" class="gl-search-box-by-type-search-icon gl-icon s16" data-testid="search-icon" > @@ -234,6 +235,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = class="gl-search-box-by-type" > <svg + aria-hidden="true" class="gl-search-box-by-type-search-icon gl-icon s16" data-testid="search-icon" > diff --git a/spec/frontend/jobs/components/log/line_spec.js b/spec/frontend/jobs/components/log/line_spec.js index 314b23ec29b..914ae2424c8 100644 --- a/spec/frontend/jobs/components/log/line_spec.js +++ b/spec/frontend/jobs/components/log/line_spec.js @@ -4,6 +4,7 @@ import LineNumber from '~/jobs/components/log/line_number.vue'; const httpUrl = 'http://example.com'; const httpsUrl = 'https://example.com'; +const queryUrl = 'https://example.com?param=val'; const mockProps = ({ text = 'Running with gitlab-runner 12.1.0 (de7731dd)' } = {}) => ({ line: { @@ -21,7 +22,6 @@ const mockProps = ({ text = 'Running with gitlab-runner 12.1.0 (de7731dd)' } = { describe('Job Log Line', () => { let wrapper; let data; - let originalGon; const createComponent = (props = {}) => { wrapper = shallowMount(Line, { @@ -33,25 +33,17 @@ describe('Job Log Line', () => { const findLine = () => wrapper.find('span'); const findLink = () => findLine().find('a'); - const findLinksAt = i => - findLine() - .findAll('a') - .at(i); + const findLinks = () => findLine().findAll('a'); + const findLinkAttributeByIndex = i => + findLinks() + .at(i) + .attributes(); beforeEach(() => { - originalGon = window.gon; - window.gon.features = { - ciJobLineLinks: false, - }; - data = mockProps(); createComponent(data); }); - afterEach(() => { - window.gon = originalGon; - }); - it('renders the line number component', () => { expect(wrapper.find(LineNumber).exists()).toBe(true); }); @@ -64,44 +56,7 @@ describe('Job Log Line', () => { expect(findLine().classes()).toContain(data.line.content[0].style); }); - describe.each([true, false])('when feature ci_job_line_links enabled = %p', ciJobLineLinks => { - beforeEach(() => { - window.gon.features = { - ciJobLineLinks, - }; - }); - - it('renders text with symbols', () => { - const text = 'apt-get update < /dev/null > /dev/null'; - createComponent(mockProps({ text })); - - expect(findLine().text()).toBe(text); - }); - - it.each` - tag | text - ${'a'} | ${'<a href="#">linked</a>'} - ${'script'} | ${'<script>doEvil();</script>'} - ${'strong'} | ${'<strong>highlighted</strong>'} - `('escapes `<$tag>` tags in text', ({ tag, text }) => { - createComponent(mockProps({ text })); - - expect( - findLine() - .find(tag) - .exists(), - ).toBe(false); - expect(findLine().text()).toBe(text); - }); - }); - - describe('when ci_job_line_links is enabled', () => { - beforeEach(() => { - window.gon.features = { - ciJobLineLinks: true, - }; - }); - + describe('job urls as links', () => { it('renders an http link', () => { createComponent(mockProps({ text: httpUrl })); @@ -116,15 +71,6 @@ describe('Job Log Line', () => { expect(findLink().attributes().href).toBe(httpsUrl); }); - it('renders a multiple links surrounded by text', () => { - createComponent(mockProps({ text: `My HTTP url: ${httpUrl} and my HTTPS url: ${httpsUrl}` })); - expect(findLine().text()).toBe( - 'My HTTP url: http://example.com and my HTTPS url: https://example.com', - ); - expect(findLinksAt(0).attributes().href).toBe(httpUrl); - expect(findLinksAt(1).attributes().href).toBe(httpsUrl); - }); - it('renders a link with rel nofollow and noopener', () => { createComponent(mockProps({ text: httpsUrl })); @@ -137,26 +83,70 @@ describe('Job Log Line', () => { expect(findLink().classes()).toEqual(['gl-reset-color!', 'gl-text-decoration-underline']); }); - it('render links surrounded by text', () => { + it('renders links with queries, surrounded by questions marks', () => { + createComponent(mockProps({ text: `Did you see my url ${queryUrl}??` })); + + expect(findLine().text()).toBe('Did you see my url https://example.com?param=val??'); + expect(findLinkAttributeByIndex(0).href).toBe(queryUrl); + }); + + it('renders links with queries, surrounded by exclamation marks', () => { + createComponent(mockProps({ text: `No! The ${queryUrl}!?` })); + + expect(findLine().text()).toBe('No! The https://example.com?param=val!?'); + expect(findLinkAttributeByIndex(0).href).toBe(queryUrl); + }); + + it('renders multiple links surrounded by text', () => { createComponent( - mockProps({ text: `My HTTP url: ${httpUrl} and my HTTPS url: ${httpsUrl} are here.` }), + mockProps({ text: `Well, my HTTP url: ${httpUrl} and my HTTPS url: ${httpsUrl}` }), ); expect(findLine().text()).toBe( - 'My HTTP url: http://example.com and my HTTPS url: https://example.com are here.', + 'Well, my HTTP url: http://example.com and my HTTPS url: https://example.com', ); - expect(findLinksAt(0).attributes().href).toBe(httpUrl); - expect(findLinksAt(1).attributes().href).toBe(httpsUrl); + + expect(findLinks()).toHaveLength(2); + + expect(findLinkAttributeByIndex(0).href).toBe(httpUrl); + expect(findLinkAttributeByIndex(1).href).toBe(httpsUrl); + }); + + it('renders multiple links surrounded by text, with other symbols', () => { + createComponent( + mockProps({ text: `${httpUrl}, ${httpUrl}: ${httpsUrl}; ${httpsUrl}. ${httpsUrl}...` }), + ); + expect(findLine().text()).toBe( + 'http://example.com, http://example.com: https://example.com; https://example.com. https://example.com...', + ); + + expect(findLinks()).toHaveLength(5); + + expect(findLinkAttributeByIndex(0).href).toBe(httpUrl); + expect(findLinkAttributeByIndex(1).href).toBe(httpUrl); + expect(findLinkAttributeByIndex(2).href).toBe(httpsUrl); + expect(findLinkAttributeByIndex(3).href).toBe(httpsUrl); + expect(findLinkAttributeByIndex(4).href).toBe(httpsUrl); + }); + + it('renders text with symbols in it', () => { + const text = 'apt-get update < /dev/null > /dev/null'; + createComponent(mockProps({ text })); + + expect(findLine().text()).toBe(text); }); const jshref = 'javascript:doEvil();'; // eslint-disable-line no-script-url - test.each` - type | text - ${'js'} | ${jshref} - ${'file'} | ${'file:///a-file'} - ${'ftp'} | ${'ftp://example.com/file'} - ${'email'} | ${'email@example.com'} - ${'no scheme'} | ${'example.com/page'} + it.each` + type | text + ${'html link'} | ${'<a href="#">linked</a>'} + ${'html script'} | ${'<script>doEvil();</script>'} + ${'html strong'} | ${'<strong>highlighted</strong>'} + ${'js'} | ${jshref} + ${'file'} | ${'file:///a-file'} + ${'ftp'} | ${'ftp://example.com/file'} + ${'email'} | ${'email@example.com'} + ${'no scheme'} | ${'example.com/page'} `('does not render a $type link', ({ text }) => { createComponent(mockProps({ text })); expect(findLink().exists()).toBe(false); diff --git a/spec/frontend/jobs/components/unmet_prerequisites_block_spec.js b/spec/frontend/jobs/components/unmet_prerequisites_block_spec.js index 68fcb321214..9092d3f8163 100644 --- a/spec/frontend/jobs/components/unmet_prerequisites_block_spec.js +++ b/spec/frontend/jobs/components/unmet_prerequisites_block_spec.js @@ -1,37 +1,41 @@ -import Vue from 'vue'; -import component from '~/jobs/components/unmet_prerequisites_block.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import { shallowMount } from '@vue/test-utils'; +import { GlAlert, GlLink } from '@gitlab/ui'; +import UnmetPrerequisitesBlock from '~/jobs/components/unmet_prerequisites_block.vue'; describe('Unmet Prerequisites Block Job component', () => { - const Component = Vue.extend(component); - let vm; + let wrapper; const helpPath = '/user/project/clusters/index.html#troubleshooting-failed-deployment-jobs'; - beforeEach(() => { - vm = mountComponent(Component, { - hasNoRunnersForProject: true, - helpPath, + const createComponent = () => { + wrapper = shallowMount(UnmetPrerequisitesBlock, { + propsData: { + helpPath, + }, }); + }; + + beforeEach(() => { + createComponent(); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders an alert with the correct message', () => { - const container = vm.$el.querySelector('.js-failed-unmet-prerequisites'); + const container = wrapper.find(GlAlert); const alertMessage = 'This job failed because the necessary resources were not successfully created.'; expect(container).not.toBeNull(); - expect(container.innerHTML).toContain(alertMessage); + expect(container.text()).toContain(alertMessage); }); it('renders link to help page', () => { - const helpLink = vm.$el.querySelector('.js-help-path'); + const helpLink = wrapper.find(GlLink); expect(helpLink).not.toBeNull(); - expect(helpLink.innerHTML).toContain('More information'); - expect(helpLink.getAttribute('href')).toEqual(helpPath); + expect(helpLink.text()).toContain('More information'); + expect(helpLink.attributes().href).toEqual(helpPath); }); }); diff --git a/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js b/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js index 2f7a6030650..2175610b7a6 100644 --- a/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js +++ b/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js @@ -44,34 +44,84 @@ describe('DelayedJobMixin', () => { }); }); - describe('if job is delayed job', () => { - let remainingTimeInMilliseconds = 42000; + describe('in REST component', () => { + describe('if job is delayed job', () => { + let remainingTimeInMilliseconds = 42000; - beforeEach(() => { - jest - .spyOn(Date, 'now') - .mockImplementation( - () => new Date(delayedJobFixture.scheduled_at).getTime() - remainingTimeInMilliseconds, - ); + beforeEach(() => { + jest + .spyOn(Date, 'now') + .mockImplementation( + () => new Date(delayedJobFixture.scheduled_at).getTime() - remainingTimeInMilliseconds, + ); - vm = mountComponent(dummyComponent, { - job: delayedJobFixture, + vm = mountComponent(dummyComponent, { + job: delayedJobFixture, + }); + }); + + describe('after mounting', () => { + beforeEach(() => vm.$nextTick()); + + it('sets remaining time', () => { + expect(vm.$el.innerText).toBe('00:00:42'); + }); + + it('updates remaining time', () => { + remainingTimeInMilliseconds = 41000; + jest.advanceTimersByTime(1000); + + return vm.$nextTick().then(() => { + expect(vm.$el.innerText).toBe('00:00:41'); + }); + }); }); }); + }); - describe('after mounting', () => { - beforeEach(() => vm.$nextTick()); + describe('in GraphQL component', () => { + const mockGraphQlJob = { + name: 'build_b', + scheduledAt: new Date(delayedJobFixture.scheduled_at), + status: { + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1515', + group: 'success', + action: null, + }, + }; + + describe('if job is delayed job', () => { + let remainingTimeInMilliseconds = 42000; - it('sets remaining time', () => { - expect(vm.$el.innerText).toBe('00:00:42'); + beforeEach(() => { + jest + .spyOn(Date, 'now') + .mockImplementation( + () => mockGraphQlJob.scheduledAt.getTime() - remainingTimeInMilliseconds, + ); + + vm = mountComponent(dummyComponent, { + job: mockGraphQlJob, + }); }); - it('updates remaining time', () => { - remainingTimeInMilliseconds = 41000; - jest.advanceTimersByTime(1000); + describe('after mounting', () => { + beforeEach(() => vm.$nextTick()); + + it('sets remaining time', () => { + expect(vm.$el.innerText).toBe('00:00:42'); + }); + + it('updates remaining time', () => { + remainingTimeInMilliseconds = 41000; + jest.advanceTimersByTime(1000); - return vm.$nextTick().then(() => { - expect(vm.$el.innerText).toBe('00:00:41'); + return vm.$nextTick().then(() => { + expect(vm.$el.innerText).toBe('00:00:41'); + }); }); }); }); diff --git a/spec/frontend/jobs/store/actions_spec.js b/spec/frontend/jobs/store/actions_spec.js index 91bd5521f70..26547d12ac7 100644 --- a/spec/frontend/jobs/store/actions_spec.js +++ b/spec/frontend/jobs/store/actions_spec.js @@ -27,6 +27,7 @@ import { hideSidebar, showSidebar, toggleSidebar, + triggerManualJob, } from '~/jobs/store/actions'; import state from '~/jobs/store/state'; import * as types from '~/jobs/store/mutation_types'; @@ -158,6 +159,32 @@ describe('Job State actions', () => { ); }); }); + + it('fetchTrace is called only if the job has started or has a trace', done => { + mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, { id: 121212, name: 'karma' }); + + mockedState.job.started = true; + + testAction( + fetchJob, + null, + mockedState, + [], + [ + { + type: 'requestJob', + }, + { + payload: { id: 121212, name: 'karma' }, + type: 'receiveJobSuccess', + }, + { + type: 'fetchTrace', + }, + ], + done, + ); + }); }); describe('receiveJobSuccess', () => { @@ -509,4 +536,43 @@ describe('Job State actions', () => { ); }); }); + + describe('triggerManualJob', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + it('should dispatch fetchTrace', done => { + const playManualJobEndpoint = `${TEST_HOST}/manual-job/jobs/1000/play`; + + mock.onPost(playManualJobEndpoint).reply(200); + + mockedState.job = { + status: { + action: { + path: playManualJobEndpoint, + }, + }, + }; + + testAction( + triggerManualJob, + [{ id: '1', key: 'test_var', secret_value: 'test_value' }], + mockedState, + [], + [ + { + type: 'fetchTrace', + }, + ], + done, + ); + }); + }); }); diff --git a/spec/frontend/jobs/store/mutations_spec.js b/spec/frontend/jobs/store/mutations_spec.js index 608abc8f7c4..a8146ba93eb 100644 --- a/spec/frontend/jobs/store/mutations_spec.js +++ b/spec/frontend/jobs/store/mutations_spec.js @@ -153,6 +153,7 @@ describe('Jobs Store Mutations', () => { mutations[types.SET_TRACE_TIMEOUT](stateCopy, id); expect(stateCopy.traceTimeout).toEqual(id); + expect(stateCopy.isTraceComplete).toBe(false); }); }); diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js index 09eb362c77e..433fb368f55 100644 --- a/spec/frontend/lib/utils/common_utils_spec.js +++ b/spec/frontend/lib/utils/common_utils_spec.js @@ -220,6 +220,7 @@ describe('common_utils', () => { beforeEach(() => { elem = document.createElement('div'); window.innerHeight = windowHeight; + window.mrTabs = { currentAction: 'show' }; jest.spyOn($.fn, 'animate'); jest.spyOn($.fn, 'offset').mockReturnValue({ top: elemTop }); }); @@ -525,8 +526,8 @@ describe('common_utils', () => { }); it('should set svg className when passed', () => { - expect(commonUtils.spriteIcon('test', 'fa fa-test')).toEqual( - '<svg class="fa fa-test"><use xlink:href="icons.svg#test" /></svg>', + expect(commonUtils.spriteIcon('test', 'first-icon-class second-icon-class')).toEqual( + '<svg class="first-icon-class second-icon-class"><use xlink:href="icons.svg#test" /></svg>', ); }); }); diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js index d7cedb939d2..9c50bf577dc 100644 --- a/spec/frontend/lib/utils/text_utility_spec.js +++ b/spec/frontend/lib/utils/text_utility_spec.js @@ -340,4 +340,27 @@ describe('text_utility', () => { expect(textUtils.isValidSha1Hash(hash)).toBe(valid); }); }); + + describe('insertFinalNewline', () => { + it.each` + input | output + ${'some text'} | ${'some text\n'} + ${'some text\n'} | ${'some text\n'} + ${'some text\n\n'} | ${'some text\n\n'} + ${'some\n text'} | ${'some\n text\n'} + `('adds a newline if it doesnt already exist for input: $input', ({ input, output }) => { + expect(textUtils.insertFinalNewline(input)).toBe(output); + }); + + it.each` + input | output + ${'some text'} | ${'some text\r\n'} + ${'some text\r\n'} | ${'some text\r\n'} + ${'some text\n'} | ${'some text\n\r\n'} + ${'some text\r\n\r\n'} | ${'some text\r\n\r\n'} + ${'some\r\n text'} | ${'some\r\n text\r\n'} + `('works with CRLF newline style; input: $input', ({ input, output }) => { + expect(textUtils.insertFinalNewline(input, '\r\n')).toBe(output); + }); + }); }); diff --git a/spec/frontend/maintenance_mode_settings/components/app_spec.js b/spec/frontend/maintenance_mode_settings/components/app_spec.js deleted file mode 100644 index ad753642e85..00000000000 --- a/spec/frontend/maintenance_mode_settings/components/app_spec.js +++ /dev/null @@ -1,42 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlToggle, GlFormTextarea, GlButton } from '@gitlab/ui'; -import MaintenanceModeSettingsApp from '~/maintenance_mode_settings/components/app.vue'; - -describe('MaintenanceModeSettingsApp', () => { - let wrapper; - - const createComponent = () => { - wrapper = shallowMount(MaintenanceModeSettingsApp); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - const findMaintenanceModeSettingsContainer = () => wrapper.find('article'); - const findGlToggle = () => wrapper.find(GlToggle); - const findGlFormTextarea = () => wrapper.find(GlFormTextarea); - const findGlButton = () => wrapper.find(GlButton); - - describe('template', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders the Maintenance Mode Settings container', () => { - expect(findMaintenanceModeSettingsContainer().exists()).toBe(true); - }); - - it('renders the GlToggle', () => { - expect(findGlToggle().exists()).toBe(true); - }); - - it('renders the GlFormTextarea', () => { - expect(findGlFormTextarea().exists()).toBe(true); - }); - - it('renders the GlButton', () => { - expect(findGlButton().exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/access_request_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js index 58cb8ef61d1..9a8434a1222 100644 --- a/spec/frontend/vue_shared/components/members/action_buttons/access_request_action_buttons_spec.js +++ b/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; -import AccessRequestActionButtons from '~/vue_shared/components/members/action_buttons/access_request_action_buttons.vue'; -import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue'; -import ApproveAccessRequestButton from '~/vue_shared/components/members/action_buttons/approve_access_request_button.vue'; -import { accessRequest as member } from '../mock_data'; +import AccessRequestActionButtons from '~/members/components/action_buttons/access_request_action_buttons.vue'; +import RemoveMemberButton from '~/members/components/action_buttons/remove_member_button.vue'; +import ApproveAccessRequestButton from '~/members/components/action_buttons/approve_access_request_button.vue'; +import { accessRequest as member } from '../../mock_data'; describe('AccessRequestActionButtons', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/members/action_buttons/approve_access_request_button_spec.js b/spec/frontend/members/components/action_buttons/approve_access_request_button_spec.js index 93edaaa400d..7ce2c633bb3 100644 --- a/spec/frontend/vue_shared/components/members/action_buttons/approve_access_request_button_spec.js +++ b/spec/frontend/members/components/action_buttons/approve_access_request_button_spec.js @@ -2,7 +2,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { GlButton, GlForm } from '@gitlab/ui'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import ApproveAccessRequestButton from '~/vue_shared/components/members/action_buttons/approve_access_request_button.vue'; +import ApproveAccessRequestButton from '~/members/components/action_buttons/approve_access_request_button.vue'; jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js index 1374cdc6aef..887b21dc1d0 100644 --- a/spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js +++ b/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; -import InviteActionButtons from '~/vue_shared/components/members/action_buttons/invite_action_buttons.vue'; -import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue'; -import ResendInviteButton from '~/vue_shared/components/members/action_buttons/resend_invite_button.vue'; -import { invite as member } from '../mock_data'; +import InviteActionButtons from '~/members/components/action_buttons/invite_action_buttons.vue'; +import RemoveMemberButton from '~/members/components/action_buttons/remove_member_button.vue'; +import ResendInviteButton from '~/members/components/action_buttons/resend_invite_button.vue'; +import { invite as member } from '../../mock_data'; describe('InviteActionButtons', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/members/action_buttons/leave_button_spec.js b/spec/frontend/members/components/action_buttons/leave_button_spec.js index 00896b23b95..2afe112c74b 100644 --- a/spec/frontend/vue_shared/components/members/action_buttons/leave_button_spec.js +++ b/spec/frontend/members/components/action_buttons/leave_button_spec.js @@ -1,10 +1,10 @@ import { shallowMount } from '@vue/test-utils'; import { GlButton } from '@gitlab/ui'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import LeaveButton from '~/vue_shared/components/members/action_buttons/leave_button.vue'; -import LeaveModal from '~/vue_shared/components/members/modals/leave_modal.vue'; -import { LEAVE_MODAL_ID } from '~/vue_shared/components/members/constants'; -import { member } from '../mock_data'; +import LeaveButton from '~/members/components/action_buttons/leave_button.vue'; +import LeaveModal from '~/members/components/modals/leave_modal.vue'; +import { LEAVE_MODAL_ID } from '~/members/constants'; +import { member } from '../../mock_data'; describe('LeaveButton', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/members/action_buttons/remove_group_link_button_spec.js b/spec/frontend/members/components/action_buttons/remove_group_link_button_spec.js index 84fe1c51773..45283788676 100644 --- a/spec/frontend/vue_shared/components/members/action_buttons/remove_group_link_button_spec.js +++ b/spec/frontend/members/components/action_buttons/remove_group_link_button_spec.js @@ -2,8 +2,8 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { GlButton } from '@gitlab/ui'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import RemoveGroupLinkButton from '~/vue_shared/components/members/action_buttons/remove_group_link_button.vue'; -import { group } from '../mock_data'; +import RemoveGroupLinkButton from '~/members/components/action_buttons/remove_group_link_button.vue'; +import { group } from '../../mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/remove_member_button_spec.js b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js index 7aa30494234..437b3e705a4 100644 --- a/spec/frontend/vue_shared/components/members/action_buttons/remove_member_button_spec.js +++ b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js @@ -1,7 +1,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue'; +import RemoveMemberButton from '~/members/components/action_buttons/remove_member_button.vue'; const localVue = createLocalVue(); localVue.use(Vuex); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/resend_invite_button_spec.js b/spec/frontend/members/components/action_buttons/resend_invite_button_spec.js index 859fdd01043..a48942dd277 100644 --- a/spec/frontend/vue_shared/components/members/action_buttons/resend_invite_button_spec.js +++ b/spec/frontend/members/components/action_buttons/resend_invite_button_spec.js @@ -2,7 +2,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { GlButton } from '@gitlab/ui'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import ResendInviteButton from '~/vue_shared/components/members/action_buttons/resend_invite_button.vue'; +import ResendInviteButton from '~/members/components/action_buttons/resend_invite_button.vue'; jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/user_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js index f766ad5b0d1..b03e80a537d 100644 --- a/spec/frontend/vue_shared/components/members/action_buttons/user_action_buttons_spec.js +++ b/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; -import UserActionButtons from '~/vue_shared/components/members/action_buttons/user_action_buttons.vue'; -import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue'; -import LeaveButton from '~/vue_shared/components/members/action_buttons/leave_button.vue'; -import { member, orphanedMember } from '../mock_data'; +import UserActionButtons from '~/members/components/action_buttons/user_action_buttons.vue'; +import RemoveMemberButton from '~/members/components/action_buttons/remove_member_button.vue'; +import LeaveButton from '~/members/components/action_buttons/leave_button.vue'; +import { member, orphanedMember } from '../../mock_data'; describe('UserActionButtons', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js b/spec/frontend/members/components/avatars/group_avatar_spec.js index d6f5773295c..658bb9462b0 100644 --- a/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js +++ b/spec/frontend/members/components/avatars/group_avatar_spec.js @@ -1,8 +1,8 @@ import { mount, createWrapper } from '@vue/test-utils'; import { getByText as getByTextHelper } from '@testing-library/dom'; import { GlAvatarLink } from '@gitlab/ui'; -import { group as member } from '../mock_data'; -import GroupAvatar from '~/vue_shared/components/members/avatars/group_avatar.vue'; +import { group as member } from '../../mock_data'; +import GroupAvatar from '~/members/components/avatars/group_avatar.vue'; describe('MemberList', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js b/spec/frontend/members/components/avatars/invite_avatar_spec.js index 7948da7eb40..13ee727528b 100644 --- a/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js +++ b/spec/frontend/members/components/avatars/invite_avatar_spec.js @@ -1,7 +1,7 @@ import { mount, createWrapper } from '@vue/test-utils'; import { getByText as getByTextHelper } from '@testing-library/dom'; -import { invite as member } from '../mock_data'; -import InviteAvatar from '~/vue_shared/components/members/avatars/invite_avatar.vue'; +import { invite as member } from '../../mock_data'; +import InviteAvatar from '~/members/components/avatars/invite_avatar.vue'; describe('MemberList', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js b/spec/frontend/members/components/avatars/user_avatar_spec.js index 93d8e640968..7d6a9065975 100644 --- a/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js +++ b/spec/frontend/members/components/avatars/user_avatar_spec.js @@ -1,8 +1,8 @@ import { mount, createWrapper } from '@vue/test-utils'; import { within } from '@testing-library/dom'; import { GlAvatarLink, GlBadge } from '@gitlab/ui'; -import { member as memberMock, orphanedMember } from '../mock_data'; -import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue'; +import { member as memberMock, orphanedMember } from '../../mock_data'; +import UserAvatar from '~/members/components/avatars/user_avatar.vue'; describe('UserAvatar', () => { let wrapper; diff --git a/spec/frontend/members/components/filter_sort/filter_sort_container_spec.js b/spec/frontend/members/components/filter_sort/filter_sort_container_spec.js new file mode 100644 index 00000000000..91277ae6d03 --- /dev/null +++ b/spec/frontend/members/components/filter_sort/filter_sort_container_spec.js @@ -0,0 +1,68 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import FilterSortContainer from '~/members/components/filter_sort/filter_sort_container.vue'; +import MembersFilteredSearchBar from '~/members/components/filter_sort/members_filtered_search_bar.vue'; +import SortDropdown from '~/members/components/filter_sort/sort_dropdown.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('FilterSortContainer', () => { + let wrapper; + + const createComponent = state => { + const store = new Vuex.Store({ + state: { + filteredSearchBar: { + show: true, + tokens: ['two_factor'], + searchParam: 'search', + placeholder: 'Filter members', + recentSearchesStorageKey: 'group_members', + }, + tableSortableFields: ['account'], + ...state, + }, + }); + + wrapper = shallowMount(FilterSortContainer, { + localVue, + store, + }); + }; + + describe('when `filteredSearchBar.show` is `false` and `tableSortableFields` is empty', () => { + it('renders nothing', () => { + createComponent({ + filteredSearchBar: { + show: false, + }, + tableSortableFields: [], + }); + + expect(wrapper.html()).toBe(''); + }); + }); + + describe('when `filteredSearchBar.show` is `true`', () => { + it('renders `MembersFilteredSearchBar`', () => { + createComponent({ + filteredSearchBar: { + show: true, + }, + }); + + expect(wrapper.find(MembersFilteredSearchBar).exists()).toBe(true); + }); + }); + + describe('when `tableSortableFields` is set', () => { + it('renders `SortDropdown`', () => { + createComponent({ + tableSortableFields: ['account'], + }); + + expect(wrapper.find(SortDropdown).exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js new file mode 100644 index 00000000000..ca885000c2f --- /dev/null +++ b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js @@ -0,0 +1,176 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlFilteredSearchToken } from '@gitlab/ui'; +import MembersFilteredSearchBar from '~/members/components/filter_sort/members_filtered_search_bar.vue'; +import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('MembersFilteredSearchBar', () => { + let wrapper; + + const createComponent = state => { + const store = new Vuex.Store({ + state: { + sourceId: 1, + filteredSearchBar: { + show: true, + tokens: ['two_factor'], + searchParam: 'search', + placeholder: 'Filter members', + recentSearchesStorageKey: 'group_members', + }, + canManageMembers: true, + ...state, + }, + }); + + wrapper = shallowMount(MembersFilteredSearchBar, { + localVue, + store, + }); + }; + + const findFilteredSearchBar = () => wrapper.find(FilteredSearchBar); + + it('passes correct props to `FilteredSearchBar` component', () => { + createComponent(); + + expect(findFilteredSearchBar().props()).toMatchObject({ + namespace: '1', + recentSearchesStorageKey: 'group_members', + searchInputPlaceholder: 'Filter members', + }); + }); + + describe('filtering tokens', () => { + it('includes tokens set in `filteredSearchBar.tokens`', () => { + createComponent(); + + expect(findFilteredSearchBar().props('tokens')).toEqual([ + { + type: 'two_factor', + icon: 'lock', + title: '2FA', + token: GlFilteredSearchToken, + unique: true, + operators: [{ value: '=', description: 'is' }], + options: [ + { value: 'enabled', title: 'Enabled' }, + { value: 'disabled', title: 'Disabled' }, + ], + requiredPermissions: 'canManageMembers', + }, + ]); + }); + + describe('when `canManageMembers` is false', () => { + it('excludes 2FA token', () => { + createComponent({ + filteredSearchBar: { + show: true, + tokens: ['two_factor', 'with_inherited_permissions'], + searchParam: 'search', + placeholder: 'Filter members', + recentSearchesStorageKey: 'group_members', + }, + canManageMembers: false, + }); + + expect(findFilteredSearchBar().props('tokens')).toEqual([ + { + type: 'with_inherited_permissions', + icon: 'group', + title: 'Membership', + token: GlFilteredSearchToken, + unique: true, + operators: [{ value: '=', description: 'is' }], + options: [{ value: 'exclude', title: 'Direct' }, { value: 'only', title: 'Inherited' }], + }, + ]); + }); + }); + }); + + describe('when filters are set via query params', () => { + beforeEach(() => { + delete window.location; + window.location = new URL('https://localhost'); + }); + + it('parses and passes tokens to `FilteredSearchBar` component as `initialFilterValue` prop', () => { + window.location.search = '?two_factor=enabled&token_not_available=foobar'; + + createComponent(); + + expect(findFilteredSearchBar().props('initialFilterValue')).toEqual([ + { + type: 'two_factor', + value: { + data: 'enabled', + operator: '=', + }, + }, + ]); + }); + + it('parses and passes search param to `FilteredSearchBar` component as `initialFilterValue` prop', () => { + window.location.search = '?search=foobar'; + + createComponent(); + + expect(findFilteredSearchBar().props('initialFilterValue')).toEqual([ + { + type: 'filtered-search-term', + value: { + data: 'foobar', + }, + }, + ]); + }); + }); + + describe('when filter bar is submitted', () => { + beforeEach(() => { + delete window.location; + window.location = new URL('https://localhost'); + }); + + it('adds correct filter query params', () => { + createComponent(); + + findFilteredSearchBar().vm.$emit('onFilter', [ + { type: 'two_factor', value: { data: 'enabled', operator: '=' } }, + ]); + + expect(window.location.href).toBe('https://localhost/?two_factor=enabled'); + }); + + it('adds search query param', () => { + createComponent(); + + findFilteredSearchBar().vm.$emit('onFilter', [ + { type: 'two_factor', value: { data: 'enabled', operator: '=' } }, + { type: 'filtered-search-term', value: { data: 'foobar' } }, + ]); + + expect(window.location.href).toBe('https://localhost/?two_factor=enabled&search=foobar'); + }); + + it('adds sort query param', () => { + window.location.search = '?sort=name_asc'; + + createComponent(); + + findFilteredSearchBar().vm.$emit('onFilter', [ + { type: 'two_factor', value: { data: 'enabled', operator: '=' } }, + { type: 'filtered-search-term', value: { data: 'foobar' } }, + ]); + + expect(window.location.href).toBe( + 'https://localhost/?two_factor=enabled&search=foobar&sort=name_asc', + ); + }); + }); +}); diff --git a/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js b/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js new file mode 100644 index 00000000000..6fe67aded3d --- /dev/null +++ b/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js @@ -0,0 +1,162 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlSorting, GlSortingItem } from '@gitlab/ui'; +import SortDropdown from '~/members/components/filter_sort/sort_dropdown.vue'; +import * as urlUtilities from '~/lib/utils/url_utility'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('SortDropdown', () => { + let wrapper; + + const URL_HOST = 'https://localhost/'; + + const createComponent = state => { + const store = new Vuex.Store({ + state: { + sourceId: 1, + tableSortableFields: ['account', 'granted', 'expires', 'maxRole', 'lastSignIn'], + filteredSearchBar: { + show: true, + tokens: ['two_factor'], + searchParam: 'search', + placeholder: 'Filter members', + recentSearchesStorageKey: 'group_members', + }, + ...state, + }, + }); + + wrapper = mount(SortDropdown, { + localVue, + store, + }); + }; + + const findSortingComponent = () => wrapper.find(GlSorting); + const findSortDirectionToggle = () => + findSortingComponent().find('button[title="Sort direction"]'); + const findDropdownToggle = () => wrapper.find('button[aria-haspopup="true"]'); + const findDropdownItemByText = text => + wrapper + .findAll(GlSortingItem) + .wrappers.find(dropdownItemWrapper => dropdownItemWrapper.text() === text); + + describe('dropdown options', () => { + beforeEach(() => { + delete window.location; + window.location = new URL(URL_HOST); + }); + + it('adds dropdown items for all the sortable fields', () => { + const URL_FILTER_PARAMS = '?two_factor=enabled&search=foobar'; + const EXPECTED_BASE_URL = `${URL_HOST}${URL_FILTER_PARAMS}&sort=`; + + window.location.search = URL_FILTER_PARAMS; + + const expectedDropdownItems = [ + { + label: 'Account', + url: `${EXPECTED_BASE_URL}name_asc`, + }, + { + label: 'Access granted', + url: `${EXPECTED_BASE_URL}last_joined`, + }, + { + label: 'Max role', + url: `${EXPECTED_BASE_URL}access_level_asc`, + }, + { + label: 'Last sign-in', + url: `${EXPECTED_BASE_URL}recent_sign_in`, + }, + ]; + + createComponent(); + + expectedDropdownItems.forEach(expectedDropdownItem => { + const dropdownItem = findDropdownItemByText(expectedDropdownItem.label); + + expect(dropdownItem).not.toBe(null); + expect(dropdownItem.find('a').attributes('href')).toBe(expectedDropdownItem.url); + }); + }); + + it('checks selected sort option', () => { + window.location.search = '?sort=access_level_asc'; + + createComponent(); + + expect(findDropdownItemByText('Max role').vm.$attrs.active).toBe(true); + }); + }); + + describe('dropdown toggle', () => { + beforeEach(() => { + delete window.location; + window.location = new URL(URL_HOST); + }); + + it('defaults to sorting by "Account" in ascending order', () => { + createComponent(); + + expect(findSortingComponent().props('isAscending')).toBe(true); + expect(findDropdownToggle().text()).toBe('Account'); + }); + + it('sets text as selected sort option', () => { + window.location.search = '?sort=access_level_asc'; + + createComponent(); + + expect(findDropdownToggle().text()).toBe('Max role'); + }); + }); + + describe('sort direction toggle', () => { + beforeEach(() => { + delete window.location; + window.location = new URL(URL_HOST); + + jest.spyOn(urlUtilities, 'visitUrl'); + }); + + describe('when current sort direction is ascending', () => { + beforeEach(() => { + window.location.search = '?sort=access_level_asc'; + + createComponent(); + }); + + describe('when sort direction toggle is clicked', () => { + beforeEach(() => { + findSortDirectionToggle().trigger('click'); + }); + + it('sorts in descending order', () => { + expect(urlUtilities.visitUrl).toHaveBeenCalledWith(`${URL_HOST}?sort=access_level_desc`); + }); + }); + }); + + describe('when current sort direction is descending', () => { + beforeEach(() => { + window.location.search = '?sort=access_level_desc'; + + createComponent(); + }); + + describe('when sort direction toggle is clicked', () => { + beforeEach(() => { + findSortDirectionToggle().trigger('click'); + }); + + it('sorts in ascending order', () => { + expect(urlUtilities.visitUrl).toHaveBeenCalledWith(`${URL_HOST}?sort=access_level_asc`); + }); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/modals/leave_modal_spec.js b/spec/frontend/members/components/modals/leave_modal_spec.js index 63de355a3c8..d7acf12212c 100644 --- a/spec/frontend/vue_shared/components/members/modals/leave_modal_spec.js +++ b/spec/frontend/members/components/modals/leave_modal_spec.js @@ -3,9 +3,9 @@ import { GlModal, GlForm } from '@gitlab/ui'; import { nextTick } from 'vue'; import { within } from '@testing-library/dom'; import Vuex from 'vuex'; -import LeaveModal from '~/vue_shared/components/members/modals/leave_modal.vue'; -import { LEAVE_MODAL_ID } from '~/vue_shared/components/members/constants'; -import { member } from '../mock_data'; +import LeaveModal from '~/members/components/modals/leave_modal.vue'; +import { LEAVE_MODAL_ID } from '~/members/constants'; +import { member } from '../../mock_data'; jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); diff --git a/spec/frontend/vue_shared/components/members/modals/remove_group_link_modal_spec.js b/spec/frontend/members/components/modals/remove_group_link_modal_spec.js index 84da051792d..593dbcd28ba 100644 --- a/spec/frontend/vue_shared/components/members/modals/remove_group_link_modal_spec.js +++ b/spec/frontend/members/components/modals/remove_group_link_modal_spec.js @@ -3,9 +3,9 @@ import { GlModal, GlForm } from '@gitlab/ui'; import { nextTick } from 'vue'; import { within } from '@testing-library/dom'; import Vuex from 'vuex'; -import RemoveGroupLinkModal from '~/vue_shared/components/members/modals/remove_group_link_modal.vue'; -import { REMOVE_GROUP_LINK_MODAL_ID } from '~/vue_shared/components/members/constants'; -import { group } from '../mock_data'; +import RemoveGroupLinkModal from '~/members/components/modals/remove_group_link_modal.vue'; +import { REMOVE_GROUP_LINK_MODAL_ID } from '~/members/constants'; +import { group } from '../../mock_data'; jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); diff --git a/spec/frontend/vue_shared/components/members/table/created_at_spec.js b/spec/frontend/members/components/table/created_at_spec.js index cf3821baf44..a9f809cd805 100644 --- a/spec/frontend/vue_shared/components/members/table/created_at_spec.js +++ b/spec/frontend/members/components/table/created_at_spec.js @@ -1,7 +1,7 @@ import { mount, createWrapper } from '@vue/test-utils'; import { within } from '@testing-library/dom'; import { useFakeDate } from 'helpers/fake_date'; -import CreatedAt from '~/vue_shared/components/members/table/created_at.vue'; +import CreatedAt from '~/members/components/table/created_at.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; describe('CreatedAt', () => { diff --git a/spec/frontend/vue_shared/components/members/table/expiration_datepicker_spec.js b/spec/frontend/members/components/table/expiration_datepicker_spec.js index a1afdbc2b49..ba1b2256e76 100644 --- a/spec/frontend/vue_shared/components/members/table/expiration_datepicker_spec.js +++ b/spec/frontend/members/components/table/expiration_datepicker_spec.js @@ -4,8 +4,8 @@ import { nextTick } from 'vue'; import { GlDatepicker } from '@gitlab/ui'; import { useFakeDate } from 'helpers/fake_date'; import waitForPromises from 'helpers/wait_for_promises'; -import ExpirationDatepicker from '~/vue_shared/components/members/table/expiration_datepicker.vue'; -import { member } from '../mock_data'; +import ExpirationDatepicker from '~/members/components/table/expiration_datepicker.vue'; +import { member } from '../../mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); diff --git a/spec/frontend/vue_shared/components/members/table/expires_at_spec.js b/spec/frontend/members/components/table/expires_at_spec.js index 95ae251b0fd..cf0fc78656e 100644 --- a/spec/frontend/vue_shared/components/members/table/expires_at_spec.js +++ b/spec/frontend/members/components/table/expires_at_spec.js @@ -2,7 +2,7 @@ import { mount, createWrapper } from '@vue/test-utils'; import { within } from '@testing-library/dom'; import { useFakeDate } from 'helpers/fake_date'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue'; +import ExpiresAt from '~/members/components/table/expires_at.vue'; describe('ExpiresAt', () => { // March 15th, 2020 diff --git a/spec/frontend/vue_shared/components/members/table/member_action_buttons_spec.js b/spec/frontend/members/components/table/member_action_buttons_spec.js index e55d9b6be2a..b7a6df3d054 100644 --- a/spec/frontend/vue_shared/components/members/table/member_action_buttons_spec.js +++ b/spec/frontend/members/components/table/member_action_buttons_spec.js @@ -1,11 +1,11 @@ import { shallowMount } from '@vue/test-utils'; -import { MEMBER_TYPES } from '~/vue_shared/components/members/constants'; -import { member as memberMock, group, invite, accessRequest } from '../mock_data'; -import MemberActionButtons from '~/vue_shared/components/members/table/member_action_buttons.vue'; -import UserActionButtons from '~/vue_shared/components/members/action_buttons/user_action_buttons.vue'; -import GroupActionButtons from '~/vue_shared/components/members/action_buttons/group_action_buttons.vue'; -import InviteActionButtons from '~/vue_shared/components/members/action_buttons/invite_action_buttons.vue'; -import AccessRequestActionButtons from '~/vue_shared/components/members/action_buttons/access_request_action_buttons.vue'; +import { MEMBER_TYPES } from '~/members/constants'; +import { member as memberMock, group, invite, accessRequest } from '../../mock_data'; +import MemberActionButtons from '~/members/components/table/member_action_buttons.vue'; +import UserActionButtons from '~/members/components/action_buttons/user_action_buttons.vue'; +import GroupActionButtons from '~/members/components/action_buttons/group_action_buttons.vue'; +import InviteActionButtons from '~/members/components/action_buttons/invite_action_buttons.vue'; +import AccessRequestActionButtons from '~/members/components/action_buttons/access_request_action_buttons.vue'; describe('MemberActionButtons', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js b/spec/frontend/members/components/table/member_avatar_spec.js index a171dd830c1..98177893c18 100644 --- a/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js +++ b/spec/frontend/members/components/table/member_avatar_spec.js @@ -1,10 +1,10 @@ import { shallowMount } from '@vue/test-utils'; -import { MEMBER_TYPES } from '~/vue_shared/components/members/constants'; -import { member as memberMock, group, invite, accessRequest } from '../mock_data'; -import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue'; -import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue'; -import GroupAvatar from '~/vue_shared/components/members/avatars/group_avatar.vue'; -import InviteAvatar from '~/vue_shared/components/members/avatars/invite_avatar.vue'; +import { MEMBER_TYPES } from '~/members/constants'; +import { member as memberMock, group, invite, accessRequest } from '../../mock_data'; +import MemberAvatar from '~/members/components/table/member_avatar.vue'; +import UserAvatar from '~/members/components/avatars/user_avatar.vue'; +import GroupAvatar from '~/members/components/avatars/group_avatar.vue'; +import InviteAvatar from '~/members/components/avatars/invite_avatar.vue'; describe('MemberList', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/members/table/member_source_spec.js b/spec/frontend/members/components/table/member_source_spec.js index 8b914d76674..48ac06f32f6 100644 --- a/spec/frontend/vue_shared/components/members/table/member_source_spec.js +++ b/spec/frontend/members/components/table/member_source_spec.js @@ -1,7 +1,7 @@ import { mount, createWrapper } from '@vue/test-utils'; import { getByText as getByTextHelper } from '@testing-library/dom'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import MemberSource from '~/vue_shared/components/members/table/member_source.vue'; +import MemberSource from '~/members/components/table/member_source.vue'; describe('MemberSource', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js b/spec/frontend/members/components/table/members_table_cell_spec.js index ba693975a88..117c9255c00 100644 --- a/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js +++ b/spec/frontend/members/components/table/members_table_cell_spec.js @@ -1,10 +1,10 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; -import { MEMBER_TYPES } from '~/vue_shared/components/members/constants'; -import { member as memberMock, group, invite, accessRequest } from '../mock_data'; -import MembersTableCell from '~/vue_shared/components/members/table/members_table_cell.vue'; +import { MEMBER_TYPES } from '~/members/constants'; +import { member as memberMock, group, invite, accessRequest } from '../../mock_data'; +import MembersTableCell from '~/members/components/table/members_table_cell.vue'; -describe('MemberList', () => { +describe('MembersTableCell', () => { const WrappedComponent = { props: { memberType: { diff --git a/spec/frontend/vue_shared/components/members/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js index e593e88438c..9945cc7ee57 100644 --- a/spec/frontend/vue_shared/components/members/table/members_table_spec.js +++ b/spec/frontend/members/components/table/members_table_spec.js @@ -6,21 +6,21 @@ import { within, } from '@testing-library/dom'; import { GlBadge, GlTable } from '@gitlab/ui'; -import MembersTable from '~/vue_shared/components/members/table/members_table.vue'; -import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue'; -import MemberSource from '~/vue_shared/components/members/table/member_source.vue'; -import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue'; -import CreatedAt from '~/vue_shared/components/members/table/created_at.vue'; -import RoleDropdown from '~/vue_shared/components/members/table/role_dropdown.vue'; -import ExpirationDatepicker from '~/vue_shared/components/members/table/expiration_datepicker.vue'; -import MemberActionButtons from '~/vue_shared/components/members/table/member_action_buttons.vue'; +import MembersTable from '~/members/components/table/members_table.vue'; +import MemberAvatar from '~/members/components/table/member_avatar.vue'; +import MemberSource from '~/members/components/table/member_source.vue'; +import ExpiresAt from '~/members/components/table/expires_at.vue'; +import CreatedAt from '~/members/components/table/created_at.vue'; +import RoleDropdown from '~/members/components/table/role_dropdown.vue'; +import ExpirationDatepicker from '~/members/components/table/expiration_datepicker.vue'; +import MemberActionButtons from '~/members/components/table/member_action_buttons.vue'; import * as initUserPopovers from '~/user_popovers'; -import { member as memberMock, invite, accessRequest } from '../mock_data'; +import { member as memberMock, invite, accessRequest } from '../../mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); -describe('MemberList', () => { +describe('MembersTable', () => { let wrapper; const createStore = (state = {}) => { diff --git a/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js b/spec/frontend/members/components/table/role_dropdown_spec.js index 55ec7000693..6c6abf35bd7 100644 --- a/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js +++ b/spec/frontend/members/components/table/role_dropdown_spec.js @@ -5,8 +5,8 @@ import { within } from '@testing-library/dom'; import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import waitForPromises from 'helpers/wait_for_promises'; -import RoleDropdown from '~/vue_shared/components/members/table/role_dropdown.vue'; -import { member } from '../mock_data'; +import RoleDropdown from '~/members/components/table/role_dropdown.vue'; +import { member } from '../../mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -137,7 +137,7 @@ describe('RoleDropdown', () => { await nextTick(); - expect(findDropdown().attributes('right')).toBe('true'); + expect(findDropdown().props('right')).toBe(true); }); it('sets the dropdown alignment to left on desktop', async () => { @@ -146,6 +146,6 @@ describe('RoleDropdown', () => { await nextTick(); - expect(findDropdown().attributes('right')).toBeUndefined(); + expect(findDropdown().props('right')).toBe(false); }); }); diff --git a/spec/frontend/vue_shared/components/members/mock_data.js b/spec/frontend/members/mock_data.js index 5674929716d..5674929716d 100644 --- a/spec/frontend/vue_shared/components/members/mock_data.js +++ b/spec/frontend/members/mock_data.js diff --git a/spec/frontend/vuex_shared/modules/members/actions_spec.js b/spec/frontend/members/store/actions_spec.js index c7048a9c421..5424fee0750 100644 --- a/spec/frontend/vuex_shared/modules/members/actions_spec.js +++ b/spec/frontend/members/store/actions_spec.js @@ -1,17 +1,17 @@ import { noop } from 'lodash'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import { members, group } from 'jest/vue_shared/components/members/mock_data'; +import { members, group } from 'jest/members/mock_data'; import testAction from 'helpers/vuex_action_helper'; import { useFakeDate } from 'helpers/fake_date'; import httpStatusCodes from '~/lib/utils/http_status'; -import * as types from '~/vuex_shared/modules/members/mutation_types'; +import * as types from '~/members/store/mutation_types'; import { updateMemberRole, showRemoveGroupLinkModal, hideRemoveGroupLinkModal, updateMemberExpiration, -} from '~/vuex_shared/modules/members/actions'; +} from '~/members/store/actions'; describe('Vuex members actions', () => { describe('update member actions', () => { diff --git a/spec/frontend/vuex_shared/modules/members/mutations_spec.js b/spec/frontend/members/store/mutations_spec.js index 710d43b8990..488bfdf15fd 100644 --- a/spec/frontend/vuex_shared/modules/members/mutations_spec.js +++ b/spec/frontend/members/store/mutations_spec.js @@ -1,6 +1,6 @@ -import { members, group } from 'jest/vue_shared/components/members/mock_data'; -import mutations from '~/vuex_shared/modules/members/mutations'; -import * as types from '~/vuex_shared/modules/members/mutation_types'; +import { members, group } from 'jest/members/mock_data'; +import mutations from '~/members/store/mutations'; +import * as types from '~/members/store/mutation_types'; describe('Vuex members mutations', () => { describe('update member mutations', () => { diff --git a/spec/frontend/vuex_shared/modules/members/utils_spec.js b/spec/frontend/members/store/utils_spec.js index 4fc3445dac0..e3cde38269c 100644 --- a/spec/frontend/vuex_shared/modules/members/utils_spec.js +++ b/spec/frontend/members/store/utils_spec.js @@ -1,5 +1,5 @@ -import { members } from 'jest/vue_shared/components/members/mock_data'; -import { findMember } from '~/vuex_shared/modules/members/utils'; +import { members } from 'jest/members/mock_data'; +import { findMember } from '~/members/store/utils'; describe('Members Vuex utils', () => { describe('findMember', () => { diff --git a/spec/frontend/vue_shared/components/members/utils_spec.js b/spec/frontend/members/utils_spec.js index 3f2b2097133..7bbfddf8fc6 100644 --- a/spec/frontend/vue_shared/components/members/utils_spec.js +++ b/spec/frontend/members/utils_spec.js @@ -7,13 +7,17 @@ import { canResend, canUpdate, canOverride, -} from '~/vue_shared/components/members/utils'; + parseSortParam, + buildSortHref, +} from '~/members/utils'; +import { DEFAULT_SORT } from '~/members/constants'; import { member as memberMock, group, invite } from './mock_data'; const DIRECT_MEMBER_ID = 178; const INHERITED_MEMBER_ID = 179; const IS_CURRENT_USER_ID = 123; const IS_NOT_CURRENT_USER_ID = 124; +const URL_HOST = 'https://localhost/'; describe('Members Utils', () => { describe('generateBadges', () => { @@ -119,4 +123,110 @@ describe('Members Utils', () => { expect(canOverride(memberMock)).toBe(false); }); }); + + describe('parseSortParam', () => { + beforeEach(() => { + delete window.location; + window.location = new URL(URL_HOST); + }); + + describe('when `sort` param is not present', () => { + it('returns default sort options', () => { + window.location.search = ''; + + expect(parseSortParam(['account'])).toEqual(DEFAULT_SORT); + }); + }); + + describe('when field passed in `sortableFields` argument does not have `sort` key defined', () => { + it('returns default sort options', () => { + window.location.search = '?sort=source_asc'; + + expect(parseSortParam(['source'])).toEqual(DEFAULT_SORT); + }); + }); + + describe.each` + sortParam | expected + ${'name_asc'} | ${{ sortByKey: 'account', sortDesc: false }} + ${'name_desc'} | ${{ sortByKey: 'account', sortDesc: true }} + ${'last_joined'} | ${{ sortByKey: 'granted', sortDesc: false }} + ${'oldest_joined'} | ${{ sortByKey: 'granted', sortDesc: true }} + ${'access_level_asc'} | ${{ sortByKey: 'maxRole', sortDesc: false }} + ${'access_level_desc'} | ${{ sortByKey: 'maxRole', sortDesc: true }} + ${'recent_sign_in'} | ${{ sortByKey: 'lastSignIn', sortDesc: false }} + ${'oldest_sign_in'} | ${{ sortByKey: 'lastSignIn', sortDesc: true }} + `('when `sort` query string param is `$sortParam`', ({ sortParam, expected }) => { + it(`returns ${JSON.stringify(expected)}`, async () => { + window.location.search = `?sort=${sortParam}`; + + expect(parseSortParam(['account', 'granted', 'expires', 'maxRole', 'lastSignIn'])).toEqual( + expected, + ); + }); + }); + }); + + describe('buildSortHref', () => { + beforeEach(() => { + delete window.location; + window.location = new URL(URL_HOST); + }); + + describe('when field passed in `sortBy` argument does not have `sort` key defined', () => { + it('returns an empty string', () => { + expect( + buildSortHref({ + sortBy: 'source', + sortDesc: false, + filteredSearchBarTokens: [], + filteredSearchBarSearchParam: 'search', + }), + ).toBe(''); + }); + }); + + describe('when there are no filter params set', () => { + it('sets `sort` param', () => { + expect( + buildSortHref({ + sortBy: 'account', + sortDesc: false, + filteredSearchBarTokens: [], + filteredSearchBarSearchParam: 'search', + }), + ).toBe(`${URL_HOST}?sort=name_asc`); + }); + }); + + describe('when filter params are set', () => { + it('merges the `sort` param with the filter params', () => { + window.location.search = '?two_factor=enabled&with_inherited_permissions=exclude'; + + expect( + buildSortHref({ + sortBy: 'account', + sortDesc: false, + filteredSearchBarTokens: ['two_factor', 'with_inherited_permissions'], + filteredSearchBarSearchParam: 'search', + }), + ).toBe(`${URL_HOST}?two_factor=enabled&with_inherited_permissions=exclude&sort=name_asc`); + }); + }); + + describe('when search param is set', () => { + it('merges the `sort` param with the search param', () => { + window.location.search = '?search=foobar'; + + expect( + buildSortHref({ + sortBy: 'account', + sortDesc: false, + filteredSearchBarTokens: ['two_factor', 'with_inherited_permissions'], + filteredSearchBarSearchParam: 'search', + }), + ).toBe(`${URL_HOST}?search=foobar&sort=name_asc`); + }); + }); + }); }); diff --git a/spec/frontend/merge_request_spec.js b/spec/frontend/merge_request_spec.js index 37509f77f71..1cb7206b97f 100644 --- a/spec/frontend/merge_request_spec.js +++ b/spec/frontend/merge_request_spec.js @@ -38,7 +38,7 @@ describe('MergeRequest', () => { .dispatchEvent(changeEvent); setImmediate(() => { expect($('.js-task-list-field').val()).toBe( - '- [x] Task List Item\n- [ ] \n- [ ] Task List Item 2\n', + '- [x] Task List Item\n- [ ]\n- [ ] Task List Item 2\n', ); done(); }); @@ -55,7 +55,7 @@ describe('MergeRequest', () => { .dispatchEvent(changeEvent); setImmediate(() => { expect($('.js-task-list-field').val()).toBe( - '- [ ] Task List Item\n- [ ] \n- [x] Task List Item 2\n', + '- [ ] Task List Item\n- [ ]\n- [x] Task List Item 2\n', ); done(); }); @@ -78,7 +78,7 @@ describe('MergeRequest', () => { `${TEST_HOST}/frontend-fixtures/merge-requests-project/-/merge_requests/1.json`, { merge_request: { - description: '- [ ] Task List Item\n- [ ] \n- [ ] Task List Item 2\n', + description: '- [ ] Task List Item\n- [ ]\n- [ ] Task List Item 2\n', lock_version: 0, update_task: { line_number: lineNumber, line_source: lineSource, index, checked }, }, diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap index 645aca0b157..17720aeb702 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -33,7 +33,7 @@ exports[`Dashboard template matches the default snapshot 1`] = ` class="mb-2 pr-2 d-flex d-sm-block" > <gl-dropdown-stub - category="tertiary" + category="primary" class="flex-grow-1" data-qa-selector="environments_dropdown" headertext="" diff --git a/spec/frontend/monitoring/components/group_empty_state_spec.js b/spec/frontend/monitoring/components/group_empty_state_spec.js index 3b94c4c6806..4a550efe23c 100644 --- a/spec/frontend/monitoring/components/group_empty_state_spec.js +++ b/spec/frontend/monitoring/components/group_empty_state_spec.js @@ -1,13 +1,9 @@ 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'; -const MockGlEmptyState = { - props: GlEmptyState.props, - template: '<div><slot name="description"></slot></div>', -}; - function createComponent(props) { return shallowMount(GroupEmptyState, { propsData: { @@ -17,7 +13,9 @@ function createComponent(props) { svgPath: '/path/to/empty-group-illustration.svg', }, stubs: { - GlEmptyState: MockGlEmptyState, + GlEmptyState: stubComponent(GlEmptyState, { + template: '<div><slot name="description"></slot></div>', + }), }, }); } @@ -47,7 +45,7 @@ describe('GroupEmptyState', () => { }); it('passes the expected props to GlEmptyState', () => { - expect(wrapper.find(MockGlEmptyState).props()).toMatchSnapshot(); + expect(wrapper.find(GlEmptyState).props()).toMatchSnapshot(); }); }); }); diff --git a/spec/frontend/notebook/cells/output/html_sanitize_fixtures.js b/spec/frontend/notebook/cells/output/html_sanitize_fixtures.js index a886715ce4b..0b585ab860b 100644 --- a/spec/frontend/notebook/cells/output/html_sanitize_fixtures.js +++ b/spec/frontend/notebook/cells/output/html_sanitize_fixtures.js @@ -1,114 +1,96 @@ +/** + * Jupyter notebooks handles the following data types + * that are to be handled by `html.vue` + * + * 'text/html'; + * 'image/svg+xml'; + * + * This file sets up fixtures for each of these types + * NOTE: The inputs are taken directly from data derived from the + * jupyter notebook file used to test nbview here: + * https://nbviewer.jupyter.org/github/ipython/ipython-in-depth/blob/master/examples/IPython%20Kernel/Rich%20Output.ipynb + */ + export default [ [ - 'protocol-based JS injection: simple, no spaces', + 'text/html table', { - input: `<a href="javascript:alert('XSS');">foo</a>`, - output: '<a>foo</a>', + input: [ + '<table>\n', + '<tr>\n', + '<th>Header 1</th>\n', + '<th>Header 2</th>\n', + '</tr>\n', + '<tr>\n', + '<td>row 1, cell 1</td>\n', + '<td>row 1, cell 2</td>\n', + '</tr>\n', + '<tr>\n', + '<td>row 2, cell 1</td>\n', + '<td>row 2, cell 2</td>\n', + '</tr>\n', + '</table>', + ].join(''), + output: '<table>', }, ], + // Note: style is sanitized out [ - 'protocol-based JS injection: simple, spaces before', + 'text/html style', { - input: `<a href="javascript :alert('XSS');">foo</a>`, - output: '<a>foo</a>', + input: [ + '<style type="text/css">\n', + '\n', + 'circle {\n', + ' fill: rgb(31, 119, 180);\n', + ' fill-opacity: .25;\n', + ' stroke: rgb(31, 119, 180);\n', + ' stroke-width: 1px;\n', + '}\n', + '\n', + '.leaf circle {\n', + ' fill: #ff7f0e;\n', + ' fill-opacity: 1;\n', + '}\n', + '\n', + 'text {\n', + ' font: 10px sans-serif;\n', + '}\n', + '\n', + '</style>', + ].join(''), + output: '<!---->', }, ], + // Note: iframe is sanitized out [ - 'protocol-based JS injection: simple, spaces after', + 'text/html iframe', { - input: `<a href="javascript: alert('XSS');">foo</a>`, - output: '<a>foo</a>', + input: [ + '\n', + ' <iframe\n', + ' width="400"\n', + ' height="300"\n', + ' src="https://www.youtube.com/embed/sjfsUzECqK0"\n', + ' frameborder="0"\n', + ' allowfullscreen\n', + ' ></iframe>\n', + ' ', + ].join(''), + output: '<!---->', }, ], [ - 'protocol-based JS injection: simple, spaces before and after', + 'image/svg+xml', { - input: `<a href="javascript : alert('XSS');">foo</a>`, - output: '<a>foo</a>', + input: [ + '<svg height="115.02pt" id="svg2" version="1.0" width="388.84pt" xmlns="http://www.w3.org/2000/svg">\n', + ' <g>\n', + ' <path d="M 184.61344,61.929363 C 184.61344,47.367213 180.46118,39.891193 172.15666,39.481813" style="fill:#646464;fill-opacity:1"/>\n', + ' </g>\n', + '</svg>', + ].join(), + output: '<svg height="115.02pt" id="svg2"', }, ], - [ - 'protocol-based JS injection: preceding colon', - { - input: `<a href=":javascript:alert('XSS');">foo</a>`, - output: '<a>foo</a>', - }, - ], - [ - 'protocol-based JS injection: UTF-8 encoding', - { - input: '<a href="javascript:">foo</a>', - output: '<a>foo</a>', - }, - ], - [ - 'protocol-based JS injection: long UTF-8 encoding', - { - input: '<a href="javascript:">foo</a>', - output: '<a>foo</a>', - }, - ], - [ - 'protocol-based JS injection: long UTF-8 encoding without semicolons', - { - input: - '<a href=javascript:alert('XSS')>foo</a>', - output: '<a>foo</a>', - }, - ], - [ - 'protocol-based JS injection: hex encoding', - { - input: '<a href="javascript:">foo</a>', - output: '<a>foo</a>', - }, - ], - [ - 'protocol-based JS injection: long hex encoding', - { - input: '<a href="javascript:">foo</a>', - output: '<a>foo</a>', - }, - ], - [ - 'protocol-based JS injection: hex encoding without semicolons', - { - input: - '<a href=javascript:alert('XSS')>foo</a>', - output: '<a>foo</a>', - }, - ], - [ - 'protocol-based JS injection: null char', - { - input: '<a href=java\u0000script:alert("XSS")>foo</a>', - output: '<a>foo</a>', - }, - ], - [ - 'protocol-based JS injection: invalid URL char', - { input: '<img src=javascript:alert("XSS")>', output: '<img>' }, - ], - [ - 'protocol-based JS injection: Unicode', - { - input: `<a href="\u0001java\u0003script:alert('XSS')">foo</a>`, - output: '<a>foo</a>', - }, - ], - [ - 'protocol-based JS injection: spaces and entities', - { - input: `<a href="  javascript:alert('XSS');">foo</a>`, - output: '<a>foo</a>', - }, - ], - [ - 'img on error', - { - input: '<img src="x" onerror="alert(document.domain)" />', - output: '<img src="x">', - }, - ], - ['style tags are removed', { input: '<style>.foo {}</style> Foo', output: 'Foo' }], ]; diff --git a/spec/frontend/notebook/cells/output/html_spec.js b/spec/frontend/notebook/cells/output/html_spec.js index 48d62d74a50..b94ce7c684d 100644 --- a/spec/frontend/notebook/cells/output/html_spec.js +++ b/spec/frontend/notebook/cells/output/html_spec.js @@ -1,26 +1,21 @@ -import Vue from 'vue'; -import htmlOutput from '~/notebook/cells/output/html.vue'; +import { mount } from '@vue/test-utils'; +import HtmlOutput from '~/notebook/cells/output/html.vue'; import sanitizeTests from './html_sanitize_fixtures'; describe('html output cell', () => { function createComponent(rawCode) { - const Component = Vue.extend(htmlOutput); - - return new Component({ + return mount(HtmlOutput, { propsData: { rawCode, count: 0, index: 0, }, - }).$mount(); + }); } it.each(sanitizeTests)('sanitizes output for: %p', (name, { input, output }) => { const vm = createComponent(input); - const outputEl = [...vm.$el.querySelectorAll('div')].pop(); - - expect(outputEl.innerHTML).toEqual(output); - vm.$destroy(); + expect(vm.html()).toContain(output); }); }); diff --git a/spec/frontend/notebook/lib/highlight_spec.js b/spec/frontend/notebook/lib/highlight_spec.js index d71c5718858..944ccd6aa9f 100644 --- a/spec/frontend/notebook/lib/highlight_spec.js +++ b/spec/frontend/notebook/lib/highlight_spec.js @@ -9,7 +9,7 @@ describe('Highlight library', () => { const el = document.createElement('div'); el.innerHTML = Prism.highlight('console.log("a");', Prism.languages.javascript); - expect(el.querySelector('.s')).not.toBeNull(); - expect(el.querySelector('.nf')).not.toBeNull(); + expect(el.querySelector('.string')).not.toBeNull(); + expect(el.querySelector('.function')).not.toBeNull(); }); }); diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 59fa7b372ed..fca1beca999 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -1,18 +1,20 @@ -import $ from 'jquery'; -import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import Autosize from 'autosize'; -import { trimText } from 'helpers/text_helper'; +import { deprecatedCreateFlash as flash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import createStore from '~/notes/stores'; import CommentForm from '~/notes/components/comment_form.vue'; import * as constants from '~/notes/constants'; +import eventHub from '~/notes/event_hub'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; -import { keyboardDownEvent } from '../../issue_show/helpers'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data'; jest.mock('autosize'); jest.mock('~/commons/nav/user_merge_requests'); +jest.mock('~/flash'); jest.mock('~/gl_form'); describe('issue_comment_form component', () => { @@ -20,17 +22,33 @@ describe('issue_comment_form component', () => { let wrapper; let axiosMock; - const setupStore = (userData, noteableData) => { - store.dispatch('setUserData', userData); + const findCloseReopenButton = () => wrapper.find('[data-testid="close-reopen-button"]'); + + const findCommentButton = () => wrapper.find('[data-testid="comment-button"]'); + + const findTextArea = () => wrapper.find('[data-testid="comment-field"]'); + + const mountComponent = ({ + initialData = {}, + noteableType = 'Issue', + noteableData = noteableDataMock, + notesData = notesDataMock, + userData = userDataMock, + mountFunction = shallowMount, + } = {}) => { store.dispatch('setNoteableData', noteableData); - store.dispatch('setNotesData', notesDataMock); - }; + store.dispatch('setNotesData', notesData); + store.dispatch('setUserData', userData); - const mountComponent = (noteableType = 'issue') => { - wrapper = mount(CommentForm, { + wrapper = mountFunction(CommentForm, { propsData: { noteableType, }, + data() { + return { + ...initialData, + }; + }, store, }); }; @@ -46,168 +64,157 @@ describe('issue_comment_form component', () => { }); describe('user is logged in', () => { - beforeEach(() => { - setupStore(userDataMock, noteableDataMock); - - mountComponent(); - }); + describe('avatar', () => { + it('should render user avatar with link', () => { + mountComponent({ mountFunction: mount }); - it('should render user avatar with link', () => { - expect(wrapper.find('.timeline-icon .user-avatar-link').attributes('href')).toEqual( - userDataMock.path, - ); + expect(wrapper.find(UserAvatarLink).attributes('href')).toBe(userDataMock.path); + }); }); describe('handleSave', () => { it('should request to save note when note is entered', () => { - wrapper.vm.note = 'hello world'; - jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(new Promise(() => {})); + mountComponent({ mountFunction: mount, initialData: { note: 'hello world' } }); + + jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); jest.spyOn(wrapper.vm, 'resizeTextarea'); jest.spyOn(wrapper.vm, 'stopPolling'); - wrapper.vm.handleSave(); + findCloseReopenButton().trigger('click'); - expect(wrapper.vm.isSubmitting).toEqual(true); - expect(wrapper.vm.note).toEqual(''); + expect(wrapper.vm.isSubmitting).toBe(true); + expect(wrapper.vm.note).toBe(''); expect(wrapper.vm.saveNote).toHaveBeenCalled(); expect(wrapper.vm.stopPolling).toHaveBeenCalled(); expect(wrapper.vm.resizeTextarea).toHaveBeenCalled(); }); it('should toggle issue state when no note', () => { + mountComponent({ mountFunction: mount }); + jest.spyOn(wrapper.vm, 'toggleIssueState'); - wrapper.vm.handleSave(); + findCloseReopenButton().trigger('click'); expect(wrapper.vm.toggleIssueState).toHaveBeenCalled(); }); - it('should disable action button while submitting', done => { + it('should disable action button while submitting', async () => { + mountComponent({ mountFunction: mount, initialData: { note: 'hello world' } }); + const saveNotePromise = Promise.resolve(); - wrapper.vm.note = 'hello world'; + jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(saveNotePromise); jest.spyOn(wrapper.vm, 'stopPolling'); - const actionButton = wrapper.find('.js-action-button'); - - wrapper.vm.handleSave(); - - wrapper.vm - .$nextTick() - .then(() => { - expect(actionButton.vm.disabled).toBeTruthy(); - }) - .then(saveNotePromise) - .then(wrapper.vm.$nextTick) - .then(() => { - expect(actionButton.vm.disabled).toBeFalsy(); - }) - .then(done) - .catch(done.fail); + const actionButton = findCloseReopenButton(); + + await actionButton.trigger('click'); + + expect(actionButton.props('disabled')).toBe(true); + + await saveNotePromise; + + await nextTick(); + + expect(actionButton.props('disabled')).toBe(false); }); }); describe('textarea', () => { - it('should render textarea with placeholder', () => { - expect(wrapper.find('.js-main-target-form textarea').attributes('placeholder')).toEqual( - 'Write a comment or drag your files here…', - ); - }); + describe('general', () => { + it('should render textarea with placeholder', () => { + mountComponent({ mountFunction: mount }); - it('should make textarea disabled while requesting', done => { - const $submitButton = $(wrapper.find('.js-comment-submit-button').element); - wrapper.vm.note = 'hello world'; - jest.spyOn(wrapper.vm, 'stopPolling'); - jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(new Promise(() => {})); + expect(findTextArea().attributes('placeholder')).toBe( + 'Write a comment or drag your files here…', + ); + }); - wrapper.vm.$nextTick(() => { - // Wait for wrapper.vm.note change triggered. It should enable $submitButton. - $submitButton.trigger('click'); + it('should make textarea disabled while requesting', async () => { + mountComponent({ mountFunction: mount }); - wrapper.vm.$nextTick(() => { - // Wait for wrapper.isSubmitting triggered. It should disable textarea. - expect(wrapper.find('.js-main-target-form textarea').attributes('disabled')).toBe( - 'disabled', - ); - done(); - }); + jest.spyOn(wrapper.vm, 'stopPolling'); + jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); + + await wrapper.setData({ note: 'hello world' }); + + await findCommentButton().trigger('click'); + + expect(findTextArea().attributes('disabled')).toBe('disabled'); }); - }); - it('should support quick actions', () => { - expect( - wrapper.find('.js-main-target-form textarea').attributes('data-supports-quick-actions'), - ).toBe('true'); - }); + it('should support quick actions', () => { + mountComponent({ mountFunction: mount }); - it('should link to markdown docs', () => { - const { markdownDocsPath } = notesDataMock; + expect(findTextArea().attributes('data-supports-quick-actions')).toBe('true'); + }); - expect( - wrapper - .find(`a[href="${markdownDocsPath}"]`) - .text() - .trim(), - ).toEqual('Markdown'); - }); + it('should link to markdown docs', () => { + mountComponent({ mountFunction: mount }); - it('should link to quick actions docs', () => { - const { quickActionsDocsPath } = notesDataMock; + const { markdownDocsPath } = notesDataMock; - expect( - wrapper - .find(`a[href="${quickActionsDocsPath}"]`) - .text() - .trim(), - ).toEqual('quick actions'); - }); + 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'); + }); + + it('should resize textarea after note discarded', async () => { + mountComponent({ mountFunction: mount, initialData: { note: 'foo' } }); - it('should resize textarea after note discarded', done => { - jest.spyOn(wrapper.vm, 'discard'); + jest.spyOn(wrapper.vm, 'discard'); - wrapper.vm.note = 'foo'; - wrapper.vm.discard(); + wrapper.vm.discard(); + + await nextTick(); - wrapper.vm.$nextTick(() => { expect(Autosize.update).toHaveBeenCalled(); - done(); }); }); describe('edit mode', () => { + beforeEach(() => { + mountComponent(); + }); + it('should enter edit mode when arrow up is pressed', () => { jest.spyOn(wrapper.vm, 'editCurrentUserLastNote'); - wrapper.find('.js-main-target-form textarea').value = 'Foo'; - wrapper - .find('.js-main-target-form textarea') - .element.dispatchEvent(keyboardDownEvent(38, true)); + + findTextArea().trigger('keydown.up'); expect(wrapper.vm.editCurrentUserLastNote).toHaveBeenCalled(); }); it('inits autosave', () => { expect(wrapper.vm.autosave).toBeDefined(); - expect(wrapper.vm.autosave.key).toEqual(`autosave/Note/Issue/${noteableDataMock.id}`); + expect(wrapper.vm.autosave.key).toBe(`autosave/Note/Issue/${noteableDataMock.id}`); }); }); describe('event enter', () => { + beforeEach(() => { + mountComponent(); + }); + it('should save note when cmd+enter is pressed', () => { jest.spyOn(wrapper.vm, 'handleSave'); - wrapper.find('.js-main-target-form textarea').value = 'Foo'; - wrapper - .find('.js-main-target-form textarea') - .element.dispatchEvent(keyboardDownEvent(13, true)); + + findTextArea().trigger('keydown.enter', { metaKey: true }); expect(wrapper.vm.handleSave).toHaveBeenCalled(); }); it('should save note when ctrl+enter is pressed', () => { jest.spyOn(wrapper.vm, 'handleSave'); - wrapper.find('.js-main-target-form textarea').value = 'Foo'; - wrapper - .find('.js-main-target-form textarea') - .element.dispatchEvent(keyboardDownEvent(13, false, true)); + + findTextArea().trigger('keydown.enter', { ctrlKey: true }); expect(wrapper.vm.handleSave).toHaveBeenCalled(); }); @@ -216,137 +223,187 @@ describe('issue_comment_form component', () => { describe('actions', () => { it('should be possible to close the issue', () => { - expect( - wrapper - .find('.btn-comment-and-close') - .text() - .trim(), - ).toEqual('Close issue'); + mountComponent(); + + expect(findCloseReopenButton().text()).toBe('Close issue'); }); it('should render comment button as disabled', () => { - expect(wrapper.find('.js-comment-submit-button').attributes('disabled')).toEqual( - 'disabled', - ); + mountComponent(); + + expect(findCommentButton().props('disabled')).toBe(true); }); - it('should enable comment button if it has note', done => { - wrapper.vm.note = 'Foo'; - wrapper.vm.$nextTick(() => { - expect(wrapper.find('.js-comment-submit-button').attributes('disabled')).toBeFalsy(); - done(); - }); + it('should enable comment button if it has note', async () => { + mountComponent(); + + await wrapper.setData({ note: 'Foo' }); + + expect(findCommentButton().props('disabled')).toBe(false); }); - it('should update buttons texts when it has note', done => { - wrapper.vm.note = 'Foo'; - wrapper.vm.$nextTick(() => { - expect( - wrapper - .find('.btn-comment-and-close') - .text() - .trim(), - ).toEqual('Comment & close issue'); - - done(); - }); + it('should update buttons texts when it has note', () => { + mountComponent({ initialData: { note: 'Foo' } }); + + expect(findCloseReopenButton().text()).toBe('Comment & close issue'); }); - it('updates button text with noteable type', done => { - wrapper.setProps({ noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE }); - - wrapper.vm.$nextTick(() => { - expect( - wrapper - .find('.btn-comment-and-close') - .text() - .trim(), - ).toEqual('Close merge request'); - done(); - }); + it('updates button text with noteable type', () => { + mountComponent({ noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE }); + + expect(findCloseReopenButton().text()).toBe('Close merge request'); }); describe('when clicking close/reopen button', () => { - it('should disable button and show a loading spinner', () => { - const toggleStateButton = wrapper.find('.js-action-button'); + it('should show a loading spinner', async () => { + mountComponent({ + noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE, + mountFunction: mount, + }); - toggleStateButton.trigger('click'); + await findCloseReopenButton().trigger('click'); - return wrapper.vm.$nextTick().then(() => { - expect(toggleStateButton.element.disabled).toEqual(true); - expect(toggleStateButton.props('loading')).toBe(true); - }); + expect(findCloseReopenButton().props('loading')).toBe(true); }); }); describe('when toggling state', () => { - it('should update MR count', done => { - jest.spyOn(wrapper.vm, 'closeIssue').mockResolvedValue(); + describe('when issue', () => { + it('emits event to toggle state', () => { + mountComponent({ mountFunction: mount }); + + jest.spyOn(eventHub, '$emit'); + + findCloseReopenButton().trigger('click'); + + expect(eventHub.$emit).toHaveBeenCalledWith('toggle.issuable.state'); + }); + }); + + describe.each` + type | noteableType + ${'merge request'} | ${'MergeRequest'} + ${'epic'} | ${'Epic'} + `('when $type', ({ type, noteableType }) => { + describe('when open', () => { + it(`makes an API call to open it`, () => { + mountComponent({ + noteableType, + noteableData: { ...noteableDataMock, state: constants.OPENED }, + mountFunction: mount, + }); + + jest.spyOn(wrapper.vm, 'closeIssuable').mockResolvedValue(); + + findCloseReopenButton().trigger('click'); + + expect(wrapper.vm.closeIssuable).toHaveBeenCalled(); + }); + + it(`shows an error when the API call fails`, async () => { + mountComponent({ + noteableType, + noteableData: { ...noteableDataMock, state: constants.OPENED }, + mountFunction: mount, + }); + + jest.spyOn(wrapper.vm, 'closeIssuable').mockRejectedValue(); + + await findCloseReopenButton().trigger('click'); + + await wrapper.vm.$nextTick; + + expect(flash).toHaveBeenCalledWith( + `Something went wrong while closing the ${type}. Please try again later.`, + ); + }); + }); + + describe('when closed', () => { + it('makes an API call to close it', () => { + mountComponent({ + noteableType, + noteableData: { ...noteableDataMock, state: constants.CLOSED }, + mountFunction: mount, + }); + + jest.spyOn(wrapper.vm, 'reopenIssuable').mockResolvedValue(); - wrapper.vm.toggleIssueState(); + findCloseReopenButton().trigger('click'); - wrapper.vm.$nextTick(() => { - expect(refreshUserMergeRequestCounts).toHaveBeenCalled(); + expect(wrapper.vm.reopenIssuable).toHaveBeenCalled(); + }); + }); + + it(`shows an error when the API call fails`, async () => { + mountComponent({ + noteableType, + noteableData: { ...noteableDataMock, state: constants.CLOSED }, + mountFunction: mount, + }); + + jest.spyOn(wrapper.vm, 'reopenIssuable').mockRejectedValue(); + + await findCloseReopenButton().trigger('click'); - done(); + await wrapper.vm.$nextTick; + + expect(flash).toHaveBeenCalledWith( + `Something went wrong while reopening the ${type}. Please try again later.`, + ); }); }); + + it('when merge request, should update MR count', async () => { + mountComponent({ + noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE, + mountFunction: mount, + }); + + jest.spyOn(wrapper.vm, 'closeIssuable').mockResolvedValue(); + + await findCloseReopenButton().trigger('click'); + + expect(refreshUserMergeRequestCounts).toHaveBeenCalled(); + }); }); }); describe('issue is confidential', () => { - it('shows information warning', done => { - store.dispatch('setNoteableData', Object.assign(noteableDataMock, { confidential: true })); - wrapper.vm.$nextTick(() => { - expect(wrapper.find('.confidential-issue-warning')).toBeDefined(); - done(); + it('shows information warning', () => { + mountComponent({ + noteableData: { ...noteableDataMock, confidential: true }, + mountFunction: mount, }); + + expect(wrapper.find('[data-testid="confidential-warning"]').exists()).toBe(true); }); }); }); describe('user is not logged in', () => { beforeEach(() => { - setupStore(null, loggedOutnoteableData); - - mountComponent(); + mountComponent({ userData: null, noteableData: loggedOutnoteableData, mountFunction: mount }); }); it('should render signed out widget', () => { - expect(trimText(wrapper.text())).toEqual('Please register or sign in to reply'); + expect(wrapper.text()).toBe('Please register or sign in to reply'); }); it('should not render submission form', () => { - expect(wrapper.find('textarea').exists()).toBe(false); + expect(findTextArea().exists()).toBe(false); }); }); - describe('when issuable is open', () => { - beforeEach(() => { - setupStore(userDataMock, noteableDataMock); - }); - - it.each([['opened', 'warning'], ['reopened', 'warning']])( - 'when %i, it changes the variant of the btn to %i', - (a, expected) => { - store.state.noteableData.state = a; - - mountComponent(); - - expect(wrapper.find('.js-action-button').props('variant')).toBe(expected); - }, - ); - }); - - describe('when issuable is not open', () => { - beforeEach(() => { - setupStore(userDataMock, noteableDataMock); - - mountComponent(); - }); + describe('close/reopen button variants', () => { + it.each([ + [constants.OPENED, 'warning'], + [constants.REOPENED, 'warning'], + [constants.CLOSED, 'default'], + ])('when %s, the variant of the btn is %s', (state, expected) => { + mountComponent({ noteableData: { ...noteableDataMock, state } }); - it('should render the "default" variant of the button', () => { - expect(wrapper.find('.js-action-button').props('variant')).toBe('warning'); + expect(findCloseReopenButton().props('variant')).toBe(expected); }); }); }); diff --git a/spec/frontend/notes/components/multiline_comment_utils_spec.js b/spec/frontend/notes/components/multiline_comment_utils_spec.js index af4394cc648..99b33e7cd5f 100644 --- a/spec/frontend/notes/components/multiline_comment_utils_spec.js +++ b/spec/frontend/notes/components/multiline_comment_utils_spec.js @@ -34,8 +34,17 @@ describe('Multiline comment utilities', () => { expect(getSymbol(type)).toEqual(result); }); }); - describe('getCommentedLines', () => { - const diffLines = [{ line_code: '1' }, { line_code: '2' }, { line_code: '3' }]; + const inlineDiffLines = [{ line_code: '1' }, { line_code: '2' }, { line_code: '3' }]; + const parallelDiffLines = inlineDiffLines.map(line => ({ + left: { ...line }, + right: { ...line }, + })); + + describe.each` + view | diffLines + ${'inline'} | ${inlineDiffLines} + ${'parallel'} | ${parallelDiffLines} + `('getCommentedLines $view view', ({ diffLines }) => { it('returns a default object when `selectedCommentPosition` is not provided', () => { expect(getCommentedLines(undefined, diffLines)).toEqual({ startLine: 4, endLine: 4 }); }); diff --git a/spec/frontend/notes/components/note_header_spec.js b/spec/frontend/notes/components/note_header_spec.js index 69aab0d051e..1c6d0bafda8 100644 --- a/spec/frontend/notes/components/note_header_spec.js +++ b/spec/frontend/notes/components/note_header_spec.js @@ -22,6 +22,10 @@ describe('NoteHeader component', () => { const findTimestamp = () => wrapper.find({ ref: 'noteTimestamp' }); const findConfidentialIndicator = () => wrapper.find('[data-testid="confidentialIndicator"]'); const findSpinner = () => wrapper.find({ ref: 'spinner' }); + const findAuthorStatus = () => wrapper.find({ ref: 'authorStatus' }); + + const statusHtml = + '"<span class="user-status-emoji has-tooltip" title="foo bar" data-html="true" data-placement="top"><gl-emoji title="basketball and hoop" data-name="basketball" data-unicode-version="6.0">🏀</gl-emoji></span>"'; const author = { avatar_url: null, @@ -30,6 +34,8 @@ describe('NoteHeader component', () => { path: '/root', state: 'active', username: 'root', + show_status: true, + status_tooltip_html: statusHtml, status: { availability: '', }, @@ -109,6 +115,32 @@ describe('NoteHeader component', () => { expect(wrapper.find('.js-user-link').text()).toContain('(Busy)'); }); + it('renders author status', () => { + createComponent({ author }); + + expect(findAuthorStatus().exists()).toBe(true); + }); + + it('does not render author status if show_status=false', () => { + createComponent({ + author: { ...author, status: { availability: AVAILABILITY_STATUS.BUSY }, show_status: false }, + }); + + expect(findAuthorStatus().exists()).toBe(false); + }); + + it('does not render author status if status_tooltip_html=null', () => { + createComponent({ + author: { + ...author, + status: { availability: AVAILABILITY_STATUS.BUSY }, + status_tooltip_html: null, + }, + }); + + expect(findAuthorStatus().exists()).toBe(false); + }); + it('renders deleted user text if author is not passed as a prop', () => { createComponent(); @@ -206,13 +238,12 @@ describe('NoteHeader component', () => { createComponent({ author: { ...author, - status_tooltip_html: - '"<span class="user-status-emoji has-tooltip" title="foo bar" data-html="true" data-placement="top"><gl-emoji title="basketball and hoop" data-name="basketball" data-unicode-version="6.0">🏀</gl-emoji></span>"', + status_tooltip_html: statusHtml, }, }); return nextTick().then(() => { - const authorStatus = wrapper.find({ ref: 'authorStatus' }); + const authorStatus = findAuthorStatus(); authorStatus.trigger('mouseenter'); expect(authorStatus.find('gl-emoji').attributes('title')).toBeUndefined(); diff --git a/spec/frontend/notes/mixins/discussion_navigation_spec.js b/spec/frontend/notes/mixins/discussion_navigation_spec.js index d203435e7bf..4114df618e5 100644 --- a/spec/frontend/notes/mixins/discussion_navigation_spec.js +++ b/spec/frontend/notes/mixins/discussion_navigation_spec.js @@ -42,6 +42,7 @@ describe('Discussion navigation mixin', () => { ); jest.spyOn(utils, 'scrollToElementWithContext'); + jest.spyOn(utils, 'scrollToElement'); expandDiscussion = jest.fn(); const { actions, ...notesRest } = notesModule(); @@ -133,7 +134,7 @@ describe('Discussion navigation mixin', () => { }); it('scrolls to element', () => { - expect(utils.scrollToElementWithContext).toHaveBeenCalledWith( + expect(utils.scrollToElement).toHaveBeenCalledWith( findDiscussion('div.discussion', expected), ); }); @@ -200,7 +201,7 @@ describe('Discussion navigation mixin', () => { }); it('scrolls to discussion', () => { - expect(utils.scrollToElementWithContext).toHaveBeenCalledWith( + expect(utils.scrollToElement).toHaveBeenCalledWith( findDiscussion('div.discussion', expected), ); }); diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 920959f41e7..c9912621785 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -174,10 +174,10 @@ describe('Actions Notes Store', () => { axiosMock.onAny().reply(200, {}); }); - describe('closeIssue', () => { + describe('closeMergeRequest', () => { it('sets state as closed', done => { store - .dispatch('closeIssue', { notesData: { closeIssuePath: '' } }) + .dispatch('closeIssuable', { notesData: { closeIssuePath: '' } }) .then(() => { expect(store.state.noteableData.state).toEqual('closed'); expect(store.state.isToggleStateButtonLoading).toEqual(false); @@ -187,10 +187,10 @@ describe('Actions Notes Store', () => { }); }); - describe('reopenIssue', () => { + describe('reopenMergeRequest', () => { it('sets state as reopened', done => { store - .dispatch('reopenIssue', { notesData: { reopenIssuePath: '' } }) + .dispatch('reopenIssuable', { notesData: { reopenIssuePath: '' } }) .then(() => { expect(store.state.noteableData.state).toEqual('reopened'); expect(store.state.isToggleStateButtonLoading).toEqual(false); @@ -253,30 +253,6 @@ describe('Actions Notes Store', () => { }); }); - describe('toggleBlockedIssueWarning', () => { - it('should set issue warning as true', done => { - testAction( - actions.toggleBlockedIssueWarning, - true, - {}, - [{ type: 'TOGGLE_BLOCKED_ISSUE_WARNING', payload: true }], - [], - done, - ); - }); - - it('should set issue warning as false', done => { - testAction( - actions.toggleBlockedIssueWarning, - false, - {}, - [{ type: 'TOGGLE_BLOCKED_ISSUE_WARNING', payload: false }], - [], - done, - ); - }); - }); - describe('fetchData', () => { describe('given there are no notes', () => { const lastFetchedAt = '13579'; @@ -944,10 +920,16 @@ describe('Actions Notes Store', () => { it('when service success, commits and resolves discussion', done => { testSubmitSuggestion(done, () => { expect(commit.mock.calls).toEqual([ + [mutationTypes.SET_RESOLVING_DISCUSSION, true], [mutationTypes.APPLY_SUGGESTION, { discussionId, noteId, suggestionId }], + [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); - expect(dispatch.mock.calls).toEqual([['resolveDiscussion', { discussionId }]]); + expect(dispatch.mock.calls).toEqual([ + ['stopPolling'], + ['resolveDiscussion', { discussionId }], + ['restartPolling'], + ]); expect(Flash).not.toHaveBeenCalled(); }); }); @@ -958,8 +940,11 @@ describe('Actions Notes Store', () => { Api.applySuggestion.mockReturnValue(Promise.reject(response)); testSubmitSuggestion(done, () => { - expect(commit).not.toHaveBeenCalled(); - expect(dispatch).not.toHaveBeenCalled(); + expect(commit.mock.calls).toEqual([ + [mutationTypes.SET_RESOLVING_DISCUSSION, true], + [mutationTypes.SET_RESOLVING_DISCUSSION, false], + ]); + expect(dispatch.mock.calls).toEqual([['stopPolling'], ['restartPolling']]); expect(Flash).toHaveBeenCalledWith(TEST_ERROR_MESSAGE, 'alert', flashContainer); }); }); @@ -970,8 +955,11 @@ describe('Actions Notes Store', () => { Api.applySuggestion.mockReturnValue(Promise.reject(response)); testSubmitSuggestion(done, () => { - expect(commit).not.toHaveBeenCalled(); - expect(dispatch).not.toHaveBeenCalled(); + expect(commit.mock.calls).toEqual([ + [mutationTypes.SET_RESOLVING_DISCUSSION, true], + [mutationTypes.SET_RESOLVING_DISCUSSION, false], + ]); + expect(dispatch.mock.calls).toEqual([['stopPolling'], ['restartPolling']]); expect(Flash).toHaveBeenCalledWith( 'Something went wrong while applying the suggestion. Please try again.', 'alert', @@ -1015,15 +1003,19 @@ describe('Actions Notes Store', () => { testSubmitSuggestionBatch(done, () => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_APPLYING_BATCH_STATE, true], + [mutationTypes.SET_RESOLVING_DISCUSSION, true], [mutationTypes.APPLY_SUGGESTION, batchSuggestionsInfo[0]], [mutationTypes.APPLY_SUGGESTION, batchSuggestionsInfo[1]], [mutationTypes.CLEAR_SUGGESTION_BATCH], [mutationTypes.SET_APPLYING_BATCH_STATE, false], + [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); expect(dispatch.mock.calls).toEqual([ + ['stopPolling'], ['resolveDiscussion', { discussionId: discussionIds[0] }], ['resolveDiscussion', { discussionId: discussionIds[1] }], + ['restartPolling'], ]); expect(Flash).not.toHaveBeenCalled(); @@ -1038,10 +1030,12 @@ describe('Actions Notes Store', () => { testSubmitSuggestionBatch(done, () => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_APPLYING_BATCH_STATE, true], + [mutationTypes.SET_RESOLVING_DISCUSSION, true], [mutationTypes.SET_APPLYING_BATCH_STATE, false], + [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); - expect(dispatch).not.toHaveBeenCalled(); + expect(dispatch.mock.calls).toEqual([['stopPolling'], ['restartPolling']]); expect(Flash).toHaveBeenCalledWith(TEST_ERROR_MESSAGE, 'alert', flashContainer); }); }); @@ -1054,10 +1048,12 @@ describe('Actions Notes Store', () => { testSubmitSuggestionBatch(done, () => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_APPLYING_BATCH_STATE, true], + [mutationTypes.SET_RESOLVING_DISCUSSION, true], [mutationTypes.SET_APPLYING_BATCH_STATE, false], + [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); - expect(dispatch).not.toHaveBeenCalled(); + expect(dispatch.mock.calls).toEqual([['stopPolling'], ['restartPolling']]); expect(Flash).toHaveBeenCalledWith( 'Something went wrong while applying the batch of suggestions. Please try again.', 'alert', @@ -1072,10 +1068,12 @@ describe('Actions Notes Store', () => { testSubmitSuggestionBatch(done, () => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_APPLYING_BATCH_STATE, true], + [mutationTypes.SET_RESOLVING_DISCUSSION, true], [mutationTypes.APPLY_SUGGESTION, batchSuggestionsInfo[0]], [mutationTypes.APPLY_SUGGESTION, batchSuggestionsInfo[1]], [mutationTypes.CLEAR_SUGGESTION_BATCH], [mutationTypes.SET_APPLYING_BATCH_STATE, false], + [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); expect(Flash).not.toHaveBeenCalled(); diff --git a/spec/frontend/notes/stores/mutation_spec.js b/spec/frontend/notes/stores/mutation_spec.js index 2618c3a53b8..ec4de925721 100644 --- a/spec/frontend/notes/stores/mutation_spec.js +++ b/spec/frontend/notes/stores/mutation_spec.js @@ -377,6 +377,16 @@ describe('Notes Store mutations', () => { }); }); + describe('SET_RESOLVING_DISCUSSION', () => { + it('should set resolving discussion state', () => { + const state = {}; + + mutations.SET_RESOLVING_DISCUSSION(state, true); + + expect(state.isResolvingDiscussion).toEqual(true); + }); + }); + describe('UPDATE_NOTE', () => { it('should update a note', () => { const state = { @@ -687,42 +697,6 @@ describe('Notes Store mutations', () => { }); }); - describe('TOGGLE_BLOCKED_ISSUE_WARNING', () => { - it('should set isToggleBlockedIssueWarning as true', () => { - const state = { - discussions: [], - targetNoteHash: null, - lastFetchedAt: null, - isToggleStateButtonLoading: false, - isToggleBlockedIssueWarning: false, - notesData: {}, - userData: {}, - noteableData: {}, - }; - - mutations.TOGGLE_BLOCKED_ISSUE_WARNING(state, true); - - expect(state.isToggleBlockedIssueWarning).toEqual(true); - }); - - it('should set isToggleBlockedIssueWarning as false', () => { - const state = { - discussions: [], - targetNoteHash: null, - lastFetchedAt: null, - isToggleStateButtonLoading: false, - isToggleBlockedIssueWarning: true, - notesData: {}, - userData: {}, - noteableData: {}, - }; - - mutations.TOGGLE_BLOCKED_ISSUE_WARNING(state, false); - - expect(state.isToggleBlockedIssueWarning).toEqual(false); - }); - }); - describe('SET_APPLYING_BATCH_STATE', () => { const buildDiscussions = suggestionsInfo => { const suggestions = suggestionsInfo.map(({ suggestionId }) => ({ id: suggestionId })); diff --git a/spec/frontend/packages/details/components/app_spec.js b/spec/frontend/packages/details/components/app_spec.js index e82c74e56e5..97df117df0b 100644 --- a/spec/frontend/packages/details/components/app_spec.js +++ b/spec/frontend/packages/details/components/app_spec.js @@ -16,6 +16,7 @@ import DependencyRow from '~/packages/details/components/dependency_row.vue'; import PackageHistory from '~/packages/details/components/package_history.vue'; import AdditionalMetadata from '~/packages/details/components/additional_metadata.vue'; import InstallationCommands from '~/packages/details/components/installation_commands.vue'; +import PackageFiles from '~/packages/details/components/package_files.vue'; import { composerPackage, @@ -23,7 +24,6 @@ import { mavenPackage, mavenFiles, npmPackage, - npmFiles, nugetPackage, } from '../../mock_data'; @@ -82,8 +82,6 @@ describe('PackagesApp', () => { const packageTitle = () => wrapper.find(PackageTitle); const emptyState = () => wrapper.find(GlEmptyState); - const allFileRows = () => wrapper.findAll('.js-file-row'); - const firstFileDownloadLink = () => wrapper.find('.js-file-download'); const deleteButton = () => wrapper.find('.js-delete-button'); const deleteModal = () => wrapper.find(GlModal); const modalDeleteButton = () => wrapper.find({ ref: 'modal-delete-button' }); @@ -98,6 +96,7 @@ describe('PackagesApp', () => { const findPackageHistory = () => wrapper.find(PackageHistory); const findAdditionalMetadata = () => wrapper.find(AdditionalMetadata); const findInstallationCommands = () => wrapper.find(InstallationCommands); + const findPackageFiles = () => wrapper.find(PackageFiles); beforeEach(() => { delete window.location; @@ -144,28 +143,7 @@ describe('PackagesApp', () => { it('hides the files table if package type is COMPOSER', () => { createComponent({ packageEntity: composerPackage }); - expect(allFileRows().exists()).toBe(false); - }); - - it('renders a single file for an npm package as they only contain one file', () => { - createComponent({ packageEntity: npmPackage, packageFiles: npmFiles }); - - expect(allFileRows()).toExist(); - expect(allFileRows()).toHaveLength(1); - }); - - it('renders multiple files for a package that contains more than one file', () => { - createComponent(); - - expect(allFileRows()).toExist(); - expect(allFileRows()).toHaveLength(2); - }); - - it('allows the user to download a package file by rendering a download link', () => { - createComponent(); - - expect(allFileRows()).toExist(); - expect(firstFileDownloadLink().vm.$attrs.href).toContain('download'); + expect(findPackageFiles().exists()).toBe(false); }); describe('deleting packages', () => { @@ -331,7 +309,7 @@ describe('PackagesApp', () => { it(`file download link call event with ${TrackingActions.PULL_PACKAGE}`, () => { createComponent({ packageEntity: conanPackage }); - firstFileDownloadLink().vm.$emit('click'); + findPackageFiles().vm.$emit('download-file'); expect(eventSpy).toHaveBeenCalledWith( category, TrackingActions.PULL_PACKAGE, diff --git a/spec/frontend/packages/details/components/package_files_spec.js b/spec/frontend/packages/details/components/package_files_spec.js new file mode 100644 index 00000000000..813a2170154 --- /dev/null +++ b/spec/frontend/packages/details/components/package_files_spec.js @@ -0,0 +1,131 @@ +import { mount } from '@vue/test-utils'; +import stubChildren from 'helpers/stub_children'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import component from '~/packages/details/components/package_files.vue'; + +import { npmFiles, mavenFiles } from '../../mock_data'; + +describe('Package Files', () => { + let wrapper; + + const findAllRows = () => wrapper.findAll('[data-testid="file-row"'); + const findFirstRow = () => findAllRows().at(0); + const findFirstRowDownloadLink = () => findFirstRow().find('[data-testid="download-link"'); + const findFirstRowCommitLink = () => findFirstRow().find('[data-testid="commit-link"'); + const findFirstRowFileIcon = () => findFirstRow().find(FileIcon); + const findFirstRowCreatedAt = () => findFirstRow().find(TimeAgoTooltip); + + const createComponent = (packageFiles = npmFiles) => { + wrapper = mount(component, { + propsData: { + packageFiles, + }, + stubs: { + ...stubChildren(component), + GlTable: false, + GlLink: '<div><slot></slot></div>', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('rows', () => { + it('renders a single file for an npm package', () => { + createComponent(); + + expect(findAllRows()).toHaveLength(1); + }); + + it('renders multiple files for a package that contains more than one file', () => { + createComponent(mavenFiles); + + expect(findAllRows()).toHaveLength(2); + }); + }); + + describe('link', () => { + it('exists', () => { + createComponent(); + + expect(findFirstRowDownloadLink().exists()).toBe(true); + }); + + it('has the correct attrs bound', () => { + createComponent(); + + expect(findFirstRowDownloadLink().attributes('href')).toBe(npmFiles[0].download_path); + }); + + it('emits "download-file" event on click', () => { + createComponent(); + + findFirstRowDownloadLink().vm.$emit('click'); + + expect(wrapper.emitted('download-file')).toEqual([[]]); + }); + }); + + describe('file-icon', () => { + it('exists', () => { + createComponent(); + + expect(findFirstRowFileIcon().exists()).toBe(true); + }); + + it('has the correct props bound', () => { + createComponent(); + + expect(findFirstRowFileIcon().props('fileName')).toBe(npmFiles[0].file_name); + }); + }); + + describe('time-ago tooltip', () => { + it('exists', () => { + createComponent(); + + expect(findFirstRowCreatedAt().exists()).toBe(true); + }); + + it('has the correct props bound', () => { + createComponent(); + + expect(findFirstRowCreatedAt().props('time')).toBe(npmFiles[0].created_at); + }); + }); + + describe('commit', () => { + describe('when package file has a pipeline associated', () => { + it('exists', () => { + createComponent(); + + expect(findFirstRowCommitLink().exists()).toBe(true); + }); + + it('the link points to the commit url', () => { + createComponent(); + + expect(findFirstRowCommitLink().attributes('href')).toBe( + npmFiles[0].pipelines[0].project.commit_url, + ); + }); + + it('the text is git_commit_message', () => { + createComponent(); + + expect(findFirstRowCommitLink().text()).toBe(npmFiles[0].pipelines[0].git_commit_message); + }); + }); + describe('when package file has no pipeline associated', () => { + it('does not exist', () => { + createComponent(mavenFiles); + + expect(findFirstRowCommitLink().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/package_history_spec.js b/spec/frontend/packages/details/components/package_history_spec.js index f745a457b0a..c43ac9b9c40 100644 --- a/spec/frontend/packages/details/components/package_history_spec.js +++ b/spec/frontend/packages/details/components/package_history_spec.js @@ -1,7 +1,9 @@ import { shallowMount } from '@vue/test-utils'; import { GlLink, GlSprintf } from '@gitlab/ui'; +import { stubComponent } from 'helpers/stub_component'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; +import { HISTORY_PIPELINES_LIMIT } from '~/packages/details/constants'; import component from '~/packages/details/components/package_history.vue'; import { mavenPackage, mockPipelineInfo } from '../../mock_data'; @@ -13,14 +15,16 @@ describe('Package History', () => { packageEntity: { ...mavenPackage }, }; + const createPipelines = amount => + [...Array(amount)].map((x, index) => ({ ...mockPipelineInfo, id: index + 1 })); + const mountComponent = props => { wrapper = shallowMount(component, { propsData: { ...defaultProps, ...props }, stubs: { - HistoryItem: { - props: HistoryItem.props, + HistoryItem: stubComponent(HistoryItem, { template: '<div data-testid="history-element"><slot></slot></div>', - }, + }), GlSprintf, }, }); @@ -56,55 +60,58 @@ describe('Package History', () => { expect.arrayContaining(['timeline', 'main-notes-list', 'notes']), ); }); - describe.each` - name | icon | text | timeAgoTooltip | link - ${'created-on'} | ${'clock'} | ${'Test package version 1.0.0 was created'} | ${mavenPackage.created_at} | ${null} - ${'updated-at'} | ${'pencil'} | ${'Test package version 1.0.0 was updated'} | ${mavenPackage.updated_at} | ${null} - ${'commit'} | ${'commit'} | ${'Commit sha-baz on branch branch-name'} | ${null} | ${mockPipelineInfo.project.commit_url} - ${'pipeline'} | ${'pipeline'} | ${'Pipeline #1 triggered by foo'} | ${mockPipelineInfo.created_at} | ${mockPipelineInfo.project.pipeline_url} - ${'published'} | ${'package'} | ${'Published to the baz project Package Registry'} | ${mavenPackage.created_at} | ${null} - `('history element $name', ({ name, icon, text, timeAgoTooltip, link }) => { - let element; - - beforeEach(() => { - mountComponent({ packageEntity: { ...mavenPackage, pipeline: mockPipelineInfo } }); - element = findHistoryElement(name); - }); - - it('has the correct icon', () => { - expect(element.props('icon')).toBe(icon); - }); - - it('has the correct text', () => { - expect(element.text()).toBe(text); - }); - - it('time-ago tooltip', () => { - const timeAgo = findElementTimeAgo(element); - const exist = Boolean(timeAgoTooltip); - - expect(timeAgo.exists()).toBe(exist); - if (exist) { - expect(timeAgo.props('time')).toBe(timeAgoTooltip); - } - }); - - it('link', () => { - const linkElement = findElementLink(element); - const exist = Boolean(link); - - expect(linkElement.exists()).toBe(exist); - if (exist) { - expect(linkElement.attributes('href')).toBe(link); - } - }); - }); - - describe('when pipelineInfo is missing', () => { - it.each(['commit', 'pipeline'])('%s history element is hidden', name => { - mountComponent(); - expect(findHistoryElement(name).exists()).toBe(false); - }); - }); + name | amount | icon | text | timeAgoTooltip | link + ${'created-on'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'clock'} | ${'Test package version 1.0.0 was first created'} | ${mavenPackage.created_at} | ${null} + ${'first-pipeline-commit'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'commit'} | ${'Created by commit #sha-baz on branch branch-name'} | ${null} | ${mockPipelineInfo.project.commit_url} + ${'first-pipeline-pipeline'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'pipeline'} | ${'Built by pipeline #1 triggered by foo'} | ${mockPipelineInfo.created_at} | ${mockPipelineInfo.project.pipeline_url} + ${'published'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'package'} | ${'Published to the baz project Package Registry'} | ${mavenPackage.created_at} | ${null} + ${'archived'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'history'} | ${'Package has 1 archived update'} | ${null} | ${null} + ${'archived'} | ${HISTORY_PIPELINES_LIMIT + 3} | ${'history'} | ${'Package has 2 archived updates'} | ${null} | ${null} + ${'pipeline-entry'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'pencil'} | ${'Package updated by commit #sha-baz on branch branch-name, built by pipeline #3, and published to the registry'} | ${mavenPackage.created_at} | ${mockPipelineInfo.project.commit_url} + `( + 'with $amount pipelines history element $name', + ({ name, icon, text, timeAgoTooltip, link, amount }) => { + let element; + + beforeEach(() => { + mountComponent({ + packageEntity: { ...mavenPackage, pipelines: createPipelines(amount) }, + }); + element = findHistoryElement(name); + }); + + it('exists', () => { + expect(element.exists()).toBe(true); + }); + + it('has the correct icon', () => { + expect(element.props('icon')).toBe(icon); + }); + + it('has the correct text', () => { + expect(element.text()).toBe(text); + }); + + it('time-ago tooltip', () => { + const timeAgo = findElementTimeAgo(element); + const exist = Boolean(timeAgoTooltip); + + expect(timeAgo.exists()).toBe(exist); + if (exist) { + expect(timeAgo.props('time')).toBe(timeAgoTooltip); + } + }); + + it('link', () => { + const linkElement = findElementLink(element); + const exist = Boolean(link); + + expect(linkElement.exists()).toBe(exist); + if (exist) { + expect(linkElement.attributes('href')).toBe(link); + } + }); + }, + ); }); diff --git a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap index d27038e765f..c51130dae00 100644 --- a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap +++ b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap @@ -202,6 +202,67 @@ exports[`packages_list_app renders 1`] = ` </b-tab-stub> <b-tab-stub tag="div" + title="Generic" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" + > + <div + class="col-12" + > + <div + class="svg-250 svg-content" + > + <img + alt="There are no Generic packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> + </div> + + <div + class="col-12" + > + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" + > + <h1 + class="h4" + > + There are no Generic packages yet + </h1> + + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div> + <!----> + + <!----> + </div> + </div> + </div> + </section> + </div> + </template> + </b-tab-stub> + <b-tab-stub + tag="div" title="Maven" titlelinkclass="gl-tab-nav-item" > diff --git a/spec/frontend/packages/mock_data.js b/spec/frontend/packages/mock_data.js index d7494bf85d0..fbc167729d9 100644 --- a/spec/frontend/packages/mock_data.js +++ b/spec/frontend/packages/mock_data.js @@ -76,6 +76,9 @@ export const npmFiles = [ id: 2, size: 200, download_path: '/-/package_files/2/download', + pipelines: [ + { id: 1, project: { commit_url: 'http://foo.bar' }, git_commit_message: 'foo bar baz?' }, + ], }, ]; diff --git a/spec/frontend/pages/admin/users/components/__snapshots__/user_operation_confirmation_modal_spec.js.snap b/spec/frontend/pages/admin/users/components/__snapshots__/user_operation_confirmation_modal_spec.js.snap deleted file mode 100644 index dbf8caae357..00000000000 --- a/spec/frontend/pages/admin/users/components/__snapshots__/user_operation_confirmation_modal_spec.js.snap +++ /dev/null @@ -1,34 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`User Operation confirmation modal renders modal with form included 1`] = ` -<gl-modal-stub - modalclass="" - modalid="user-operation-modal" - ok-title="action" - ok-variant="warning" - size="md" - title="title" - titletag="h4" -> - <form - action="/url" - method="post" - > - <span> - content - </span> - - <input - name="_method" - type="hidden" - value="method" - /> - - <input - name="authenticity_token" - type="hidden" - value="csrf" - /> - </form> -</gl-modal-stub> -`; diff --git a/spec/frontend/pages/admin/users/components/user_modal_manager_spec.js b/spec/frontend/pages/admin/users/components/user_modal_manager_spec.js index 3d615d9d05f..6df2efd624d 100644 --- a/spec/frontend/pages/admin/users/components/user_modal_manager_spec.js +++ b/spec/frontend/pages/admin/users/components/user_modal_manager_spec.js @@ -14,21 +14,18 @@ describe('Users admin page Modal Manager', () => { }, }; - const actionModals = { - action1: ModalStub, - action2: ModalStub, - }; - let wrapper; const createComponent = (props = {}) => { wrapper = mount(UserModalManager, { propsData: { - actionModals, modalConfiguration, csrfToken: 'dummyCSRF', ...props, }, + stubs: { + DeleteUserModal: ModalStub, + }, }); }; @@ -43,11 +40,6 @@ describe('Users admin page Modal Manager', () => { expect(wrapper.find({ ref: 'modal' }).exists()).toBeFalsy(); }); - it('throws if non-existing action is requested', () => { - createComponent(); - expect(() => wrapper.vm.show({ glModalAction: 'non-existing' })).toThrow(); - }); - it('throws if action has no proper configuration', () => { createComponent({ modalConfiguration: {}, diff --git a/spec/frontend/pages/admin/users/components/user_operation_confirmation_modal_spec.js b/spec/frontend/pages/admin/users/components/user_operation_confirmation_modal_spec.js deleted file mode 100644 index f3a37a255cd..00000000000 --- a/spec/frontend/pages/admin/users/components/user_operation_confirmation_modal_spec.js +++ /dev/null @@ -1,46 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlModal } from '@gitlab/ui'; -import UserOperationConfirmationModal from '~/pages/admin/users/components/user_operation_confirmation_modal.vue'; - -describe('User Operation confirmation modal', () => { - let wrapper; - - const createComponent = (props = {}) => { - wrapper = shallowMount(UserOperationConfirmationModal, { - propsData: { - title: 'title', - content: 'content', - action: 'action', - url: '/url', - username: 'username', - csrfToken: 'csrf', - method: 'method', - ...props, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('renders modal with form included', () => { - createComponent(); - expect(wrapper.element).toMatchSnapshot(); - }); - - it('closing modal with ok button triggers form submit', () => { - createComponent(); - const form = wrapper.find('form'); - jest.spyOn(form.element, 'submit').mockReturnValue(); - wrapper.find(GlModal).vm.$emit('ok'); - return wrapper.vm.$nextTick().then(() => { - expect(form.element.submit).toHaveBeenCalled(); - expect(form.element.action).toContain(wrapper.props('url')); - expect(new FormData(form.element).get('authenticity_token')).toEqual( - wrapper.props('csrfToken'), - ); - }); - }); -}); diff --git a/spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js b/spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js index 67ace608127..695d1b686a5 100644 --- a/spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js +++ b/spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js @@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import { GlButton } from '@gitlab/ui'; import BitbucketServerStatusTable from '~/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue'; -import BitbucketStatusTable from '~/import_projects/components/bitbucket_status_table.vue'; +import BitbucketStatusTable from '~/import_entities/import_projects/components/bitbucket_status_table.vue'; const BitbucketStatusTableStub = { name: 'BitbucketStatusTable', diff --git a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap index 8ccad7d5c22..324c9788309 100644 --- a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap +++ b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap @@ -10,7 +10,7 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`] <!----> <gl-dropdown-stub - category="tertiary" + category="primary" headertext="" size="medium" text="rspec" @@ -20,6 +20,7 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`] avatarurl="" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischecked="true" ischeckitem="true" @@ -34,6 +35,7 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`] avatarurl="" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischeckitem="true" secondarytext="" @@ -47,6 +49,7 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`] avatarurl="" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischeckitem="true" secondarytext="" diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js index e760cead760..0b58260ed1c 100644 --- a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js +++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js @@ -21,11 +21,13 @@ const defaultProps = { wikiAccessLevel: 20, snippetsAccessLevel: 20, pagesAccessLevel: 10, + analyticsAccessLevel: 20, containerRegistryEnabled: true, lfsEnabled: true, emailsDisabled: false, packagesEnabled: true, showDefaultAwardEmojis: true, + allowEditingCommitMessages: false, }, canDisableEmails: true, canChangeVisibilityLevel: true, @@ -49,7 +51,7 @@ describe('Settings Panel', () => { let wrapper; const mountComponent = ( - { currentSettings = {}, ...customProps } = {}, + { currentSettings = {}, glFeatures = {}, ...customProps } = {}, mountFn = shallowMount, ) => { const propsData = { @@ -60,6 +62,9 @@ describe('Settings Panel', () => { return mountFn(settingsPanel, { propsData, + provide: { + glFeatures, + }, }); }; @@ -75,6 +80,8 @@ describe('Settings Panel', () => { const findRepositoryFeatureSetting = () => findRepositoryFeatureProjectRow().find(projectFeatureSetting); + const findAnalyticsRow = () => wrapper.find({ ref: 'analytics-settings' }); + beforeEach(() => { wrapper = mountComponent(); }); @@ -539,4 +546,26 @@ describe('Settings Panel', () => { expect(metricsSettingsRow.find('select').attributes('disabled')).toBe('disabled'); }); }); + + describe('Settings panel with feature flags', () => { + describe('Allow edit of commit message', () => { + it('should show the allow editing of commit messages checkbox', async () => { + wrapper = mountComponent({ + glFeatures: { allowEditingCommitMessages: true }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find({ ref: 'allow-editing-commit-messages' }).exists()).toBe(true); + }); + }); + }); + + describe('Analytics', () => { + it('should show the analytics toggle', async () => { + await wrapper.vm.$nextTick(); + + expect(findAnalyticsRow().exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js b/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js new file mode 100644 index 00000000000..ae2a9e5065d --- /dev/null +++ b/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js @@ -0,0 +1,116 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import { GlFormInput, GlFormTextarea } from '@gitlab/ui'; + +import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue'; + +import { mockCommitMessage, mockDefaultBranch } from '../../mock_data'; + +describe('~/pipeline_editor/pipeline_editor_app.vue', () => { + let wrapper; + + const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => { + wrapper = mountFn(CommitForm, { + propsData: { + defaultMessage: mockCommitMessage, + defaultBranch: mockDefaultBranch, + ...props, + }, + + // attachToDocument is required for input/submit events + attachToDocument: mountFn === mount, + }); + }; + + const findCommitTextarea = () => wrapper.find(GlFormTextarea); + const findBranchInput = () => wrapper.find(GlFormInput); + const findNewMrCheckbox = () => wrapper.find('[data-testid="new-mr-checkbox"]'); + const findSubmitBtn = () => wrapper.find('[type="submit"]'); + const findCancelBtn = () => wrapper.find('[type="reset"]'); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when the form is displayed', () => { + beforeEach(async () => { + createComponent(); + }); + + it('shows a default commit message', () => { + expect(findCommitTextarea().attributes('value')).toBe(mockCommitMessage); + }); + + it('shows a default branch', () => { + expect(findBranchInput().attributes('value')).toBe(mockDefaultBranch); + }); + + it('shows buttons', () => { + expect(findSubmitBtn().exists()).toBe(true); + expect(findCancelBtn().exists()).toBe(true); + }); + + it('does not show a new MR checkbox by default', () => { + expect(findNewMrCheckbox().exists()).toBe(false); + }); + }); + + describe('when buttons are clicked', () => { + beforeEach(async () => { + createComponent({}, mount); + }); + + it('emits an event when the form submits', () => { + findSubmitBtn().trigger('click'); + + expect(wrapper.emitted('submit')[0]).toEqual([ + { + message: mockCommitMessage, + branch: mockDefaultBranch, + openMergeRequest: false, + }, + ]); + }); + + it('emits an event when the form resets', () => { + findCancelBtn().trigger('click'); + + expect(wrapper.emitted('cancel')).toHaveLength(1); + }); + }); + + describe('when user inputs values', () => { + const anotherMessage = 'Another commit message'; + const anotherBranch = 'my-branch'; + + beforeEach(() => { + createComponent({}, mount); + + findCommitTextarea().setValue(anotherMessage); + findBranchInput().setValue(anotherBranch); + }); + + it('shows a new MR checkbox', () => { + expect(findNewMrCheckbox().exists()).toBe(true); + }); + + it('emits an event with values', async () => { + await findNewMrCheckbox().setChecked(); + await findSubmitBtn().trigger('click'); + + expect(wrapper.emitted('submit')[0]).toEqual([ + { + message: anotherMessage, + branch: anotherBranch, + openMergeRequest: true, + }, + ]); + }); + + it('when the commit message is empty, submit button is disabled', async () => { + await findCommitTextarea().setValue(''); + + expect(findSubmitBtn().attributes('disabled')).toBe('disabled'); + }); + }); +}); diff --git a/spec/frontend/ci_lint/components/ci_lint_results_spec.js b/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js index 93c2d2dbcf3..e9c6ed60860 100644 --- a/spec/frontend/ci_lint/components/ci_lint_results_spec.js +++ b/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js @@ -1,8 +1,8 @@ import { shallowMount, mount } from '@vue/test-utils'; import { GlTable, GlLink } from '@gitlab/ui'; -import CiLintResults from '~/ci_lint/components/ci_lint_results.vue'; +import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; -import { mockJobs, mockErrors, mockWarnings } from '../mock_data'; +import { mockJobs, mockErrors, mockWarnings } from '../../mock_data'; describe('CI Lint Results', () => { let wrapper; diff --git a/spec/frontend/ci_lint/components/ci_lint_warnings_spec.js b/spec/frontend/pipeline_editor/components/lint/ci_lint_warnings_spec.js index 6e0a4881e14..b441d26c146 100644 --- a/spec/frontend/ci_lint/components/ci_lint_warnings_spec.js +++ b/spec/frontend/pipeline_editor/components/lint/ci_lint_warnings_spec.js @@ -1,7 +1,7 @@ import { mount } from '@vue/test-utils'; import { GlAlert, GlSprintf } from '@gitlab/ui'; import { trimText } from 'helpers/text_helper'; -import CiLintWarnings from '~/ci_lint/components/ci_lint_warnings.vue'; +import CiLintWarnings from '~/pipeline_editor/components/lint/ci_lint_warnings.vue'; const warnings = ['warning 1', 'warning 2', 'warning 3']; diff --git a/spec/frontend/pipeline_editor/components/text_editor_spec.js b/spec/frontend/pipeline_editor/components/text_editor_spec.js index 39d205839f4..18f71ebc95c 100644 --- a/spec/frontend/pipeline_editor/components/text_editor_spec.js +++ b/spec/frontend/pipeline_editor/components/text_editor_spec.js @@ -6,12 +6,16 @@ import TextEditor from '~/pipeline_editor/components/text_editor.vue'; describe('~/pipeline_editor/components/text_editor.vue', () => { let wrapper; + const editorReadyListener = jest.fn(); - const createComponent = (props = {}, mountFn = shallowMount) => { + const createComponent = (attrs = {}, mountFn = shallowMount) => { wrapper = mountFn(TextEditor, { - propsData: { + attrs: { value: mockCiYml, - ...props, + ...attrs, + }, + listeners: { + 'editor-ready': editorReadyListener, }, }); }; @@ -28,14 +32,13 @@ describe('~/pipeline_editor/components/text_editor.vue', () => { expect(findEditor().props('value')).toBe(mockCiYml); }); - it('editor is readony and configured for .yml', () => { - expect(findEditor().props('editorOptions')).toEqual({ readOnly: true }); + it('editor is configured for .yml', () => { expect(findEditor().props('fileName')).toBe('*.yml'); }); - it('bubbles up editor-ready event', () => { + it('bubbles up events', () => { findEditor().vm.$emit('editor-ready'); - expect(wrapper.emitted('editor-ready')).toHaveLength(1); + expect(editorReadyListener).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/ci_lint/graphql/__snapshots__/resolvers_spec.js.snap b/spec/frontend/pipeline_editor/graphql/__snapshots__/resolvers_spec.js.snap index 87bec82e350..d7d4d0af90c 100644 --- a/spec/frontend/ci_lint/graphql/__snapshots__/resolvers_spec.js.snap +++ b/spec/frontend/pipeline_editor/graphql/__snapshots__/resolvers_spec.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`~/ci_lint/graphql/resolvers Mutation lintCI resolves lint data with type names 1`] = ` +exports[`~/pipeline_editor/graphql/resolvers Mutation lintCI lint data is as expected 1`] = ` Object { "__typename": "CiLintContent", "errors": Array [], diff --git a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js index 90acdf3ec0b..b531f8af797 100644 --- a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js +++ b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js @@ -1,6 +1,14 @@ +import MockAdapter from 'axios-mock-adapter'; import Api from '~/api'; -import { mockProjectPath, mockDefaultBranch, mockCiConfigPath, mockCiYml } from '../mock_data'; - +import { + mockCiConfigPath, + mockCiYml, + mockDefaultBranch, + mockLintResponse, + mockProjectPath, +} from '../mock_data'; +import httpStatus from '~/lib/utils/http_status'; +import axios from '~/lib/utils/axios_utils'; import { resolvers } from '~/pipeline_editor/graphql/resolvers'; jest.mock('~/api', () => { @@ -39,4 +47,43 @@ describe('~/pipeline_editor/graphql/resolvers', () => { }); }); }); + + describe('Mutation', () => { + describe('lintCI', () => { + let mock; + let result; + + const endpoint = '/ci/lint'; + + beforeEach(async () => { + mock = new MockAdapter(axios); + mock.onPost(endpoint).reply(httpStatus.OK, mockLintResponse); + + result = await resolvers.Mutation.lintCI(null, { + endpoint, + content: 'content', + dry_run: true, + }); + }); + + afterEach(() => { + mock.restore(); + }); + + /* eslint-disable no-underscore-dangle */ + it('lint data has correct type names', async () => { + expect(result.__typename).toBe('CiLintContent'); + + expect(result.jobs[0].__typename).toBe('CiLintJob'); + expect(result.jobs[1].__typename).toBe('CiLintJob'); + + expect(result.jobs[1].only.__typename).toBe('CiLintJobOnlyPolicy'); + }); + /* eslint-enable no-underscore-dangle */ + + it('lint data is as expected', () => { + expect(result).toMatchSnapshot(); + }); + }); + }); }); diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index 96fa6e5e004..d882490c272 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -1,5 +1,8 @@ export const mockProjectPath = 'user1/project1'; export const mockDefaultBranch = 'master'; +export const mockNewMergeRequestPath = '/-/merge_requests/new'; +export const mockCommitId = 'aabbccdd'; +export const mockCommitMessage = 'My commit message'; export const mockCiConfigPath = '.gitlab-ci.yml'; export const mockCiYml = ` @@ -8,3 +11,97 @@ job1: script: - echo 'test' `; + +export const mockCiConfigQueryResponse = { + data: { + ciConfig: { + errors: [], + stages: [], + status: '', + }, + }, +}; + +export const mockLintResponse = { + valid: true, + errors: [], + warnings: [], + jobs: [ + { + name: 'job_1', + stage: 'test', + before_script: ["echo 'before script 1'"], + script: ["echo 'script 1'"], + after_script: ["echo 'after script 1"], + tag_list: ['tag 1'], + environment: 'prd', + when: 'on_success', + allow_failure: false, + only: null, + except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, + }, + { + name: 'job_2', + stage: 'test', + before_script: ["echo 'before script 2'"], + script: ["echo 'script 2'"], + after_script: ["echo 'after script 2"], + tag_list: ['tag 2'], + environment: 'stg', + when: 'on_success', + allow_failure: true, + only: { refs: ['web', 'chat', 'pushes'] }, + except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, + }, + ], +}; + +export const mockJobs = [ + { + name: 'job_1', + stage: 'build', + beforeScript: [], + script: ["echo 'Building'"], + afterScript: [], + tagList: [], + environment: null, + when: 'on_success', + allowFailure: true, + only: { refs: ['web', 'chat', 'pushes'] }, + except: null, + }, + { + name: 'multi_project_job', + stage: 'test', + beforeScript: [], + script: [], + afterScript: [], + tagList: [], + environment: null, + when: 'on_success', + allowFailure: false, + only: { refs: ['branches', 'tags'] }, + except: null, + }, + { + name: 'job_2', + stage: 'test', + beforeScript: ["echo 'before script'"], + script: ["echo 'script'"], + afterScript: ["echo 'after script"], + tagList: [], + environment: null, + when: 'on_success', + allowFailure: false, + only: { refs: ['branches@gitlab-org/gitlab'] }, + except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, + }, +]; + +export const mockErrors = [ + '"job_1 job: chosen stage does not exist; available stages are .pre, build, test, deploy, .post"', +]; + +export const mockWarnings = [ + '"jobs:multi_project_job may allow multiple pipelines to run for a single action due to `rules:when` clause with no `workflow:rules` - read more: https://docs.gitlab.com/ee/ci/troubleshooting.html#pipeline-warnings"', +]; diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index 46523baadf3..14d6b03645c 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -1,139 +1,445 @@ import { nextTick } from 'vue'; -import { shallowMount } from '@vue/test-utils'; -import { GlAlert, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui'; +import { mount, shallowMount, createLocalVue } from '@vue/test-utils'; +import { + GlAlert, + GlButton, + GlFormInput, + GlFormTextarea, + GlLoadingIcon, + GlTabs, + GlTab, +} from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; -import { mockProjectPath, mockDefaultBranch, mockCiConfigPath, mockCiYml } from './mock_data'; -import TextEditor from '~/pipeline_editor/components/text_editor.vue'; -import EditorLite from '~/vue_shared/components/editor_lite.vue'; +import { objectToQuery, redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility'; +import { + mockCiConfigPath, + mockCiConfigQueryResponse, + mockCiYml, + mockCommitId, + mockCommitMessage, + mockDefaultBranch, + mockProjectPath, + mockNewMergeRequestPath, +} from './mock_data'; + +import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue'; +import getCiConfig from '~/pipeline_editor/graphql/queries/ci_config.graphql'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue'; +import TextEditor from '~/pipeline_editor/components/text_editor.vue'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +jest.mock('~/lib/utils/url_utility', () => ({ + redirectTo: jest.fn(), + refreshCurrentPage: jest.fn(), + objectToQuery: jest.requireActual('~/lib/utils/url_utility').objectToQuery, + mergeUrlParams: jest.requireActual('~/lib/utils/url_utility').mergeUrlParams, +})); describe('~/pipeline_editor/pipeline_editor_app.vue', () => { let wrapper; - const createComponent = ( - { props = {}, data = {}, loading = false } = {}, + let mockApollo; + let mockBlobContentData; + let mockCiConfigData; + let mockMutate; + + const createComponent = ({ + props = {}, + blobLoading = false, + lintLoading = false, + options = {}, mountFn = shallowMount, - ) => { + provide = { + glFeatures: { + ciConfigVisualizationTab: true, + }, + }, + } = {}) => { + mockMutate = jest.fn().mockResolvedValue({ + data: { + commitCreate: { + errors: [], + commit: {}, + }, + }, + }); + wrapper = mountFn(PipelineEditorApp, { propsData: { - projectPath: mockProjectPath, - defaultBranch: mockDefaultBranch, ciConfigPath: mockCiConfigPath, + commitId: mockCommitId, + defaultBranch: mockDefaultBranch, + projectPath: mockProjectPath, + newMergeRequestPath: mockNewMergeRequestPath, ...props, }, - data() { - return data; - }, + provide, stubs: { GlTabs, + GlButton, + CommitForm, + EditorLite: { + template: '<div/>', + }, TextEditor, }, mocks: { $apollo: { queries: { content: { - loading, + loading: blobLoading, + }, + ciConfigData: { + loading: lintLoading, }, }, + mutate: mockMutate, }, }, + // attachToDocument is required for input/submit events + attachToDocument: mountFn === mount, + ...options, }); }; + const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => { + const handlers = [[getCiConfig, mockCiConfigData]]; + const resolvers = { + Query: { + blobContent() { + return { + __typename: 'BlobContent', + rawData: mockBlobContentData(), + }; + }, + }, + }; + + mockApollo = createMockApollo(handlers, resolvers); + + const options = { + localVue, + mocks: {}, + apolloProvider: mockApollo, + }; + + createComponent({ props, options }, mountFn); + }; + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findAlert = () => wrapper.find(GlAlert); + const findBlobFailureAlert = () => wrapper.find(GlAlert); const findTabAt = i => wrapper.findAll(GlTab).at(i); - const findEditorLite = () => wrapper.find(EditorLite); + const findVisualizationTab = () => wrapper.find('[data-testid="visualization-tab"]'); + const findTextEditor = () => wrapper.find(TextEditor); + const findCommitForm = () => wrapper.find(CommitForm); + const findPipelineGraph = () => wrapper.find(PipelineGraph); + const findCommitBtnLoadingIcon = () => wrapper.find('[type="submit"]').find(GlLoadingIcon); beforeEach(() => { - createComponent(); + mockBlobContentData = jest.fn(); + mockCiConfigData = jest.fn().mockResolvedValue(mockCiConfigQueryResponse); }); afterEach(() => { + mockBlobContentData.mockReset(); + mockCiConfigData.mockReset(); + refreshCurrentPage.mockReset(); + redirectTo.mockReset(); + mockMutate.mockReset(); + wrapper.destroy(); wrapper = null; }); - it('displays content', () => { - createComponent({ data: { content: mockCiYml } }); - - expect(findLoadingIcon().exists()).toBe(false); - expect(findEditorLite().props('value')).toBe(mockCiYml); - }); - - it('displays a loading icon if the query is loading', () => { - createComponent({ loading: true }); + it('displays a loading icon if the blob query is loading', () => { + createComponent({ blobLoading: true }); expect(findLoadingIcon().exists()).toBe(true); + expect(findTextEditor().exists()).toBe(false); }); describe('tabs', () => { - it('displays tabs and their content', () => { - createComponent({ data: { content: mockCiYml } }); - - expect( - findTabAt(0) - .find(EditorLite) - .exists(), - ).toBe(true); - expect( - findTabAt(1) - .find(PipelineGraph) - .exists(), - ).toBe(true); + describe('editor tab', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays the tab and its content', async () => { + expect( + findTabAt(0) + .find(TextEditor) + .exists(), + ).toBe(true); + }); + + it('displays tab lazily, until editor is ready', async () => { + expect(findTabAt(0).attributes('lazy')).toBe('true'); + + findTextEditor().vm.$emit('editor-ready'); + + await nextTick(); + + expect(findTabAt(0).attributes('lazy')).toBe(undefined); + }); }); - it('displays editor tab lazily, until editor is ready', async () => { - createComponent({ data: { content: mockCiYml } }); + describe('visualization tab', () => { + describe('with feature flag on', () => { + beforeEach(() => { + createComponent(); + }); + + it('display the tab', () => { + expect(findVisualizationTab().exists()).toBe(true); + }); + + it('displays a loading icon if the lint query is loading', () => { + createComponent({ lintLoading: true }); - expect(findTabAt(0).attributes('lazy')).toBe('true'); + expect(findLoadingIcon().exists()).toBe(true); + expect(findPipelineGraph().exists()).toBe(false); + }); + }); - findEditorLite().vm.$emit('editor-ready'); - await nextTick(); + describe('with feature flag off', () => { + beforeEach(() => { + createComponent({ provide: { glFeatures: { ciConfigVisualizationTab: false } } }); + }); - expect(findTabAt(0).attributes('lazy')).toBe(undefined); + it('does not display the tab', () => { + expect(findVisualizationTab().exists()).toBe(false); + }); + }); }); }); - describe('when in error state', () => { - class MockError extends Error { - constructor(message, data) { - super(message); - if (data) { - this.networkError = { - response: { data }, - }; + describe('when data is set', () => { + beforeEach(async () => { + createComponent({ mountFn: mount }); + + await wrapper.setData({ + content: mockCiYml, + contentModel: mockCiYml, + }); + }); + + it('displays content after the query loads', () => { + expect(findLoadingIcon().exists()).toBe(false); + expect(findTextEditor().attributes('value')).toBe(mockCiYml); + }); + + describe('commit form', () => { + const mockVariables = { + content: mockCiYml, + filePath: mockCiConfigPath, + lastCommitId: mockCommitId, + message: mockCommitMessage, + projectPath: mockProjectPath, + startBranch: mockDefaultBranch, + }; + + const findInForm = selector => findCommitForm().find(selector); + + const submitCommit = async ({ + message = mockCommitMessage, + branch = mockDefaultBranch, + openMergeRequest = false, + } = {}) => { + await findInForm(GlFormTextarea).setValue(message); + await findInForm(GlFormInput).setValue(branch); + if (openMergeRequest) { + await findInForm('[data-testid="new-mr-checkbox"]').setChecked(openMergeRequest); } - } - } + await findInForm('[type="submit"]').trigger('click'); + }; + + const cancelCommitForm = async () => { + const findCancelBtn = () => wrapper.find('[type="reset"]'); + await findCancelBtn().trigger('click'); + }; + + describe('when the user commits changes to the current branch', () => { + beforeEach(async () => { + await submitCommit(); + }); + + it('calls the mutation with the default branch', () => { + expect(mockMutate).toHaveBeenCalledWith({ + mutation: expect.any(Object), + variables: { + ...mockVariables, + branch: mockDefaultBranch, + }, + }); + }); + + it('refreshes the page', () => { + expect(refreshCurrentPage).toHaveBeenCalled(); + }); + + it('shows no saving state', () => { + expect(findCommitBtnLoadingIcon().exists()).toBe(false); + }); + }); + + describe('when the user commits changes to a new branch', () => { + const newBranch = 'new-branch'; + + beforeEach(async () => { + await submitCommit({ + branch: newBranch, + }); + }); + + it('calls the mutation with the new branch', () => { + expect(mockMutate).toHaveBeenCalledWith({ + mutation: expect.any(Object), + variables: { + ...mockVariables, + branch: newBranch, + }, + }); + }); + + it('refreshes the page', () => { + expect(refreshCurrentPage).toHaveBeenCalledWith(); + }); + }); + + describe('when the user commits changes to open a new merge request', () => { + const newBranch = 'new-branch'; + + beforeEach(async () => { + await submitCommit({ + branch: newBranch, + openMergeRequest: true, + }); + }); + + it('redirects to the merge request page with source and target branches', () => { + const branchesQuery = objectToQuery({ + 'merge_request[source_branch]': newBranch, + 'merge_request[target_branch]': mockDefaultBranch, + }); + + expect(redirectTo).toHaveBeenCalledWith(`${mockNewMergeRequestPath}?${branchesQuery}`); + }); + }); + + describe('when the commit is ocurring', () => { + it('shows a saving state', async () => { + await mockMutate.mockImplementationOnce(() => { + expect(findCommitBtnLoadingIcon().exists()).toBe(true); + return Promise.resolve(); + }); + + await submitCommit({ + message: mockCommitMessage, + branch: mockDefaultBranch, + openMergeRequest: false, + }); + }); + }); + + describe('when the commit fails', () => { + it('shows a the error message', async () => { + mockMutate.mockRejectedValueOnce(new Error('commit failed')); - it('shows a generic error', () => { - const error = new MockError('An error message'); - createComponent({ data: { error } }); + await submitCommit(); - expect(findAlert().text()).toBe('CI file could not be loaded: An error message'); + await waitForPromises(); + + expect(findAlert().text()).toMatchInterpolatedText( + 'The GitLab CI configuration could not be updated. commit failed', + ); + }); + + it('shows an unkown error', async () => { + mockMutate.mockRejectedValueOnce(); + + await submitCommit(); + + await waitForPromises(); + + expect(findAlert().text()).toMatchInterpolatedText( + 'The GitLab CI configuration could not be updated.', + ); + }); + }); + + describe('when the commit form is cancelled', () => { + const otherContent = 'other content'; + + beforeEach(async () => { + findTextEditor().vm.$emit('input', otherContent); + await nextTick(); + }); + + it('content is restored after cancel is called', async () => { + await cancelCommitForm(); + + expect(findTextEditor().attributes('value')).toBe(mockCiYml); + }); + }); + }); + }); + + describe('displays fetch content errors', () => { + it('no error is shown when data is set', async () => { + mockBlobContentData.mockResolvedValue(mockCiYml); + createComponentWithApollo(); + + await waitForPromises(); + + expect(findBlobFailureAlert().exists()).toBe(false); + expect(findTextEditor().attributes('value')).toBe(mockCiYml); }); - it('shows a ref missing error state', () => { - const error = new MockError('Ref missing!', { - error: 'ref is missing, ref is empty', + it('shows a 404 error message', async () => { + mockBlobContentData.mockRejectedValueOnce({ + response: { + status: 404, + }, }); - createComponent({ data: { error } }); + createComponentWithApollo(); + + await waitForPromises(); - expect(findAlert().text()).toMatch( - 'CI file could not be loaded: ref is missing, ref is empty', + expect(findBlobFailureAlert().text()).toBe( + 'No CI file found in this repository, please add one.', ); }); - it('shows a file missing error state', async () => { - const error = new MockError('File missing!', { - message: 'file not found', + it('shows a 400 error message', async () => { + mockBlobContentData.mockRejectedValueOnce({ + response: { + status: 400, + }, }); + createComponentWithApollo(); + + await waitForPromises(); + + expect(findBlobFailureAlert().text()).toBe( + 'Repository does not have a default branch, please set one.', + ); + }); - await wrapper.setData({ error }); + it('shows a unkown error message', async () => { + mockBlobContentData.mockRejectedValueOnce(new Error('My error!')); + createComponentWithApollo(); + await waitForPromises(); - expect(findAlert().text()).toMatch('CI file could not be loaded: file not found'); + expect(findBlobFailureAlert().text()).toBe( + 'The CI configuration was not loaded, please try again.', + ); }); }); }); diff --git a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js index 197f646a22e..b42339f626e 100644 --- a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js +++ b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js @@ -5,7 +5,14 @@ import waitForPromises from 'helpers/wait_for_promises'; import httpStatusCodes from '~/lib/utils/http_status'; import axios from '~/lib/utils/axios_utils'; import PipelineNewForm from '~/pipeline_new/components/pipeline_new_form.vue'; -import { mockRefs, mockParams, mockPostParams, mockProjectId, mockError } from '../mock_data'; +import { + mockBranches, + mockTags, + mockParams, + mockPostParams, + mockProjectId, + mockError, +} from '../mock_data'; import { redirectTo } from '~/lib/utils/url_utility'; jest.mock('~/lib/utils/url_utility', () => ({ @@ -37,6 +44,10 @@ describe('Pipeline New Form', () => { const findWarnings = () => wrapper.findAll('[data-testid="run-pipeline-warning"]'); const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const getExpectedPostParams = () => JSON.parse(mock.history.post[0].data); + const changeRef = i => + findDropdownItems() + .at(i) + .vm.$emit('click'); const createComponent = (term = '', props = {}, method = shallowMount) => { wrapper = method(PipelineNewForm, { @@ -44,7 +55,8 @@ describe('Pipeline New Form', () => { projectId: mockProjectId, pipelinesPath, configVariablesPath, - refs: mockRefs, + branches: mockBranches, + tags: mockTags, defaultBranch: 'master', settingsLink: '', maxWarnings: 25, @@ -76,8 +88,11 @@ describe('Pipeline New Form', () => { }); it('displays dropdown with all branches and tags', () => { + const refLength = mockBranches.length + mockTags.length; + createComponent(); - expect(findDropdownItems()).toHaveLength(mockRefs.length); + + expect(findDropdownItems()).toHaveLength(refLength); }); it('when user enters search term the list is filtered', () => { @@ -130,15 +145,6 @@ describe('Pipeline New Form', () => { expect(findVariableRows()).toHaveLength(2); }); - it('creates a pipeline on submit', async () => { - findForm().vm.$emit('submit', dummySubmitEvent); - - await waitForPromises(); - - expect(getExpectedPostParams()).toEqual(mockPostParams); - expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${postResponse.id}`); - }); - it('creates blank variable on input change event', async () => { const input = findKeyInputs().at(2); input.element.value = 'test_var_2'; @@ -150,45 +156,81 @@ describe('Pipeline New Form', () => { expect(findKeyInputs().at(3).element.value).toBe(''); expect(findValueInputs().at(3).element.value).toBe(''); }); + }); - describe('when the form has been modified', () => { - const selectRef = i => - findDropdownItems() - .at(i) - .vm.$emit('click'); + describe('Pipeline creation', () => { + beforeEach(async () => { + mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, postResponse); - beforeEach(async () => { - const input = findKeyInputs().at(0); - input.element.value = 'test_var_2'; - input.trigger('change'); + await waitForPromises(); + }); + it('creates pipeline with full ref and variables', async () => { + createComponent(); - findRemoveIcons() - .at(1) - .trigger('click'); + changeRef(0); - await wrapper.vm.$nextTick(); - }); + findForm().vm.$emit('submit', dummySubmitEvent); - it('form values are restored when the ref changes', async () => { - expect(findVariableRows()).toHaveLength(2); + await waitForPromises(); - selectRef(1); - await waitForPromises(); + expect(getExpectedPostParams().ref).toEqual(wrapper.vm.$data.refValue.fullName); + expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${postResponse.id}`); + }); + it('creates a pipeline with short ref and variables', async () => { + // query params are used + createComponent('', mockParams); - expect(findVariableRows()).toHaveLength(3); - expect(findKeyInputs().at(0).element.value).toBe('test_var'); - }); + await waitForPromises(); - it('form values are restored again when the ref is reverted', async () => { - selectRef(1); - await waitForPromises(); + findForm().vm.$emit('submit', dummySubmitEvent); - selectRef(2); - await waitForPromises(); + await waitForPromises(); - expect(findVariableRows()).toHaveLength(2); - expect(findKeyInputs().at(0).element.value).toBe('test_var_2'); - }); + expect(getExpectedPostParams()).toEqual(mockPostParams); + expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${postResponse.id}`); + }); + }); + + describe('When the ref has been changed', () => { + beforeEach(async () => { + createComponent('', {}, mount); + + await waitForPromises(); + }); + it('variables persist between ref changes', async () => { + changeRef(0); // change to master + + await waitForPromises(); + + const masterInput = findKeyInputs().at(0); + masterInput.element.value = 'build_var'; + masterInput.trigger('change'); + + await wrapper.vm.$nextTick(); + + changeRef(1); // change to branch-1 + + await waitForPromises(); + + const branchOneInput = findKeyInputs().at(0); + branchOneInput.element.value = 'deploy_var'; + branchOneInput.trigger('change'); + + await wrapper.vm.$nextTick(); + + changeRef(0); // change back to master + + await waitForPromises(); + + expect(findKeyInputs().at(0).element.value).toBe('build_var'); + expect(findVariableRows().length).toBe(2); + + changeRef(1); // change back to branch-1 + + await waitForPromises(); + + expect(findKeyInputs().at(0).element.value).toBe('deploy_var'); + expect(findVariableRows().length).toBe(2); }); }); @@ -321,6 +363,7 @@ describe('Pipeline New Form', () => { it('shows the correct warning title', () => { const { length } = mockError.warnings; + expect(findWarningAlertSummary().attributes('message')).toBe(`${length} warnings found:`); }); diff --git a/spec/frontend/pipeline_new/mock_data.js b/spec/frontend/pipeline_new/mock_data.js index cdbd6d4437e..feb24ec602d 100644 --- a/spec/frontend/pipeline_new/mock_data.js +++ b/spec/frontend/pipeline_new/mock_data.js @@ -1,4 +1,14 @@ -export const mockRefs = ['master', 'branch-1', 'tag-1']; +export const mockBranches = [ + { shortName: 'master', fullName: 'refs/heads/master' }, + { shortName: 'branch-1', fullName: 'refs/heads/branch-1' }, + { shortName: 'branch-2', fullName: 'refs/heads/branch-2' }, +]; + +export const mockTags = [ + { shortName: '1.0.0', fullName: 'refs/tags/1.0.0' }, + { shortName: '1.1.0', fullName: 'refs/tags/1.1.0' }, + { shortName: '1.2.0', fullName: 'refs/tags/1.2.0' }, +]; export const mockParams = { refParam: 'tag-1', @@ -31,3 +41,7 @@ export const mockError = { ], total_warnings: 7, }; + +export const mockBranchRefs = ['master', 'dev', 'release']; + +export const mockTagRefs = ['1.0.0', '1.1.0', '1.2.0']; diff --git a/spec/frontend/pipeline_new/utils/format_refs_spec.js b/spec/frontend/pipeline_new/utils/format_refs_spec.js new file mode 100644 index 00000000000..1fda6a8af83 --- /dev/null +++ b/spec/frontend/pipeline_new/utils/format_refs_spec.js @@ -0,0 +1,21 @@ +import formatRefs from '~/pipeline_new/utils/format_refs'; +import { BRANCH_REF_TYPE, TAG_REF_TYPE } from '~/pipeline_new/constants'; +import { mockBranchRefs, mockTagRefs } from '../mock_data'; + +describe('Format refs util', () => { + it('formats branch ref correctly', () => { + expect(formatRefs(mockBranchRefs, BRANCH_REF_TYPE)).toEqual([ + { fullName: 'refs/heads/master', shortName: 'master' }, + { fullName: 'refs/heads/dev', shortName: 'dev' }, + { fullName: 'refs/heads/release', shortName: 'release' }, + ]); + }); + + it('formats tag ref correctly', () => { + expect(formatRefs(mockTagRefs, TAG_REF_TYPE)).toEqual([ + { fullName: 'refs/tags/1.0.0', shortName: '1.0.0' }, + { fullName: 'refs/tags/1.1.0', shortName: '1.1.0' }, + { fullName: 'refs/tags/1.2.0', shortName: '1.2.0' }, + ]); + }); +}); diff --git a/spec/frontend/pipelines/empty_state_spec.js b/spec/frontend/pipelines/empty_state_spec.js index 79356664834..28a73c8863c 100644 --- a/spec/frontend/pipelines/empty_state_spec.js +++ b/spec/frontend/pipelines/empty_state_spec.js @@ -1,58 +1,52 @@ -import Vue from 'vue'; -import emptyStateComp from '~/pipelines/components/pipelines_list/empty_state.vue'; -import mountComponent from '../helpers/vue_mount_component_helper'; +import { shallowMount } from '@vue/test-utils'; +import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue'; describe('Pipelines Empty State', () => { - let component; - let EmptyStateComponent; + let wrapper; + + const findGetStartedButton = () => wrapper.find('[data-testid="get-started-pipelines"]'); + const findInfoText = () => wrapper.find('[data-testid="info-text"]').text(); + const createWrapper = () => { + wrapper = shallowMount(EmptyState, { + propsData: { + helpPagePath: 'foo', + emptyStateSvgPath: 'foo', + canSetCi: true, + }, + }); + }; - beforeEach(() => { - EmptyStateComponent = Vue.extend(emptyStateComp); + describe('renders', () => { + beforeEach(() => { + createWrapper(); + }); - component = mountComponent(EmptyStateComponent, { - helpPagePath: 'foo', - emptyStateSvgPath: 'foo', - canSetCi: true, + afterEach(() => { + wrapper.destroy(); + wrapper = null; }); - }); - afterEach(() => { - component.$destroy(); - }); + it('should render empty state SVG', () => { + expect(wrapper.find('img').attributes('src')).toBe('foo'); + }); - it('should render empty state SVG', () => { - expect(component.$el.querySelector('.svg-content svg')).toBeDefined(); - }); + it('should render empty state header', () => { + expect(wrapper.find('[data-testid="header-text"]').text()).toBe('Build with confidence'); + }); - it('should render empty state information', () => { - expect(component.$el.querySelector('h4').textContent).toContain('Build with confidence'); - - expect( - component.$el - .querySelector('p') - .innerHTML.trim() - .replace(/\n+\s+/m, ' ') - .replace(/\s\s+/g, ' '), - ).toContain('Continuous Integration can help catch bugs by running your tests automatically,'); - - expect( - component.$el - .querySelector('p') - .innerHTML.trim() - .replace(/\n+\s+/m, ' ') - .replace(/\s\s+/g, ' '), - ).toContain( - 'while Continuous Deployment can help you deliver code to your product environment', - ); - }); + it('should render a link with provided help path', () => { + expect(findGetStartedButton().attributes('href')).toBe('foo'); + }); - it('should render a link with provided help path', () => { - expect(component.$el.querySelector('.js-get-started-pipelines').getAttribute('href')).toEqual( - 'foo', - ); + it('should render empty state information', () => { + expect(findInfoText()).toContain( + 'Continuous Integration can help catch bugs by running your tests automatically', + 'while Continuous Deployment can help you deliver code to your product environment', + ); + }); - expect(component.$el.querySelector('.js-get-started-pipelines').textContent).toContain( - 'Get started with Pipelines', - ); + it('should render a button', () => { + expect(findGetStartedButton().text()).toBe('Get started with Pipelines'); + }); }); }); diff --git a/spec/frontend/pipelines/graph/graph_component_legacy_spec.js b/spec/frontend/pipelines/graph/graph_component_legacy_spec.js new file mode 100644 index 00000000000..3b1909b6564 --- /dev/null +++ b/spec/frontend/pipelines/graph/graph_component_legacy_spec.js @@ -0,0 +1,306 @@ +import Vue from 'vue'; +import { mount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { setHTMLFixture } from 'helpers/fixtures'; +import PipelineStore from '~/pipelines/stores/pipeline_store'; +import GraphComponentLegacy from '~/pipelines/components/graph/graph_component_legacy.vue'; +import StageColumnComponentLegacy from '~/pipelines/components/graph/stage_column_component_legacy.vue'; +import LinkedPipelinesColumnLegacy from '~/pipelines/components/graph/linked_pipelines_column_legacy.vue'; +import graphJSON from './mock_data_legacy'; +import linkedPipelineJSON from './linked_pipelines_mock_data'; +import PipelinesMediator from '~/pipelines/pipeline_details_mediator'; + +describe('graph component', () => { + let store; + let mediator; + let wrapper; + + const findExpandPipelineBtn = () => wrapper.find('[data-testid="expand-pipeline-button"]'); + const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expand-pipeline-button"]'); + const findStageColumns = () => wrapper.findAll(StageColumnComponentLegacy); + const findStageColumnAt = i => findStageColumns().at(i); + + beforeEach(() => { + mediator = new PipelinesMediator({ endpoint: '' }); + store = new PipelineStore(); + store.storePipeline(linkedPipelineJSON); + + setHTMLFixture('<div class="layout-page"></div>'); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('while is loading', () => { + it('should render a loading icon', () => { + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: true, + pipeline: {}, + mediator, + }, + }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + }); + + describe('with data', () => { + beforeEach(() => { + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline: graphJSON, + mediator, + }, + }); + }); + + it('renders the graph', () => { + expect(wrapper.find('.js-pipeline-graph').exists()).toBe(true); + expect(wrapper.find('.loading-icon').exists()).toBe(false); + expect(wrapper.find('.stage-column-list').exists()).toBe(true); + }); + + it('renders columns in the graph', () => { + expect(findStageColumns()).toHaveLength(graphJSON.details.stages.length); + }); + }); + + describe('when linked pipelines are present', () => { + beforeEach(() => { + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline: store.state.pipeline, + mediator, + }, + }); + }); + + describe('rendered output', () => { + it('should include the pipelines graph', () => { + expect(wrapper.find('.js-pipeline-graph').exists()).toBe(true); + }); + + it('should not include the loading icon', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + }); + + it('should include the stage column', () => { + expect(findStageColumnAt(0).exists()).toBe(true); + }); + + it('stage column should have no-margin, gl-mr-26, has-only-one-job classes if there is only one job', () => { + expect(findStageColumnAt(0).classes()).toEqual( + expect.arrayContaining(['no-margin', 'gl-mr-26', 'has-only-one-job']), + ); + }); + + it('should include the left-margin class on the second child', () => { + expect(findStageColumnAt(1).classes('left-margin')).toBe(true); + }); + + it('should include the left-connector class in the build of the second child', () => { + expect( + findStageColumnAt(1) + .find('.build:nth-child(1)') + .classes('left-connector'), + ).toBe(true); + }); + + it('should include the js-has-linked-pipelines flag', () => { + expect(wrapper.find('.js-has-linked-pipelines').exists()).toBe(true); + }); + }); + + describe('computeds and methods', () => { + describe('capitalizeStageName', () => { + it('it capitalizes the stage name', () => { + expect( + wrapper + .findAll('.stage-column .stage-name') + .at(1) + .text(), + ).toBe('Prebuild'); + }); + }); + + describe('stageConnectorClass', () => { + it('it returns left-margin when there is a triggerer', () => { + expect(findStageColumnAt(1).classes('left-margin')).toBe(true); + }); + }); + }); + + describe('linked pipelines components', () => { + beforeEach(() => { + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline: store.state.pipeline, + mediator, + }, + }); + }); + + it('should render an upstream pipelines column at first position', () => { + expect(wrapper.find(LinkedPipelinesColumnLegacy).exists()).toBe(true); + expect(wrapper.find('.stage-column .stage-name').text()).toBe('Upstream'); + }); + + it('should render a downstream pipelines column at last position', () => { + const stageColumnNames = wrapper.findAll('.stage-column .stage-name'); + + expect(wrapper.find(LinkedPipelinesColumnLegacy).exists()).toBe(true); + expect(stageColumnNames.at(stageColumnNames.length - 1).text()).toBe('Downstream'); + }); + + describe('triggered by', () => { + describe('on click', () => { + it('should emit `onClickUpstreamPipeline` when triggered by linked pipeline is clicked', () => { + const btnWrapper = findExpandPipelineBtn(); + + btnWrapper.trigger('click'); + + btnWrapper.vm.$nextTick(() => { + expect(wrapper.emitted().onClickUpstreamPipeline).toEqual([ + store.state.pipeline.triggered_by, + ]); + }); + }); + }); + + describe('with expanded pipeline', () => { + it('should render expanded pipeline', done => { + // expand the pipeline + store.state.pipeline.triggered_by[0].isExpanded = true; + + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline: store.state.pipeline, + mediator, + }, + }); + + Vue.nextTick() + .then(() => { + expect(wrapper.find('.js-upstream-pipeline-12').exists()).toBe(true); + }) + .then(done) + .catch(done.fail); + }); + }); + }); + + describe('triggered', () => { + describe('on click', () => { + it('should emit `onClickTriggered`', () => { + // We have to mock this method since we do both style change and + // emit and event, not mocking returns an error. + wrapper.setMethods({ + handleClickedDownstream: jest.fn(() => + wrapper.vm.$emit('onClickTriggered', ...store.state.pipeline.triggered), + ), + }); + + const btnWrappers = findAllExpandPipelineBtns(); + const downstreamBtnWrapper = btnWrappers.at(btnWrappers.length - 1); + + downstreamBtnWrapper.trigger('click'); + + downstreamBtnWrapper.vm.$nextTick(() => { + expect(wrapper.emitted().onClickTriggered).toEqual([store.state.pipeline.triggered]); + }); + }); + }); + + describe('with expanded pipeline', () => { + it('should render expanded pipeline', done => { + // expand the pipeline + store.state.pipeline.triggered[0].isExpanded = true; + + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline: store.state.pipeline, + mediator, + }, + }); + + Vue.nextTick() + .then(() => { + expect(wrapper.find('.js-downstream-pipeline-34993051')).not.toBeNull(); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('when column requests a refresh', () => { + beforeEach(() => { + findStageColumnAt(0).vm.$emit('refreshPipelineGraph'); + }); + + it('refreshPipelineGraph is emitted', () => { + expect(wrapper.emitted().refreshPipelineGraph).toHaveLength(1); + }); + }); + }); + }); + }); + + describe('when linked pipelines are not present', () => { + beforeEach(() => { + const pipeline = Object.assign(linkedPipelineJSON, { triggered: null, triggered_by: null }); + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline, + mediator, + }, + }); + }); + + describe('rendered output', () => { + it('should include the first column with a no margin', () => { + const firstColumn = wrapper.find('.stage-column'); + + expect(firstColumn.classes('no-margin')).toBe(true); + }); + + it('should not render a linked pipelines column', () => { + expect(wrapper.find('.linked-pipelines-column').exists()).toBe(false); + }); + }); + + describe('stageConnectorClass', () => { + it('it returns no-margin when no triggerer and there is one job', () => { + expect(findStageColumnAt(0).classes('no-margin')).toBe(true); + }); + + it('it returns left-margin when no triggerer and not the first stage', () => { + expect(findStageColumnAt(1).classes('left-margin')).toBe(true); + }); + }); + }); + + describe('capitalizeStageName', () => { + it('capitalizes and escapes stage name', () => { + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline: graphJSON, + mediator, + }, + }); + + expect(findStageColumnAt(1).props('title')).toEqual( + 'Deploy <img src=x onerror=alert(document.domain)>', + ); + }); + }); +}); diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js index 5a17be1af23..7572dd83798 100644 --- a/spec/frontend/pipelines/graph/graph_component_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_spec.js @@ -1,305 +1,83 @@ -import Vue from 'vue'; -import { mount } from '@vue/test-utils'; -import { setHTMLFixture } from 'helpers/fixtures'; -import PipelineStore from '~/pipelines/stores/pipeline_store'; -import graphComponent from '~/pipelines/components/graph/graph_component.vue'; +import { mount, shallowMount } from '@vue/test-utils'; +import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; -import linkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; -import graphJSON from './mock_data'; -import linkedPipelineJSON from './linked_pipelines_mock_data'; -import PipelinesMediator from '~/pipelines/pipeline_details_mediator'; +import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; +import { GRAPHQL } from '~/pipelines/components/graph/constants'; +import { + generateResponse, + mockPipelineResponse, + pipelineWithUpstreamDownstream, +} from './mock_data'; describe('graph component', () => { - let store; - let mediator; let wrapper; - const findExpandPipelineBtn = () => wrapper.find('[data-testid="expandPipelineButton"]'); - const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expandPipelineButton"]'); + const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn); const findStageColumns = () => wrapper.findAll(StageColumnComponent); - const findStageColumnAt = i => findStageColumns().at(i); - beforeEach(() => { - mediator = new PipelinesMediator({ endpoint: '' }); - store = new PipelineStore(); - store.storePipeline(linkedPipelineJSON); - - setHTMLFixture('<div class="layout-page"></div>'); - }); + const defaultProps = { + pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'), + }; + + const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => { + wrapper = mountFn(PipelineGraph, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + dataMethod: GRAPHQL, + }, + }); + }; afterEach(() => { wrapper.destroy(); wrapper = null; }); - describe('while is loading', () => { - it('should render a loading icon', () => { - wrapper = mount(graphComponent, { - propsData: { - isLoading: true, - pipeline: {}, - mediator, - }, - }); - - expect(wrapper.find('.gl-spinner').exists()).toBe(true); - }); - }); - describe('with data', () => { beforeEach(() => { - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline: graphJSON, - mediator, - }, - }); + createComponent({ mountFn: mount }); }); - it('renders the graph', () => { - expect(wrapper.find('.js-pipeline-graph').exists()).toBe(true); - expect(wrapper.find('.loading-icon').exists()).toBe(false); - expect(wrapper.find('.stage-column-list').exists()).toBe(true); - }); - - it('renders columns in the graph', () => { - expect(findStageColumns()).toHaveLength(graphJSON.details.stages.length); - }); - }); - - describe('when linked pipelines are present', () => { - beforeEach(() => { - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline: store.state.pipeline, - mediator, - }, - }); - }); - - describe('rendered output', () => { - it('should include the pipelines graph', () => { - expect(wrapper.find('.js-pipeline-graph').exists()).toBe(true); - }); - - it('should not include the loading icon', () => { - expect(wrapper.find('.fa-spinner').exists()).toBe(false); - }); - - it('should include the stage column', () => { - expect(findStageColumnAt(0).exists()).toBe(true); - }); - - it('stage column should have no-margin, gl-mr-26, has-only-one-job classes if there is only one job', () => { - expect(findStageColumnAt(0).classes()).toEqual( - expect.arrayContaining(['no-margin', 'gl-mr-26', 'has-only-one-job']), - ); - }); - - it('should include the left-margin class on the second child', () => { - expect(findStageColumnAt(1).classes('left-margin')).toBe(true); - }); - - it('should include the left-connector class in the build of the second child', () => { - expect( - findStageColumnAt(1) - .find('.build:nth-child(1)') - .classes('left-connector'), - ).toBe(true); - }); - - it('should include the js-has-linked-pipelines flag', () => { - expect(wrapper.find('.js-has-linked-pipelines').exists()).toBe(true); - }); - }); - - describe('computeds and methods', () => { - describe('capitalizeStageName', () => { - it('it capitalizes the stage name', () => { - expect( - wrapper - .findAll('.stage-column .stage-name') - .at(1) - .text(), - ).toBe('Prebuild'); - }); - }); - - describe('stageConnectorClass', () => { - it('it returns left-margin when there is a triggerer', () => { - expect(findStageColumnAt(1).classes('left-margin')).toBe(true); - }); - }); + it('renders the main columns in the graph', () => { + expect(findStageColumns()).toHaveLength(defaultProps.pipeline.stages.length); }); - describe('linked pipelines components', () => { + describe('when column requests a refresh', () => { beforeEach(() => { - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline: store.state.pipeline, - mediator, - }, - }); + findStageColumns() + .at(0) + .vm.$emit('refreshPipelineGraph'); }); - it('should render an upstream pipelines column at first position', () => { - expect(wrapper.find(linkedPipelinesColumn).exists()).toBe(true); - expect(wrapper.find('.stage-column .stage-name').text()).toBe('Upstream'); - }); - - it('should render a downstream pipelines column at last position', () => { - const stageColumnNames = wrapper.findAll('.stage-column .stage-name'); - - expect(wrapper.find(linkedPipelinesColumn).exists()).toBe(true); - expect(stageColumnNames.at(stageColumnNames.length - 1).text()).toBe('Downstream'); - }); - - describe('triggered by', () => { - describe('on click', () => { - it('should emit `onClickUpstreamPipeline` when triggered by linked pipeline is clicked', () => { - const btnWrapper = findExpandPipelineBtn(); - - btnWrapper.trigger('click'); - - btnWrapper.vm.$nextTick(() => { - expect(wrapper.emitted().onClickUpstreamPipeline).toEqual([ - store.state.pipeline.triggered_by, - ]); - }); - }); - }); - - describe('with expanded pipeline', () => { - it('should render expanded pipeline', done => { - // expand the pipeline - store.state.pipeline.triggered_by[0].isExpanded = true; - - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline: store.state.pipeline, - mediator, - }, - }); - - Vue.nextTick() - .then(() => { - expect(wrapper.find('.js-upstream-pipeline-12').exists()).toBe(true); - }) - .then(done) - .catch(done.fail); - }); - }); - }); - - describe('triggered', () => { - describe('on click', () => { - it('should emit `onClickTriggered`', () => { - // We have to mock this method since we do both style change and - // emit and event, not mocking returns an error. - wrapper.setMethods({ - handleClickedDownstream: jest.fn(() => - wrapper.vm.$emit('onClickTriggered', ...store.state.pipeline.triggered), - ), - }); - - const btnWrappers = findAllExpandPipelineBtns(); - const downstreamBtnWrapper = btnWrappers.at(btnWrappers.length - 1); - - downstreamBtnWrapper.trigger('click'); - - downstreamBtnWrapper.vm.$nextTick(() => { - expect(wrapper.emitted().onClickTriggered).toEqual([store.state.pipeline.triggered]); - }); - }); - }); - - describe('with expanded pipeline', () => { - it('should render expanded pipeline', done => { - // expand the pipeline - store.state.pipeline.triggered[0].isExpanded = true; - - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline: store.state.pipeline, - mediator, - }, - }); - - Vue.nextTick() - .then(() => { - expect(wrapper.find('.js-downstream-pipeline-34993051')).not.toBeNull(); - }) - .then(done) - .catch(done.fail); - }); - }); - - describe('when column requests a refresh', () => { - beforeEach(() => { - findStageColumnAt(0).vm.$emit('refreshPipelineGraph'); - }); - - it('refreshPipelineGraph is emitted', () => { - expect(wrapper.emitted().refreshPipelineGraph).toHaveLength(1); - }); - }); + it('refreshPipelineGraph is emitted', () => { + expect(wrapper.emitted().refreshPipelineGraph).toHaveLength(1); }); }); }); describe('when linked pipelines are not present', () => { beforeEach(() => { - const pipeline = Object.assign(linkedPipelineJSON, { triggered: null, triggered_by: null }); - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline, - mediator, - }, - }); + createComponent({ mountFn: mount }); }); - describe('rendered output', () => { - it('should include the first column with a no margin', () => { - const firstColumn = wrapper.find('.stage-column'); - - expect(firstColumn.classes('no-margin')).toBe(true); - }); - - it('should not render a linked pipelines column', () => { - expect(wrapper.find('.linked-pipelines-column').exists()).toBe(false); - }); - }); - - describe('stageConnectorClass', () => { - it('it returns no-margin when no triggerer and there is one job', () => { - expect(findStageColumnAt(0).classes('no-margin')).toBe(true); - }); - - it('it returns left-margin when no triggerer and not the first stage', () => { - expect(findStageColumnAt(1).classes('left-margin')).toBe(true); - }); + it('should not render a linked pipelines column', () => { + expect(findLinkedColumns()).toHaveLength(0); }); }); - describe('capitalizeStageName', () => { - it('capitalizes and escapes stage name', () => { - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline: graphJSON, - mediator, - }, + describe('when linked pipelines are present', () => { + beforeEach(() => { + createComponent({ + mountFn: mount, + props: { pipeline: pipelineWithUpstreamDownstream(mockPipelineResponse) }, }); + }); - expect(findStageColumnAt(1).props('title')).toEqual( - 'Deploy <img src=x onerror=alert(document.domain)>', - ); + it('should render linked pipelines columns', () => { + expect(findLinkedColumns()).toHaveLength(2); }); }); }); diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js new file mode 100644 index 00000000000..875aaa48037 --- /dev/null +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -0,0 +1,124 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMount } from '@vue/test-utils'; +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import PipelineGraphWrapper from '~/pipelines/components/graph/graph_component_wrapper.vue'; +import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; +import getPipelineDetails from '~/pipelines/graphql/queries/get_pipeline_details.query.graphql'; +import { mockPipelineResponse } from './mock_data'; + +const defaultProvide = { + pipelineProjectPath: 'frog/amphibirama', + pipelineIid: '22', +}; + +describe('Pipeline graph wrapper', () => { + Vue.use(VueApollo); + + let wrapper; + const getAlert = () => wrapper.find(GlAlert); + const getLoadingIcon = () => wrapper.find(GlLoadingIcon); + const getGraph = () => wrapper.find(PipelineGraph); + + const createComponent = ({ + apolloProvider, + data = {}, + provide = defaultProvide, + mountFn = shallowMount, + } = {}) => { + wrapper = mountFn(PipelineGraphWrapper, { + provide, + apolloProvider, + data() { + return { + ...data, + }; + }, + }); + }; + + const createComponentWithApollo = ( + getPipelineDetailsHandler = jest.fn().mockResolvedValue(mockPipelineResponse), + ) => { + const requestHandlers = [[getPipelineDetails, getPipelineDetailsHandler]]; + + const apolloProvider = createMockApollo(requestHandlers); + createComponent({ apolloProvider }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when data is loading', () => { + it('displays the loading icon', () => { + createComponentWithApollo(); + expect(getLoadingIcon().exists()).toBe(true); + }); + + it('does not display the alert', () => { + createComponentWithApollo(); + expect(getAlert().exists()).toBe(false); + }); + + it('does not display the graph', () => { + createComponentWithApollo(); + expect(getGraph().exists()).toBe(false); + }); + }); + + describe('when data has loaded', () => { + beforeEach(async () => { + createComponentWithApollo(); + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + }); + + it('does not display the loading icon', () => { + expect(getLoadingIcon().exists()).toBe(false); + }); + + it('does not display the alert', () => { + expect(getAlert().exists()).toBe(false); + }); + + it('displays the graph', () => { + expect(getGraph().exists()).toBe(true); + }); + }); + + describe('when there is an error', () => { + beforeEach(async () => { + createComponentWithApollo(jest.fn().mockRejectedValue(new Error('GraphQL error'))); + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + }); + + it('does not display the loading icon', () => { + expect(getLoadingIcon().exists()).toBe(false); + }); + + it('displays the alert', () => { + expect(getAlert().exists()).toBe(true); + }); + + it('does not display the graph', () => { + expect(getGraph().exists()).toBe(false); + }); + }); + + describe('when refresh action is emitted', () => { + beforeEach(async () => { + createComponentWithApollo(); + jest.spyOn(wrapper.vm.$apollo.queries.pipeline, 'refetch'); + await wrapper.vm.$nextTick(); + getGraph().vm.$emit('refreshPipelineGraph'); + }); + + it('calls refetch', () => { + expect(wrapper.vm.$apollo.queries.pipeline.refetch).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js index 67986ca7739..fb005d628a9 100644 --- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js @@ -17,7 +17,7 @@ describe('Linked pipeline', () => { const findLinkedPipeline = () => wrapper.find({ ref: 'linkedPipeline' }); const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findPipelineLink = () => wrapper.find('[data-testid="pipelineLink"]'); - const findExpandButton = () => wrapper.find('[data-testid="expandPipelineButton"]'); + const findExpandButton = () => wrapper.find('[data-testid="expand-pipeline-button"]'); const createWrapper = (propsData, data = []) => { wrapper = mount(LinkedPipelineComponent, { @@ -40,20 +40,13 @@ describe('Linked pipeline', () => { projectId: invalidTriggeredPipelineId, columnTitle: 'Downstream', type: DOWNSTREAM, + expanded: false, }; beforeEach(() => { createWrapper(props); }); - it('should render a list item as the containing element', () => { - expect(wrapper.element.tagName).toBe('LI'); - }); - - it('should render a button', () => { - expect(findButton().exists()).toBe(true); - }); - it('should render the project name', () => { expect(wrapper.text()).toContain(props.pipeline.project.name); }); @@ -105,12 +98,14 @@ describe('Linked pipeline', () => { projectId: validTriggeredPipelineId, columnTitle: 'Downstream', type: DOWNSTREAM, + expanded: false, }; const upstreamProps = { ...downstreamProps, columnTitle: 'Upstream', type: UPSTREAM, + expanded: false, }; it('parent/child label container should exist', () => { @@ -173,7 +168,7 @@ describe('Linked pipeline', () => { `( '$pipelineType.columnTitle pipeline button icon should be $anglePosition if expanded state is $expanded', ({ pipelineType, anglePosition, expanded }) => { - createWrapper(pipelineType, { expanded }); + createWrapper({ ...pipelineType, expanded }); expect(findExpandButton().props('icon')).toBe(anglePosition); }, ); @@ -185,6 +180,7 @@ describe('Linked pipeline', () => { projectId: invalidTriggeredPipelineId, columnTitle: 'Downstream', type: DOWNSTREAM, + expanded: false, }; beforeEach(() => { @@ -202,6 +198,7 @@ describe('Linked pipeline', () => { projectId: validTriggeredPipelineId, columnTitle: 'Downstream', type: DOWNSTREAM, + expanded: false, }; beforeEach(() => { @@ -219,10 +216,7 @@ describe('Linked pipeline', () => { jest.spyOn(wrapper.vm.$root, '$emit'); findButton().trigger('click'); - expect(wrapper.vm.$root.$emit.mock.calls[0]).toEqual([ - 'bv::hide::tooltip', - 'js-linked-pipeline-34993051', - ]); + expect(wrapper.vm.$root.$emit.mock.calls[0]).toEqual(['bv::hide::tooltip']); }); it('should emit downstreamHovered with job name on mouseover', () => { diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_legacy_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_legacy_spec.js new file mode 100644 index 00000000000..b6c700c65d2 --- /dev/null +++ b/spec/frontend/pipelines/graph/linked_pipelines_column_legacy_spec.js @@ -0,0 +1,40 @@ +import { shallowMount } from '@vue/test-utils'; +import LinkedPipelinesColumnLegacy from '~/pipelines/components/graph/linked_pipelines_column_legacy.vue'; +import LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue'; +import { UPSTREAM } from '~/pipelines/components/graph/constants'; +import mockData from './linked_pipelines_mock_data'; + +describe('Linked Pipelines Column', () => { + const propsData = { + columnTitle: 'Upstream', + linkedPipelines: mockData.triggered, + graphPosition: 'right', + projectId: 19, + type: UPSTREAM, + }; + let wrapper; + + beforeEach(() => { + wrapper = shallowMount(LinkedPipelinesColumnLegacy, { propsData }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the pipeline orientation', () => { + const titleElement = wrapper.find('.linked-pipelines-column-title'); + + expect(titleElement.text()).toBe(propsData.columnTitle); + }); + + it('renders the correct number of linked pipelines', () => { + const linkedPipelineElements = wrapper.findAll(LinkedPipeline); + + expect(linkedPipelineElements.length).toBe(propsData.linkedPipelines.length); + }); + + it('renders cross project triangle when column is upstream', () => { + expect(wrapper.find('.cross-project-triangle').exists()).toBe(true); + }); +}); diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js index e6ae3154d1d..37eb5f900dd 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js @@ -1,40 +1,120 @@ -import { shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import { mount, shallowMount, createLocalVue } from '@vue/test-utils'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; import LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue'; -import { UPSTREAM } from '~/pipelines/components/graph/constants'; -import mockData from './linked_pipelines_mock_data'; +import getPipelineDetails from '~/pipelines/graphql/queries/get_pipeline_details.query.graphql'; +import { DOWNSTREAM, GRAPHQL } from '~/pipelines/components/graph/constants'; +import { LOAD_FAILURE } from '~/pipelines/constants'; +import { + mockPipelineResponse, + pipelineWithUpstreamDownstream, + wrappedPipelineReturn, +} from './mock_data'; + +const processedPipeline = pipelineWithUpstreamDownstream(mockPipelineResponse); describe('Linked Pipelines Column', () => { - const propsData = { + const defaultProps = { columnTitle: 'Upstream', - linkedPipelines: mockData.triggered, - graphPosition: 'right', - projectId: 19, - type: UPSTREAM, + linkedPipelines: processedPipeline.downstream, + type: DOWNSTREAM, }; + let wrapper; + const findLinkedColumnTitle = () => wrapper.find('[data-testid="linked-column-title"]'); + const findLinkedPipelineElements = () => wrapper.findAll(LinkedPipeline); + const findPipelineGraph = () => wrapper.find(PipelineGraph); + const findExpandButton = () => wrapper.find('[data-testid="expand-pipeline-button"]'); - beforeEach(() => { - wrapper = shallowMount(LinkedPipelinesColumn, { propsData }); - }); + const localVue = createLocalVue(); + localVue.use(VueApollo); + + const createComponent = ({ apolloProvider, mountFn = shallowMount, props = {} } = {}) => { + wrapper = mountFn(LinkedPipelinesColumn, { + apolloProvider, + localVue, + propsData: { + ...defaultProps, + ...props, + }, + provide: { + dataMethod: GRAPHQL, + }, + }); + }; + + const createComponentWithApollo = ( + mountFn = shallowMount, + getPipelineDetailsHandler = jest.fn().mockResolvedValue(wrappedPipelineReturn), + ) => { + const requestHandlers = [[getPipelineDetails, getPipelineDetailsHandler]]; + + const apolloProvider = createMockApollo(requestHandlers); + createComponent({ apolloProvider, mountFn }); + }; afterEach(() => { wrapper.destroy(); + wrapper = null; }); - it('renders the pipeline orientation', () => { - const titleElement = wrapper.find('.linked-pipelines-column-title'); + describe('it renders correctly', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the pipeline title', () => { + expect(findLinkedColumnTitle().text()).toBe(defaultProps.columnTitle); + }); - expect(titleElement.text()).toBe(propsData.columnTitle); + it('renders the correct number of linked pipelines', () => { + expect(findLinkedPipelineElements()).toHaveLength(defaultProps.linkedPipelines.length); + }); }); - it('renders the correct number of linked pipelines', () => { - const linkedPipelineElements = wrapper.findAll(LinkedPipeline); + describe('click action', () => { + const clickExpandButton = async () => { + await findExpandButton().trigger('click'); + await wrapper.vm.$nextTick(); + }; - expect(linkedPipelineElements.length).toBe(propsData.linkedPipelines.length); - }); + const clickExpandButtonAndAwaitTimers = async () => { + await clickExpandButton(); + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + }; + + describe('when successful', () => { + beforeEach(() => { + createComponentWithApollo(mount); + }); + + it('toggles the pipeline visibility', async () => { + expect(findPipelineGraph().exists()).toBe(false); + await clickExpandButtonAndAwaitTimers(); + expect(findPipelineGraph().exists()).toBe(true); + await clickExpandButton(); + expect(findPipelineGraph().exists()).toBe(false); + }); + }); + + describe('on error', () => { + beforeEach(() => { + createComponentWithApollo(mount, jest.fn().mockRejectedValue(new Error('GraphQL error'))); + }); + + it('emits the error', async () => { + await clickExpandButton(); + expect(wrapper.emitted().error).toEqual([[LOAD_FAILURE]]); + }); - it('renders cross project triangle when column is upstream', () => { - expect(wrapper.find('.cross-project-triangle').exists()).toBe(true); + it('does not show the pipeline', async () => { + expect(findPipelineGraph().exists()).toBe(false); + await clickExpandButtonAndAwaitTimers(); + expect(findPipelineGraph().exists()).toBe(false); + }); + }); }); }); diff --git a/spec/frontend/pipelines/graph/mock_data.js b/spec/frontend/pipelines/graph/mock_data.js index a4a5d78f906..d53a11eea0e 100644 --- a/spec/frontend/pipelines/graph/mock_data.js +++ b/spec/frontend/pipelines/graph/mock_data.js @@ -1,261 +1,665 @@ -export default { - id: 123, - user: { - name: 'Root', - username: 'root', - id: 1, - state: 'active', - avatar_url: null, - web_url: 'http://localhost:3000/root', - }, - active: false, - coverage: null, - path: '/root/ci-mock/pipelines/123', - details: { - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/pipelines/123', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - }, - duration: 9, - finished_at: '2017-04-19T14:30:27.542Z', - stages: [ - { - name: 'test', - title: 'test: passed', - groups: [ - { - name: 'test', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4153', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4153/retry', - method: 'post', +import { unwrapPipelineData } from '~/pipelines/components/graph/utils'; + +export const mockPipelineResponse = { + data: { + project: { + __typename: 'Project', + pipeline: { + __typename: 'Pipeline', + id: 163, + iid: '22', + downstream: null, + upstream: null, + stages: { + __typename: 'CiStageConnection', + nodes: [ + { + __typename: 'CiStage', + name: 'build', + status: { + __typename: 'DetailedStatus', + action: null, }, - }, - jobs: [ - { - id: 4153, - name: 'test', - build_path: '/root/ci-mock/builds/4153', - retry_path: '/root/ci-mock/builds/4153/retry', - playable: false, - created_at: '2017-04-13T09:25:18.959Z', - updated_at: '2017-04-13T09:25:23.118Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4153', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4153/retry', - method: 'post', + groups: { + __typename: 'CiGroupConnection', + nodes: [ + { + __typename: 'CiGroup', + name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + size: 1, + status: { + __typename: 'DetailedStatus', + label: 'passed', + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1482', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1482/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [], + }, + }, + ], + }, }, - }, - }, - ], - }, - ], - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/pipelines/123#test', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - }, - path: '/root/ci-mock/pipelines/123#test', - dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=test', - }, - { - name: 'deploy <img src=x onerror=alert(document.domain)>', - title: 'deploy: passed', - groups: [ - { - name: 'deploy to production', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4166', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4166/retry', - method: 'post', - }, - }, - jobs: [ - { - id: 4166, - name: 'deploy to production', - build_path: '/root/ci-mock/builds/4166', - retry_path: '/root/ci-mock/builds/4166/retry', - playable: false, - created_at: '2017-04-19T14:29:46.463Z', - updated_at: '2017-04-19T14:30:27.498Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4166', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4166/retry', - method: 'post', + { + __typename: 'CiGroup', + name: 'build_b', + size: 1, + status: { + __typename: 'DetailedStatus', + label: 'passed', + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_b', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1515', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1515/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [], + }, + }, + ], + }, }, - }, - }, - ], - }, - { - name: 'deploy to staging', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4159', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4159/retry', - method: 'post', + { + __typename: 'CiGroup', + name: 'build_c', + size: 1, + status: { + __typename: 'DetailedStatus', + label: 'passed', + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_c', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1484', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1484/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [], + }, + }, + ], + }, + }, + { + __typename: 'CiGroup', + name: 'build_d', + size: 3, + status: { + __typename: 'DetailedStatus', + label: 'passed', + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_d 1/3', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1485', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1485/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [], + }, + }, + { + __typename: 'CiJob', + name: 'build_d 2/3', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1486', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1486/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [], + }, + }, + { + __typename: 'CiJob', + name: 'build_d 3/3', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1487', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1487/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [], + }, + }, + ], + }, + }, + ], }, }, - jobs: [ - { - id: 4159, - name: 'deploy to staging', - build_path: '/root/ci-mock/builds/4159', - retry_path: '/root/ci-mock/builds/4159/retry', - playable: false, - created_at: '2017-04-18T16:32:08.420Z', - updated_at: '2017-04-18T16:32:12.631Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4159', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4159/retry', - method: 'post', + { + __typename: 'CiStage', + name: 'test', + status: { + __typename: 'DetailedStatus', + action: null, + }, + groups: { + __typename: 'CiGroupConnection', + nodes: [ + { + __typename: 'CiGroup', + name: 'test_a', + size: 1, + status: { + __typename: 'DetailedStatus', + label: 'passed', + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'test_a', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1514', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1514/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_c', + }, + { + __typename: 'CiJob', + name: 'build_b', + }, + { + __typename: 'CiJob', + name: + 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + }, + ], + }, + }, + ], + }, + }, + { + __typename: 'CiGroup', + name: 'test_b', + size: 2, + status: { + __typename: 'DetailedStatus', + label: 'passed', + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'test_b 1/2', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1489', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1489/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_d 3/3', + }, + { + __typename: 'CiJob', + name: 'build_d 2/3', + }, + { + __typename: 'CiJob', + name: 'build_d 1/3', + }, + { + __typename: 'CiJob', + name: 'build_b', + }, + { + __typename: 'CiJob', + name: + 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + }, + ], + }, + }, + { + __typename: 'CiJob', + name: 'test_b 2/2', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1490', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1490/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_d 3/3', + }, + { + __typename: 'CiJob', + name: 'build_d 2/3', + }, + { + __typename: 'CiJob', + name: 'build_d 1/3', + }, + { + __typename: 'CiJob', + name: 'build_b', + }, + { + __typename: 'CiJob', + name: + 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + }, + ], + }, + }, + ], + }, + }, + { + __typename: 'CiGroup', + name: 'test_c', + size: 1, + status: { + __typename: 'DetailedStatus', + label: null, + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'test_c', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: null, + hasDetails: true, + detailsPath: '/root/kinder-pipe/-/pipelines/154', + group: 'success', + action: null, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_c', + }, + { + __typename: 'CiJob', + name: 'build_b', + }, + { + __typename: 'CiJob', + name: + 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + }, + ], + }, + }, + ], + }, + }, + { + __typename: 'CiGroup', + name: 'test_d', + size: 1, + status: { + __typename: 'DetailedStatus', + label: null, + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'test_d', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: null, + hasDetails: true, + detailsPath: '/root/abcd-dag/-/pipelines/153', + group: 'success', + action: null, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_b', + }, + ], + }, + }, + ], + }, }, - }, + ], }, - ], - }, - ], - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/pipelines/123#deploy', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + }, + ], }, - path: '/root/ci-mock/pipelines/123#deploy', - dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=deploy', - }, - ], - artifacts: [], - manual_actions: [ - { - name: 'deploy to production', - path: '/root/ci-mock/builds/4166/play', - playable: false, }, - ], + }, }, - flags: { - latest: true, - triggered: false, - stuck: false, - yaml_errors: false, - retryable: false, - cancelable: false, +}; + +export const downstream = { + nodes: [ + { + id: 175, + iid: '31', + path: '/root/elemenohpee/-/pipelines/175', + status: { + group: 'success', + label: 'passed', + icon: 'status_success', + __typename: 'DetailedStatus', + }, + sourceJob: { + name: 'test_c', + __typename: 'CiJob', + }, + project: { + id: 'gid://gitlab/Project/25', + name: 'elemenohpee', + fullPath: 'root/elemenohpee', + __typename: 'Project', + }, + __typename: 'Pipeline', + multiproject: true, + }, + { + id: 181, + iid: '27', + path: '/root/abcd-dag/-/pipelines/181', + status: { + group: 'success', + label: 'passed', + icon: 'status_success', + __typename: 'DetailedStatus', + }, + sourceJob: { + name: 'test_d', + __typename: 'CiJob', + }, + project: { + id: 'gid://gitlab/Project/23', + name: 'abcd-dag', + fullPath: 'root/abcd-dag', + __typename: 'Project', + }, + __typename: 'Pipeline', + multiproject: false, + }, + ], +}; + +export const upstream = { + id: 161, + iid: '24', + path: '/root/abcd-dag/-/pipelines/161', + status: { + group: 'success', + label: 'passed', + icon: 'status_success', + __typename: 'DetailedStatus', }, - ref: { - name: 'master', - path: '/root/ci-mock/tree/master', - tag: false, - branch: true, + sourceJob: null, + project: { + id: 'gid://gitlab/Project/23', + name: 'abcd-dag', + fullPath: 'root/abcd-dag', + __typename: 'Project', }, - commit: { - id: '798e5f902592192afaba73f4668ae30e56eae492', - short_id: '798e5f90', - title: "Merge branch 'new-branch' into 'master'\r", - created_at: '2017-04-13T10:25:17.000+01:00', - parent_ids: [ - '54d483b1ed156fbbf618886ddf7ab023e24f8738', - 'c8e2d38a6c538822e81c57022a6e3a0cfedebbcc', - ], - message: - "Merge branch 'new-branch' into 'master'\r\n\r\nAdd new file\r\n\r\nSee merge request !1", - author_name: 'Root', - author_email: 'admin@example.com', - authored_date: '2017-04-13T10:25:17.000+01:00', - committer_name: 'Root', - committer_email: 'admin@example.com', - committed_date: '2017-04-13T10:25:17.000+01:00', - author: { - name: 'Root', - username: 'root', - id: 1, - state: 'active', - avatar_url: null, - web_url: 'http://localhost:3000/root', + __typename: 'Pipeline', + multiproject: true, +}; + +export const wrappedPipelineReturn = { + data: { + project: { + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/175', + iid: '38', + downstream: { + nodes: [], + }, + upstream: { + id: 'gid://gitlab/Ci::Pipeline/174', + iid: '37', + path: '/root/elemenohpee/-/pipelines/174', + status: { + group: 'success', + label: 'passed', + icon: 'status_success', + }, + sourceJob: { + name: 'test_c', + }, + project: { + id: 'gid://gitlab/Project/25', + name: 'elemenohpee', + fullPath: 'root/elemenohpee', + }, + }, + stages: { + nodes: [ + { + name: 'build', + status: { + action: null, + }, + groups: { + nodes: [ + { + status: { + label: 'passed', + group: 'success', + icon: 'status_success', + }, + name: 'build_n', + size: 1, + jobs: { + nodes: [ + { + name: 'build_n', + scheduledAt: null, + needs: { + nodes: [], + }, + status: { + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/elemenohpee/-/jobs/1662', + group: 'success', + action: { + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/elemenohpee/-/jobs/1662/retry', + title: 'Retry', + }, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, }, - author_gravatar_url: null, - commit_url: - 'http://localhost:3000/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492', - commit_path: '/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492', }, - created_at: '2017-04-13T09:25:18.881Z', - updated_at: '2017-04-19T14:30:27.561Z', +}; + +export const generateResponse = (raw, mockPath) => unwrapPipelineData(mockPath, raw.data); + +export const pipelineWithUpstreamDownstream = base => { + const pip = { ...base }; + pip.data.project.pipeline.downstream = downstream; + pip.data.project.pipeline.upstream = upstream; + + return generateResponse(pip, 'root/abcd-dag'); }; diff --git a/spec/frontend/pipelines/graph/mock_data_legacy.js b/spec/frontend/pipelines/graph/mock_data_legacy.js new file mode 100644 index 00000000000..a4a5d78f906 --- /dev/null +++ b/spec/frontend/pipelines/graph/mock_data_legacy.js @@ -0,0 +1,261 @@ +export default { + id: 123, + user: { + name: 'Root', + username: 'root', + id: 1, + state: 'active', + avatar_url: null, + web_url: 'http://localhost:3000/root', + }, + active: false, + coverage: null, + path: '/root/ci-mock/pipelines/123', + details: { + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/pipelines/123', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + }, + duration: 9, + finished_at: '2017-04-19T14:30:27.542Z', + stages: [ + { + name: 'test', + title: 'test: passed', + groups: [ + { + name: 'test', + size: 1, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4153', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4153/retry', + method: 'post', + }, + }, + jobs: [ + { + id: 4153, + name: 'test', + build_path: '/root/ci-mock/builds/4153', + retry_path: '/root/ci-mock/builds/4153/retry', + playable: false, + created_at: '2017-04-13T09:25:18.959Z', + updated_at: '2017-04-13T09:25:23.118Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4153', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4153/retry', + method: 'post', + }, + }, + }, + ], + }, + ], + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/pipelines/123#test', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + }, + path: '/root/ci-mock/pipelines/123#test', + dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=test', + }, + { + name: 'deploy <img src=x onerror=alert(document.domain)>', + title: 'deploy: passed', + groups: [ + { + name: 'deploy to production', + size: 1, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4166', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4166/retry', + method: 'post', + }, + }, + jobs: [ + { + id: 4166, + name: 'deploy to production', + build_path: '/root/ci-mock/builds/4166', + retry_path: '/root/ci-mock/builds/4166/retry', + playable: false, + created_at: '2017-04-19T14:29:46.463Z', + updated_at: '2017-04-19T14:30:27.498Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4166', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4166/retry', + method: 'post', + }, + }, + }, + ], + }, + { + name: 'deploy to staging', + size: 1, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4159', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4159/retry', + method: 'post', + }, + }, + jobs: [ + { + id: 4159, + name: 'deploy to staging', + build_path: '/root/ci-mock/builds/4159', + retry_path: '/root/ci-mock/builds/4159/retry', + playable: false, + created_at: '2017-04-18T16:32:08.420Z', + updated_at: '2017-04-18T16:32:12.631Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4159', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4159/retry', + method: 'post', + }, + }, + }, + ], + }, + ], + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/pipelines/123#deploy', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + }, + path: '/root/ci-mock/pipelines/123#deploy', + dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=deploy', + }, + ], + artifacts: [], + manual_actions: [ + { + name: 'deploy to production', + path: '/root/ci-mock/builds/4166/play', + playable: false, + }, + ], + }, + flags: { + latest: true, + triggered: false, + stuck: false, + yaml_errors: false, + retryable: false, + cancelable: false, + }, + ref: { + name: 'master', + path: '/root/ci-mock/tree/master', + tag: false, + branch: true, + }, + commit: { + id: '798e5f902592192afaba73f4668ae30e56eae492', + short_id: '798e5f90', + title: "Merge branch 'new-branch' into 'master'\r", + created_at: '2017-04-13T10:25:17.000+01:00', + parent_ids: [ + '54d483b1ed156fbbf618886ddf7ab023e24f8738', + 'c8e2d38a6c538822e81c57022a6e3a0cfedebbcc', + ], + message: + "Merge branch 'new-branch' into 'master'\r\n\r\nAdd new file\r\n\r\nSee merge request !1", + author_name: 'Root', + author_email: 'admin@example.com', + authored_date: '2017-04-13T10:25:17.000+01:00', + committer_name: 'Root', + committer_email: 'admin@example.com', + committed_date: '2017-04-13T10:25:17.000+01:00', + author: { + name: 'Root', + username: 'root', + id: 1, + state: 'active', + avatar_url: null, + web_url: 'http://localhost:3000/root', + }, + author_gravatar_url: null, + commit_url: + 'http://localhost:3000/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492', + commit_path: '/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492', + }, + created_at: '2017-04-13T09:25:18.881Z', + updated_at: '2017-04-19T14:30:27.561Z', +}; diff --git a/spec/frontend/pipelines/graph/stage_column_component_legacy_spec.js b/spec/frontend/pipelines/graph/stage_column_component_legacy_spec.js new file mode 100644 index 00000000000..463e4c12c7d --- /dev/null +++ b/spec/frontend/pipelines/graph/stage_column_component_legacy_spec.js @@ -0,0 +1,135 @@ +import { shallowMount } from '@vue/test-utils'; +import StageColumnComponentLegacy from '~/pipelines/components/graph/stage_column_component_legacy.vue'; + +describe('stage column component', () => { + const mockJob = { + id: 4250, + name: 'test', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + details_path: '/root/ci-mock/builds/4250', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4250/retry', + method: 'post', + }, + }, + }; + + let wrapper; + + beforeEach(() => { + const mockGroups = []; + for (let i = 0; i < 3; i += 1) { + const mockedJob = { ...mockJob }; + mockedJob.id += i; + mockGroups.push(mockedJob); + } + + wrapper = shallowMount(StageColumnComponentLegacy, { + propsData: { + title: 'foo', + groups: mockGroups, + hasTriggeredBy: false, + }, + }); + }); + + it('should render provided title', () => { + expect( + wrapper + .find('.stage-name') + .text() + .trim(), + ).toBe('foo'); + }); + + it('should render the provided groups', () => { + expect(wrapper.findAll('.builds-container > ul > li').length).toBe( + wrapper.props('groups').length, + ); + }); + + describe('jobId', () => { + it('escapes job name', () => { + wrapper = shallowMount(StageColumnComponentLegacy, { + propsData: { + groups: [ + { + id: 4259, + name: '<img src=x onerror=alert(document.domain)>', + status: { + icon: 'status_success', + label: 'success', + tooltip: '<img src=x onerror=alert(document.domain)>', + }, + }, + ], + title: 'test', + hasTriggeredBy: false, + }, + }); + + expect(wrapper.find('.builds-container li').attributes('id')).toBe( + 'ci-badge-<img src=x onerror=alert(document.domain)>', + ); + }); + }); + + describe('with action', () => { + it('renders action button', () => { + wrapper = shallowMount(StageColumnComponentLegacy, { + propsData: { + groups: [ + { + id: 4259, + name: '<img src=x onerror=alert(document.domain)>', + status: { + icon: 'status_success', + label: 'success', + tooltip: '<img src=x onerror=alert(document.domain)>', + }, + }, + ], + title: 'test', + hasTriggeredBy: false, + action: { + icon: 'play', + title: 'Play all', + path: 'action', + }, + }, + }); + + expect(wrapper.find('.js-stage-action').exists()).toBe(true); + }); + }); + + describe('without action', () => { + it('does not render action button', () => { + wrapper = shallowMount(StageColumnComponentLegacy, { + propsData: { + groups: [ + { + id: 4259, + name: '<img src=x onerror=alert(document.domain)>', + status: { + icon: 'status_success', + label: 'success', + tooltip: '<img src=x onerror=alert(document.domain)>', + }, + }, + ], + title: 'test', + hasTriggeredBy: false, + }, + }); + + expect(wrapper.find('.js-stage-action').exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/pipelines/graph/stage_column_component_spec.js b/spec/frontend/pipelines/graph/stage_column_component_spec.js index d32534326c5..44803929f6d 100644 --- a/spec/frontend/pipelines/graph/stage_column_component_spec.js +++ b/spec/frontend/pipelines/graph/stage_column_component_spec.js @@ -1,64 +1,101 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount, shallowMount } from '@vue/test-utils'; +import ActionComponent from '~/pipelines/components/graph/action_component.vue'; +import JobItem from '~/pipelines/components/graph/job_item.vue'; +import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; -import stageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; - -describe('stage column component', () => { - const mockJob = { - id: 4250, - name: 'test', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - details_path: '/root/ci-mock/builds/4250', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4250/retry', - method: 'post', - }, +const mockJob = { + id: 4250, + name: 'test', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + details_path: '/root/ci-mock/builds/4250', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4250/retry', + method: 'post', }, - }; + }, +}; +const mockGroups = Array(4) + .fill(0) + .map((item, idx) => { + return { ...mockJob, id: idx, name: `fish-${idx}` }; + }); + +const defaultProps = { + title: 'Fish', + groups: mockGroups, +}; + +describe('stage column component', () => { let wrapper; - beforeEach(() => { - const mockGroups = []; - for (let i = 0; i < 3; i += 1) { - const mockedJob = { ...mockJob }; - mockedJob.id += i; - mockGroups.push(mockedJob); - } + const findStageColumnTitle = () => wrapper.find('[data-testid="stage-column-title"]'); + const findStageColumnGroup = () => wrapper.find('[data-testid="stage-column-group"]'); + const findAllStageColumnGroups = () => wrapper.findAll('[data-testid="stage-column-group"]'); + const findJobItem = () => wrapper.find(JobItem); + const findActionComponent = () => wrapper.find(ActionComponent); - wrapper = shallowMount(stageColumnComponent, { + const createComponent = ({ method = shallowMount, props = {} } = {}) => { + wrapper = method(StageColumnComponent, { propsData: { - title: 'foo', - groups: mockGroups, - hasTriggeredBy: false, + ...defaultProps, + ...props, }, }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; }); - it('should render provided title', () => { - expect( - wrapper - .find('.stage-name') - .text() - .trim(), - ).toBe('foo'); + describe('when mounted', () => { + beforeEach(() => { + createComponent({ method: mount }); + }); + + it('should render provided title', () => { + expect(findStageColumnTitle().text()).toBe(defaultProps.title); + }); + + it('should render the provided groups', () => { + expect(findAllStageColumnGroups().length).toBe(mockGroups.length); + }); }); - it('should render the provided groups', () => { - expect(wrapper.findAll('.builds-container > ul > li').length).toBe( - wrapper.props('groups').length, - ); + describe('when job notifies action is complete', () => { + beforeEach(() => { + createComponent({ + method: mount, + props: { + groups: [ + { + title: 'Fish', + size: 1, + jobs: [mockJob], + }, + ], + }, + }); + findJobItem().vm.$emit('pipelineActionRequestComplete'); + }); + + it('emits refreshPipelineGraph', () => { + expect(wrapper.emitted().refreshPipelineGraph).toHaveLength(1); + }); }); - describe('jobId', () => { - it('escapes job name', () => { - wrapper = shallowMount(stageColumnComponent, { - propsData: { + describe('job', () => { + beforeEach(() => { + createComponent({ + method: mount, + props: { groups: [ { id: 4259, @@ -70,21 +107,29 @@ describe('stage column component', () => { }, }, ], - title: 'test', - hasTriggeredBy: false, + title: 'test <img src=x onerror=alert(document.domain)>', }, }); + }); - expect(wrapper.find('.builds-container li').attributes('id')).toBe( + it('capitalizes and escapes name', () => { + expect(findStageColumnTitle().text()).toBe( + 'Test <img src=x onerror=alert(document.domain)>', + ); + }); + + it('escapes id', () => { + expect(findStageColumnGroup().attributes('id')).toBe( 'ci-badge-<img src=x onerror=alert(document.domain)>', ); }); }); describe('with action', () => { - it('renders action button', () => { - wrapper = shallowMount(stageColumnComponent, { - propsData: { + beforeEach(() => { + createComponent({ + method: mount, + props: { groups: [ { id: 4259, @@ -105,15 +150,18 @@ describe('stage column component', () => { }, }, }); + }); - expect(wrapper.find('.js-stage-action').exists()).toBe(true); + it('renders action button', () => { + expect(findActionComponent().exists()).toBe(true); }); }); describe('without action', () => { - it('does not render action button', () => { - wrapper = shallowMount(stageColumnComponent, { - propsData: { + beforeEach(() => { + createComponent({ + method: mount, + props: { groups: [ { id: 4259, @@ -129,8 +177,10 @@ describe('stage column component', () => { hasTriggeredBy: false, }, }); + }); - expect(wrapper.find('.js-stage-action').exists()).toBe(false); + it('does not render action button', () => { + expect(findActionComponent().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/pipelines/pipeline_graph/gitlab_ci_yaml_visualization_spec.js b/spec/frontend/pipelines/pipeline_graph/gitlab_ci_yaml_visualization_spec.js deleted file mode 100644 index fea42350959..00000000000 --- a/spec/frontend/pipelines/pipeline_graph/gitlab_ci_yaml_visualization_spec.js +++ /dev/null @@ -1,47 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlTab } from '@gitlab/ui'; -import { yamlString } from './mock_data'; -import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; -import GitlabCiYamlVisualization from '~/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue'; - -describe('gitlab yaml visualization component', () => { - const defaultProps = { blobData: yamlString }; - let wrapper; - - const createComponent = props => { - return shallowMount(GitlabCiYamlVisualization, { - propsData: { - ...defaultProps, - ...props, - }, - }); - }; - - const findGlTabComponents = () => wrapper.findAll(GlTab); - const findPipelineGraph = () => wrapper.find(PipelineGraph); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('tabs component', () => { - beforeEach(() => { - wrapper = createComponent(); - }); - - it('renders the file and visualization tabs', () => { - expect(findGlTabComponents()).toHaveLength(2); - }); - }); - - describe('graph component', () => { - beforeEach(() => { - wrapper = createComponent(); - }); - - it('is hidden by default', () => { - expect(findPipelineGraph().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/pipelines/pipeline_graph/mock_data.js b/spec/frontend/pipelines/pipeline_graph/mock_data.js index 4f55fdd6b28..a77973b293c 100644 --- a/spec/frontend/pipelines/pipeline_graph/mock_data.js +++ b/spec/frontend/pipelines/pipeline_graph/mock_data.js @@ -1,4 +1,4 @@ -import { createUniqueJobId } from '~/pipelines/utils'; +import { createUniqueLinkId } from '~/pipelines/utils'; export const yamlString = `stages: - empty @@ -41,10 +41,10 @@ deploy_a: script: echo hello `; -const jobId1 = createUniqueJobId('build', 'build_1'); -const jobId2 = createUniqueJobId('test', 'test_1'); -const jobId3 = createUniqueJobId('test', 'test_2'); -const jobId4 = createUniqueJobId('deploy', 'deploy_1'); +const jobId1 = createUniqueLinkId('build', 'build_1'); +const jobId2 = createUniqueLinkId('test', 'test_1'); +const jobId3 = createUniqueLinkId('test', 'test_2'); +const jobId4 = createUniqueLinkId('deploy', 'deploy_1'); export const pipelineData = { stages: [ diff --git a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js index 7c8ebc27974..6704ee06c1a 100644 --- a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js +++ b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js @@ -1,5 +1,8 @@ import { shallowMount } from '@vue/test-utils'; +import { GlAlert } from '@gitlab/ui'; import { pipelineData, singleStageData } from './mock_data'; +import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants'; +import { DRAW_FAILURE, EMPTY_PIPELINE_DATA, INVALID_CI_CONFIG } from '~/pipelines/constants'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import StagePill from '~/pipelines/components/pipeline_graph/stage_pill.vue'; import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue'; @@ -8,15 +11,16 @@ describe('pipeline graph component', () => { const defaultProps = { pipelineData }; let wrapper; - const createComponent = props => { + const createComponent = (props = defaultProps) => { return shallowMount(PipelineGraph, { propsData: { - ...defaultProps, ...props, }, }); }; + const findPipelineGraph = () => wrapper.find('[data-testid="graph-container"]'); + const findAlert = () => wrapper.find(GlAlert); const findAllStagePills = () => wrapper.findAll(StagePill); const findAllStageBackgroundElements = () => wrapper.findAll('[data-testid="stage-background"]'); const findStageBackgroundElementAt = index => findAllStageBackgroundElements().at(index); @@ -33,54 +37,92 @@ describe('pipeline graph component', () => { }); it('renders an empty section', () => { - expect(wrapper.text()).toContain( - 'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.', - ); + expect(wrapper.text()).toBe(wrapper.vm.$options.warningTexts[EMPTY_PIPELINE_DATA]); + expect(findPipelineGraph().exists()).toBe(false); expect(findAllStagePills()).toHaveLength(0); expect(findAllJobPills()).toHaveLength(0); }); }); - describe('with data', () => { + describe('with `INVALID` status', () => { + beforeEach(() => { + wrapper = createComponent({ pipelineData: { status: CI_CONFIG_STATUS_INVALID } }); + }); + + it('renders an error message and does not render the graph', () => { + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(wrapper.vm.$options.warningTexts[INVALID_CI_CONFIG]); + expect(findPipelineGraph().exists()).toBe(false); + }); + }); + + describe('without `INVALID` status', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('renders the graph with no status error', () => { + expect(findAlert().text()).not.toBe(wrapper.vm.$options.warningTexts[INVALID_CI_CONFIG]); + expect(findPipelineGraph().exists()).toBe(true); + }); + }); + + describe('with error while rendering the links', () => { beforeEach(() => { wrapper = createComponent(); }); + it('renders the error that link could not be drawn', () => { + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[DRAW_FAILURE]); + }); + }); + + describe('with only one stage', () => { + beforeEach(() => { + wrapper = createComponent({ pipelineData: singleStageData }); + }); + it('renders the right number of stage pills', () => { - const expectedStagesLength = pipelineData.stages.length; + const expectedStagesLength = singleStageData.stages.length; expect(findAllStagePills()).toHaveLength(expectedStagesLength); }); - it.each` - cssClass | expectedState - ${'gl-rounded-bottom-left-6'} | ${true} - ${'gl-rounded-top-left-6'} | ${true} - ${'gl-rounded-top-right-6'} | ${false} - ${'gl-rounded-bottom-right-6'} | ${false} - `( - 'rounds corner: $class should be $expectedState on the first element', - ({ cssClass, expectedState }) => { + it('renders the right number of job pills', () => { + // We count the number of jobs in the mock data + const expectedJobsLength = singleStageData.stages.reduce((acc, val) => { + return acc + val.groups.length; + }, 0); + + expect(findAllJobPills()).toHaveLength(expectedJobsLength); + }); + + describe('rounds corner', () => { + it.each` + cssClass | expectedState + ${'gl-rounded-bottom-left-6'} | ${true} + ${'gl-rounded-top-left-6'} | ${true} + ${'gl-rounded-top-right-6'} | ${true} + ${'gl-rounded-bottom-right-6'} | ${true} + `('$cssClass should be $expectedState on the only element', ({ cssClass, expectedState }) => { const classes = findStageBackgroundElementAt(0).classes(); expect(classes.includes(cssClass)).toBe(expectedState); - }, - ); - - it.each` - cssClass | expectedState - ${'gl-rounded-bottom-left-6'} | ${false} - ${'gl-rounded-top-left-6'} | ${false} - ${'gl-rounded-top-right-6'} | ${true} - ${'gl-rounded-bottom-right-6'} | ${true} - `( - 'rounds corner: $class should be $expectedState on the last element', - ({ cssClass, expectedState }) => { - const classes = findStageBackgroundElementAt(pipelineData.stages.length - 1).classes(); + }); + }); + }); - expect(classes.includes(cssClass)).toBe(expectedState); - }, - ); + describe('with multiple stages and jobs', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('renders the right number of stage pills', () => { + const expectedStagesLength = pipelineData.stages.length; + + expect(findAllStagePills()).toHaveLength(expectedStagesLength); + }); it('renders the right number of job pills', () => { // We count the number of jobs in the mock data @@ -90,26 +132,34 @@ describe('pipeline graph component', () => { expect(findAllJobPills()).toHaveLength(expectedJobsLength); }); - }); - describe('with only one stage', () => { - beforeEach(() => { - wrapper = createComponent({ pipelineData: singleStageData }); - }); + describe('rounds corner', () => { + it.each` + cssClass | expectedState + ${'gl-rounded-bottom-left-6'} | ${true} + ${'gl-rounded-top-left-6'} | ${true} + ${'gl-rounded-top-right-6'} | ${false} + ${'gl-rounded-bottom-right-6'} | ${false} + `( + '$cssClass should be $expectedState on the first element', + ({ cssClass, expectedState }) => { + const classes = findStageBackgroundElementAt(0).classes(); + + expect(classes.includes(cssClass)).toBe(expectedState); + }, + ); - it.each` - cssClass | expectedState - ${'gl-rounded-bottom-left-6'} | ${true} - ${'gl-rounded-top-left-6'} | ${true} - ${'gl-rounded-top-right-6'} | ${true} - ${'gl-rounded-bottom-right-6'} | ${true} - `( - 'rounds corner: $class should be $expectedState on the only element', - ({ cssClass, expectedState }) => { - const classes = findStageBackgroundElementAt(0).classes(); + it.each` + cssClass | expectedState + ${'gl-rounded-bottom-left-6'} | ${false} + ${'gl-rounded-top-left-6'} | ${false} + ${'gl-rounded-top-right-6'} | ${true} + ${'gl-rounded-bottom-right-6'} | ${true} + `('$cssClass should be $expectedState on the last element', ({ cssClass, expectedState }) => { + const classes = findStageBackgroundElementAt(pipelineData.stages.length - 1).classes(); expect(classes.includes(cssClass)).toBe(expectedState); - }, - ); + }); + }); }); }); diff --git a/spec/frontend/pipelines/pipeline_graph/utils_spec.js b/spec/frontend/pipelines/pipeline_graph/utils_spec.js index ade026c7053..12154df6fcf 100644 --- a/spec/frontend/pipelines/pipeline_graph/utils_spec.js +++ b/spec/frontend/pipelines/pipeline_graph/utils_spec.js @@ -1,19 +1,24 @@ -import { - preparePipelineGraphData, - createUniqueJobId, - generateJobNeedsDict, -} from '~/pipelines/utils'; +import { createJobsHash, generateJobNeedsDict } from '~/pipelines/utils'; describe('utils functions', () => { - const emptyResponse = { stages: [], jobs: {} }; const jobName1 = 'build_1'; const jobName2 = 'build_2'; const jobName3 = 'test_1'; const jobName4 = 'deploy_1'; - const job1 = { script: 'echo hello', stage: 'build' }; - const job2 = { script: 'echo build', stage: 'build' }; - const job3 = { script: 'echo test', stage: 'test', needs: [jobName1, jobName2] }; - const job4 = { script: 'echo deploy', stage: 'deploy', needs: [jobName3] }; + const job1 = { name: jobName1, script: 'echo hello', stage: 'build' }; + const job2 = { name: jobName2, script: 'echo build', stage: 'build' }; + const job3 = { + name: jobName3, + script: 'echo test', + stage: 'test', + needs: [jobName1, jobName2], + }; + const job4 = { + name: jobName4, + script: 'echo deploy', + stage: 'deploy', + needs: [jobName3], + }; const userDefinedStage = 'myStage'; const pipelineGraphData = { @@ -28,7 +33,6 @@ describe('utils functions', () => { { name: jobName4, jobs: [{ ...job4 }], - id: createUniqueJobId(job4.stage, jobName4), }, ], }, @@ -38,12 +42,10 @@ describe('utils functions', () => { { name: jobName1, jobs: [{ ...job1 }], - id: createUniqueJobId(job1.stage, jobName1), }, { name: jobName2, jobs: [{ ...job2 }], - id: createUniqueJobId(job2.stage, jobName2), }, ], }, @@ -53,158 +55,59 @@ describe('utils functions', () => { { name: jobName3, jobs: [{ ...job3 }], - id: createUniqueJobId(job3.stage, jobName3), }, ], }, ], - jobs: { - [jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) }, - [jobName2]: { ...job2, id: createUniqueJobId(job2.stage, jobName2) }, - [jobName3]: { ...job3, id: createUniqueJobId(job3.stage, jobName3) }, - [jobName4]: { ...job4, id: createUniqueJobId(job4.stage, jobName4) }, - }, }; - describe('preparePipelineGraphData', () => { - describe('returns an empty array of stages and empty job objects if', () => { - it('no data is passed', () => { - expect(preparePipelineGraphData({})).toEqual(emptyResponse); - }); - - it('no stages are found', () => { - expect(preparePipelineGraphData({ includes: 'template/myTemplate.gitlab-ci.yml' })).toEqual( - emptyResponse, - ); - }); + describe('createJobsHash', () => { + it('returns an empty object if there are no jobs received as argument', () => { + expect(createJobsHash([])).toEqual({}); }); - describe('returns the correct array of stages and object of jobs', () => { - it('when multiple jobs are in the same stage', () => { - const expectedData = { - stages: [ - { - name: job1.stage, - groups: [ - { - name: jobName1, - jobs: [{ ...job1 }], - id: createUniqueJobId(job1.stage, jobName1), - }, - { - name: jobName2, - jobs: [{ ...job2 }], - id: createUniqueJobId(job2.stage, jobName2), - }, - ], - }, - ], - jobs: { - [jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) }, - [jobName2]: { ...job2, id: createUniqueJobId(job2.stage, jobName2) }, - }, - }; - expect( - preparePipelineGraphData({ [jobName1]: { ...job1 }, [jobName2]: { ...job2 } }), - ).toEqual(expectedData); - }); - - it('when stages are defined by the user', () => { - const userDefinedStage2 = 'myStage2'; - - const expectedData = { - stages: [ - { - name: userDefinedStage, - groups: [], - }, - { - name: userDefinedStage2, - groups: [], - }, - ], - jobs: {}, - }; - - expect(preparePipelineGraphData({ stages: [userDefinedStage, userDefinedStage2] })).toEqual( - expectedData, - ); - }); - - it('by combining user defined stage and job stages, it preserves user defined order', () => { - const userDefinedStageThatOverlaps = 'deploy'; - - expect( - preparePipelineGraphData({ - stages: [userDefinedStage, userDefinedStageThatOverlaps], - [jobName1]: { ...job1 }, - [jobName2]: { ...job2 }, - [jobName3]: { ...job3 }, - [jobName4]: { ...job4 }, - }), - ).toEqual(pipelineGraphData); - }); - - it('with only unique values', () => { - const expectedData = { - stages: [ - { - name: job1.stage, - groups: [ - { - name: jobName1, - jobs: [{ ...job1 }], - id: createUniqueJobId(job1.stage, jobName1), - }, - ], - }, - ], - jobs: { - [jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) }, - }, - }; + it('returns a hash with the jobname as key and all its data as value', () => { + const jobs = { + [jobName1]: job1, + [jobName2]: job2, + [jobName3]: job3, + [jobName4]: job4, + }; - expect( - preparePipelineGraphData({ - stages: ['build'], - [jobName1]: { ...job1 }, - [jobName1]: { ...job1 }, - }), - ).toEqual(expectedData); - }); + expect(createJobsHash(pipelineGraphData.stages)).toEqual(jobs); }); }); describe('generateJobNeedsDict', () => { it('generates an empty object if it receives no jobs', () => { - expect(generateJobNeedsDict({ jobs: {} })).toEqual({}); + expect(generateJobNeedsDict({})).toEqual({}); }); it('generates a dict with empty needs if there are no dependencies', () => { const smallGraph = { - jobs: { - [jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) }, - [jobName2]: { ...job2, id: createUniqueJobId(job2.stage, jobName2) }, - }, + [jobName1]: job1, + [jobName2]: job2, }; expect(generateJobNeedsDict(smallGraph)).toEqual({ - [pipelineGraphData.jobs[jobName1].id]: [], - [pipelineGraphData.jobs[jobName2].id]: [], + [jobName1]: [], + [jobName2]: [], }); }); it('generates a dict where key is the a job and its value is an array of all its needs', () => { - const uniqueJobName1 = pipelineGraphData.jobs[jobName1].id; - const uniqueJobName2 = pipelineGraphData.jobs[jobName2].id; - const uniqueJobName3 = pipelineGraphData.jobs[jobName3].id; - const uniqueJobName4 = pipelineGraphData.jobs[jobName4].id; + const jobsWithNeeds = { + [jobName1]: job1, + [jobName2]: job2, + [jobName3]: job3, + [jobName4]: job4, + }; - expect(generateJobNeedsDict(pipelineGraphData)).toEqual({ - [uniqueJobName1]: [], - [uniqueJobName2]: [], - [uniqueJobName3]: [uniqueJobName1, uniqueJobName2], - [uniqueJobName4]: [uniqueJobName3, uniqueJobName1, uniqueJobName2], + expect(generateJobNeedsDict(jobsWithNeeds)).toEqual({ + [jobName1]: [], + [jobName2]: [], + [jobName3]: [jobName1, jobName2], + [jobName4]: [jobName3, jobName1, jobName2], }); }); }); diff --git a/spec/frontend/pipelines/pipeline_url_spec.js b/spec/frontend/pipelines/pipeline_url_spec.js index 0bcc3f96f7c..fc45af2c254 100644 --- a/spec/frontend/pipelines/pipeline_url_spec.js +++ b/spec/frontend/pipelines/pipeline_url_spec.js @@ -16,6 +16,7 @@ describe('Pipeline Url Component', () => { const findAutoDevopsTag = () => wrapper.find('[data-testid="pipeline-url-autodevops"]'); const findStuckTag = () => wrapper.find('[data-testid="pipeline-url-stuck"]'); const findDetachedTag = () => wrapper.find('[data-testid="pipeline-url-detached"]'); + const findForkTag = () => wrapper.find('[data-testid="pipeline-url-fork"]'); const defaultProps = { pipeline: { @@ -30,6 +31,9 @@ describe('Pipeline Url Component', () => { const createComponent = props => { wrapper = shallowMount(PipelineUrlComponent, { propsData: { ...defaultProps, ...props }, + provide: { + targetProjectFullPath: 'test/test', + }, }); }; @@ -137,4 +141,15 @@ describe('Pipeline Url Component', () => { expect(findScheduledTag().exists()).toBe(true); expect(findScheduledTag().text()).toContain('Scheduled'); }); + it('should render the fork badge when the pipeline was run in a fork', () => { + createComponent({ + pipeline: { + flags: {}, + project: { fullPath: 'test/forked' }, + }, + }); + + expect(findForkTag().exists()).toBe(true); + expect(findForkTag().text()).toBe('fork'); + }); }); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index a272803f9b6..ce0e76ba22d 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -31,7 +31,7 @@ describe('Pipelines', () => { const paths = { endpoint: 'twitter/flight/pipelines.json', - autoDevopsPath: '/help/topics/autodevops/index.md', + autoDevopsHelpPath: '/help/topics/autodevops/index.md', helpPagePath: '/help/ci/quick_start/README', emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg', errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', @@ -43,7 +43,7 @@ describe('Pipelines', () => { const noPermissions = { endpoint: 'twitter/flight/pipelines.json', - autoDevopsPath: '/help/topics/autodevops/index.md', + autoDevopsHelpPath: '/help/topics/autodevops/index.md', helpPagePath: '/help/ci/quick_start/README', emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg', errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', diff --git a/spec/frontend/pipelines/test_reports/stores/getters_spec.js b/spec/frontend/pipelines/test_reports/stores/getters_spec.js index 58e8065033f..8cef499fdb9 100644 --- a/spec/frontend/pipelines/test_reports/stores/getters_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/getters_spec.js @@ -10,11 +10,19 @@ describe('Getters TestReports Store', () => { const defaultState = { testReports, selectedSuiteIndex: 0, + pageInfo: { + page: 1, + perPage: 2, + }, }; const emptyState = { testReports: {}, selectedSuite: null, + pageInfo: { + page: 1, + perPage: 2, + }, }; beforeEach(() => { @@ -59,15 +67,17 @@ describe('Getters TestReports Store', () => { }); describe('getSuiteTests', () => { - it('should return the test cases inside the suite', () => { + it('should return the current page of test cases inside the suite', () => { setupState(); const cases = getters.getSuiteTests(state); - const expected = testReports.test_suites[0].test_cases.map(x => ({ - ...x, - formattedTime: formattedTime(x.execution_time), - icon: iconForTestStatus(x.status), - })); + const expected = testReports.test_suites[0].test_cases + .map(x => ({ + ...x, + formattedTime: formattedTime(x.execution_time), + icon: iconForTestStatus(x.status), + })) + .slice(0, state.pageInfo.perPage); expect(cases).toEqual(expected); }); @@ -78,4 +88,15 @@ describe('Getters TestReports Store', () => { expect(getters.getSuiteTests(state)).toEqual([]); }); }); + + describe('getSuiteTestCount', () => { + it('should return the total number of test cases', () => { + setupState(); + + const testCount = getters.getSuiteTestCount(state); + const expected = testReports.test_suites[0].test_cases.length; + + expect(testCount).toEqual(expected); + }); + }); }); diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js index b935029bc6a..191e9e7391c 100644 --- a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js @@ -12,12 +12,25 @@ describe('Mutations TestReports Store', () => { testReports: {}, selectedSuite: null, isLoading: false, + pageInfo: { + page: 1, + perPage: 2, + }, }; beforeEach(() => { mockState = { ...defaultState }; }); + describe('set page', () => { + it('should set the current page to display', () => { + const pageToDisplay = 3; + mutations[types.SET_PAGE](mockState, pageToDisplay); + + expect(mockState.pageInfo.page).toEqual(pageToDisplay); + }); + }); + describe('set suite', () => { it('should set the suite at the given index', () => { mockState.testReports = testReports; diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js index 284099b000b..0e00ca670a7 100644 --- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js +++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js @@ -1,7 +1,7 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import { getJSONFixture } from 'helpers/fixtures'; -import { GlButton, GlFriendlyWrap } from '@gitlab/ui'; +import { GlButton, GlFriendlyWrap, GlPagination } from '@gitlab/ui'; import SuiteTable from '~/pipelines/components/test_reports/test_suite_table.vue'; import * as getters from '~/pipelines/stores/test_reports/getters'; import { TestStatus } from '~/pipelines/constants'; @@ -26,13 +26,17 @@ describe('Test reports suite table', () => { const findCaseRowAtIndex = index => wrapper.findAll('.js-case-row').at(index); const findIconForRow = (row, status) => row.find(`.ci-status-icon-${status}`); - const createComponent = (suite = testSuite) => { + const createComponent = (suite = testSuite, perPage = 20) => { store = new Vuex.Store({ state: { testReports: { test_suites: [suite], }, selectedSuiteIndex: 0, + pageInfo: { + page: 1, + perPage, + }, }, getters, }); @@ -86,4 +90,20 @@ describe('Test reports suite table', () => { expect(button.attributes('data-clipboard-text')).toBe(file); }); }); + + describe('when a test suite has more test cases than the pagination size', () => { + const perPage = 2; + + beforeEach(() => { + createComponent(testSuite, perPage); + }); + + it('renders one page of test cases', () => { + expect(allCaseRows().length).toBe(perPage); + }); + + it('renders a pagination component', () => { + expect(wrapper.find(GlPagination).exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js index b53955ab743..1db736ba01e 100644 --- a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js @@ -1,18 +1,12 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; import PipelineStatusToken from '~/pipelines/components/pipelines_list/tokens/pipeline_status_token.vue'; describe('Pipeline Status Token', () => { let wrapper; - const stubs = { - GlFilteredSearchToken: { - props: GlFilteredSearchToken.props, - template: `<div><slot name="suggestions"></slot></div>`, - }, - }; - - const findFilteredSearchToken = () => wrapper.find(stubs.GlFilteredSearchToken); + const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken); const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion); const findAllGlIcons = () => wrapper.findAll(GlIcon); @@ -33,7 +27,11 @@ describe('Pipeline Status Token', () => { propsData: { ...defaultProps, }, - stubs, + stubs: { + GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { + template: `<div><slot name="suggestions"></slot></div>`, + }), + }, }); }; diff --git a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js index 9363944a719..375325c0c6a 100644 --- a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js @@ -1,4 +1,5 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import { stubComponent } from 'helpers/stub_component'; import { shallowMount } from '@vue/test-utils'; import Api from '~/api'; import PipelineTriggerAuthorToken from '~/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue'; @@ -7,14 +8,7 @@ import { users } from '../mock_data'; describe('Pipeline Trigger Author Token', () => { let wrapper; - const stubs = { - GlFilteredSearchToken: { - props: GlFilteredSearchToken.props, - template: `<div><slot name="suggestions"></slot></div>`, - }, - }; - - const findFilteredSearchToken = () => wrapper.find(stubs.GlFilteredSearchToken); + const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken); const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion); const findLoadingIcon = () => wrapper.find(GlLoadingIcon); @@ -42,7 +36,11 @@ describe('Pipeline Trigger Author Token', () => { ...data, }; }, - stubs, + stubs: { + GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { + template: `<div><slot name="suggestions"></slot></div>`, + }), + }, }); }; diff --git a/spec/frontend/pipelines/unwrapping_utils_spec.js b/spec/frontend/pipelines/unwrapping_utils_spec.js new file mode 100644 index 00000000000..3533599611f --- /dev/null +++ b/spec/frontend/pipelines/unwrapping_utils_spec.js @@ -0,0 +1,151 @@ +import { + unwrapArrayOfJobs, + unwrapGroups, + unwrapNodesWithName, + unwrapStagesWithNeeds, +} from '~/pipelines/components/unwrapping_utils'; + +const groupsArray = [ + { + name: 'build_a', + size: 1, + status: { + label: 'passed', + group: 'success', + icon: 'status_success', + }, + }, + { + name: 'bob_the_build', + size: 1, + status: { + label: 'passed', + group: 'success', + icon: 'status_success', + }, + }, +]; + +const basicStageInfo = { + name: 'center_stage', + status: { + action: null, + }, +}; + +const stagesAndGroups = [ + { + ...basicStageInfo, + groups: { + nodes: groupsArray, + }, + }, +]; + +const needArray = [ + { + name: 'build_b', + }, +]; + +const elephantArray = [ + { + name: 'build_b', + elephant: 'gray', + }, +]; + +const baseJobs = { + name: 'test_d', + status: { + icon: 'status_success', + tooltip: null, + hasDetails: true, + detailsPath: '/root/abcd-dag/-/pipelines/162', + group: 'success', + action: null, + }, +}; + +const jobArrayWithNeeds = [ + { + ...baseJobs, + needs: { + nodes: needArray, + }, + }, +]; + +const jobArrayWithElephant = [ + { + ...baseJobs, + needs: { + nodes: elephantArray, + }, + }, +]; + +const completeMock = [ + { + ...basicStageInfo, + groups: { + nodes: groupsArray.map(group => ({ ...group, jobs: { nodes: jobArrayWithNeeds } })), + }, + }, +]; + +describe('Shared pipeline unwrapping utils', () => { + describe('unwrapArrayOfJobs', () => { + it('returns an empty array if the input is an empty undefined', () => { + expect(unwrapArrayOfJobs(undefined)).toEqual([]); + }); + + it('returns an empty array if the input is an empty array', () => { + expect(unwrapArrayOfJobs([])).toEqual([]); + }); + + it('returns a flatten array of each job with their data and stage name', () => { + expect( + unwrapArrayOfJobs([ + { name: 'build', groups: [{ name: 'job_a_1' }, { name: 'job_a_2' }] }, + { name: 'test', groups: [{ name: 'job_b' }] }, + ]), + ).toMatchObject([ + { category: 'build', name: 'job_a_1' }, + { category: 'build', name: 'job_a_2' }, + { category: 'test', name: 'job_b' }, + ]); + }); + }); + + describe('unwrapGroups', () => { + it('takes stages without nodes and returns the unwrapped groups', () => { + expect(unwrapGroups(stagesAndGroups)[0].groups).toEqual(groupsArray); + }); + + it('keeps other stage properties intact', () => { + expect(unwrapGroups(stagesAndGroups)[0]).toMatchObject(basicStageInfo); + }); + }); + + describe('unwrapNodesWithName', () => { + it('works with no field argument', () => { + expect(unwrapNodesWithName(jobArrayWithNeeds, 'needs')[0].needs).toEqual([needArray[0].name]); + }); + + it('works with custom field argument', () => { + expect(unwrapNodesWithName(jobArrayWithElephant, 'needs', 'elephant')[0].needs).toEqual([ + elephantArray[0].elephant, + ]); + }); + }); + + describe('unwrapStagesWithNeeds', () => { + it('removes nodes from groups, jobs, and needs', () => { + const firstProcessedGroup = unwrapStagesWithNeeds(completeMock)[0].groups[0]; + expect(firstProcessedGroup).toMatchObject(groupsArray[0]); + expect(firstProcessedGroup.jobs[0]).toMatchObject(baseJobs); + expect(firstProcessedGroup.jobs[0].needs[0]).toBe(needArray[0].name); + }); + }); +}); diff --git a/spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap b/spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap index ac87fe893b9..c7e760486c0 100644 --- a/spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap +++ b/spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`StatisticsList matches the snapshot 1`] = ` +exports[`StatisticsList displays the counts data with labels 1`] = ` <ul> <li> <span> @@ -35,7 +35,7 @@ exports[`StatisticsList matches the snapshot 1`] = ` </span> <strong> - 50% + 50.00% </strong> </li> <li> diff --git a/spec/frontend/projects/pipelines/charts/components/app_legacy_spec.js b/spec/frontend/projects/pipelines/charts/components/app_legacy_spec.js new file mode 100644 index 00000000000..c03b571eb26 --- /dev/null +++ b/spec/frontend/projects/pipelines/charts/components/app_legacy_spec.js @@ -0,0 +1,72 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlColumnChart } from '@gitlab/ui/dist/charts'; +import Component from '~/projects/pipelines/charts/components/app_legacy.vue'; +import StatisticsList from '~/projects/pipelines/charts/components/statistics_list.vue'; +import PipelinesAreaChart from '~/projects/pipelines/charts/components/pipelines_area_chart.vue'; +import { + counts, + timesChartData, + areaChartData as lastWeekChartData, + areaChartData as lastMonthChartData, + lastYearChartData, +} from '../mock_data'; + +describe('ProjectsPipelinesChartsApp', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallowMount(Component, { + propsData: { + counts, + timesChartData, + lastWeekChartData, + lastMonthChartData, + lastYearChartData, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('overall statistics', () => { + it('displays the statistics list', () => { + const list = wrapper.find(StatisticsList); + + expect(list.exists()).toBeTruthy(); + expect(list.props('counts')).toBe(counts); + }); + + it('displays the commit duration chart', () => { + const chart = wrapper.find(GlColumnChart); + + expect(chart.exists()).toBeTruthy(); + expect(chart.props('yAxisTitle')).toBe('Minutes'); + expect(chart.props('xAxisTitle')).toBe('Commit'); + expect(chart.props('bars')).toBe(wrapper.vm.timesChartTransformedData); + expect(chart.props('option')).toBe(wrapper.vm.$options.timesChartOptions); + }); + }); + + describe('pipelines charts', () => { + it('displays 3 area charts', () => { + expect(wrapper.findAll(PipelinesAreaChart).length).toBe(3); + }); + + describe('displays individual correctly', () => { + it('renders with the correct data', () => { + const charts = wrapper.findAll(PipelinesAreaChart); + + for (let i = 0; i < charts.length; i += 1) { + const chart = charts.at(i); + + expect(chart.exists()).toBeTruthy(); + expect(chart.props('chartData')).toBe(wrapper.vm.areaCharts[i].data); + expect(chart.text()).toBe(wrapper.vm.areaCharts[i].title); + } + }); + }); + }); +}); diff --git a/spec/frontend/projects/pipelines/charts/components/app_spec.js b/spec/frontend/projects/pipelines/charts/components/app_spec.js index 0dd3407dbbc..f8737dda5f6 100644 --- a/spec/frontend/projects/pipelines/charts/components/app_spec.js +++ b/spec/frontend/projects/pipelines/charts/components/app_spec.js @@ -1,29 +1,45 @@ -import { shallowMount } from '@vue/test-utils'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; import { GlColumnChart } from '@gitlab/ui/dist/charts'; import Component from '~/projects/pipelines/charts/components/app.vue'; import StatisticsList from '~/projects/pipelines/charts/components/statistics_list.vue'; import PipelinesAreaChart from '~/projects/pipelines/charts/components/pipelines_area_chart.vue'; -import { - counts, - timesChartData, - areaChartData as lastWeekChartData, - areaChartData as lastMonthChartData, - lastYearChartData, -} from '../mock_data'; +import getPipelineCountByStatus from '~/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql'; +import getProjectPipelineStatistics from '~/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql'; +import { mockPipelineCount, mockPipelineStatistics } from '../mock_data'; + +const projectPath = 'gitlab-org/gitlab'; +const localVue = createLocalVue(); +localVue.use(VueApollo); describe('ProjectsPipelinesChartsApp', () => { let wrapper; - beforeEach(() => { - wrapper = shallowMount(Component, { - propsData: { - counts, - timesChartData, - lastWeekChartData, - lastMonthChartData, - lastYearChartData, + function createMockApolloProvider() { + const requestHandlers = [ + [getPipelineCountByStatus, jest.fn().mockResolvedValue(mockPipelineCount)], + [getProjectPipelineStatistics, jest.fn().mockResolvedValue(mockPipelineStatistics)], + ]; + + return createMockApollo(requestHandlers); + } + + function createComponent(options = {}) { + const { fakeApollo } = options; + + return shallowMount(Component, { + provide: { + projectPath, }, + localVue, + apolloProvider: fakeApollo, }); + } + + beforeEach(() => { + const fakeApollo = createMockApolloProvider(); + wrapper = createComponent({ fakeApollo }); }); afterEach(() => { @@ -35,14 +51,20 @@ describe('ProjectsPipelinesChartsApp', () => { it('displays the statistics list', () => { const list = wrapper.find(StatisticsList); - expect(list.exists()).toBeTruthy(); - expect(list.props('counts')).toBe(counts); + expect(list.exists()).toBe(true); + expect(list.props('counts')).toMatchObject({ + failed: 1, + success: 23, + total: 34, + successRatio: 95.83333333333334, + totalDuration: 2471, + }); }); it('displays the commit duration chart', () => { const chart = wrapper.find(GlColumnChart); - expect(chart.exists()).toBeTruthy(); + expect(chart.exists()).toBe(true); expect(chart.props('yAxisTitle')).toBe('Minutes'); expect(chart.props('xAxisTitle')).toBe('Commit'); expect(chart.props('bars')).toBe(wrapper.vm.timesChartTransformedData); @@ -52,7 +74,7 @@ describe('ProjectsPipelinesChartsApp', () => { describe('pipelines charts', () => { it('displays 3 area charts', () => { - expect(wrapper.findAll(PipelinesAreaChart).length).toBe(3); + expect(wrapper.findAll(PipelinesAreaChart)).toHaveLength(3); }); describe('displays individual correctly', () => { @@ -62,7 +84,9 @@ describe('ProjectsPipelinesChartsApp', () => { for (let i = 0; i < charts.length; i += 1) { const chart = charts.at(i); - expect(chart.exists()).toBeTruthy(); + expect(chart.exists()).toBe(true); + // TODO: Refactor this to use the mocked data instead of the vm data + // https://gitlab.com/gitlab-org/gitlab/-/issues/292085 expect(chart.props('chartData')).toBe(wrapper.vm.areaCharts[i].data); expect(chart.text()).toBe(wrapper.vm.areaCharts[i].title); } diff --git a/spec/frontend/projects/pipelines/charts/components/statistics_list_spec.js b/spec/frontend/projects/pipelines/charts/components/statistics_list_spec.js index f78608e9cb2..4e79f62ce81 100644 --- a/spec/frontend/projects/pipelines/charts/components/statistics_list_spec.js +++ b/spec/frontend/projects/pipelines/charts/components/statistics_list_spec.js @@ -18,7 +18,7 @@ describe('StatisticsList', () => { wrapper = null; }); - it('matches the snapshot', () => { + it('displays the counts data with labels', () => { expect(wrapper.element).toMatchSnapshot(); }); }); diff --git a/spec/frontend/projects/pipelines/charts/mock_data.js b/spec/frontend/projects/pipelines/charts/mock_data.js index 84e0ccb828a..da055536fcc 100644 --- a/spec/frontend/projects/pipelines/charts/mock_data.js +++ b/spec/frontend/projects/pipelines/charts/mock_data.js @@ -32,3 +32,218 @@ export const transformedAreaChartData = [ data: [['01 Jan', 3], ['02 Jan', 3], ['03 Jan', 3], ['04 Jan', 3], ['05 Jan', 5]], }, ]; + +export const mockPipelineCount = { + data: { + project: { + totalPipelines: { count: 34, __typename: 'PipelineConnection' }, + successfulPipelines: { count: 23, __typename: 'PipelineConnection' }, + failedPipelines: { count: 1, __typename: 'PipelineConnection' }, + totalPipelineDuration: 2471, + __typename: 'Project', + }, + }, +}; + +export const mockPipelineStatistics = { + data: { + project: { + pipelineAnalytics: { + weekPipelinesTotals: [0, 0, 0, 0, 0, 0, 0, 0], + weekPipelinesLabels: [ + '24 November', + '25 November', + '26 November', + '27 November', + '28 November', + '29 November', + '30 November', + '01 December', + ], + weekPipelinesSuccessful: [0, 0, 0, 0, 0, 0, 0, 0], + monthPipelinesLabels: [ + '01 November', + '02 November', + '03 November', + '04 November', + '05 November', + '06 November', + '07 November', + '08 November', + '09 November', + '10 November', + '11 November', + '12 November', + '13 November', + '14 November', + '15 November', + '16 November', + '17 November', + '18 November', + '19 November', + '20 November', + '21 November', + '22 November', + '23 November', + '24 November', + '25 November', + '26 November', + '27 November', + '28 November', + '29 November', + '30 November', + '01 December', + ], + monthPipelinesTotals: [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + monthPipelinesSuccessful: [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + yearPipelinesLabels: [ + 'December 2019', + 'January 2020', + 'February 2020', + 'March 2020', + 'April 2020', + 'May 2020', + 'June 2020', + 'July 2020', + 'August 2020', + 'September 2020', + 'October 2020', + 'November 2020', + 'December 2020', + ], + yearPipelinesTotals: [0, 0, 0, 0, 0, 0, 0, 0, 23, 7, 2, 2, 0], + yearPipelinesSuccessful: [0, 0, 0, 0, 0, 0, 0, 0, 17, 5, 1, 0, 0], + pipelineTimesLabels: [ + 'b3781247', + 'b3781247', + 'a50ba059', + '8e414f3b', + 'b2964d50', + '7caa525b', + '761b164e', + 'd3eccd18', + 'e2750f63', + 'e2750f63', + '1dfb4b96', + 'b49d6f94', + '66fa2f80', + 'e2750f63', + 'fc82cf15', + '19fb20b2', + '25f03a24', + 'e054110f', + '0278b7b2', + '38478c16', + '38478c16', + '38478c16', + '1fb2103e', + '97b99fb5', + '8abc6e87', + 'c94e80e3', + '5d349a50', + '5d349a50', + '9c581037', + '02d95fb2', + ], + pipelineTimesValues: [ + 1, + 0, + 0, + 0, + 0, + 1, + 1, + 2, + 1, + 0, + 1, + 2, + 2, + 0, + 4, + 2, + 1, + 2, + 1, + 1, + 0, + 1, + 1, + 0, + 1, + 5, + 2, + 0, + 0, + 0, + ], + __typename: 'Analytics', + }, + __typename: 'Project', + }, + }, +}; diff --git a/spec/frontend/projects/settings/components/shared_runners_toggle_spec.js b/spec/frontend/projects/settings/components/shared_runners_toggle_spec.js new file mode 100644 index 00000000000..1fac3d07b16 --- /dev/null +++ b/spec/frontend/projects/settings/components/shared_runners_toggle_spec.js @@ -0,0 +1,157 @@ +import { GlAlert, GlToggle, GlTooltip } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import MockAxiosAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import SharedRunnersToggleComponent from '~/projects/settings/components/shared_runners_toggle.vue'; +import axios from '~/lib/utils/axios_utils'; + +const TEST_UPDATE_PATH = '/test/update_shared_runners'; + +jest.mock('~/flash'); + +describe('projects/settings/components/shared_runners', () => { + let wrapper; + let mockAxios; + + const createComponent = (props = {}) => { + wrapper = shallowMount(SharedRunnersToggleComponent, { + propsData: { + isEnabled: false, + isDisabledAndUnoverridable: false, + isLoading: false, + updatePath: TEST_UPDATE_PATH, + ...props, + }, + }); + }; + + const findErrorAlert = () => wrapper.find(GlAlert); + const findSharedRunnersToggle = () => wrapper.find(GlToggle); + const findToggleTooltip = () => wrapper.find(GlTooltip); + const getToggleValue = () => findSharedRunnersToggle().props('value'); + const isToggleLoading = () => findSharedRunnersToggle().props('isLoading'); + const isToggleDisabled = () => findSharedRunnersToggle().props('disabled'); + + beforeEach(() => { + mockAxios = new MockAxiosAdapter(axios); + mockAxios.onPost(TEST_UPDATE_PATH).reply(200); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + mockAxios.restore(); + }); + + describe('with group share settings DISABLED', () => { + beforeEach(() => { + createComponent({ + isDisabledAndUnoverridable: true, + }); + }); + + it('toggle should be disabled', () => { + expect(isToggleDisabled()).toBe(true); + }); + + it('tooltip should exist explaining why the toggle is disabled', () => { + expect(findToggleTooltip().exists()).toBe(true); + }); + }); + + describe('with group share settings ENABLED', () => { + beforeEach(() => { + createComponent(); + }); + + it('toggle should be enabled', () => { + expect(isToggleDisabled()).toBe(false); + }); + + it('loading icon, error message, and tooltip should not exist', () => { + expect(isToggleLoading()).toBe(false); + expect(findErrorAlert().exists()).toBe(false); + expect(findToggleTooltip().exists()).toBe(false); + }); + + describe('with shared runners DISABLED', () => { + beforeEach(() => { + createComponent(); + }); + + it('toggle should be turned off', () => { + expect(getToggleValue()).toBe(false); + }); + + it('can enable toggle', async () => { + findSharedRunnersToggle().vm.$emit('change', true); + await waitForPromises(); + + expect(mockAxios.history.post[0].data).toEqual(undefined); + expect(mockAxios.history.post).toHaveLength(1); + expect(findErrorAlert().exists()).toBe(false); + expect(getToggleValue()).toBe(true); + }); + }); + + describe('with shared runners ENABLED', () => { + beforeEach(() => { + createComponent({ isEnabled: true }); + }); + + it('toggle should be turned on', () => { + expect(getToggleValue()).toBe(true); + }); + + it('can disable toggle', async () => { + findSharedRunnersToggle().vm.$emit('change', true); + await waitForPromises(); + + expect(mockAxios.history.post[0].data).toEqual(undefined); + expect(mockAxios.history.post).toHaveLength(1); + expect(findErrorAlert().exists()).toBe(false); + expect(getToggleValue()).toBe(false); + }); + }); + + describe('loading icon', () => { + it('should show and hide on request', async () => { + createComponent(); + expect(isToggleLoading()).toBe(false); + + findSharedRunnersToggle().vm.$emit('change', true); + await wrapper.vm.$nextTick(); + expect(isToggleLoading()).toBe(true); + + await waitForPromises(); + expect(isToggleLoading()).toBe(false); + }); + }); + + describe('when request encounters an error', () => { + it('should show custom error message from API if it exists', async () => { + mockAxios.onPost(TEST_UPDATE_PATH).reply(401, { error: 'Custom API Error message' }); + createComponent(); + expect(getToggleValue()).toBe(false); + + findSharedRunnersToggle().vm.$emit('change', true); + await waitForPromises(); + + expect(findErrorAlert().text()).toBe('Custom API Error message'); + expect(getToggleValue()).toBe(false); // toggle value should not change + }); + + it('should show default error message if API does not return a custom error message', async () => { + mockAxios.onPost(TEST_UPDATE_PATH).reply(401); + createComponent(); + expect(getToggleValue()).toBe(false); + + findSharedRunnersToggle().vm.$emit('change', true); + await waitForPromises(); + + expect(findErrorAlert().text()).toBe('An error occurred while updating the configuration.'); + expect(getToggleValue()).toBe(false); // toggle value should not change + }); + }); + }); +}); diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js index 5eee22f479e..7f0a4c7d3f4 100644 --- a/spec/frontend/ref/components/ref_selector_spec.js +++ b/spec/frontend/ref/components/ref_selector_spec.js @@ -128,14 +128,17 @@ describe('Ref selector component', () => { const selectFirstBranch = () => { findFirstBranchDropdownItem().vm.$emit('click'); + return wrapper.vm.$nextTick(); }; const selectFirstTag = () => { findFirstTagDropdownItem().vm.$emit('click'); + return wrapper.vm.$nextTick(); }; const selectFirstCommit = () => { findFirstCommitDropdownItem().vm.$emit('click'); + return wrapper.vm.$nextTick(); }; const waitForRequests = ({ andClearMocks } = { andClearMocks: false }) => @@ -522,75 +525,73 @@ describe('Ref selector component', () => { return waitForRequests(); }); - it('renders a checkmark by the selected item', () => { + it('renders a checkmark by the selected item', async () => { expect(findFirstBranchDropdownItem().find(GlIcon).element).toHaveClass( 'gl-visibility-hidden', ); - selectFirstBranch(); + await selectFirstBranch(); - return localVue.nextTick().then(() => { - expect(findFirstBranchDropdownItem().find(GlIcon).element).not.toHaveClass( - 'gl-visibility-hidden', - ); - }); + expect(findFirstBranchDropdownItem().find(GlIcon).element).not.toHaveClass( + 'gl-visibility-hidden', + ); }); describe('when a branch is seleceted', () => { - it("displays the branch name in the dropdown's button", () => { + it("displays the branch name in the dropdown's button", async () => { expect(findButtonContent().text()).toBe(DEFAULT_I18N.noRefSelected); - selectFirstBranch(); + await selectFirstBranch(); return localVue.nextTick().then(() => { expect(findButtonContent().text()).toBe(fixtures.branches[0].name); }); }); - it("updates the v-model binding with the branch's name", () => { + it("updates the v-model binding with the branch's name", async () => { expect(wrapper.vm.value).toEqual(''); - selectFirstBranch(); + await selectFirstBranch(); expect(wrapper.vm.value).toEqual(fixtures.branches[0].name); }); }); describe('when a tag is seleceted', () => { - it("displays the tag name in the dropdown's button", () => { + it("displays the tag name in the dropdown's button", async () => { expect(findButtonContent().text()).toBe(DEFAULT_I18N.noRefSelected); - selectFirstTag(); + await selectFirstTag(); return localVue.nextTick().then(() => { expect(findButtonContent().text()).toBe(fixtures.tags[0].name); }); }); - it("updates the v-model binding with the tag's name", () => { + it("updates the v-model binding with the tag's name", async () => { expect(wrapper.vm.value).toEqual(''); - selectFirstTag(); + await selectFirstTag(); expect(wrapper.vm.value).toEqual(fixtures.tags[0].name); }); }); describe('when a commit is selected', () => { - it("displays the full SHA in the dropdown's button", () => { + it("displays the full SHA in the dropdown's button", async () => { expect(findButtonContent().text()).toBe(DEFAULT_I18N.noRefSelected); - selectFirstCommit(); + await selectFirstCommit(); return localVue.nextTick().then(() => { expect(findButtonContent().text()).toBe(fixtures.commit.id); }); }); - it("updates the v-model binding with the commit's full SHA", () => { + it("updates the v-model binding with the commit's full SHA", async () => { expect(wrapper.vm.value).toEqual(''); - selectFirstCommit(); + await selectFirstCommit(); expect(wrapper.vm.value).toEqual(fixtures.commit.id); }); diff --git a/spec/frontend/registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap b/spec/frontend/registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap index aeb49f88770..5f191ef5561 100644 --- a/spec/frontend/registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap +++ b/spec/frontend/registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap @@ -2,9 +2,7 @@ exports[`TagsLoader component has the correct markup 1`] = ` <div> - <div - preserve-aspect-ratio="xMinYMax meet" - > + <div> <rect height="15" rx="4" diff --git a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js index fc93e9094c9..ec883886026 100644 --- a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js @@ -7,9 +7,27 @@ import { DETAILS_PAGE_TITLE } from '~/registry/explorer/constants'; describe('Details Header', () => { let wrapper; - const mountComponent = propsData => { + const defaultImage = { + name: 'foo', + updatedAt: '2020-11-03T13:29:21Z', + project: { + visibility: 'public', + }, + }; + + const findLastUpdatedAndVisibility = () => wrapper.find('[data-testid="updated-and-visibility"]'); + + const waitForMetadataItems = async () => { + // Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + }; + + const mountComponent = (image = defaultImage) => { wrapper = shallowMount(component, { - propsData, + propsData: { + image, + }, stubs: { GlSprintf, TitleArea, @@ -23,12 +41,34 @@ describe('Details Header', () => { }); it('has the correct title ', () => { - mountComponent(); + mountComponent({ ...defaultImage, name: '' }); expect(wrapper.text()).toMatchInterpolatedText(DETAILS_PAGE_TITLE); }); it('shows imageName in the title', () => { - mountComponent({ imageName: 'foo' }); + mountComponent(); expect(wrapper.text()).toContain('foo'); }); + + it('has a metadata item with last updated text', async () => { + mountComponent(); + await waitForMetadataItems(); + + expect(findLastUpdatedAndVisibility().props('text')).toBe('Last updated 1 month ago'); + }); + + describe('visibility icon', () => { + it('shows an eye when the project is public', async () => { + mountComponent(); + await waitForMetadataItems(); + + expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye'); + }); + it('shows an eye slashed when the project is not public', async () => { + mountComponent({ ...defaultImage, project: { visibility: 'private' } }); + await waitForMetadataItems(); + + expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye-slash'); + }); + }); }); diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js index 3276ef911e3..e1b75636735 100644 --- a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js @@ -15,12 +15,12 @@ import { NOT_AVAILABLE_SIZE, } from '~/registry/explorer/constants/index'; -import { tagsListResponse } from '../../mock_data'; +import { tagsMock } from '../../mock_data'; import { ListItem } from '../../stubs'; describe('tags list row', () => { let wrapper; - const [tag] = [...tagsListResponse.data]; + const [tag] = [...tagsMock]; const defaultProps = { tag, isMobile: false, index: 0 }; @@ -65,7 +65,7 @@ describe('tags list row', () => { }); it("does not exist when the row can't be deleted", () => { - const customTag = { ...tag, destroy_path: '' }; + const customTag = { ...tag, canDelete: false }; mountComponent({ ...defaultProps, tag: customTag }); @@ -137,8 +137,8 @@ describe('tags list row', () => { mountComponent(); expect(findClipboardButton().attributes()).toMatchObject({ - text: 'location', - title: 'location', + text: tag.location, + title: tag.location, }); }); }); @@ -171,26 +171,26 @@ describe('tags list row', () => { expect(findSize().exists()).toBe(true); }); - it('contains the total_size and layers', () => { - mountComponent({ ...defaultProps, tag: { ...tag, total_size: 1024 } }); + it('contains the totalSize and layers', () => { + mountComponent({ ...defaultProps, tag: { ...tag, totalSize: 1024, layers: 10 } }); expect(findSize().text()).toMatchInterpolatedText('1.00 KiB · 10 layers'); }); - it('when total_size is missing', () => { - mountComponent(); + it('when totalSize is missing', () => { + mountComponent({ ...defaultProps, tag: { ...tag, totalSize: 0, layers: 10 } }); expect(findSize().text()).toMatchInterpolatedText(`${NOT_AVAILABLE_SIZE} · 10 layers`); }); it('when layers are missing', () => { - mountComponent({ ...defaultProps, tag: { ...tag, total_size: 1024, layers: null } }); + mountComponent({ ...defaultProps, tag: { ...tag, totalSize: 1024 } }); expect(findSize().text()).toMatchInterpolatedText('1.00 KiB'); }); it('when there is 1 layer', () => { - mountComponent({ ...defaultProps, tag: { ...tag, layers: 1 } }); + mountComponent({ ...defaultProps, tag: { ...tag, totalSize: 0, layers: 1 } }); expect(findSize().text()).toMatchInterpolatedText(`${NOT_AVAILABLE_SIZE} · 1 layer`); }); @@ -218,7 +218,7 @@ describe('tags list row', () => { it('pass the correct props to time ago tooltip', () => { mountComponent(); - expect(findTimeAgoTooltip().attributes()).toMatchObject({ time: tag.created_at }); + expect(findTimeAgoTooltip().attributes()).toMatchObject({ time: tag.createdAt }); }); }); @@ -232,7 +232,7 @@ describe('tags list row', () => { it('has the correct text', () => { mountComponent(); - expect(findShortRevision().text()).toMatchInterpolatedText('Digest: 1ab51d5'); + expect(findShortRevision().text()).toMatchInterpolatedText('Digest: 2cf3d2f'); }); it(`displays ${NOT_AVAILABLE_TEXT} when digest is missing`, () => { @@ -260,18 +260,15 @@ describe('tags list row', () => { }); it.each` - destroy_path | digest - ${'foo'} | ${null} - ${null} | ${'foo'} - ${null} | ${null} - `( - 'is disabled when destroy_path is $destroy_path and digest is $digest', - ({ destroy_path, digest }) => { - mountComponent({ ...defaultProps, tag: { ...tag, destroy_path, digest } }); - - expect(findDeleteButton().attributes('disabled')).toBe('true'); - }, - ); + canDelete | digest + ${true} | ${null} + ${false} | ${'foo'} + ${false} | ${null} + `('is disabled when canDelete is $canDelete and digest is $digest', ({ canDelete, digest }) => { + mountComponent({ ...defaultProps, tag: { ...tag, canDelete, digest } }); + + expect(findDeleteButton().attributes('disabled')).toBe('true'); + }); it('delete event emits delete', () => { mountComponent(); @@ -295,10 +292,10 @@ describe('tags list row', () => { }); describe.each` - name | finderFunction | text | icon | clipboard - ${'published date detail'} | ${findPublishedDateDetail} | ${'Published to the bar image repository at 10:23 GMT+0000 on 2020-06-29'} | ${'clock'} | ${false} - ${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:1ab51d519f574b636ae7788051c60239334ae8622a9fd82a0cf7bae7786dfd5c'} | ${'log'} | ${true} - ${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43'} | ${'cloud-gear'} | ${true} + name | finderFunction | text | icon | clipboard + ${'published date detail'} | ${findPublishedDateDetail} | ${'Published to the gitlab-org/gitlab-test/rails-12009 image repository at 01:29 GMT+0000 on 2020-11-03'} | ${'clock'} | ${false} + ${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:2cf3d2fdac1b04a14301d47d51cb88dcd26714c74f91440eeee99ce399089062'} | ${'log'} | ${true} + ${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:c2613843ab33aabf847965442b13a8b55a56ae28837ce182627c0716eb08c02b'} | ${'cloud-gear'} | ${true} `('$name details row', ({ finderFunction, text, icon, clipboard }) => { it(`has ${text} as text`, () => { expect(finderFunction().text()).toMatchInterpolatedText(text); diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js index ebeaa8ff870..035b59731c9 100644 --- a/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js @@ -3,12 +3,12 @@ import { GlButton } from '@gitlab/ui'; import component from '~/registry/explorer/components/details_page/tags_list.vue'; import TagsListRow from '~/registry/explorer/components/details_page/tags_list_row.vue'; import { TAGS_LIST_TITLE, REMOVE_TAGS_BUTTON_TITLE } from '~/registry/explorer/constants/index'; -import { tagsListResponse } from '../../mock_data'; +import { tagsMock } from '../../mock_data'; describe('Tags List', () => { let wrapper; - const tags = [...tagsListResponse.data]; - const readOnlyTags = tags.map(t => ({ ...t, destroy_path: undefined })); + const tags = [...tagsMock]; + const readOnlyTags = tags.map(t => ({ ...t, canDelete: false })); const findTagsListRow = () => wrapper.findAll(TagsListRow); const findDeleteButton = () => wrapper.find(GlButton); @@ -92,7 +92,7 @@ describe('Tags List', () => { .vm.$emit('select'); findDeleteButton().vm.$emit('click'); - expect(wrapper.emitted('delete')).toEqual([[{ centos6: true }]]); + expect(wrapper.emitted('delete')).toEqual([[{ 'beta-24753': true }]]); }); }); @@ -132,7 +132,7 @@ describe('Tags List', () => { findTagsListRow() .at(0) .vm.$emit('delete'); - expect(wrapper.emitted('delete')).toEqual([[{ centos6: true }]]); + expect(wrapper.emitted('delete')).toEqual([[{ 'beta-24753': true }]]); }); }); }); diff --git a/spec/frontend/registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap b/spec/frontend/registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap index a8412e2bde9..56579847468 100644 --- a/spec/frontend/registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap +++ b/spec/frontend/registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap @@ -1,10 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Registry Group Empty state to match the default snapshot 1`] = ` -<div - svg-path="foo" - title="There are no container images available in this group" -> +<div> <p> With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. <gl-link-stub diff --git a/spec/frontend/registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap b/spec/frontend/registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap index 8413e17c7b2..bab6b25cc15 100644 --- a/spec/frontend/registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap +++ b/spec/frontend/registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap @@ -1,10 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Registry Project Empty state to match the default snapshot 1`] = ` -<div - svg-path="bazFoo" - title="There are no container images stored for this project" -> +<div> <p> With the Container Registry, every project can have its own space to store its Docker images. <gl-link-stub @@ -46,7 +43,7 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` class="gl-font-monospace!" readonly="" type="text" - value="docker login bar" + value="bazbaz" /> </gl-form-input-group-stub> @@ -67,7 +64,7 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` class="gl-font-monospace!" readonly="" type="text" - value="docker build -t foo ." + value="foofoo" /> </gl-form-input-group-stub> @@ -79,7 +76,7 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` class="gl-font-monospace!" readonly="" type="text" - value="docker push foo" + value="barbar" /> </gl-form-input-group-stub> </div> diff --git a/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js b/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js index 551d1eee68d..74b9ea5fd96 100644 --- a/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js @@ -2,10 +2,8 @@ import Vuex from 'vuex'; import { mount, createLocalVue } from '@vue/test-utils'; import { GlDropdown } from '@gitlab/ui'; import Tracking from '~/tracking'; -import * as getters from '~/registry/explorer/stores/getters'; import QuickstartDropdown from '~/registry/explorer/components/list_page/cli_commands.vue'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; - import { QUICK_START, LOGIN_COMMAND_LABEL, @@ -14,31 +12,33 @@ import { COPY_BUILD_TITLE, PUSH_COMMAND_LABEL, COPY_PUSH_TITLE, -} from '~/registry/explorer//constants'; +} from '~/registry/explorer/constants'; + +import { dockerCommands } from '../../mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); describe('cli_commands', () => { let wrapper; - let store; + + const config = { + repositoryUrl: 'foo', + registryHostUrlWithPort: 'bar', + }; const findDropdownButton = () => wrapper.find(GlDropdown); const findCodeInstruction = () => wrapper.findAll(CodeInstruction); const mountComponent = () => { - store = new Vuex.Store({ - state: { - config: { - repositoryUrl: 'foo', - registryHostUrlWithPort: 'bar', - }, - }, - getters, - }); wrapper = mount(QuickstartDropdown, { localVue, - store, + provide() { + return { + config, + ...dockerCommands, + }; + }, }); }; @@ -50,7 +50,6 @@ describe('cli_commands', () => { afterEach(() => { wrapper.destroy(); wrapper = null; - store = null; }); it('shows the correct text on the button', () => { @@ -67,11 +66,11 @@ describe('cli_commands', () => { }); describe.each` - index | labelText | titleText | getter | trackedEvent - ${0} | ${LOGIN_COMMAND_LABEL} | ${COPY_LOGIN_TITLE} | ${'dockerLoginCommand'} | ${'click_copy_login'} - ${1} | ${BUILD_COMMAND_LABEL} | ${COPY_BUILD_TITLE} | ${'dockerBuildCommand'} | ${'click_copy_build'} - ${2} | ${PUSH_COMMAND_LABEL} | ${COPY_PUSH_TITLE} | ${'dockerPushCommand'} | ${'click_copy_push'} - `('code instructions at $index', ({ index, labelText, titleText, getter, trackedEvent }) => { + index | labelText | titleText | command | trackedEvent + ${0} | ${LOGIN_COMMAND_LABEL} | ${COPY_LOGIN_TITLE} | ${dockerCommands.dockerLoginCommand} | ${'click_copy_login'} + ${1} | ${BUILD_COMMAND_LABEL} | ${COPY_BUILD_TITLE} | ${dockerCommands.dockerBuildCommand} | ${'click_copy_build'} + ${2} | ${PUSH_COMMAND_LABEL} | ${COPY_PUSH_TITLE} | ${dockerCommands.dockerPushCommand} | ${'click_copy_push'} + `('code instructions at $index', ({ index, labelText, titleText, command, trackedEvent }) => { let codeInstruction; beforeEach(() => { @@ -85,7 +84,7 @@ describe('cli_commands', () => { it(`has the correct props`, () => { expect(codeInstruction.props()).toMatchObject({ label: labelText, - instruction: store.getters[getter], + instruction: command, copyText: titleText, trackingAction: trackedEvent, trackingLabel: 'quickstart_dropdown', diff --git a/spec/frontend/registry/explorer/components/list_page/group_empty_state_spec.js b/spec/frontend/registry/explorer/components/list_page/group_empty_state_spec.js index 2f51e875672..1ba2036dc34 100644 --- a/spec/frontend/registry/explorer/components/list_page/group_empty_state_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/group_empty_state_spec.js @@ -9,24 +9,21 @@ localVue.use(Vuex); describe('Registry Group Empty state', () => { let wrapper; - let store; + const config = { + noContainersImage: 'foo', + helpPagePath: 'baz', + }; beforeEach(() => { - store = new Vuex.Store({ - state: { - config: { - noContainersImage: 'foo', - helpPagePath: 'baz', - }, - }, - }); wrapper = shallowMount(groupEmptyState, { localVue, - store, stubs: { GlEmptyState, GlSprintf, }, + provide() { + return { config }; + }, }); }); diff --git a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js index 9f7a2758ae1..b9839d92f1d 100644 --- a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js @@ -1,6 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import { GlIcon, GlSprintf } from '@gitlab/ui'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import Component from '~/registry/explorer/components/list_page/image_list_row.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; @@ -11,13 +12,15 @@ import { REMOVE_REPOSITORY_LABEL, ASYNC_DELETE_IMAGE_ERROR_MESSAGE, CLEANUP_TIMED_OUT_ERROR_MESSAGE, + IMAGE_DELETE_SCHEDULED_STATUS, + IMAGE_FAILED_DELETED_STATUS, } from '~/registry/explorer/constants'; import { RouterLink } from '../../stubs'; import { imagesListResponse } from '../../mock_data'; describe('Image List Row', () => { let wrapper; - const item = imagesListResponse.data[0]; + const [item] = imagesListResponse; const findDetailsLink = () => wrapper.find('[data-testid="details-link"]'); const findTagsCount = () => wrapper.find('[data-testid="tagsCount"]'); @@ -50,13 +53,15 @@ describe('Image List Row', () => { describe('main tooltip', () => { it(`the title is ${ROW_SCHEDULED_FOR_DELETION}`, () => { mountComponent(); + const tooltip = getBinding(wrapper.element, 'gl-tooltip'); expect(tooltip).toBeDefined(); expect(tooltip.value.title).toBe(ROW_SCHEDULED_FOR_DELETION); }); it('is disabled when item is being deleted', () => { - mountComponent({ item: { ...item, deleting: true } }); + mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } }); + const tooltip = getBinding(wrapper.element, 'gl-tooltip'); expect(tooltip.value.disabled).toBe(false); }); @@ -65,12 +70,13 @@ describe('Image List Row', () => { describe('image title and path', () => { it('contains a link to the details page', () => { mountComponent(); + const link = findDetailsLink(); expect(link.html()).toContain(item.path); expect(link.props('to')).toMatchObject({ name: 'details', params: { - id: item.id, + id: getIdFromGraphQLId(item.id), }, }); }); @@ -85,16 +91,18 @@ describe('Image List Row', () => { describe('warning icon', () => { it.each` - failedDelete | cleanup_policy_started_at | shown | title - ${true} | ${true} | ${true} | ${ASYNC_DELETE_IMAGE_ERROR_MESSAGE} - ${false} | ${true} | ${true} | ${CLEANUP_TIMED_OUT_ERROR_MESSAGE} - ${false} | ${false} | ${false} | ${''} + status | expirationPolicyStartedAt | shown | title + ${IMAGE_FAILED_DELETED_STATUS} | ${true} | ${true} | ${ASYNC_DELETE_IMAGE_ERROR_MESSAGE} + ${''} | ${true} | ${true} | ${CLEANUP_TIMED_OUT_ERROR_MESSAGE} + ${''} | ${false} | ${false} | ${''} `( - 'when failedDelete is $failedDelete and cleanup_policy_started_at is $cleanup_policy_started_at', - ({ cleanup_policy_started_at, failedDelete, shown, title }) => { - mountComponent({ item: { ...item, failedDelete, cleanup_policy_started_at } }); + 'when status is $status and expirationPolicyStartedAt is $expirationPolicyStartedAt', + ({ expirationPolicyStartedAt, status, shown, title }) => { + mountComponent({ item: { ...item, status, expirationPolicyStartedAt } }); + const icon = findWarningIcon(); expect(icon.exists()).toBe(shown); + if (shown) { const tooltip = getBinding(icon.element, 'gl-tooltip'); expect(tooltip.value.title).toBe(title); @@ -112,30 +120,33 @@ describe('Image List Row', () => { it('has the correct props', () => { mountComponent(); - expect(findDeleteBtn().attributes()).toMatchObject({ + + expect(findDeleteBtn().props()).toMatchObject({ title: REMOVE_REPOSITORY_LABEL, - tooltipdisabled: `${Boolean(item.destroy_path)}`, - tooltiptitle: LIST_DELETE_BUTTON_DISABLED, + tooltipDisabled: item.canDelete, + tooltipTitle: LIST_DELETE_BUTTON_DISABLED, }); }); it('emits a delete event', () => { mountComponent(); + findDeleteBtn().vm.$emit('delete'); expect(wrapper.emitted('delete')).toEqual([[item]]); }); it.each` - destroy_path | deleting | state - ${null} | ${null} | ${'true'} - ${null} | ${true} | ${'true'} - ${'foo'} | ${true} | ${'true'} - ${'foo'} | ${false} | ${undefined} + canDelete | status | state + ${false} | ${''} | ${true} + ${false} | ${IMAGE_DELETE_SCHEDULED_STATUS} | ${true} + ${true} | ${IMAGE_DELETE_SCHEDULED_STATUS} | ${true} + ${true} | ${''} | ${false} `( - 'disabled is $state when destroy_path is $destroy_path and deleting is $deleting', - ({ destroy_path, deleting, state }) => { - mountComponent({ item: { ...item, destroy_path, deleting } }); - expect(findDeleteBtn().attributes('disabled')).toBe(state); + 'disabled is $state when canDelete is $canDelete and status is $status', + ({ canDelete, status, state }) => { + mountComponent({ item: { ...item, canDelete, status } }); + + expect(findDeleteBtn().props('disabled')).toBe(state); }, ); }); @@ -155,11 +166,13 @@ describe('Image List Row', () => { describe('tags count text', () => { it('with one tag in the image', () => { - mountComponent({ item: { ...item, tags_count: 1 } }); + mountComponent({ item: { ...item, tagsCount: 1 } }); + expect(findTagsCount().text()).toMatchInterpolatedText('1 Tag'); }); it('with more than one tag in the image', () => { - mountComponent({ item: { ...item, tags_count: 3 } }); + mountComponent({ item: { ...item, tagsCount: 3 } }); + expect(findTagsCount().text()).toMatchInterpolatedText('3 Tags'); }); }); diff --git a/spec/frontend/registry/explorer/components/list_page/image_list_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_spec.js index 03ba6ad7f80..54befc9973a 100644 --- a/spec/frontend/registry/explorer/components/list_page/image_list_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/image_list_spec.js @@ -1,29 +1,25 @@ import { shallowMount } from '@vue/test-utils'; -import { GlPagination } from '@gitlab/ui'; +import { GlKeysetPagination } from '@gitlab/ui'; import Component from '~/registry/explorer/components/list_page/image_list.vue'; import ImageListRow from '~/registry/explorer/components/list_page/image_list_row.vue'; -import { imagesListResponse, imagePagination } from '../../mock_data'; +import { imagesListResponse, pageInfo as defaultPageInfo } from '../../mock_data'; describe('Image List', () => { let wrapper; const findRow = () => wrapper.findAll(ImageListRow); - const findPagination = () => wrapper.find(GlPagination); + const findPagination = () => wrapper.find(GlKeysetPagination); - const mountComponent = () => { + const mountComponent = (pageInfo = defaultPageInfo) => { wrapper = shallowMount(Component, { propsData: { - images: imagesListResponse.data, - pagination: imagePagination, + images: imagesListResponse, + pageInfo, }, }); }; - beforeEach(() => { - mountComponent(); - }); - afterEach(() => { wrapper.destroy(); wrapper = null; @@ -31,10 +27,14 @@ describe('Image List', () => { describe('list', () => { it('contains one list element for each image', () => { - expect(findRow().length).toBe(imagesListResponse.data.length); + mountComponent(); + + expect(findRow().length).toBe(imagesListResponse.length); }); it('when delete event is emitted on the row it emits up a delete event', () => { + mountComponent(); + findRow() .at(0) .vm.$emit('delete', 'foo'); @@ -44,19 +44,41 @@ describe('Image List', () => { describe('pagination', () => { it('exists', () => { + mountComponent(); + expect(findPagination().exists()).toBe(true); }); - it('is wired to the correct pagination props', () => { - const pagination = findPagination(); - expect(pagination.props('perPage')).toBe(imagePagination.perPage); - expect(pagination.props('totalItems')).toBe(imagePagination.total); - expect(pagination.props('value')).toBe(imagePagination.page); + it.each` + hasNextPage | hasPreviousPage | isVisible + ${true} | ${true} | ${true} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + `( + 'when hasNextPage is $hasNextPage and hasPreviousPage is $hasPreviousPage: is $isVisible that the component is visible', + ({ hasNextPage, hasPreviousPage, isVisible }) => { + mountComponent({ hasNextPage, hasPreviousPage }); + + expect(findPagination().exists()).toBe(isVisible); + expect(findPagination().props('hasPreviousPage')).toBe(hasPreviousPage); + expect(findPagination().props('hasNextPage')).toBe(hasNextPage); + }, + ); + + it('emits "prev-page" when the user clicks the back page button', () => { + mountComponent({ hasPreviousPage: true }); + + findPagination().vm.$emit('prev'); + + expect(wrapper.emitted('prev-page')).toEqual([[]]); }); - it('emits a pageChange event when the page change', () => { - findPagination().vm.$emit(GlPagination.model.event, 2); - expect(wrapper.emitted('pageChange')).toEqual([[2]]); + it('emits "next-page" when the user clicks the forward page button', () => { + mountComponent({ hasNextPage: true }); + + findPagination().vm.$emit('next'); + + expect(wrapper.emitted('next-page')).toEqual([[]]); }); }); }); diff --git a/spec/frontend/registry/explorer/components/list_page/project_empty_state_spec.js b/spec/frontend/registry/explorer/components/list_page/project_empty_state_spec.js index 73746c545cb..3a27cf1923c 100644 --- a/spec/frontend/registry/explorer/components/list_page/project_empty_state_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/project_empty_state_spec.js @@ -3,36 +3,35 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import { GlSprintf } from '@gitlab/ui'; import { GlEmptyState } from '../../stubs'; import projectEmptyState from '~/registry/explorer/components/list_page/project_empty_state.vue'; -import * as getters from '~/registry/explorer/stores/getters'; +import { dockerCommands } from '../../mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); describe('Registry Project Empty state', () => { let wrapper; - let store; + const config = { + repositoryUrl: 'foo', + registryHostUrlWithPort: 'bar', + helpPagePath: 'baz', + twoFactorAuthHelpLink: 'barBaz', + personalAccessTokensHelpLink: 'fooBaz', + noContainersImage: 'bazFoo', + }; beforeEach(() => { - store = new Vuex.Store({ - state: { - config: { - repositoryUrl: 'foo', - registryHostUrlWithPort: 'bar', - helpPagePath: 'baz', - twoFactorAuthHelpLink: 'barBaz', - personalAccessTokensHelpLink: 'fooBaz', - noContainersImage: 'bazFoo', - }, - }, - getters, - }); wrapper = shallowMount(projectEmptyState, { localVue, - store, stubs: { GlEmptyState, GlSprintf, }, + provide() { + return { + config, + ...dockerCommands, + }; + }, }); }); diff --git a/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js b/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js index d730bfcde24..fb0b98ba004 100644 --- a/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js +++ b/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js @@ -32,10 +32,6 @@ describe('Registry Breadcrumb', () => { { name: 'baz', meta: { nameGenerator } }, ]; - const state = { - imageDetails: { foo: 'bar' }, - }; - const findDivider = () => wrapper.find('.js-divider'); const findRootRoute = () => wrapper.find({ ref: 'rootRouteLink' }); const findChildRoute = () => wrapper.find({ ref: 'childRouteLink' }); @@ -56,9 +52,6 @@ describe('Registry Breadcrumb', () => { routes, }, }, - $store: { - state, - }, }, }); }; @@ -87,7 +80,6 @@ describe('Registry Breadcrumb', () => { }); it('the link text is calculated by nameGenerator', () => { - expect(nameGenerator).toHaveBeenCalledWith(state); expect(nameGenerator).toHaveBeenCalledTimes(1); }); }); @@ -111,7 +103,6 @@ describe('Registry Breadcrumb', () => { }); it('the link text is calculated by nameGenerator', () => { - expect(nameGenerator).toHaveBeenCalledWith(state); expect(nameGenerator).toHaveBeenCalledTimes(2); }); }); diff --git a/spec/frontend/registry/explorer/mock_data.js b/spec/frontend/registry/explorer/mock_data.js index da5f1840b5c..992d880581a 100644 --- a/spec/frontend/registry/explorer/mock_data.js +++ b/spec/frontend/registry/explorer/mock_data.js @@ -1,110 +1,211 @@ -export const headers = { - 'X-PER-PAGE': 5, - 'X-PAGE': 1, - 'X-TOTAL': 13, - 'X-TOTAL_PAGES': 1, - 'X-NEXT-PAGE': null, - 'X-PREVIOUS-PAGE': null, -}; -export const reposServerResponse = [ +export const imagesListResponse = [ { - destroy_path: 'path', - id: '123', - location: 'location', - path: 'foo', - tags_path: 'tags_path', + __typename: 'ContainerRepository', + id: 'gid://gitlab/ContainerRepository/26', + name: 'rails-12009', + path: 'gitlab-org/gitlab-test/rails-12009', + status: null, + location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-12009', + canDelete: true, + createdAt: '2020-11-03T13:29:21Z', + tagsCount: 18, + expirationPolicyStartedAt: null, }, { - destroy_path: 'path_', - id: '456', - location: 'location_', - path: 'bar', - tags_path: 'tags_path_', + __typename: 'ContainerRepository', + id: 'gid://gitlab/ContainerRepository/11', + name: 'rails-20572', + path: 'gitlab-org/gitlab-test/rails-20572', + status: null, + location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-20572', + canDelete: true, + createdAt: '2020-09-21T06:57:43Z', + tagsCount: 1, + expirationPolicyStartedAt: null, }, ]; -export const registryServerResponse = [ - { - name: 'centos7', - short_revision: 'b118ab5b0', - revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43', - total_size: 679, - layers: 19, - location: 'location', - created_at: 1505828744434, - destroy_path: 'path_', +export const pageInfo = { + hasNextPage: true, + hasPreviousPage: true, + startCursor: 'eyJpZCI6IjI2In0', + endCursor: 'eyJpZCI6IjgifQ', + __typename: 'ContainerRepositoryConnection', +}; + +export const graphQLImageListMock = { + data: { + project: { + __typename: 'Project', + containerRepositoriesCount: 2, + containerRepositories: { + __typename: 'ContainerRepositoryConnection', + nodes: imagesListResponse, + pageInfo, + }, + }, }, - { - name: 'centos6', - short_revision: 'b118ab5b0', - revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43', - total_size: 679, - layers: 19, - location: 'location', - created_at: 1505828744434, +}; + +export const graphQLEmptyImageListMock = { + data: { + project: { + __typename: 'Project', + containerRepositoriesCount: 2, + containerRepositories: { + __typename: 'ContainerRepositoryConnection', + nodes: [], + pageInfo, + }, + }, }, -]; +}; -export const imagesListResponse = { - data: [ - { - path: 'foo', - location: 'location', - destroy_path: 'path', +export const graphQLEmptyGroupImageListMock = { + data: { + group: { + __typename: 'Group', + containerRepositoriesCount: 2, + containerRepositories: { + __typename: 'ContainerRepositoryConnection', + nodes: [], + pageInfo, + }, }, - { - path: 'bar', - location: 'location-2', - destroy_path: 'path-2', + }, +}; + +export const deletedContainerRepository = { + id: 'gid://gitlab/ContainerRepository/11', + status: 'DELETE_SCHEDULED', + path: 'gitlab-org/gitlab-test/rails-12009', + __typename: 'ContainerRepository', +}; + +export const graphQLImageDeleteMock = { + data: { + destroyContainerRepository: { + containerRepository: { + ...deletedContainerRepository, + }, + errors: [], + __typename: 'DestroyContainerRepositoryPayload', + }, + }, +}; + +export const graphQLImageDeleteMockError = { + data: { + destroyContainerRepository: { + containerRepository: { + ...deletedContainerRepository, + }, + errors: ['foo'], + __typename: 'DestroyContainerRepositoryPayload', }, - ], - headers, + }, +}; + +export const containerRepositoryMock = { + id: 'gid://gitlab/ContainerRepository/26', + name: 'rails-12009', + path: 'gitlab-org/gitlab-test/rails-12009', + status: null, + location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009', + canDelete: true, + createdAt: '2020-11-03T13:29:21Z', + updatedAt: '2020-11-03T13:29:21Z', + tagsCount: 13, + expirationPolicyStartedAt: null, + project: { + visibility: 'public', + __typename: 'Project', + }, }; -export const tagsListResponse = { - data: [ - { - name: 'centos6', - revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43', - short_revision: 'b118ab5b0', - size: 19, - layers: 10, - location: 'location', - path: 'bar:centos6', - created_at: '2020-06-29T10:23:51.766+00:00', - destroy_path: 'path', - digest: 'sha256:1ab51d519f574b636ae7788051c60239334ae8622a9fd82a0cf7bae7786dfd5c', +export const tagsPageInfo = { + __typename: 'PageInfo', + hasNextPage: true, + hasPreviousPage: true, + startCursor: 'MQ', + endCursor: 'MTA', +}; + +export const tagsMock = [ + { + digest: 'sha256:2cf3d2fdac1b04a14301d47d51cb88dcd26714c74f91440eeee99ce399089062', + location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009:beta-24753', + path: 'gitlab-org/gitlab-test/rails-12009:beta-24753', + name: 'beta-24753', + revision: 'c2613843ab33aabf847965442b13a8b55a56ae28837ce182627c0716eb08c02b', + shortRevision: 'c2613843a', + createdAt: '2020-11-03T13:29:38+00:00', + totalSize: 105, + canDelete: true, + __typename: 'ContainerRepositoryTag', + }, + { + digest: 'sha256:7f94f97dff89ffd122cafe50cd32329adf682356a7a96f69cbfe313ee589791c', + location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009:beta-31075', + path: 'gitlab-org/gitlab-test/rails-12009:beta-31075', + name: 'beta-31075', + revision: 'df44e7228f0f255c73e35b6f0699624a615f42746e3e8e2e4b3804a6d6fc3292', + shortRevision: 'df44e7228', + createdAt: '2020-11-03T13:29:32+00:00', + totalSize: 104, + canDelete: true, + __typename: 'ContainerRepositoryTag', + }, +]; + +export const graphQLImageDetailsMock = override => ({ + data: { + containerRepository: { + ...containerRepositoryMock, + + tags: { + nodes: tagsMock, + pageInfo: { ...tagsPageInfo }, + __typename: 'ContainerRepositoryTagConnection', + }, + __typename: 'ContainerRepositoryDetails', + ...override, }, - { - name: 'test-tag', - revision: 'b969de599faea2b3d9b6605a8b0897261c571acaa36db1bdc7349b5775b4e0b4', - short_revision: 'b969de599', - size: 19, - layers: 10, - path: 'foo:test-tag', - location: 'location-2', - created_at: '2020-06-29T10:23:51.766+00:00', - digest: 'sha256:1ab51d519f574b636ae7788051c60239334ae8622a9fd82a0cf7bae7736dfd5c', + }, +}); + +export const graphQLImageDetailsEmptyTagsMock = { + data: { + containerRepository: { + ...containerRepositoryMock, + tags: { + nodes: [], + pageInfo: { + __typename: 'PageInfo', + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + }, + __typename: 'ContainerRepositoryTagConnection', + }, + __typename: 'ContainerRepositoryDetails', }, - ], - headers, + }, }; -export const imagePagination = { - perPage: 10, - page: 1, - total: 14, - totalPages: 2, - nextPage: 2, +export const graphQLDeleteImageRepositoryTagsMock = { + data: { + destroyContainerRepositoryTags: { + deletedTagNames: [], + errors: [], + __typename: 'DestroyContainerRepositoryTagsPayload', + }, + }, }; -export const imageDetailsMock = { - id: 1, - name: 'rails-32309', - path: 'gitlab-org/gitlab-test/rails-32309', - project_id: 1, - location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-32309', - created_at: '2020-06-29T10:23:47.838Z', - cleanup_policy_started_at: null, - delete_api_path: 'http://0.0.0.0:3000/api/v4/projects/1/registry/repositories/1', +export const dockerCommands = { + dockerBuildCommand: 'foofoo', + dockerPushCommand: 'barbar', + dockerLoginCommand: 'bazbaz', }; diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js index c09b7e0c067..d307dfe590c 100644 --- a/spec/frontend/registry/explorer/pages/details_spec.js +++ b/spec/frontend/registry/explorer/pages/details_spec.js @@ -1,5 +1,8 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlPagination } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlKeysetPagination } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import Tracking from '~/tracking'; import component from '~/registry/explorer/pages/details.vue'; import DeleteAlert from '~/registry/explorer/components/details_page/delete_alert.vue'; @@ -8,25 +11,28 @@ import DetailsHeader from '~/registry/explorer/components/details_page/details_h import TagsLoader from '~/registry/explorer/components/details_page/tags_loader.vue'; import TagsList from '~/registry/explorer/components/details_page/tags_list.vue'; import EmptyTagsState from '~/registry/explorer/components/details_page/empty_tags_state.vue'; -import { createStore } from '~/registry/explorer/stores/'; + +import getContainerRepositoryDetailsQuery from '~/registry/explorer/graphql/queries/get_container_repository_details.query.graphql'; +import deleteContainerRepositoryTagsMutation from '~/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql'; + import { - SET_MAIN_LOADING, - SET_TAGS_LIST_SUCCESS, - SET_TAGS_PAGINATION, - SET_INITIAL_STATE, - SET_IMAGE_DETAILS, -} from '~/registry/explorer/stores/mutation_types'; - -import { tagsListResponse, imageDetailsMock } from '../mock_data'; + graphQLImageDetailsMock, + graphQLImageDetailsEmptyTagsMock, + graphQLDeleteImageRepositoryTagsMock, + containerRepositoryMock, + tagsMock, + tagsPageInfo, +} from '../mock_data'; import { DeleteModal } from '../stubs'; +const localVue = createLocalVue(); + describe('Details Page', () => { let wrapper; - let dispatchSpy; - let store; + let apolloProvider; const findDeleteModal = () => wrapper.find(DeleteModal); - const findPagination = () => wrapper.find(GlPagination); + const findPagination = () => wrapper.find(GlKeysetPagination); const findTagsLoader = () => wrapper.find(TagsLoader); const findTagsList = () => wrapper.find(TagsList); const findDeleteAlert = () => wrapper.find(DeleteAlert); @@ -36,15 +42,46 @@ describe('Details Page', () => { const routeId = 1; + const breadCrumbState = { + updateName: jest.fn(), + }; + + const cleanTags = tagsMock.map(t => { + const result = { ...t }; + // eslint-disable-next-line no-underscore-dangle + delete result.__typename; + return result; + }); + + const waitForApolloRequestRender = async () => { + await waitForPromises(); + await wrapper.vm.$nextTick(); + }; + const tagsArrayToSelectedTags = tags => tags.reduce((acc, c) => { acc[c.name] = true; return acc; }, {}); - const mountComponent = ({ options } = {}) => { + const mountComponent = ({ + resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock()), + mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock), + options, + config = {}, + } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [ + [getContainerRepositoryDetailsQuery, resolver], + [deleteContainerRepositoryTagsMutation, mutationResolver], + ]; + + apolloProvider = createMockApollo(requestHandlers); + wrapper = shallowMount(component, { - store, + localVue, + apolloProvider, stubs: { DeleteModal, }, @@ -55,17 +92,17 @@ describe('Details Page', () => { }, }, }, + provide() { + return { + breadCrumbState, + config, + }; + }, ...options, }); }; beforeEach(() => { - store = createStore(); - dispatchSpy = jest.spyOn(store, 'dispatch'); - dispatchSpy.mockResolvedValue(); - store.commit(SET_TAGS_LIST_SUCCESS, tagsListResponse.data); - store.commit(SET_TAGS_PAGINATION, tagsListResponse.headers); - store.commit(SET_IMAGE_DETAILS, imageDetailsMock); jest.spyOn(Tracking, 'event'); }); @@ -74,85 +111,90 @@ describe('Details Page', () => { wrapper = null; }); - describe('lifecycle events', () => { - it('calls the appropriate action on mount', () => { - mountComponent(); - expect(dispatchSpy).toHaveBeenCalledWith('requestImageDetailsAndTagsList', routeId); - }); - }); - describe('when isLoading is true', () => { - beforeEach(() => { - store.commit(SET_MAIN_LOADING, true); + it('shows the loader', () => { mountComponent(); - }); - - afterEach(() => store.commit(SET_MAIN_LOADING, false)); - it('shows the loader', () => { expect(findTagsLoader().exists()).toBe(true); }); it('does not show the list', () => { + mountComponent(); + expect(findTagsList().exists()).toBe(false); }); it('does not show pagination', () => { + mountComponent(); + expect(findPagination().exists()).toBe(false); }); }); describe('when the list of tags is empty', () => { - beforeEach(() => { - store.commit(SET_TAGS_LIST_SUCCESS, []); - mountComponent(); - }); + const resolver = jest.fn().mockResolvedValue(graphQLImageDetailsEmptyTagsMock); + + it('has the empty state', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); - it('has the empty state', () => { expect(findEmptyTagsState().exists()).toBe(true); }); - it('does not show the loader', () => { + it('does not show the loader', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + expect(findTagsLoader().exists()).toBe(false); }); - it('does not show the list', () => { + it('does not show the list', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + expect(findTagsList().exists()).toBe(false); }); }); describe('list', () => { - beforeEach(() => { + it('exists', async () => { mountComponent(); - }); - it('exists', () => { + await waitForApolloRequestRender(); + expect(findTagsList().exists()).toBe(true); }); - it('has the correct props bound', () => { + it('has the correct props bound', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + expect(findTagsList().props()).toMatchObject({ isMobile: false, - tags: store.state.tags, + tags: cleanTags, }); }); describe('deleteEvent', () => { describe('single item', () => { let tagToBeDeleted; - beforeEach(() => { - [tagToBeDeleted] = store.state.tags; + beforeEach(async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + [tagToBeDeleted] = cleanTags; findTagsList().vm.$emit('delete', { [tagToBeDeleted.name]: true }); }); - it('open the modal', () => { + it('open the modal', async () => { expect(DeleteModal.methods.show).toHaveBeenCalled(); }); - it('maps the selection to itemToBeDeleted', () => { - expect(wrapper.vm.itemsToBeDeleted).toEqual([tagToBeDeleted]); - }); - it('tracks a single delete event', () => { expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { label: 'registry_tag_delete', @@ -161,18 +203,18 @@ describe('Details Page', () => { }); describe('multiple items', () => { - beforeEach(() => { - findTagsList().vm.$emit('delete', tagsArrayToSelectedTags(store.state.tags)); + beforeEach(async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + findTagsList().vm.$emit('delete', tagsArrayToSelectedTags(cleanTags)); }); it('open the modal', () => { expect(DeleteModal.methods.show).toHaveBeenCalled(); }); - it('maps the selection to itemToBeDeleted', () => { - expect(wrapper.vm.itemsToBeDeleted).toEqual(store.state.tags); - }); - it('tracks a single delete event', () => { expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { label: 'bulk_registry_tag_delete', @@ -183,40 +225,77 @@ describe('Details Page', () => { }); describe('pagination', () => { - beforeEach(() => { + it('exists', async () => { mountComponent(); - }); - it('exists', () => { + await waitForApolloRequestRender(); + expect(findPagination().exists()).toBe(true); }); - it('is wired to the correct pagination props', () => { - const pagination = findPagination(); - expect(pagination.props('perPage')).toBe(store.state.tagsPagination.perPage); - expect(pagination.props('totalItems')).toBe(store.state.tagsPagination.total); - expect(pagination.props('value')).toBe(store.state.tagsPagination.page); + it('is hidden when there are no more pages', async () => { + mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLImageDetailsEmptyTagsMock) }); + + await waitForApolloRequestRender(); + + expect(findPagination().exists()).toBe(false); }); - it('fetch the data from the API when the v-model changes', () => { - dispatchSpy.mockResolvedValue(); - findPagination().vm.$emit(GlPagination.model.event, 2); - expect(store.dispatch).toHaveBeenCalledWith('requestTagsList', { - page: 2, + it('is wired to the correct pagination props', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findPagination().props()).toMatchObject({ + hasNextPage: tagsPageInfo.hasNextPage, + hasPreviousPage: tagsPageInfo.hasPreviousPage, }); }); + + it('fetch next page when user clicks next', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock()); + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + findPagination().vm.$emit('next'); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ after: tagsPageInfo.endCursor }), + ); + }); + + it('fetch previous page when user clicks prev', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock()); + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + findPagination().vm.$emit('prev'); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ first: null, before: tagsPageInfo.startCursor }), + ); + }); }); describe('modal', () => { - it('exists', () => { + it('exists', async () => { mountComponent(); + + await waitForApolloRequestRender(); + expect(findDeleteModal().exists()).toBe(true); }); describe('cancel event', () => { - it('tracks cancel_delete', () => { + it('tracks cancel_delete', async () => { mountComponent(); + + await waitForApolloRequestRender(); + findDeleteModal().vm.$emit('cancel'); + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'cancel_delete', { label: 'registry_tag_delete', }); @@ -224,45 +303,62 @@ describe('Details Page', () => { }); describe('confirmDelete event', () => { + let mutationResolver; + + beforeEach(() => { + mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock); + mountComponent({ mutationResolver }); + + return waitForApolloRequestRender(); + }); describe('when one item is selected to be deleted', () => { - beforeEach(() => { - mountComponent(); - findTagsList().vm.$emit('delete', { [store.state.tags[0].name]: true }); - }); + it('calls apollo mutation with the right parameters', async () => { + findTagsList().vm.$emit('delete', { [cleanTags[0].name]: true }); + + await wrapper.vm.$nextTick(); - it('dispatch requestDeleteTag with the right parameters', () => { findDeleteModal().vm.$emit('confirmDelete'); - expect(dispatchSpy).toHaveBeenCalledWith('requestDeleteTag', { - tag: store.state.tags[0], - }); + + expect(mutationResolver).toHaveBeenCalledWith( + expect.objectContaining({ tagNames: [cleanTags[0].name] }), + ); }); }); describe('when more than one item is selected to be deleted', () => { - beforeEach(() => { - mountComponent(); - findTagsList().vm.$emit('delete', tagsArrayToSelectedTags(store.state.tags)); - }); + it('calls apollo mutation with the right parameters', async () => { + findTagsList().vm.$emit('delete', { ...tagsArrayToSelectedTags(tagsMock) }); + + await wrapper.vm.$nextTick(); - it('dispatch requestDeleteTags with the right parameters', () => { findDeleteModal().vm.$emit('confirmDelete'); - expect(dispatchSpy).toHaveBeenCalledWith('requestDeleteTags', { - ids: store.state.tags.map(t => t.name), - }); + + expect(mutationResolver).toHaveBeenCalledWith( + expect.objectContaining({ tagNames: tagsMock.map(t => t.name) }), + ); }); }); }); }); describe('Header', () => { - it('exists', () => { + it('exists', async () => { mountComponent(); + + await waitForApolloRequestRender(); expect(findDetailsHeader().exists()).toBe(true); }); - it('has the correct props', () => { + it('has the correct props', async () => { mountComponent(); - expect(findDetailsHeader().props()).toEqual({ imageName: imageDetailsMock.name }); + + await waitForApolloRequestRender(); + expect(findDetailsHeader().props('image')).toMatchObject({ + name: containerRepositoryMock.name, + project: { + visibility: containerRepositoryMock.project.visibility, + }, + }); }); }); @@ -273,20 +369,25 @@ describe('Details Page', () => { }; const deleteAlertType = 'success_tag'; - it('exists', () => { + it('exists', async () => { mountComponent(); + + await waitForApolloRequestRender(); expect(findDeleteAlert().exists()).toBe(true); }); - it('has the correct props', () => { - store.commit(SET_INITIAL_STATE, { ...config }); + it('has the correct props', async () => { mountComponent({ options: { data: () => ({ deleteAlertType, }), }, + config, }); + + await waitForApolloRequestRender(); + expect(findDeleteAlert().props()).toEqual({ ...config, deleteAlertType }); }); }); @@ -298,30 +399,38 @@ describe('Details Page', () => { }; describe('when expiration_policy_started is not null', () => { + let resolver; + beforeEach(() => { - store.commit(SET_IMAGE_DETAILS, { - ...imageDetailsMock, - cleanup_policy_started_at: Date.now().toString(), - }); + resolver = jest.fn().mockResolvedValue( + graphQLImageDetailsMock({ + expirationPolicyStartedAt: Date.now().toString(), + }), + ); }); - it('exists', () => { - mountComponent(); + it('exists', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); expect(findPartialCleanupAlert().exists()).toBe(true); }); - it('has the correct props', () => { - store.commit(SET_INITIAL_STATE, { ...config }); + it('has the correct props', async () => { + mountComponent({ resolver, config }); - mountComponent(); + await waitForApolloRequestRender(); expect(findPartialCleanupAlert().props()).toEqual({ ...config }); }); it('dismiss hides the component', async () => { - mountComponent(); + mountComponent({ resolver }); + + await waitForApolloRequestRender(); expect(findPartialCleanupAlert().exists()).toBe(true); + findPartialCleanupAlert().vm.$emit('dismiss'); await wrapper.vm.$nextTick(); @@ -331,11 +440,22 @@ describe('Details Page', () => { }); describe('when expiration_policy_started is null', () => { - it('the component is hidden', () => { + it('the component is hidden', async () => { mountComponent(); + await waitForApolloRequestRender(); expect(findPartialCleanupAlert().exists()).toBe(false); }); }); }); + + describe('Breadcrumb connection', () => { + it('when the details are fetched updates the name', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(breadCrumbState.updateName).toHaveBeenCalledWith(containerRepositoryMock.name); + }); + }); }); diff --git a/spec/frontend/registry/explorer/pages/index_spec.js b/spec/frontend/registry/explorer/pages/index_spec.js index 1dc5376cacf..b5f718b3e61 100644 --- a/spec/frontend/registry/explorer/pages/index_spec.js +++ b/spec/frontend/registry/explorer/pages/index_spec.js @@ -1,16 +1,13 @@ import { shallowMount } from '@vue/test-utils'; import component from '~/registry/explorer/pages/index.vue'; -import { createStore } from '~/registry/explorer/stores/'; describe('List Page', () => { let wrapper; - let store; const findRouterView = () => wrapper.find({ ref: 'router-view' }); const mountComponent = () => { wrapper = shallowMount(component, { - store, stubs: { RouterView: true, }, @@ -18,7 +15,6 @@ describe('List Page', () => { }; beforeEach(() => { - store = createStore(); mountComponent(); }); diff --git a/spec/frontend/registry/explorer/pages/list_spec.js b/spec/frontend/registry/explorer/pages/list_spec.js index b24422adb03..7d32a667011 100644 --- a/spec/frontend/registry/explorer/pages/list_spec.js +++ b/spec/frontend/registry/explorer/pages/list_spec.js @@ -1,5 +1,7 @@ -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; import { GlSkeletonLoader, GlSprintf, GlAlert, GlSearchBoxByClick } from '@gitlab/ui'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import Tracking from '~/tracking'; import component from '~/registry/explorer/pages/list.vue'; @@ -9,27 +11,35 @@ import ProjectEmptyState from '~/registry/explorer/components/list_page/project_ import RegistryHeader from '~/registry/explorer/components/list_page/registry_header.vue'; import ImageList from '~/registry/explorer/components/list_page/image_list.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; -import { createStore } from '~/registry/explorer/stores/'; -import { - SET_MAIN_LOADING, - SET_IMAGES_LIST_SUCCESS, - SET_PAGINATION, - SET_INITIAL_STATE, -} from '~/registry/explorer/stores/mutation_types'; + import { DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE, IMAGE_REPOSITORY_LIST_LABEL, SEARCH_PLACEHOLDER_TEXT, } from '~/registry/explorer/constants'; -import { imagesListResponse } from '../mock_data'; + +import getProjectContainerRepositoriesQuery from '~/registry/explorer/graphql/queries/get_project_container_repositories.query.graphql'; +import getGroupContainerRepositoriesQuery from '~/registry/explorer/graphql/queries/get_group_container_repositories.query.graphql'; +import deleteContainerRepositoryMutation from '~/registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql'; + +import { + graphQLImageListMock, + graphQLImageDeleteMock, + deletedContainerRepository, + graphQLImageDeleteMockError, + graphQLEmptyImageListMock, + graphQLEmptyGroupImageListMock, + pageInfo, +} from '../mock_data'; import { GlModal, GlEmptyState } from '../stubs'; import { $toast } from '../../shared/mocks'; +const localVue = createLocalVue(); + describe('List Page', () => { let wrapper; - let dispatchSpy; - let store; + let apolloProvider; const findDeleteModal = () => wrapper.find(GlModal); const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader); @@ -47,9 +57,31 @@ describe('List Page', () => { const findSearchBox = () => wrapper.find(GlSearchBoxByClick); const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]'); - const mountComponent = ({ mocks } = {}) => { + const waitForApolloRequestRender = async () => { + await waitForPromises(); + await wrapper.vm.$nextTick(); + }; + + const mountComponent = ({ + mocks, + resolver = jest.fn().mockResolvedValue(graphQLImageListMock), + groupResolver = jest.fn().mockResolvedValue(graphQLImageListMock), + mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock), + config = {}, + } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [ + [getProjectContainerRepositoriesQuery, resolver], + [getGroupContainerRepositoriesQuery, groupResolver], + [deleteContainerRepositoryMutation, mutationResolver], + ]; + + apolloProvider = createMockApollo(requestHandlers); + wrapper = shallowMount(component, { - store, + localVue, + apolloProvider, stubs: { GlModal, GlEmptyState, @@ -64,42 +96,27 @@ describe('List Page', () => { }, ...mocks, }, + provide() { + return { + config, + }; + }, }); }; - beforeEach(() => { - store = createStore(); - dispatchSpy = jest.spyOn(store, 'dispatch'); - dispatchSpy.mockResolvedValue(); - store.commit(SET_IMAGES_LIST_SUCCESS, imagesListResponse.data); - store.commit(SET_PAGINATION, imagesListResponse.headers); - }); - afterEach(() => { wrapper.destroy(); }); - describe('API calls', () => { - it.each` - imageList | name | called - ${[]} | ${'foo'} | ${['requestImagesList']} - ${imagesListResponse.data} | ${undefined} | ${['requestImagesList']} - ${imagesListResponse.data} | ${'foo'} | ${undefined} - `( - 'with images equal $imageList and name $name dispatch calls $called', - ({ imageList, name, called }) => { - store.commit(SET_IMAGES_LIST_SUCCESS, imageList); - dispatchSpy.mockClear(); - mountComponent({ mocks: { $route: { name } } }); - - expect(dispatchSpy.mock.calls[0]).toEqual(called); - }, - ); - }); - - it('contains registry header', () => { + it('contains registry header', async () => { mountComponent(); + + await waitForApolloRequestRender(); + expect(findRegistryHeader().exists()).toBe(true); + expect(findRegistryHeader().props()).toMatchObject({ + imagesCount: 2, + }); }); describe('connection error', () => { @@ -109,88 +126,100 @@ describe('List Page', () => { helpPagePath: 'bar', }; - beforeEach(() => { - store.commit(SET_INITIAL_STATE, config); - mountComponent(); - }); - - afterEach(() => { - store.commit(SET_INITIAL_STATE, {}); - }); - it('should show an empty state', () => { + mountComponent({ config }); + expect(findEmptyState().exists()).toBe(true); }); it('empty state should have an svg-path', () => { - expect(findEmptyState().attributes('svg-path')).toBe(config.containersErrorImage); + mountComponent({ config }); + + expect(findEmptyState().props('svgPath')).toBe(config.containersErrorImage); }); it('empty state should have a description', () => { - expect(findEmptyState().html()).toContain('connection error'); + mountComponent({ config }); + + expect(findEmptyState().props('title')).toContain('connection error'); }); it('should not show the loading or default state', () => { + mountComponent({ config }); + expect(findSkeletonLoader().exists()).toBe(false); expect(findImageList().exists()).toBe(false); }); }); describe('isLoading is true', () => { - beforeEach(() => { - store.commit(SET_MAIN_LOADING, true); + it('shows the skeleton loader', () => { mountComponent(); - }); - - afterEach(() => store.commit(SET_MAIN_LOADING, false)); - it('shows the skeleton loader', () => { expect(findSkeletonLoader().exists()).toBe(true); }); it('imagesList is not visible', () => { + mountComponent(); + expect(findImageList().exists()).toBe(false); }); it('cli commands is not visible', () => { + mountComponent(); + expect(findCliCommands().exists()).toBe(false); }); }); describe('list is empty', () => { - beforeEach(() => { - store.commit(SET_IMAGES_LIST_SUCCESS, []); - mountComponent(); - return waitForPromises(); - }); + describe('project page', () => { + const resolver = jest.fn().mockResolvedValue(graphQLEmptyImageListMock); - it('cli commands is not visible', () => { - expect(findCliCommands().exists()).toBe(false); - }); + it('cli commands is not visible', async () => { + mountComponent({ resolver }); - it('project empty state is visible', () => { - expect(findProjectEmptyState().exists()).toBe(true); - }); + await waitForApolloRequestRender(); - describe('is group page is true', () => { - beforeEach(() => { - store.commit(SET_INITIAL_STATE, { isGroupPage: true }); - mountComponent(); + expect(findCliCommands().exists()).toBe(false); }); - afterEach(() => { - store.commit(SET_INITIAL_STATE, { isGroupPage: undefined }); + it('project empty state is visible', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findProjectEmptyState().exists()).toBe(true); }); + }); + describe('group page', () => { + const groupResolver = jest.fn().mockResolvedValue(graphQLEmptyGroupImageListMock); + + const config = { + isGroupPage: true, + }; + + it('group empty state is visible', async () => { + mountComponent({ groupResolver, config }); + + await waitForApolloRequestRender(); - it('group empty state is visible', () => { expect(findGroupEmptyState().exists()).toBe(true); }); - it('cli commands is not visible', () => { + it('cli commands is not visible', async () => { + mountComponent({ groupResolver, config }); + + await waitForApolloRequestRender(); + expect(findCliCommands().exists()).toBe(false); }); - it('list header is not visible', () => { + it('list header is not visible', async () => { + mountComponent({ groupResolver, config }); + + await waitForApolloRequestRender(); + expect(findListHeader().exists()).toBe(false); }); }); @@ -198,55 +227,91 @@ describe('List Page', () => { describe('list is not empty', () => { describe('unfiltered state', () => { - beforeEach(() => { + it('quick start is visible', async () => { mountComponent(); - }); - it('quick start is visible', () => { + await waitForApolloRequestRender(); + expect(findCliCommands().exists()).toBe(true); }); - it('list component is visible', () => { + it('list component is visible', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + expect(findImageList().exists()).toBe(true); }); - it('list header is visible', () => { + it('list header is visible', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + const header = findListHeader(); expect(header.exists()).toBe(true); expect(header.text()).toBe(IMAGE_REPOSITORY_LIST_LABEL); }); describe('delete image', () => { - const itemToDelete = { path: 'bar' }; - it('should call deleteItem when confirming deletion', () => { - dispatchSpy.mockResolvedValue(); - findImageList().vm.$emit('delete', itemToDelete); - expect(wrapper.vm.itemToDelete).toEqual(itemToDelete); + const deleteImage = async () => { + await wrapper.vm.$nextTick(); + + findImageList().vm.$emit('delete', deletedContainerRepository); findDeleteModal().vm.$emit('ok'); - expect(store.dispatch).toHaveBeenCalledWith( - 'requestDeleteImage', - wrapper.vm.itemToDelete, + + await waitForApolloRequestRender(); + }; + + it('should call deleteItem when confirming deletion', async () => { + const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock); + mountComponent({ mutationResolver }); + + await deleteImage(); + + expect(wrapper.vm.itemToDelete).toEqual(deletedContainerRepository); + expect(mutationResolver).toHaveBeenCalledWith({ id: deletedContainerRepository.id }); + + const updatedImage = findImageList() + .props('images') + .find(i => i.id === deletedContainerRepository.id); + + expect(updatedImage.status).toBe(deletedContainerRepository.status); + }); + + it('should show a success alert when delete request is successful', async () => { + const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock); + mountComponent({ mutationResolver }); + + await deleteImage(); + + const alert = findDeleteAlert(); + expect(alert.exists()).toBe(true); + expect(alert.text().replace(/\s\s+/gm, ' ')).toBe( + DELETE_IMAGE_SUCCESS_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path), ); }); - it('should show a success alert when delete request is successful', () => { - dispatchSpy.mockResolvedValue(); - findImageList().vm.$emit('delete', itemToDelete); - expect(wrapper.vm.itemToDelete).toEqual(itemToDelete); - return wrapper.vm.handleDeleteImage().then(() => { + describe('when delete request fails it shows an alert', () => { + it('user recoverable error', async () => { + const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMockError); + mountComponent({ mutationResolver }); + + await deleteImage(); + const alert = findDeleteAlert(); expect(alert.exists()).toBe(true); expect(alert.text().replace(/\s\s+/gm, ' ')).toBe( - DELETE_IMAGE_SUCCESS_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path), + DELETE_IMAGE_ERROR_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path), ); }); - }); - it('should show an error alert when delete request fails', () => { - dispatchSpy.mockRejectedValue(); - findImageList().vm.$emit('delete', itemToDelete); - expect(wrapper.vm.itemToDelete).toEqual(itemToDelete); - return wrapper.vm.handleDeleteImage().then(() => { + it('network error', async () => { + const mutationResolver = jest.fn().mockRejectedValue(); + mountComponent({ mutationResolver }); + + await deleteImage(); + const alert = findDeleteAlert(); expect(alert.exists()).toBe(true); expect(alert.text().replace(/\s\s+/gm, ' ')).toBe( @@ -258,38 +323,68 @@ describe('List Page', () => { }); describe('search', () => { - it('has a search box element', () => { + const doSearch = async () => { + await waitForApolloRequestRender(); + findSearchBox().vm.$emit('submit', 'centos6'); + await wrapper.vm.$nextTick(); + }; + + it('has a search box element', async () => { mountComponent(); + + await waitForApolloRequestRender(); + const searchBox = findSearchBox(); expect(searchBox.exists()).toBe(true); expect(searchBox.attributes('placeholder')).toBe(SEARCH_PLACEHOLDER_TEXT); }); - it('performs a search', () => { - mountComponent(); - findSearchBox().vm.$emit('submit', 'foo'); - expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', { - name: 'foo', - }); + it('performs a search', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + mountComponent({ resolver }); + + await doSearch(); + + expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ name: 'centos6' })); }); - it('when search result is empty displays an empty search message', () => { - mountComponent(); - store.commit(SET_IMAGES_LIST_SUCCESS, []); - return wrapper.vm.$nextTick().then(() => { - expect(findEmptySearchMessage().exists()).toBe(true); - }); + it('when search result is empty displays an empty search message', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + mountComponent({ resolver }); + + resolver.mockResolvedValue(graphQLEmptyImageListMock); + + await doSearch(); + + expect(findEmptySearchMessage().exists()).toBe(true); }); }); describe('pagination', () => { - it('pageChange event triggers the appropriate store function', () => { - mountComponent(); - findImageList().vm.$emit('pageChange', 2); - expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', { - pagination: { page: 2 }, - name: wrapper.vm.search, - }); + it('prev-page event triggers a fetchMore request', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + findImageList().vm.$emit('prev-page'); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ first: null, before: pageInfo.startCursor }), + ); + }); + + it('next-page event triggers a fetchMore request', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + findImageList().vm.$emit('next-page'); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ after: pageInfo.endCursor }), + ); }); }); }); @@ -324,11 +419,11 @@ describe('List Page', () => { beforeEach(() => { jest.spyOn(Tracking, 'event'); - dispatchSpy.mockResolvedValue(); }); it('send an event when delete button is clicked', () => { findImageList().vm.$emit('delete', {}); + testTrackingCall('click_button'); }); diff --git a/spec/frontend/registry/explorer/stores/actions_spec.js b/spec/frontend/registry/explorer/stores/actions_spec.js deleted file mode 100644 index dcd4d8015a4..00000000000 --- a/spec/frontend/registry/explorer/stores/actions_spec.js +++ /dev/null @@ -1,362 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import testAction from 'helpers/vuex_action_helper'; -import { TEST_HOST } from 'helpers/test_constants'; -import createFlash from '~/flash'; -import Api from '~/api'; -import axios from '~/lib/utils/axios_utils'; -import * as actions from '~/registry/explorer/stores/actions'; -import * as types from '~/registry/explorer/stores/mutation_types'; -import { reposServerResponse, registryServerResponse } from '../mock_data'; -import * as utils from '~/registry/explorer/utils'; -import { - FETCH_IMAGES_LIST_ERROR_MESSAGE, - FETCH_TAGS_LIST_ERROR_MESSAGE, - FETCH_IMAGE_DETAILS_ERROR_MESSAGE, -} from '~/registry/explorer/constants/index'; - -jest.mock('~/flash.js'); -jest.mock('~/registry/explorer/utils'); - -describe('Actions RegistryExplorer Store', () => { - let mock; - const endpoint = `${TEST_HOST}/endpoint.json`; - - const url = `${endpoint}/1}`; - jest.spyOn(utils, 'pathGenerator').mockReturnValue(url); - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - it('sets initial state', done => { - const initialState = { - config: { - endpoint, - }, - }; - - testAction( - actions.setInitialState, - initialState, - null, - [{ type: types.SET_INITIAL_STATE, payload: initialState }], - [], - done, - ); - }); - - it('setShowGarbageCollectionTip', done => { - testAction( - actions.setShowGarbageCollectionTip, - true, - null, - [{ type: types.SET_SHOW_GARBAGE_COLLECTION_TIP, payload: true }], - [], - done, - ); - }); - - describe('receives api responses', () => { - const response = { - data: [1, 2, 3], - headers: { - page: 1, - perPage: 10, - }, - }; - - it('images list response', done => { - testAction( - actions.receiveImagesListSuccess, - response, - null, - [ - { type: types.SET_IMAGES_LIST_SUCCESS, payload: response.data }, - { type: types.SET_PAGINATION, payload: response.headers }, - ], - [], - done, - ); - }); - - it('tags list response', done => { - testAction( - actions.receiveTagsListSuccess, - response, - null, - [ - { type: types.SET_TAGS_LIST_SUCCESS, payload: response.data }, - { type: types.SET_TAGS_PAGINATION, payload: response.headers }, - ], - [], - done, - ); - }); - }); - - describe('fetch images list', () => { - it('sets the imagesList and pagination', done => { - mock.onGet(endpoint).replyOnce(200, reposServerResponse, {}); - - testAction( - actions.requestImagesList, - {}, - { - config: { - endpoint, - }, - }, - [ - { type: types.SET_MAIN_LOADING, payload: true }, - { type: types.SET_MAIN_LOADING, payload: false }, - ], - [{ type: 'receiveImagesListSuccess', payload: { data: reposServerResponse, headers: {} } }], - done, - ); - }); - - it('should create flash on error', done => { - testAction( - actions.requestImagesList, - {}, - { - config: { - endpoint: null, - }, - }, - [ - { type: types.SET_MAIN_LOADING, payload: true }, - { type: types.SET_MAIN_LOADING, payload: false }, - ], - [], - () => { - expect(createFlash).toHaveBeenCalledWith({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); - done(); - }, - ); - }); - }); - - describe('fetch tags list', () => { - it('sets the tagsList', done => { - mock.onGet(url).replyOnce(200, registryServerResponse, {}); - - testAction( - actions.requestTagsList, - {}, - {}, - [ - { type: types.SET_MAIN_LOADING, payload: true }, - { type: types.SET_MAIN_LOADING, payload: false }, - ], - [ - { - type: 'receiveTagsListSuccess', - payload: { data: registryServerResponse, headers: {} }, - }, - ], - done, - ); - }); - - it('should create flash on error', done => { - testAction( - actions.requestTagsList, - {}, - {}, - [ - { type: types.SET_MAIN_LOADING, payload: true }, - { type: types.SET_MAIN_LOADING, payload: false }, - ], - [], - () => { - expect(createFlash).toHaveBeenCalledWith({ message: FETCH_TAGS_LIST_ERROR_MESSAGE }); - done(); - }, - ); - }); - }); - - describe('request delete single tag', () => { - it('successfully performs the delete request', done => { - const deletePath = 'delete/path'; - mock.onDelete(deletePath).replyOnce(200); - - testAction( - actions.requestDeleteTag, - { - tag: { - destroy_path: deletePath, - }, - }, - { - tagsPagination: {}, - }, - [ - { type: types.SET_MAIN_LOADING, payload: true }, - { type: types.SET_MAIN_LOADING, payload: false }, - ], - [ - { - type: 'setShowGarbageCollectionTip', - payload: true, - }, - { - type: 'requestTagsList', - payload: {}, - }, - ], - done, - ); - }); - - it('should turn off loading on error', done => { - testAction( - actions.requestDeleteTag, - { - tag: { - destroy_path: null, - }, - }, - {}, - [ - { type: types.SET_MAIN_LOADING, payload: true }, - { type: types.SET_MAIN_LOADING, payload: false }, - ], - [], - ).catch(() => done()); - }); - }); - - describe('requestImageDetailsAndTagsList', () => { - it('sets the imageDetails and dispatch requestTagsList', done => { - const resolvedValue = { foo: 'bar' }; - jest.spyOn(Api, 'containerRegistryDetails').mockResolvedValue({ data: resolvedValue }); - - testAction( - actions.requestImageDetailsAndTagsList, - 1, - {}, - [ - { type: types.SET_MAIN_LOADING, payload: true }, - { type: types.SET_IMAGE_DETAILS, payload: resolvedValue }, - ], - [ - { - type: 'requestTagsList', - }, - ], - done, - ); - }); - - it('should create flash on error', done => { - jest.spyOn(Api, 'containerRegistryDetails').mockRejectedValue(); - testAction( - actions.requestImageDetailsAndTagsList, - 1, - {}, - [ - { type: types.SET_MAIN_LOADING, payload: true }, - { type: types.SET_MAIN_LOADING, payload: false }, - ], - [], - () => { - expect(createFlash).toHaveBeenCalledWith({ message: FETCH_IMAGE_DETAILS_ERROR_MESSAGE }); - done(); - }, - ); - }); - }); - - describe('request delete multiple tags', () => { - it('successfully performs the delete request', done => { - mock.onDelete(url).replyOnce(200); - - testAction( - actions.requestDeleteTags, - { - ids: [1, 2], - }, - { - tagsPagination: {}, - }, - [ - { type: types.SET_MAIN_LOADING, payload: true }, - { type: types.SET_MAIN_LOADING, payload: false }, - ], - [ - { - type: 'setShowGarbageCollectionTip', - payload: true, - }, - { - type: 'requestTagsList', - payload: {}, - }, - ], - done, - ); - }); - - it('should turn off loading on error', done => { - mock.onDelete(url).replyOnce(500); - - testAction( - actions.requestDeleteTags, - { - ids: [1, 2], - }, - { - tagsPagination: {}, - }, - [ - { type: types.SET_MAIN_LOADING, payload: true }, - { type: types.SET_MAIN_LOADING, payload: false }, - ], - [], - ).catch(() => done()); - }); - }); - - describe('request delete single image', () => { - const image = { - destroy_path: 'delete/path', - }; - - it('successfully performs the delete request', done => { - mock.onDelete(image.destroy_path).replyOnce(200); - - testAction( - actions.requestDeleteImage, - image, - {}, - [ - { type: types.SET_MAIN_LOADING, payload: true }, - { type: types.UPDATE_IMAGE, payload: { ...image, deleting: true } }, - { type: types.SET_MAIN_LOADING, payload: false }, - ], - [], - done, - ); - }); - - it('should turn off loading on error', done => { - mock.onDelete(image.destroy_path).replyOnce(400); - testAction( - actions.requestDeleteImage, - image, - {}, - [ - { type: types.SET_MAIN_LOADING, payload: true }, - { type: types.SET_MAIN_LOADING, payload: false }, - ], - [], - ).catch(() => done()); - }); - }); -}); diff --git a/spec/frontend/registry/explorer/stores/getters_spec.js b/spec/frontend/registry/explorer/stores/getters_spec.js deleted file mode 100644 index 4cab65d2bb0..00000000000 --- a/spec/frontend/registry/explorer/stores/getters_spec.js +++ /dev/null @@ -1,41 +0,0 @@ -import * as getters from '~/registry/explorer/stores/getters'; - -describe('Getters RegistryExplorer store', () => { - let state; - - describe.each` - getter | prefix | configParameter | suffix - ${'dockerBuildCommand'} | ${'docker build -t'} | ${'repositoryUrl'} | ${'.'} - ${'dockerPushCommand'} | ${'docker push'} | ${'repositoryUrl'} | ${null} - ${'dockerLoginCommand'} | ${'docker login'} | ${'registryHostUrlWithPort'} | ${null} - `('$getter', ({ getter, prefix, configParameter, suffix }) => { - beforeEach(() => { - state = { - config: { repositoryUrl: 'foo', registryHostUrlWithPort: 'bar' }, - }; - }); - - it(`returns ${prefix} concatenated with ${configParameter} and optionally suffixed with ${suffix}`, () => { - const expectedPieces = [prefix, state.config[configParameter], suffix].filter(p => p); - expect(getters[getter](state)).toBe(expectedPieces.join(' ')); - }); - }); - - describe('showGarbageCollection', () => { - it.each` - result | showGarbageCollectionTip | isAdmin - ${true} | ${true} | ${true} - ${false} | ${true} | ${false} - ${false} | ${false} | ${true} - `( - 'return $result when showGarbageCollectionTip $showGarbageCollectionTip and isAdmin is $isAdmin', - ({ result, showGarbageCollectionTip, isAdmin }) => { - state = { - config: { isAdmin }, - showGarbageCollectionTip, - }; - expect(getters.showGarbageCollection(state)).toBe(result); - }, - ); - }); -}); diff --git a/spec/frontend/registry/explorer/stores/mutations_spec.js b/spec/frontend/registry/explorer/stores/mutations_spec.js deleted file mode 100644 index 1908d3f0350..00000000000 --- a/spec/frontend/registry/explorer/stores/mutations_spec.js +++ /dev/null @@ -1,133 +0,0 @@ -import mutations from '~/registry/explorer/stores/mutations'; -import * as types from '~/registry/explorer/stores/mutation_types'; - -describe('Mutations Registry Explorer Store', () => { - let mockState; - - beforeEach(() => { - mockState = {}; - }); - - describe('SET_INITIAL_STATE', () => { - it('should set the initial state', () => { - const payload = { - endpoint: 'foo', - isGroupPage: '', - expirationPolicy: { foo: 'bar' }, - isAdmin: '', - }; - const expectedState = { - ...mockState, - config: { ...payload, isGroupPage: false, isAdmin: false }, - }; - mutations[types.SET_INITIAL_STATE](mockState, { - ...payload, - expirationPolicy: JSON.stringify(payload.expirationPolicy), - }); - - expect(mockState).toEqual(expectedState); - }); - }); - - describe('SET_IMAGES_LIST_SUCCESS', () => { - it('should set the images list', () => { - const images = [{ name: 'foo' }, { name: 'bar' }]; - const defaultStatus = { deleting: false, failedDelete: false }; - const expectedState = { - ...mockState, - images: [{ name: 'foo', ...defaultStatus }, { name: 'bar', ...defaultStatus }], - }; - mutations[types.SET_IMAGES_LIST_SUCCESS](mockState, images); - - expect(mockState).toEqual(expectedState); - }); - }); - - describe('UPDATE_IMAGE', () => { - it('should update an image', () => { - mockState.images = [{ id: 1, name: 'foo' }, { id: 2, name: 'bar' }]; - const payload = { id: 1, name: 'baz' }; - const expectedState = { - ...mockState, - images: [payload, { id: 2, name: 'bar' }], - }; - mutations[types.UPDATE_IMAGE](mockState, payload); - - expect(mockState).toEqual(expectedState); - }); - }); - - describe('SET_TAGS_LIST_SUCCESS', () => { - it('should set the tags list', () => { - const tags = [1, 2, 3]; - const expectedState = { ...mockState, tags }; - mutations[types.SET_TAGS_LIST_SUCCESS](mockState, tags); - - expect(mockState).toEqual(expectedState); - }); - }); - - describe('SET_MAIN_LOADING', () => { - it('should set the isLoading', () => { - const expectedState = { ...mockState, isLoading: true }; - mutations[types.SET_MAIN_LOADING](mockState, true); - - expect(mockState).toEqual(expectedState); - }); - }); - - describe('SET_SHOW_GARBAGE_COLLECTION_TIP', () => { - it('should set the showGarbageCollectionTip', () => { - const expectedState = { ...mockState, showGarbageCollectionTip: true }; - mutations[types.SET_SHOW_GARBAGE_COLLECTION_TIP](mockState, true); - - expect(mockState).toEqual(expectedState); - }); - }); - - describe('SET_PAGINATION', () => { - const generatePagination = () => [ - { - 'X-PAGE': '1', - 'X-PER-PAGE': '20', - 'X-TOTAL': '100', - 'X-TOTAL-PAGES': '5', - 'X-NEXT-PAGE': '2', - 'X-PREV-PAGE': '0', - }, - { - page: 1, - perPage: 20, - total: 100, - totalPages: 5, - nextPage: 2, - previousPage: 0, - }, - ]; - - it('should set the images pagination', () => { - const [headers, expectedResult] = generatePagination(); - const expectedState = { ...mockState, pagination: expectedResult }; - mutations[types.SET_PAGINATION](mockState, headers); - - expect(mockState).toEqual(expectedState); - }); - - it('should set the tags pagination', () => { - const [headers, expectedResult] = generatePagination(); - const expectedState = { ...mockState, tagsPagination: expectedResult }; - mutations[types.SET_TAGS_PAGINATION](mockState, headers); - - expect(mockState).toEqual(expectedState); - }); - }); - - describe('SET_IMAGE_DETAILS', () => { - it('should set imageDetails', () => { - const expectedState = { ...mockState, imageDetails: { foo: 'bar' } }; - mutations[types.SET_IMAGE_DETAILS](mockState, { foo: 'bar' }); - - expect(mockState).toEqual(expectedState); - }); - }); -}); diff --git a/spec/frontend/registry/explorer/stubs.js b/spec/frontend/registry/explorer/stubs.js index b6c0ee67757..d6fba863ee0 100644 --- a/spec/frontend/registry/explorer/stubs.js +++ b/spec/frontend/registry/explorer/stubs.js @@ -1,35 +1,33 @@ +import { + GlModal as RealGlModal, + GlEmptyState as RealGlEmptyState, + GlSkeletonLoader as RealGlSkeletonLoader, +} from '@gitlab/ui'; +import { RouterLinkStub } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; import RealDeleteModal from '~/registry/explorer/components/details_page/delete_modal.vue'; import RealListItem from '~/vue_shared/components/registry/list_item.vue'; -export const GlModal = { +export const GlModal = stubComponent(RealGlModal, { template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>', methods: { show: jest.fn(), }, -}; +}); -export const GlEmptyState = { +export const GlEmptyState = stubComponent(RealGlEmptyState, { template: '<div><slot name="description"></slot></div>', - name: 'GlEmptyStateSTub', -}; +}); -export const RouterLink = { - template: `<div><slot></slot></div>`, - props: ['to'], -}; +export const RouterLink = RouterLinkStub; -export const DeleteModal = { - template: '<div></div>', +export const DeleteModal = stubComponent(RealDeleteModal, { methods: { show: jest.fn(), }, - props: RealDeleteModal.props, -}; +}); -export const GlSkeletonLoader = { - template: `<div><slot></slot></div>`, - props: ['width', 'height'], -}; +export const GlSkeletonLoader = stubComponent(RealGlSkeletonLoader); export const ListItem = { ...RealListItem, diff --git a/spec/frontend/registry/explorer/utils_spec.js b/spec/frontend/registry/explorer/utils_spec.js deleted file mode 100644 index 7a5d6958a09..00000000000 --- a/spec/frontend/registry/explorer/utils_spec.js +++ /dev/null @@ -1,56 +0,0 @@ -import { pathGenerator } from '~/registry/explorer/utils'; - -describe('Utils', () => { - describe('pathGenerator', () => { - const imageDetails = { - path: 'foo/bar/baz', - name: 'baz', - id: 1, - }; - - beforeEach(() => { - window.gon.relative_url_root = null; - }); - - it('returns the fetch url when no ending is passed', () => { - expect(pathGenerator(imageDetails)).toBe('/foo/bar/registry/repository/1/tags?format=json'); - }); - - it('returns the url with an ending when is passed', () => { - expect(pathGenerator(imageDetails, '/foo')).toBe('/foo/bar/registry/repository/1/tags/foo'); - }); - - describe.each` - path | name | result - ${'foo/foo'} | ${''} | ${'/foo/foo/registry/repository/1/tags?format=json'} - ${'foo/foo/foo'} | ${'foo'} | ${'/foo/foo/registry/repository/1/tags?format=json'} - ${'baz/foo/foo/foo'} | ${'foo'} | ${'/baz/foo/foo/registry/repository/1/tags?format=json'} - ${'baz/foo/foo/foo'} | ${'foo'} | ${'/baz/foo/foo/registry/repository/1/tags?format=json'} - ${'foo/foo/baz/foo/foo'} | ${'foo/foo'} | ${'/foo/foo/baz/registry/repository/1/tags?format=json'} - ${'foo/foo/baz/foo/bar'} | ${'foo/bar'} | ${'/foo/foo/baz/registry/repository/1/tags?format=json'} - ${'baz/foo/foo'} | ${'foo'} | ${'/baz/foo/registry/repository/1/tags?format=json'} - ${'baz/foo/bar'} | ${'foo'} | ${'/baz/foo/bar/registry/repository/1/tags?format=json'} - `('when path is $path and name is $name', ({ name, path, result }) => { - it('returns the correct value', () => { - expect(pathGenerator({ id: 1, name, path })).toBe(result); - }); - - it('produces a correct relative url', () => { - window.gon.relative_url_root = '/gitlab'; - expect(pathGenerator({ id: 1, name, path })).toBe(`/gitlab${result}`); - }); - }); - - it('returns the url unchanged when imageDetails have no name', () => { - const imageDetailsWithoutName = { - path: 'foo/bar/baz', - name: '', - id: 1, - }; - - expect(pathGenerator(imageDetailsWithoutName)).toBe( - '/foo/bar/baz/registry/repository/1/tags?format=json', - ); - }); - }); -}); diff --git a/spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap b/spec/frontend/registry/settings/__snapshots__/utils_spec.js.snap index 032007bba51..7062773b46b 100644 --- a/spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap +++ b/spec/frontend/registry/settings/__snapshots__/utils_spec.js.snap @@ -76,25 +76,25 @@ Array [ Object { "default": false, "key": "SEVEN_DAYS", - "label": "7 days until tags are automatically removed", + "label": "7 days", "variable": 7, }, Object { "default": false, "key": "FOURTEEN_DAYS", - "label": "14 days until tags are automatically removed", + "label": "14 days", "variable": 14, }, Object { "default": false, "key": "THIRTY_DAYS", - "label": "30 days until tags are automatically removed", + "label": "30 days", "variable": 30, }, Object { "default": true, "key": "NINETY_DAYS", - "label": "90 days until tags are automatically removed", + "label": "90 days", "variable": 90, }, ] diff --git a/spec/frontend/registry/settings/components/__snapshots__/settings_form_spec.js.snap b/spec/frontend/registry/settings/components/__snapshots__/settings_form_spec.js.snap new file mode 100644 index 00000000000..d7f89ce070e --- /dev/null +++ b/spec/frontend/registry/settings/components/__snapshots__/settings_form_spec.js.snap @@ -0,0 +1,64 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Settings Form Cadence matches snapshot 1`] = ` +<expiration-dropdown-stub + class="gl-mr-7 gl-mb-0!" + data-testid="cadence-dropdown" + formoptions="[object Object],[object Object],[object Object],[object Object],[object Object]" + label="Run cleanup:" + name="cadence" + value="EVERY_DAY" +/> +`; + +exports[`Settings Form Enable matches snapshot 1`] = ` +<expiration-toggle-stub + class="gl-mb-0!" + data-testid="enable-toggle" + value="true" +/> +`; + +exports[`Settings Form Keep N matches snapshot 1`] = ` +<expiration-dropdown-stub + data-testid="keep-n-dropdown" + formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]" + label="Keep the most recent:" + name="keep-n" + value="TEN_TAGS" +/> +`; + +exports[`Settings Form Keep Regex matches snapshot 1`] = ` +<expiration-input-stub + data-testid="keep-regex-input" + description="Tags with names that match this regex pattern are kept. %{linkStart}More information%{linkEnd}" + error="" + label="Keep tags matching:" + name="keep-regex" + placeholder="" + value="sss" +/> +`; + +exports[`Settings Form OlderThan matches snapshot 1`] = ` +<expiration-dropdown-stub + data-testid="older-than-dropdown" + formoptions="[object Object],[object Object],[object Object],[object Object]" + label="Remove tags older than:" + name="older-than" + value="FOURTEEN_DAYS" +/> +`; + +exports[`Settings Form Remove regex matches snapshot 1`] = ` +<expiration-input-stub + data-testid="remove-regex-input" + description="Tags with names that match this regex pattern are removed. %{linkStart}More information%{linkEnd}" + error="" + label="Remove tags matching:" + name="remove-regex" + placeholder=".*" + value="asdasdssssdfdf" +/> +`; diff --git a/spec/frontend/registry/settings/components/expiration_dropdown_spec.js b/spec/frontend/registry/settings/components/expiration_dropdown_spec.js new file mode 100644 index 00000000000..e0cac317ad6 --- /dev/null +++ b/spec/frontend/registry/settings/components/expiration_dropdown_spec.js @@ -0,0 +1,83 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlFormGroup, GlFormSelect } from 'jest/registry/shared/stubs'; +import component from '~/registry/settings/components/expiration_dropdown.vue'; + +describe('ExpirationDropdown', () => { + let wrapper; + + const defaultProps = { + name: 'foo', + label: 'label-bar', + formOptions: [{ key: 'foo', label: 'bar' }, { key: 'baz', label: 'zab' }], + }; + + const findFormSelect = () => wrapper.find(GlFormSelect); + const findFormGroup = () => wrapper.find(GlFormGroup); + const findOptions = () => wrapper.findAll('[data-testid="option"]'); + + const mountComponent = props => { + wrapper = shallowMount(component, { + stubs: { + GlFormGroup, + GlFormSelect, + }, + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('structure', () => { + it('has a form-select component', () => { + mountComponent(); + expect(findFormSelect().exists()).toBe(true); + }); + + it('has the correct options', () => { + mountComponent(); + + expect(findOptions()).toHaveLength(defaultProps.formOptions.length); + }); + }); + + describe('model', () => { + it('assign the right props to the form-select component', () => { + const value = 'foobar'; + const disabled = true; + + mountComponent({ value, disabled }); + + expect(findFormSelect().props()).toMatchObject({ + value, + disabled, + }); + expect(findFormSelect().attributes('id')).toBe(defaultProps.name); + }); + + it('assign the right props to the form-group component', () => { + mountComponent(); + + expect(findFormGroup().attributes()).toMatchObject({ + id: `${defaultProps.name}-form-group`, + 'label-for': defaultProps.name, + label: defaultProps.label, + }); + }); + + it('emits input event when form-select emits input', () => { + const emittedValue = 'barfoo'; + + mountComponent(); + + findFormSelect().vm.$emit('input', emittedValue); + + expect(wrapper.emitted('input')).toEqual([[emittedValue]]); + }); + }); +}); diff --git a/spec/frontend/registry/settings/components/expiration_input_spec.js b/spec/frontend/registry/settings/components/expiration_input_spec.js new file mode 100644 index 00000000000..849f85aa265 --- /dev/null +++ b/spec/frontend/registry/settings/components/expiration_input_spec.js @@ -0,0 +1,169 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlSprintf, GlFormInput, GlLink } from '@gitlab/ui'; +import { GlFormGroup } from 'jest/registry/shared/stubs'; +import component from '~/registry/settings/components/expiration_input.vue'; +import { NAME_REGEX_LENGTH } from '~/registry/settings/constants'; + +describe('ExpirationInput', () => { + let wrapper; + + const defaultProps = { + name: 'foo', + label: 'label-bar', + placeholder: 'placeholder-baz', + description: '%{linkStart}description-foo%{linkEnd}', + }; + + const tagsRegexHelpPagePath = 'fooPath'; + + const findInput = () => wrapper.find(GlFormInput); + const findFormGroup = () => wrapper.find(GlFormGroup); + const findLabel = () => wrapper.find('[data-testid="label"]'); + const findDescription = () => wrapper.find('[data-testid="description"]'); + const findDescriptionLink = () => wrapper.find(GlLink); + + const mountComponent = props => { + wrapper = shallowMount(component, { + stubs: { + GlSprintf, + GlFormGroup, + }, + provide: { + tagsRegexHelpPagePath, + }, + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('structure', () => { + it('has a label', () => { + mountComponent(); + + expect(findLabel().text()).toBe(defaultProps.label); + }); + + it('has a textarea component', () => { + mountComponent(); + + expect(findInput().exists()).toBe(true); + }); + + it('has a description', () => { + mountComponent(); + + expect(findDescription().text()).toMatchInterpolatedText(defaultProps.description); + }); + + it('has a description link', () => { + mountComponent(); + + const link = findDescriptionLink(); + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe(tagsRegexHelpPagePath); + }); + }); + + describe('model', () => { + it('assigns the right props to the textarea component', () => { + const value = 'foobar'; + const disabled = true; + + mountComponent({ value, disabled }); + + expect(findInput().attributes()).toMatchObject({ + id: defaultProps.name, + value, + placeholder: defaultProps.placeholder, + disabled: `${disabled}`, + trim: '', + }); + }); + + it('emits input event when textarea emits input', () => { + const emittedValue = 'barfoo'; + + mountComponent(); + + findInput().vm.$emit('input', emittedValue); + expect(wrapper.emitted('input')).toEqual([[emittedValue]]); + }); + }); + + describe('regex textarea validation', () => { + const invalidString = new Array(NAME_REGEX_LENGTH + 2).join(','); + + describe('when error contains an error message', () => { + const errorMessage = 'something went wrong'; + + it('shows the error message on the relevant field', () => { + mountComponent({ error: errorMessage }); + + expect(findFormGroup().attributes('invalid-feedback')).toBe(errorMessage); + }); + + it('gives precedence to API errors compared to local ones', () => { + mountComponent({ + error: errorMessage, + value: invalidString, + }); + + expect(findFormGroup().attributes('invalid-feedback')).toBe(errorMessage); + }); + }); + + describe('when error is empty', () => { + describe('if the user did not type', () => { + it('validation is not emitted', () => { + mountComponent(); + + expect(wrapper.emitted('validation')).toBeUndefined(); + }); + + it('no error message is shown', () => { + mountComponent(); + + expect(findFormGroup().props('state')).toBe(true); + expect(findFormGroup().attributes('invalid-feedback')).toBe(''); + }); + }); + + describe('when the user typed something', () => { + describe(`when name regex is longer than ${NAME_REGEX_LENGTH}`, () => { + beforeEach(() => { + // since the component has no state we both emit the event and set the prop + mountComponent({ value: invalidString }); + + findInput().vm.$emit('input', invalidString); + }); + + it('textAreaValidation state is false', () => { + expect(findFormGroup().props('state')).toBe(false); + expect(findInput().attributes('state')).toBeUndefined(); + }); + + it('emits the @validation event with false payload', () => { + expect(wrapper.emitted('validation')).toEqual([[false]]); + }); + }); + + it(`when user input is less than ${NAME_REGEX_LENGTH} state is "true"`, () => { + mountComponent(); + + findInput().vm.$emit('input', 'foo'); + + expect(findFormGroup().props('state')).toBe(true); + expect(findInput().attributes('state')).toBe('true'); + expect(wrapper.emitted('validation')).toEqual([[true]]); + }); + }); + }); + }); +}); diff --git a/spec/frontend/registry/settings/components/expiration_run_text_spec.js b/spec/frontend/registry/settings/components/expiration_run_text_spec.js new file mode 100644 index 00000000000..c594b1f449d --- /dev/null +++ b/spec/frontend/registry/settings/components/expiration_run_text_spec.js @@ -0,0 +1,62 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlFormInput } from '@gitlab/ui'; +import { GlFormGroup } from 'jest/registry/shared/stubs'; +import component from '~/registry/settings/components/expiration_run_text.vue'; +import { NEXT_CLEANUP_LABEL, NOT_SCHEDULED_POLICY_TEXT } from '~/registry/settings/constants'; + +describe('ExpirationToggle', () => { + let wrapper; + const value = 'foo'; + + const findInput = () => wrapper.find(GlFormInput); + const findFormGroup = () => wrapper.find(GlFormGroup); + + const mountComponent = propsData => { + wrapper = shallowMount(component, { + stubs: { + GlFormGroup, + }, + propsData, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('structure', () => { + it('has an input component', () => { + mountComponent(); + + expect(findInput().exists()).toBe(true); + }); + }); + + describe('model', () => { + it('assigns the right props to the form-group component', () => { + mountComponent(); + + expect(findFormGroup().attributes()).toMatchObject({ + label: NEXT_CLEANUP_LABEL, + }); + }); + }); + + describe('formattedValue', () => { + it.each` + valueProp | enabled | expected + ${value} | ${true} | ${value} + ${value} | ${false} | ${NOT_SCHEDULED_POLICY_TEXT} + ${undefined} | ${false} | ${NOT_SCHEDULED_POLICY_TEXT} + ${undefined} | ${true} | ${NOT_SCHEDULED_POLICY_TEXT} + `( + 'when value is $valueProp and enabled is $enabled the input value is $expected', + ({ valueProp, enabled, expected }) => { + mountComponent({ value: valueProp, enabled }); + + expect(findInput().attributes('value')).toBe(expected); + }, + ); + }); +}); diff --git a/spec/frontend/registry/settings/components/expiration_toggle_spec.js b/spec/frontend/registry/settings/components/expiration_toggle_spec.js new file mode 100644 index 00000000000..99ff7a7f77a --- /dev/null +++ b/spec/frontend/registry/settings/components/expiration_toggle_spec.js @@ -0,0 +1,77 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlToggle, GlSprintf } from '@gitlab/ui'; +import { GlFormGroup } from 'jest/registry/shared/stubs'; +import component from '~/registry/settings/components/expiration_toggle.vue'; +import { + ENABLED_TOGGLE_DESCRIPTION, + DISABLED_TOGGLE_DESCRIPTION, +} from '~/registry/settings/constants'; + +describe('ExpirationToggle', () => { + let wrapper; + + const findToggle = () => wrapper.find(GlToggle); + const findDescription = () => wrapper.find('[data-testid="description"]'); + + const mountComponent = propsData => { + wrapper = shallowMount(component, { + stubs: { + GlFormGroup, + GlSprintf, + }, + propsData, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('structure', () => { + it('has a toggle component', () => { + mountComponent(); + + expect(findToggle().exists()).toBe(true); + }); + + it('has a description', () => { + mountComponent(); + + expect(findDescription().exists()).toBe(true); + }); + }); + + describe('model', () => { + it('assigns the right props to the toggle component', () => { + mountComponent({ value: true, disabled: true }); + + expect(findToggle().props()).toMatchObject({ + value: true, + disabled: true, + }); + }); + + it('emits input event when toggle is updated', () => { + mountComponent(); + + findToggle().vm.$emit('change', false); + + expect(wrapper.emitted('input')).toEqual([[false]]); + }); + }); + + describe('toggle description', () => { + it('says enabled when the toggle is on', () => { + mountComponent({ value: true }); + + expect(findDescription().text()).toMatchInterpolatedText(ENABLED_TOGGLE_DESCRIPTION); + }); + + it('says disabled when the toggle is off', () => { + mountComponent({ value: false }); + + expect(findDescription().text()).toMatchInterpolatedText(DISABLED_TOGGLE_DESCRIPTION); + }); + }); +}); diff --git a/spec/frontend/registry/settings/components/registry_settings_app_spec.js b/spec/frontend/registry/settings/components/registry_settings_app_spec.js index a784396f47a..c31c7bdf99b 100644 --- a/spec/frontend/registry/settings/components/registry_settings_app_spec.js +++ b/spec/frontend/registry/settings/components/registry_settings_app_spec.js @@ -3,15 +3,19 @@ import VueApollo from 'vue-apollo'; import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; import createMockApollo from 'jest/helpers/mock_apollo_helper'; import component from '~/registry/settings/components/registry_settings_app.vue'; -import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.graphql'; +import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.query.graphql'; import SettingsForm from '~/registry/settings/components/settings_form.vue'; -import { FETCH_SETTINGS_ERROR_MESSAGE } from '~/registry/shared/constants'; import { + FETCH_SETTINGS_ERROR_MESSAGE, UNAVAILABLE_FEATURE_INTRO_TEXT, UNAVAILABLE_USER_FEATURE_TEXT, } from '~/registry/settings/constants'; -import { expirationPolicyPayload, emptyExpirationPolicyPayload } from '../mock_data'; +import { + expirationPolicyPayload, + emptyExpirationPolicyPayload, + containerExpirationPolicyData, +} from '../mock_data'; const localVue = createLocalVue(); @@ -62,6 +66,29 @@ describe('Registry Settings App', () => { wrapper.destroy(); }); + describe('isEdited status', () => { + it.each` + description | apiResponse | workingCopy | result + ${'empty response and no changes from user'} | ${emptyExpirationPolicyPayload()} | ${{}} | ${false} + ${'empty response and changes from user'} | ${emptyExpirationPolicyPayload()} | ${{ enabled: true }} | ${true} + ${'response and no changes'} | ${expirationPolicyPayload()} | ${containerExpirationPolicyData()} | ${false} + ${'response and changes'} | ${expirationPolicyPayload()} | ${{ ...containerExpirationPolicyData(), nameRegex: '12345' }} | ${true} + ${'response and empty'} | ${expirationPolicyPayload()} | ${{}} | ${true} + `('$description', async ({ apiResponse, workingCopy, result }) => { + const requests = mountComponentWithApollo({ + provide: { ...defaultProvidedValues, enableHistoricEntries: true }, + resolver: jest.fn().mockResolvedValue(apiResponse), + }); + await Promise.all(requests); + + findSettingsComponent().vm.$emit('input', workingCopy); + + await wrapper.vm.$nextTick(); + + expect(findSettingsComponent().props('isEdited')).toBe(result); + }); + }); + it('renders the setting form', async () => { const requests = mountComponentWithApollo({ resolver: jest.fn().mockResolvedValue(expirationPolicyPayload()), diff --git a/spec/frontend/registry/settings/components/settings_form_spec.js b/spec/frontend/registry/settings/components/settings_form_spec.js index 4346cfadcc8..b89269c0ae4 100644 --- a/spec/frontend/registry/settings/components/settings_form_spec.js +++ b/spec/frontend/registry/settings/components/settings_form_spec.js @@ -4,13 +4,12 @@ import createMockApollo from 'jest/helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import Tracking from '~/tracking'; import component from '~/registry/settings/components/settings_form.vue'; -import expirationPolicyFields from '~/registry/shared/components/expiration_policy_fields.vue'; -import updateContainerExpirationPolicyMutation from '~/registry/settings/graphql/mutations/update_container_expiration_policy.graphql'; -import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.graphql'; +import updateContainerExpirationPolicyMutation from '~/registry/settings/graphql/mutations/update_container_expiration_policy.mutation.graphql'; +import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.query.graphql'; import { UPDATE_SETTINGS_ERROR_MESSAGE, UPDATE_SETTINGS_SUCCESS_MESSAGE, -} from '~/registry/shared/constants'; +} from '~/registry/settings/constants'; import { GlCard, GlLoadingIcon } from '../../shared/stubs'; import { expirationPolicyPayload, expirationPolicyMutationPayload } from '../mock_data'; @@ -39,9 +38,15 @@ describe('Settings Form', () => { }; const findForm = () => wrapper.find({ ref: 'form-element' }); - const findFields = () => wrapper.find(expirationPolicyFields); - const findCancelButton = () => wrapper.find({ ref: 'cancel-button' }); - const findSaveButton = () => wrapper.find({ ref: 'save-button' }); + + const findCancelButton = () => wrapper.find('[data-testid="cancel-button"'); + const findSaveButton = () => wrapper.find('[data-testid="save-button"'); + const findEnableToggle = () => wrapper.find('[data-testid="enable-toggle"]'); + const findCadenceDropdown = () => wrapper.find('[data-testid="cadence-dropdown"]'); + const findKeepNDropdown = () => wrapper.find('[data-testid="keep-n-dropdown"]'); + const findKeepRegexInput = () => wrapper.find('[data-testid="keep-regex-input"]'); + const findOlderThanDropdown = () => wrapper.find('[data-testid="older-than-dropdown"]'); + const findRemoveRegexInput = () => wrapper.find('[data-testid="remove-regex-input"]'); const mountComponent = ({ props = defaultProps, @@ -109,45 +114,136 @@ describe('Settings Form', () => { wrapper.destroy(); }); - describe('data binding', () => { - it('v-model change update the settings property', () => { + describe.each` + model | finder | fieldName | type | defaultValue + ${'enabled'} | ${findEnableToggle} | ${'Enable'} | ${'toggle'} | ${false} + ${'cadence'} | ${findCadenceDropdown} | ${'Cadence'} | ${'dropdown'} | ${'EVERY_DAY'} + ${'keepN'} | ${findKeepNDropdown} | ${'Keep N'} | ${'dropdown'} | ${'TEN_TAGS'} + ${'nameRegexKeep'} | ${findKeepRegexInput} | ${'Keep Regex'} | ${'textarea'} | ${''} + ${'olderThan'} | ${findOlderThanDropdown} | ${'OlderThan'} | ${'dropdown'} | ${'NINETY_DAYS'} + ${'nameRegex'} | ${findRemoveRegexInput} | ${'Remove regex'} | ${'textarea'} | ${''} + `('$fieldName', ({ model, finder, type, defaultValue }) => { + it('matches snapshot', () => { mountComponent(); - findFields().vm.$emit('input', { newValue: 'foo' }); - expect(wrapper.emitted('input')).toEqual([['foo']]); + + expect(finder().element).toMatchSnapshot(); }); - it('v-model change update the api error property', () => { - const apiErrors = { baz: 'bar' }; - mountComponent({ data: { apiErrors } }); - expect(findFields().props('apiErrors')).toEqual(apiErrors); - findFields().vm.$emit('input', { newValue: 'foo', modified: 'baz' }); - expect(findFields().props('apiErrors')).toEqual({}); + it('input event triggers a model update', () => { + mountComponent(); + + finder().vm.$emit('input', 'foo'); + expect(wrapper.emitted('input')[0][0]).toMatchObject({ + [model]: 'foo', + }); }); it('shows the default option when none are selected', () => { mountComponent({ props: { value: {} } }); - expect(findFields().props('value')).toEqual({ - cadence: 'EVERY_DAY', - keepN: 'TEN_TAGS', - olderThan: 'NINETY_DAYS', - }); + expect(finder().props('value')).toEqual(defaultValue); }); + + if (type !== 'toggle') { + it.each` + isLoading | mutationLoading | enabledValue + ${false} | ${false} | ${false} + ${true} | ${false} | ${false} + ${true} | ${true} | ${true} + ${false} | ${true} | ${true} + ${false} | ${false} | ${false} + `( + 'is disabled when is loading is $isLoading, mutationLoading is $mutationLoading and enabled is $enabledValue', + ({ isLoading, mutationLoading, enabledValue }) => { + mountComponent({ + props: { isLoading, value: { enabled: enabledValue } }, + data: { mutationLoading }, + }); + expect(finder().props('disabled')).toEqual(true); + }, + ); + } else { + it.each` + isLoading | mutationLoading + ${true} | ${false} + ${true} | ${true} + ${false} | ${true} + `( + 'is disabled when is loading is $isLoading and mutationLoading is $mutationLoading', + ({ isLoading, mutationLoading }) => { + mountComponent({ + props: { isLoading, value: {} }, + data: { mutationLoading }, + }); + expect(finder().props('disabled')).toEqual(true); + }, + ); + } + + if (type === 'textarea') { + it('input event updates the api error property', async () => { + const apiErrors = { [model]: 'bar' }; + mountComponent({ data: { apiErrors } }); + + finder().vm.$emit('input', 'foo'); + expect(finder().props('error')).toEqual('bar'); + + await wrapper.vm.$nextTick(); + + expect(finder().props('error')).toEqual(''); + }); + + it('validation event updates buttons disabled state', async () => { + mountComponent(); + + expect(findSaveButton().props('disabled')).toBe(false); + + finder().vm.$emit('validation', false); + + await wrapper.vm.$nextTick(); + + expect(findSaveButton().props('disabled')).toBe(true); + }); + } + + if (type === 'dropdown') { + it('has the correct formOptions', () => { + mountComponent(); + expect(finder().props('formOptions')).toEqual(wrapper.vm.$options.formOptions[model]); + }); + } }); describe('form', () => { describe('form reset event', () => { - beforeEach(() => { + it('calls the appropriate function', () => { mountComponent(); findForm().trigger('reset'); - }); - it('calls the appropriate function', () => { + expect(wrapper.emitted('reset')).toEqual([[]]); }); it('tracks the reset event', () => { + mountComponent(); + + findForm().trigger('reset'); + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'reset_form', trackingPayload); }); + + it('resets the errors objects', async () => { + mountComponent({ + data: { apiErrors: { nameRegex: 'bar' }, localErrors: { nameRegexKeep: false } }, + }); + + findForm().trigger('reset'); + + await wrapper.vm.$nextTick(); + + expect(findKeepRegexInput().props('error')).toBe(''); + expect(findRemoveRegexInput().props('error')).toBe(''); + expect(findSaveButton().props('disabled')).toBe(false); + }); }); describe('form submit event ', () => { @@ -209,6 +305,7 @@ describe('Settings Form', () => { }); }); }); + describe('global errors', () => { it('shows an error', async () => { const handlers = mountComponentWithApollo({ @@ -230,7 +327,7 @@ describe('Settings Form', () => { graphQLErrors: [ { extensions: { - problems: [{ path: ['name'], message: 'baz' }], + problems: [{ path: ['nameRegexKeep'], message: 'baz' }], }, }, ], @@ -241,7 +338,7 @@ describe('Settings Form', () => { await waitForPromises(); await wrapper.vm.$nextTick(); - expect(findFields().props('apiErrors')).toEqual({ name: 'baz' }); + expect(findKeepRegexInput().props('error')).toEqual('baz'); }); }); }); @@ -257,23 +354,21 @@ describe('Settings Form', () => { }); it.each` - isLoading | isEdited | mutationLoading | isDisabled - ${true} | ${true} | ${true} | ${true} - ${false} | ${true} | ${true} | ${true} - ${false} | ${false} | ${true} | ${true} - ${true} | ${false} | ${false} | ${true} - ${false} | ${false} | ${false} | ${true} - ${false} | ${true} | ${false} | ${false} + isLoading | isEdited | mutationLoading + ${true} | ${true} | ${true} + ${false} | ${true} | ${true} + ${false} | ${false} | ${true} + ${true} | ${false} | ${false} + ${false} | ${false} | ${false} `( - 'when isLoading is $isLoading and isEdited is $isEdited and mutationLoading is $mutationLoading is $isDisabled that the is disabled', - ({ isEdited, isLoading, mutationLoading, isDisabled }) => { + 'when isLoading is $isLoading, isEdited is $isEdited and mutationLoading is $mutationLoading is disabled', + ({ isEdited, isLoading, mutationLoading }) => { mountComponent({ props: { ...defaultProps, isEdited, isLoading }, data: { mutationLoading }, }); - const expectation = isDisabled ? 'true' : undefined; - expect(findCancelButton().attributes('disabled')).toBe(expectation); + expect(findCancelButton().props('disabled')).toBe(true); }, ); }); @@ -284,24 +379,24 @@ describe('Settings Form', () => { expect(findSaveButton().attributes('type')).toBe('submit'); }); + it.each` - isLoading | fieldsAreValid | mutationLoading | isDisabled - ${true} | ${true} | ${true} | ${true} - ${false} | ${true} | ${true} | ${true} - ${false} | ${false} | ${true} | ${true} - ${true} | ${false} | ${false} | ${true} - ${false} | ${false} | ${false} | ${true} - ${false} | ${true} | ${false} | ${false} + isLoading | localErrors | mutationLoading + ${true} | ${{}} | ${true} + ${true} | ${{}} | ${false} + ${false} | ${{}} | ${true} + ${false} | ${{ foo: false }} | ${true} + ${true} | ${{ foo: false }} | ${false} + ${false} | ${{ foo: false }} | ${false} `( - 'when isLoading is $isLoading and fieldsAreValid is $fieldsAreValid and mutationLoading is $mutationLoading is $isDisabled that the is disabled', - ({ fieldsAreValid, isLoading, mutationLoading, isDisabled }) => { + 'when isLoading is $isLoading, localErrors is $localErrors and mutationLoading is $mutationLoading is disabled', + ({ localErrors, isLoading, mutationLoading }) => { mountComponent({ props: { ...defaultProps, isLoading }, - data: { mutationLoading, fieldsAreValid }, + data: { mutationLoading, localErrors }, }); - const expectation = isDisabled ? 'true' : undefined; - expect(findSaveButton().attributes('disabled')).toBe(expectation); + expect(findSaveButton().props('disabled')).toBe(true); }, ); diff --git a/spec/frontend/registry/settings/graphql/cache_updated_spec.js b/spec/frontend/registry/settings/graphql/cache_updated_spec.js index e5f69a08285..d88a5576f26 100644 --- a/spec/frontend/registry/settings/graphql/cache_updated_spec.js +++ b/spec/frontend/registry/settings/graphql/cache_updated_spec.js @@ -1,5 +1,5 @@ import { updateContainerExpirationPolicy } from '~/registry/settings/graphql/utils/cache_update'; -import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.graphql'; +import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.query.graphql'; describe('Registry settings cache update', () => { let client; diff --git a/spec/frontend/registry/settings/mock_data.js b/spec/frontend/registry/settings/mock_data.js index 7f3772ce7fe..7cc645fcf55 100644 --- a/spec/frontend/registry/settings/mock_data.js +++ b/spec/frontend/registry/settings/mock_data.js @@ -1,13 +1,18 @@ +export const containerExpirationPolicyData = () => ({ + cadence: 'EVERY_DAY', + enabled: true, + keepN: 'TEN_TAGS', + nameRegex: 'asdasdssssdfdf', + nameRegexKeep: 'sss', + olderThan: 'FOURTEEN_DAYS', + nextRunAt: '2020-11-19T07:37:03.941Z', +}); + export const expirationPolicyPayload = override => ({ data: { project: { containerExpirationPolicy: { - cadence: 'EVERY_DAY', - enabled: true, - keepN: 'TEN_TAGS', - nameRegex: 'asdasdssssdfdf', - nameRegexKeep: 'sss', - olderThan: 'FOURTEEN_DAYS', + ...containerExpirationPolicyData(), ...override, }, }, @@ -26,12 +31,7 @@ export const expirationPolicyMutationPayload = ({ override, errors = [] } = {}) data: { updateContainerExpirationPolicy: { containerExpirationPolicy: { - cadence: 'EVERY_DAY', - enabled: true, - keepN: 'TEN_TAGS', - nameRegex: 'asdasdssssdfdf', - nameRegexKeep: 'sss', - olderThan: 'FOURTEEN_DAYS', + ...containerExpirationPolicyData(), ...override, }, errors, diff --git a/spec/frontend/registry/shared/utils_spec.js b/spec/frontend/registry/settings/utils_spec.js index edb0c3261be..f92d51db307 100644 --- a/spec/frontend/registry/shared/utils_spec.js +++ b/spec/frontend/registry/settings/utils_spec.js @@ -2,7 +2,7 @@ import { formOptionsGenerator, optionLabelGenerator, olderThanTranslationGenerator, -} from '~/registry/shared/utils'; +} from '~/registry/settings/utils'; describe('Utils', () => { describe('optionLabelGenerator', () => { @@ -11,10 +11,7 @@ describe('Utils', () => { [{ variable: 1 }, { variable: 2 }], olderThanTranslationGenerator, ); - expect(result).toEqual([ - { variable: 1, label: '1 day until tags are automatically removed' }, - { variable: 2, label: '2 days until tags are automatically removed' }, - ]); + expect(result).toEqual([{ variable: 1, label: '1 day' }, { variable: 2, label: '2 days' }]); }); }); diff --git a/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap b/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap deleted file mode 100644 index 2ceb2655d40..00000000000 --- a/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap +++ /dev/null @@ -1,148 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Expiration Policy Form renders 1`] = ` -<div - class="gl-line-height-20" -> - <gl-form-group-stub - id="expiration-policy-toggle-group" - label="Cleanup policy:" - label-align="right" - label-cols="3" - label-for="expiration-policy-toggle" - > - <div - class="gl-display-flex" - > - <gl-toggle-stub - id="expiration-policy-toggle" - labelposition="top" - /> - - <span - class="gl-mb-3 gl-ml-3 gl-line-height-20" - > - <strong> - Disabled - </strong> - - Tags matching the patterns defined below will be scheduled for deletion - </span> - </div> - </gl-form-group-stub> - - <gl-form-group-stub - id="expiration-policy-interval-group" - label="Expiration interval:" - label-align="right" - label-cols="3" - label-for="expiration-policy-interval" - > - <gl-form-select-stub - disabled="true" - id="expiration-policy-interval" - > - <option - value="foo" - > - - Foo - - </option> - <option - value="bar" - > - - Bar - - </option> - </gl-form-select-stub> - </gl-form-group-stub> - <gl-form-group-stub - id="expiration-policy-schedule-group" - label="Expiration schedule:" - label-align="right" - label-cols="3" - label-for="expiration-policy-schedule" - > - <gl-form-select-stub - disabled="true" - id="expiration-policy-schedule" - > - <option - value="foo" - > - - Foo - - </option> - <option - value="bar" - > - - Bar - - </option> - </gl-form-select-stub> - </gl-form-group-stub> - <gl-form-group-stub - id="expiration-policy-latest-group" - label="Number of tags to retain:" - label-align="right" - label-cols="3" - label-for="expiration-policy-latest" - > - <gl-form-select-stub - disabled="true" - id="expiration-policy-latest" - > - <option - value="foo" - > - - Foo - - </option> - <option - value="bar" - > - - Bar - - </option> - </gl-form-select-stub> - </gl-form-group-stub> - - <gl-form-group-stub - id="expiration-policy-name-matching-group" - label-align="right" - label-cols="3" - label-for="expiration-policy-name-matching" - > - - <gl-form-textarea-stub - disabled="true" - id="expiration-policy-name-matching" - noresize="true" - placeholder="" - trim="" - value="" - /> - </gl-form-group-stub> - <gl-form-group-stub - id="expiration-policy-keep-name-group" - label-align="right" - label-cols="3" - label-for="expiration-policy-keep-name" - > - - <gl-form-textarea-stub - disabled="true" - id="expiration-policy-keep-name" - noresize="true" - placeholder="" - trim="" - value="" - /> - </gl-form-group-stub> -</div> -`; diff --git a/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js b/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js deleted file mode 100644 index bee9bca5369..00000000000 --- a/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js +++ /dev/null @@ -1,202 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlSprintf } from '@gitlab/ui'; -import component from '~/registry/shared/components/expiration_policy_fields.vue'; - -import { NAME_REGEX_LENGTH, ENABLED_TEXT, DISABLED_TEXT } from '~/registry/shared/constants'; -import { formOptions } from '../mock_data'; - -describe('Expiration Policy Form', () => { - let wrapper; - - const FORM_ELEMENTS_ID_PREFIX = '#expiration-policy'; - - const findFormGroup = name => wrapper.find(`${FORM_ELEMENTS_ID_PREFIX}-${name}-group`); - const findFormElements = (name, parent = wrapper) => - parent.find(`${FORM_ELEMENTS_ID_PREFIX}-${name}`); - - const mountComponent = props => { - wrapper = shallowMount(component, { - stubs: { - GlSprintf, - }, - propsData: { - formOptions, - ...props, - }, - methods: { - // override idGenerator to avoid having to test with dynamic uid - idGenerator: value => value, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders', () => { - mountComponent(); - expect(wrapper.element).toMatchSnapshot(); - }); - - describe.each` - elementName | modelName | value | disabledByToggle - ${'toggle'} | ${'enabled'} | ${true} | ${'not disabled'} - ${'interval'} | ${'olderThan'} | ${'foo'} | ${'disabled'} - ${'schedule'} | ${'cadence'} | ${'foo'} | ${'disabled'} - ${'latest'} | ${'keepN'} | ${'foo'} | ${'disabled'} - ${'name-matching'} | ${'nameRegex'} | ${'foo'} | ${'disabled'} - ${'keep-name'} | ${'nameRegexKeep'} | ${'bar'} | ${'disabled'} - `( - `${FORM_ELEMENTS_ID_PREFIX}-$elementName form element`, - ({ elementName, modelName, value, disabledByToggle }) => { - it(`${elementName} form group exist in the dom`, () => { - mountComponent(); - const formGroup = findFormGroup(elementName); - expect(formGroup.exists()).toBe(true); - }); - - it(`${elementName} form group has a label-for property`, () => { - mountComponent(); - const formGroup = findFormGroup(elementName); - expect(formGroup.attributes('label-for')).toBe(`expiration-policy-${elementName}`); - }); - - it(`${elementName} form group has a label-cols property`, () => { - mountComponent({ labelCols: '1' }); - const formGroup = findFormGroup(elementName); - return wrapper.vm.$nextTick().then(() => { - expect(formGroup.attributes('label-cols')).toBe('1'); - }); - }); - - it(`${elementName} form group has a label-align property`, () => { - mountComponent({ labelAlign: 'foo' }); - const formGroup = findFormGroup(elementName); - return wrapper.vm.$nextTick().then(() => { - expect(formGroup.attributes('label-align')).toBe('foo'); - }); - }); - - it(`${elementName} form group contains an input element`, () => { - mountComponent(); - const formGroup = findFormGroup(elementName); - expect(findFormElements(elementName, formGroup).exists()).toBe(true); - }); - - it(`${elementName} form element change updated ${modelName} with ${value}`, () => { - mountComponent(); - const formGroup = findFormGroup(elementName); - const element = findFormElements(elementName, formGroup); - - const modelUpdateEvent = element.vm.$options.model - ? element.vm.$options.model.event - : 'input'; - element.vm.$emit(modelUpdateEvent, value); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('input')).toEqual([ - [{ newValue: { [modelName]: value }, modified: modelName }], - ]); - }); - }); - - it(`${elementName} is ${disabledByToggle} by enabled set to false`, () => { - mountComponent({ settings: { enabled: false } }); - const formGroup = findFormGroup(elementName); - const expectation = disabledByToggle === 'disabled' ? 'true' : undefined; - expect(findFormElements(elementName, formGroup).attributes('disabled')).toBe(expectation); - }); - }, - ); - - describe('when isLoading is true', () => { - beforeEach(() => { - mountComponent({ isLoading: true }); - }); - - it.each` - elementName - ${'toggle'} - ${'interval'} - ${'schedule'} - ${'latest'} - ${'name-matching'} - ${'keep-name'} - `(`${FORM_ELEMENTS_ID_PREFIX}-$elementName is disabled`, ({ elementName }) => { - expect(findFormElements(elementName).attributes('disabled')).toBe('true'); - }); - }); - - describe.each` - modelName | elementName - ${'nameRegex'} | ${'name-matching'} - ${'nameRegexKeep'} | ${'keep-name'} - `('regex textarea validation', ({ modelName, elementName }) => { - const invalidString = new Array(NAME_REGEX_LENGTH + 2).join(','); - - describe('when apiError contains an error message', () => { - const errorMessage = 'something went wrong'; - - it('shows the error message on the relevant field', () => { - mountComponent({ apiErrors: { [modelName]: errorMessage } }); - expect(findFormGroup(elementName).attributes('invalid-feedback')).toBe(errorMessage); - }); - - it('gives precedence to API errors compared to local ones', () => { - mountComponent({ - apiErrors: { [modelName]: errorMessage }, - value: { [modelName]: invalidString }, - }); - expect(findFormGroup(elementName).attributes('invalid-feedback')).toBe(errorMessage); - }); - }); - - describe('when apiErrors is empty', () => { - it('if the user did not type validation is null', async () => { - mountComponent({ value: { [modelName]: '' } }); - expect(findFormGroup(elementName).attributes('state')).toBeUndefined(); - expect(wrapper.emitted('validated')).toBeTruthy(); - }); - - it(`if the user typed and is less than ${NAME_REGEX_LENGTH} state is true`, () => { - mountComponent({ value: { [modelName]: 'foo' } }); - - const formGroup = findFormGroup(elementName); - const formElement = findFormElements(elementName, formGroup); - expect(formGroup.attributes('state')).toBeTruthy(); - expect(formElement.attributes('state')).toBeTruthy(); - }); - - describe(`when name regex is longer than ${NAME_REGEX_LENGTH}`, () => { - beforeEach(() => { - mountComponent({ value: { [modelName]: invalidString } }); - }); - - it('textAreaValidation state is false', () => { - expect(findFormGroup(elementName).attributes('state')).toBeUndefined(); - // we are forced to check the model attribute because falsy attrs are all casted to undefined in attrs - // while in this case false shows an error and null instead shows nothing. - expect(wrapper.vm.textAreaValidation[modelName].state).toBe(false); - }); - - it('emit the @invalidated event', () => { - expect(wrapper.emitted('invalidated')).toBeTruthy(); - }); - }); - }); - }); - - describe('help text', () => { - it('toggleDescriptionText show disabled when settings.enabled is false', () => { - mountComponent(); - const toggleHelpText = findFormGroup('toggle').find('span'); - expect(toggleHelpText.html()).toContain(DISABLED_TEXT); - }); - - it('toggleDescriptionText show enabled when settings.enabled is true', () => { - mountComponent({ value: { enabled: true } }); - const toggleHelpText = findFormGroup('toggle').find('span'); - expect(toggleHelpText.html()).toContain(ENABLED_TEXT); - }); - }); -}); diff --git a/spec/frontend/registry/shared/mock_data.js b/spec/frontend/registry/shared/mock_data.js deleted file mode 100644 index 411363c2c95..00000000000 --- a/spec/frontend/registry/shared/mock_data.js +++ /dev/null @@ -1,12 +0,0 @@ -export const options = [{ key: 'foo', label: 'Foo' }, { key: 'bar', label: 'Bar', default: true }]; -export const stringifiedOptions = JSON.stringify(options); -export const stringifiedFormOptions = { - cadenceOptions: stringifiedOptions, - keepNOptions: stringifiedOptions, - olderThanOptions: stringifiedOptions, -}; -export const formOptions = { - cadence: options, - keepN: options, - olderThan: options, -}; diff --git a/spec/frontend/registry/shared/stubs.js b/spec/frontend/registry/shared/stubs.js index f6b88d70e49..ad41eb42df4 100644 --- a/spec/frontend/registry/shared/stubs.js +++ b/spec/frontend/registry/shared/stubs.js @@ -9,3 +9,23 @@ export const GlCard = { </div> `, }; + +export const GlFormGroup = { + name: 'gl-form-group-stub', + props: ['state'], + template: ` + <div> + <slot name="label"></slot> + <slot></slot> + <slot name="description"></slot> + </div>`, +}; + +export const GlFormSelect = { + name: 'gl-form-select-stub', + props: ['disabled', 'value'], + template: ` + <div> + <slot></slot> + </div>`, +}; diff --git a/spec/frontend/reports/components/grouped_test_reports_app_spec.js b/spec/frontend/reports/components/grouped_test_reports_app_spec.js index ae2718db17f..66d429017b2 100644 --- a/spec/frontend/reports/components/grouped_test_reports_app_spec.js +++ b/spec/frontend/reports/components/grouped_test_reports_app_spec.js @@ -264,7 +264,9 @@ describe('Grouped test reports app', () => { }); it('renders the recent failures count on the test case', () => { - expect(findIssueDescription().text()).toContain('Failed 8 times in the last 14 days'); + expect(findIssueDescription().text()).toContain( + 'Failed 8 times in master in the last 14 days', + ); }); }); diff --git a/spec/frontend/reports/mock_data/recent_failures_report.json b/spec/frontend/reports/mock_data/recent_failures_report.json index a47bc30a8e5..bc86d788ee2 100644 --- a/spec/frontend/reports/mock_data/recent_failures_report.json +++ b/spec/frontend/reports/mock_data/recent_failures_report.json @@ -10,7 +10,10 @@ "name": "Test#sum when a is 1 and b is 2 returns summary", "execution_time": 0.009411, "system_output": "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in <top (required)>'", - "recent_failures": 8 + "recent_failures": { + "count": 8, + "base_branch": "master" + } }, { "result": "failure", @@ -33,7 +36,10 @@ "result": "failure", "name": "Test#sum when a is 100 and b is 200 returns summary", "execution_time": 0.000562, - "recent_failures": 3 + "recent_failures": { + "count": 3, + "base_branch": "master" + } } ], "resolved_failures": [], diff --git a/spec/frontend/reports/store/mutations_spec.js b/spec/frontend/reports/store/mutations_spec.js index 82a399c876d..c1c5862a37c 100644 --- a/spec/frontend/reports/store/mutations_spec.js +++ b/spec/frontend/reports/store/mutations_spec.js @@ -46,7 +46,10 @@ describe('Reports Store Mutations', () => { name: 'StringHelper#concatenate when a is git and b is lab returns summary', execution_time: 0.0092435, system_output: "Failure/Error: is_expected.to eq('gitlab')", - recent_failures: 4, + recent_failures: { + count: 4, + base_branch: 'master', + }, }, ], resolved_failures: [ diff --git a/spec/frontend/reports/store/utils_spec.js b/spec/frontend/reports/store/utils_spec.js index 8977268115e..5249e9ffcce 100644 --- a/spec/frontend/reports/store/utils_spec.js +++ b/spec/frontend/reports/store/utils_spec.js @@ -188,9 +188,9 @@ describe('Reports store utils', () => { describe('countRecentlyFailedTests', () => { it('counts tests with more than one recent failure in a report', () => { const report = { - new_failures: [{ recent_failures: 2 }], - existing_failures: [{ recent_failures: 1 }], - resolved_failures: [{ recent_failures: 20 }, { recent_failures: 5 }], + new_failures: [{ recent_failures: { count: 2 } }], + existing_failures: [{ recent_failures: { count: 1 } }], + resolved_failures: [{ recent_failures: { count: 20 } }, { recent_failures: { count: 5 } }], }; const result = utils.countRecentlyFailedTests(report); @@ -200,14 +200,17 @@ describe('Reports store utils', () => { it('counts tests with more than one recent failure in an array of reports', () => { const reports = [ { - new_failures: [{ recent_failures: 2 }], - existing_failures: [{ recent_failures: 20 }, { recent_failures: 5 }], - resolved_failures: [{ recent_failures: 2 }], + new_failures: [{ recent_failures: { count: 2 } }], + existing_failures: [ + { recent_failures: { count: 20 } }, + { recent_failures: { count: 5 } }, + ], + resolved_failures: [{ recent_failures: { count: 2 } }], }, { - new_failures: [{ recent_failures: 8 }, { recent_failures: 14 }], - existing_failures: [{ recent_failures: 1 }], - resolved_failures: [{ recent_failures: 7 }, { recent_failures: 5 }], + new_failures: [{ recent_failures: { count: 8 } }, { recent_failures: { count: 14 } }], + existing_failures: [{ recent_failures: { count: 1 } }], + resolved_failures: [{ recent_failures: { count: 7 } }, { recent_failures: { count: 5 } }], }, ]; const result = utils.countRecentlyFailedTests(reports); diff --git a/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap b/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap index e2ccc07d0f2..48a4feca1e5 100644 --- a/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap +++ b/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap @@ -11,7 +11,6 @@ exports[`Repository file preview component renders file HTML 1`] = ` class="file-header-content" > <gl-icon-stub - aria-hidden="true" name="doc-text" size="16" /> diff --git a/spec/frontend/search/index_spec.js b/spec/frontend/search/index_spec.js index 8a86cc4c52a..31b5aa3686b 100644 --- a/spec/frontend/search/index_spec.js +++ b/spec/frontend/search/index_spec.js @@ -2,8 +2,8 @@ import { initSearchApp } from '~/search'; import createStore from '~/search/store'; jest.mock('~/search/store'); +jest.mock('~/search/topbar'); jest.mock('~/search/sidebar'); -jest.mock('~/search/group_filter'); describe('initSearchApp', () => { let defaultLocation; diff --git a/spec/frontend/search/mock_data.js b/spec/frontend/search/mock_data.js index 68fc432881a..ee509eaad8d 100644 --- a/spec/frontend/search/mock_data.js +++ b/spec/frontend/search/mock_data.js @@ -2,6 +2,7 @@ export const MOCK_QUERY = { scope: 'issues', state: 'all', confidential: null, + group_id: 'test_1', }; export const MOCK_GROUP = { @@ -22,3 +23,25 @@ export const MOCK_GROUPS = [ id: 'test_2', }, ]; + +export const MOCK_PROJECT = { + name: 'test project', + namespace_id: MOCK_GROUP.id, + nameWithNamespace: 'test group test project', + id: 'test_1', +}; + +export const MOCK_PROJECTS = [ + { + name: 'test project', + namespace_id: MOCK_GROUP.id, + name_with_namespace: 'test group test project', + id: 'test_1', + }, + { + name: 'test project 2', + namespace_id: MOCK_GROUP.id, + name_with_namespace: 'test group test project 2', + id: 'test_2', + }, +]; diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js index c8ea6167399..e4536a3e136 100644 --- a/spec/frontend/search/store/actions_spec.js +++ b/spec/frontend/search/store/actions_spec.js @@ -1,22 +1,24 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; +import Api from '~/api'; import * as actions from '~/search/store/actions'; import * as types from '~/search/store/mutation_types'; -import { setUrlParams, visitUrl } from '~/lib/utils/url_utility'; -import state from '~/search/store/state'; +import * as urlUtils from '~/lib/utils/url_utility'; +import createState from '~/search/store/state'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; -import { MOCK_GROUPS } from '../mock_data'; +import { MOCK_QUERY, MOCK_GROUPS, MOCK_PROJECT, MOCK_PROJECTS } from '../mock_data'; jest.mock('~/flash'); jest.mock('~/lib/utils/url_utility', () => ({ setUrlParams: jest.fn(), + joinPaths: jest.fn().mockReturnValue(''), visitUrl: jest.fn(), - joinPaths: jest.fn(), // For the axios specs })); describe('Global Search Store Actions', () => { let mock; + let state; const noCallback = () => {}; const flashCallback = () => { @@ -25,66 +27,97 @@ describe('Global Search Store Actions', () => { }; beforeEach(() => { + state = createState({ query: MOCK_QUERY }); mock = new MockAdapter(axios); }); afterEach(() => { + state = null; mock.restore(); }); describe.each` - action | axiosMock | type | mutationCalls | callback - ${actions.fetchGroups} | ${{ method: 'onGet', code: 200, res: MOCK_GROUPS }} | ${'success'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_SUCCESS, payload: MOCK_GROUPS }]} | ${noCallback} - ${actions.fetchGroups} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_ERROR }]} | ${flashCallback} - `(`axios calls`, ({ action, axiosMock, type, mutationCalls, callback }) => { + action | axiosMock | type | expectedMutations | callback + ${actions.fetchGroups} | ${{ method: 'onGet', code: 200, res: MOCK_GROUPS }} | ${'success'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_SUCCESS, payload: MOCK_GROUPS }]} | ${noCallback} + ${actions.fetchGroups} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_ERROR }]} | ${flashCallback} + ${actions.fetchProjects} | ${{ method: 'onGet', code: 200, res: MOCK_PROJECTS }} | ${'success'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_SUCCESS, payload: MOCK_PROJECTS }]} | ${noCallback} + ${actions.fetchProjects} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_ERROR }]} | ${flashCallback} + `(`axios calls`, ({ action, axiosMock, type, expectedMutations, callback }) => { describe(action.name, () => { describe(`on ${type}`, () => { beforeEach(() => { mock[axiosMock.method]().replyOnce(axiosMock.code, axiosMock.res); }); it(`should dispatch the correct mutations`, () => { - return testAction(action, null, state, mutationCalls, []).then(() => callback()); + return testAction({ action, state, expectedMutations }).then(() => callback()); }); }); }); }); + describe('getProjectsData', () => { + const mockCommit = () => {}; + beforeEach(() => { + jest.spyOn(Api, 'groupProjects').mockResolvedValue(MOCK_PROJECTS); + jest.spyOn(Api, 'projects').mockResolvedValue(MOCK_PROJECT); + }); + + describe('when groupId is set', () => { + it('calls Api.groupProjects', () => { + actions.fetchProjects({ commit: mockCommit, state }); + + expect(Api.groupProjects).toHaveBeenCalled(); + expect(Api.projects).not.toHaveBeenCalled(); + }); + }); + + describe('when groupId is not set', () => { + beforeEach(() => { + state = createState({ query: { group_id: null } }); + }); + + it('calls Api.projects', () => { + actions.fetchProjects({ commit: mockCommit, state }); + + expect(Api.groupProjects).not.toHaveBeenCalled(); + expect(Api.projects).toHaveBeenCalled(); + }); + }); + }); + describe('setQuery', () => { const payload = { key: 'key1', value: 'value1' }; - it('calls the SET_QUERY mutation', done => { - testAction(actions.setQuery, payload, state, [{ type: types.SET_QUERY, payload }], [], done); + it('calls the SET_QUERY mutation', () => { + return testAction({ + action: actions.setQuery, + payload, + state, + expectedMutations: [{ type: types.SET_QUERY, payload }], + }); }); }); describe('applyQuery', () => { it('calls visitUrl and setParams with the state.query', () => { - testAction(actions.applyQuery, null, state, [], [], () => { - expect(setUrlParams).toHaveBeenCalledWith({ ...state.query, page: null }); - expect(visitUrl).toHaveBeenCalled(); + return testAction(actions.applyQuery, null, state, [], [], () => { + expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ ...state.query, page: null }); + expect(urlUtils.visitUrl).toHaveBeenCalled(); }); }); }); describe('resetQuery', () => { it('calls visitUrl and setParams with empty values', () => { - testAction(actions.resetQuery, null, state, [], [], () => { - expect(setUrlParams).toHaveBeenCalledWith({ + return testAction(actions.resetQuery, null, state, [], [], () => { + expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ ...state.query, page: null, state: null, confidential: null, }); - expect(visitUrl).toHaveBeenCalled(); + expect(urlUtils.visitUrl).toHaveBeenCalled(); }); }); }); }); - -describe('setQuery', () => { - const payload = { key: 'key1', value: 'value1' }; - - it('calls the SET_QUERY mutation', done => { - testAction(actions.setQuery, payload, state, [{ type: types.SET_QUERY, payload }], [], done); - }); -}); diff --git a/spec/frontend/search/store/mutations_spec.js b/spec/frontend/search/store/mutations_spec.js index 28d9646b97e..560ed66263b 100644 --- a/spec/frontend/search/store/mutations_spec.js +++ b/spec/frontend/search/store/mutations_spec.js @@ -1,7 +1,7 @@ import mutations from '~/search/store/mutations'; import createState from '~/search/store/state'; import * as types from '~/search/store/mutation_types'; -import { MOCK_QUERY, MOCK_GROUPS } from '../mock_data'; +import { MOCK_QUERY, MOCK_GROUPS, MOCK_PROJECTS } from '../mock_data'; describe('Global Search Store Mutations', () => { let state; @@ -36,6 +36,32 @@ describe('Global Search Store Mutations', () => { }); }); + describe('REQUEST_PROJECTS', () => { + it('sets fetchingProjects to true', () => { + mutations[types.REQUEST_PROJECTS](state); + + expect(state.fetchingProjects).toBe(true); + }); + }); + + describe('RECEIVE_PROJECTS_SUCCESS', () => { + it('sets fetchingProjects to false and sets projects', () => { + mutations[types.RECEIVE_PROJECTS_SUCCESS](state, MOCK_PROJECTS); + + expect(state.fetchingProjects).toBe(false); + expect(state.projects).toBe(MOCK_PROJECTS); + }); + }); + + describe('RECEIVE_PROJECTS_ERROR', () => { + it('sets fetchingProjects to false and clears projects', () => { + mutations[types.RECEIVE_PROJECTS_ERROR](state); + + expect(state.fetchingProjects).toBe(false); + expect(state.projects).toEqual([]); + }); + }); + describe('SET_QUERY', () => { const payload = { key: 'key1', value: 'value1' }; diff --git a/spec/frontend/search/topbar/components/group_filter_spec.js b/spec/frontend/search/topbar/components/group_filter_spec.js new file mode 100644 index 00000000000..017808d576e --- /dev/null +++ b/spec/frontend/search/topbar/components/group_filter_spec.js @@ -0,0 +1,121 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data'; +import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; +import GroupFilter from '~/search/topbar/components/group_filter.vue'; +import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue'; +import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '~/search/topbar/constants'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn(), + setUrlParams: jest.fn(), +})); + +describe('GroupFilter', () => { + let wrapper; + + const actionSpies = { + fetchGroups: jest.fn(), + }; + + const defaultProps = { + initialData: null, + }; + + const createComponent = (initialState, props) => { + const store = new Vuex.Store({ + state: { + query: MOCK_QUERY, + ...initialState, + }, + actions: actionSpies, + }); + + wrapper = shallowMount(GroupFilter, { + localVue, + store, + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findSearchableDropdown = () => wrapper.find(SearchableDropdown); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders SearchableDropdown always', () => { + expect(findSearchableDropdown().exists()).toBe(true); + }); + }); + + describe('events', () => { + describe('when @search is emitted', () => { + const search = 'test'; + + beforeEach(() => { + createComponent(); + + findSearchableDropdown().vm.$emit('search', search); + }); + + it('calls fetchGroups with the search paramter', () => { + expect(actionSpies.fetchGroups).toHaveBeenCalledTimes(1); + expect(actionSpies.fetchGroups).toHaveBeenCalledWith(expect.any(Object), search); + }); + }); + + describe('when @change is emitted', () => { + beforeEach(() => { + createComponent(); + + findSearchableDropdown().vm.$emit('change', MOCK_GROUP); + }); + + it('calls calls setUrlParams with group id, project id null, and visitUrl', () => { + expect(setUrlParams).toHaveBeenCalledWith({ + [GROUP_DATA.queryParam]: MOCK_GROUP.id, + [PROJECT_DATA.queryParam]: null, + }); + + expect(visitUrl).toHaveBeenCalled(); + }); + }); + }); + + describe('computed', () => { + describe('selectedGroup', () => { + describe('when initialData is null', () => { + beforeEach(() => { + createComponent(); + }); + + it('sets selectedGroup to ANY_OPTION', () => { + expect(wrapper.vm.selectedGroup).toBe(ANY_OPTION); + }); + }); + + describe('when initialData is set', () => { + beforeEach(() => { + createComponent({}, { initialData: MOCK_GROUP }); + }); + + it('sets selectedGroup to ANY_OPTION', () => { + expect(wrapper.vm.selectedGroup).toBe(MOCK_GROUP); + }); + }); + }); + }); +}); diff --git a/spec/frontend/search/topbar/components/project_filter_spec.js b/spec/frontend/search/topbar/components/project_filter_spec.js new file mode 100644 index 00000000000..c1fc61d7e89 --- /dev/null +++ b/spec/frontend/search/topbar/components/project_filter_spec.js @@ -0,0 +1,134 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { MOCK_PROJECT, MOCK_QUERY } from 'jest/search/mock_data'; +import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; +import ProjectFilter from '~/search/topbar/components/project_filter.vue'; +import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue'; +import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '~/search/topbar/constants'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn(), + setUrlParams: jest.fn(), +})); + +describe('ProjectFilter', () => { + let wrapper; + + const actionSpies = { + fetchProjects: jest.fn(), + }; + + const defaultProps = { + initialData: null, + }; + + const createComponent = (initialState, props) => { + const store = new Vuex.Store({ + state: { + query: MOCK_QUERY, + ...initialState, + }, + actions: actionSpies, + }); + + wrapper = shallowMount(ProjectFilter, { + localVue, + store, + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findSearchableDropdown = () => wrapper.find(SearchableDropdown); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders SearchableDropdown always', () => { + expect(findSearchableDropdown().exists()).toBe(true); + }); + }); + + describe('events', () => { + beforeEach(() => { + createComponent(); + }); + + describe('when @search is emitted', () => { + const search = 'test'; + + beforeEach(() => { + findSearchableDropdown().vm.$emit('search', search); + }); + + it('calls fetchProjects with the search paramter', () => { + expect(actionSpies.fetchProjects).toHaveBeenCalledWith(expect.any(Object), search); + }); + }); + + describe('when @change is emitted', () => { + describe('with Any', () => { + beforeEach(() => { + findSearchableDropdown().vm.$emit('change', ANY_OPTION); + }); + + it('calls setUrlParams with project id, not group id, then calls visitUrl', () => { + expect(setUrlParams).toHaveBeenCalledWith({ + [PROJECT_DATA.queryParam]: ANY_OPTION.id, + }); + expect(visitUrl).toHaveBeenCalled(); + }); + }); + + describe('with a Project', () => { + beforeEach(() => { + findSearchableDropdown().vm.$emit('change', MOCK_PROJECT); + }); + + it('calls setUrlParams with project id, group id, then calls visitUrl', () => { + expect(setUrlParams).toHaveBeenCalledWith({ + [GROUP_DATA.queryParam]: MOCK_PROJECT.namespace_id, + [PROJECT_DATA.queryParam]: MOCK_PROJECT.id, + }); + expect(visitUrl).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('computed', () => { + describe('selectedProject', () => { + describe('when initialData is null', () => { + beforeEach(() => { + createComponent(); + }); + + it('sets selectedProject to ANY_OPTION', () => { + expect(wrapper.vm.selectedProject).toBe(ANY_OPTION); + }); + }); + + describe('when initialData is set', () => { + beforeEach(() => { + createComponent({}, { initialData: MOCK_PROJECT }); + }); + + it('sets selectedProject to the initialData', () => { + expect(wrapper.vm.selectedProject).toBe(MOCK_PROJECT); + }); + }); + }); + }); +}); diff --git a/spec/frontend/search/group_filter/components/group_filter_spec.js b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js index fd3a4449f41..c4ebaabbf96 100644 --- a/spec/frontend/search/group_filter/components/group_filter_spec.js +++ b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js @@ -1,41 +1,34 @@ import Vuex from 'vuex'; import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui'; -import * as urlUtils from '~/lib/utils/url_utility'; -import GroupFilter from '~/search/group_filter/components/group_filter.vue'; -import { GROUP_QUERY_PARAM, PROJECT_QUERY_PARAM, ANY_GROUP } from '~/search/group_filter/constants'; -import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from '../../mock_data'; +import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data'; +import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue'; +import { ANY_OPTION, GROUP_DATA } from '~/search/topbar/constants'; const localVue = createLocalVue(); localVue.use(Vuex); -jest.mock('~/flash'); -jest.mock('~/lib/utils/url_utility', () => ({ - visitUrl: jest.fn(), - setUrlParams: jest.fn(), -})); - -describe('Global Search Group Filter', () => { +describe('Global Search Searchable Dropdown', () => { let wrapper; - const actionSpies = { - fetchGroups: jest.fn(), - }; - const defaultProps = { - initialGroup: null, + headerText: GROUP_DATA.headerText, + selectedDisplayValue: GROUP_DATA.selectedDisplayValue, + itemsDisplayValue: GROUP_DATA.itemsDisplayValue, + loading: false, + selectedItem: ANY_OPTION, + items: [], }; - const createComponent = (initialState, props = {}, mountFn = shallowMount) => { + const createComponent = (initialState, props, mountFn = shallowMount) => { const store = new Vuex.Store({ state: { query: MOCK_QUERY, ...initialState, }, - actions: actionSpies, }); - wrapper = mountFn(GroupFilter, { + wrapper = mountFn(SearchableDropdown, { localVue, store, propsData: { @@ -78,22 +71,22 @@ describe('Global Search Group Filter', () => { }); describe('onSearch', () => { - const groupSearch = 'test search'; + const search = 'test search'; beforeEach(() => { - findGlDropdownSearch().vm.$emit('input', groupSearch); + findGlDropdownSearch().vm.$emit('input', search); }); - it('calls fetchGroups when input event is fired from GlSearchBoxByType', () => { - expect(actionSpies.fetchGroups).toHaveBeenCalledWith(expect.any(Object), groupSearch); + it('$emits @search when input event is fired from GlSearchBoxByType', () => { + expect(wrapper.emitted('search')[0]).toEqual([search]); }); }); }); describe('findDropdownItems', () => { - describe('when fetchingGroups is false', () => { + describe('when loading is false', () => { beforeEach(() => { - createComponent({ groups: MOCK_GROUPS }); + createComponent({}, { items: MOCK_GROUPS }); }); it('does not render loader', () => { @@ -101,14 +94,14 @@ describe('Global Search Group Filter', () => { }); it('renders an instance for each namespace', () => { - const groupsIncludingAny = ['Any'].concat(MOCK_GROUPS.map(n => n.full_name)); - expect(findDropdownItemsText()).toStrictEqual(groupsIncludingAny); + const resultsIncludeAny = ['Any'].concat(MOCK_GROUPS.map(n => n.full_name)); + expect(findDropdownItemsText()).toStrictEqual(resultsIncludeAny); }); }); - describe('when fetchingGroups is true', () => { + describe('when loading is true', () => { beforeEach(() => { - createComponent({ fetchingGroups: true, groups: MOCK_GROUPS }); + createComponent({}, { loading: true, items: MOCK_GROUPS }); }); it('does render loader', () => { @@ -119,26 +112,36 @@ describe('Global Search Group Filter', () => { expect(findDropdownItemsText()).toStrictEqual(['Any']); }); }); + + describe('when item is selected', () => { + beforeEach(() => { + createComponent({}, { items: MOCK_GROUPS, selectedItem: MOCK_GROUPS[0] }); + }); + + it('marks the dropdown as checked', () => { + expect(findFirstGroupDropdownItem().attributes('ischecked')).toBe('true'); + }); + }); }); describe('Dropdown Text', () => { - describe('when initialGroup is null', () => { + describe('when selectedItem is any', () => { beforeEach(() => { createComponent({}, {}, mount); }); it('sets dropdown text to Any', () => { - expect(findDropdownText().text()).toBe(ANY_GROUP.name); + expect(findDropdownText().text()).toBe(ANY_OPTION.name); }); }); - describe('initialGroup is set', () => { + describe('selectedItem is set', () => { beforeEach(() => { - createComponent({}, { initialGroup: MOCK_GROUP }, mount); + createComponent({}, { selectedItem: MOCK_GROUP }, mount); }); - it('sets dropdown text to group name', () => { - expect(findDropdownText().text()).toBe(MOCK_GROUP.name); + it('sets dropdown text to the selectedItem selectedDisplayValue', () => { + expect(findDropdownText().text()).toBe(MOCK_GROUP[GROUP_DATA.selectedDisplayValue]); }); }); }); @@ -146,27 +149,19 @@ describe('Global Search Group Filter', () => { describe('actions', () => { beforeEach(() => { - createComponent({ groups: MOCK_GROUPS }); + createComponent({}, { items: MOCK_GROUPS }); }); - it('clicking "Any" dropdown item calls setUrlParams with group id null, project id null,and visitUrl', () => { + it('clicking "Any" dropdown item $emits @change with ANY_OPTION', () => { findAnyDropdownItem().vm.$emit('click'); - expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ - [GROUP_QUERY_PARAM]: ANY_GROUP.id, - [PROJECT_QUERY_PARAM]: null, - }); - expect(urlUtils.visitUrl).toHaveBeenCalled(); + expect(wrapper.emitted('change')[0]).toEqual([ANY_OPTION]); }); - it('clicking group dropdown item calls setUrlParams with group id, project id null, and visitUrl', () => { + it('clicking result dropdown item $emits @change with result', () => { findFirstGroupDropdownItem().vm.$emit('click'); - expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ - [GROUP_QUERY_PARAM]: MOCK_GROUPS[0].id, - [PROJECT_QUERY_PARAM]: null, - }); - expect(urlUtils.visitUrl).toHaveBeenCalled(); + expect(wrapper.emitted('change')[0]).toEqual([MOCK_GROUPS[0]]); }); }); }); diff --git a/spec/frontend/search_spec.js b/spec/frontend/search_spec.js index cbbc2df6c78..d234a7fccb9 100644 --- a/spec/frontend/search_spec.js +++ b/spec/frontend/search_spec.js @@ -1,6 +1,4 @@ -import $ from 'jquery'; import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result'; -import Api from '~/api'; import Search from '~/pages/search/show/search'; jest.mock('~/api'); @@ -8,13 +6,6 @@ jest.mock('ee_else_ce/search/highlight_blob_search_result'); describe('Search', () => { const fixturePath = 'search/show.html'; - const searchTerm = 'some search'; - const fillDropdownInput = dropdownSelector => { - const dropdownElement = document.querySelector(dropdownSelector).parentNode; - const inputElement = dropdownElement.querySelector('.dropdown-input-field'); - inputElement.value = searchTerm; - return inputElement; - }; preloadFixtures(fixturePath); @@ -29,20 +20,4 @@ describe('Search', () => { expect(setHighlightClass).toHaveBeenCalled(); }); }); - - describe('dropdown behavior', () => { - beforeEach(() => { - loadFixtures(fixturePath); - new Search(); // eslint-disable-line no-new - }); - - it('requests projects from backend when filtering', () => { - jest.spyOn(Api, 'projects').mockImplementation(term => { - expect(term).toBe(searchTerm); - }); - const inputElement = fillDropdownInput('.js-search-project-dropdown'); - - $(inputElement).trigger('input'); - }); - }); }); diff --git a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap b/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap index 11ab1ca3aaa..2367667544d 100644 --- a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap +++ b/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap @@ -10,7 +10,6 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i title="Not confidential" > <gl-icon-stub - aria-hidden="true" name="eye" size="16" /> @@ -35,7 +34,6 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i data-testid="not-confidential" > <gl-icon-stub - aria-hidden="true" class="sidebar-item-icon inline" name="eye" size="16" @@ -58,7 +56,6 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i title="Not confidential" > <gl-icon-stub - aria-hidden="true" name="eye" size="16" /> @@ -91,7 +88,6 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i data-testid="not-confidential" > <gl-icon-stub - aria-hidden="true" class="sidebar-item-icon inline" name="eye" size="16" @@ -114,7 +110,6 @@ exports[`Confidential Issue Sidebar Block renders for confidential = true and is title="Confidential" > <gl-icon-stub - aria-hidden="true" name="eye-slash" size="16" /> @@ -138,7 +133,6 @@ exports[`Confidential Issue Sidebar Block renders for confidential = true and is class="value sidebar-item-value hide-collapsed" > <gl-icon-stub - aria-hidden="true" class="sidebar-item-icon inline is-active" name="eye-slash" size="16" @@ -161,7 +155,6 @@ exports[`Confidential Issue Sidebar Block renders for confidential = true and is title="Confidential" > <gl-icon-stub - aria-hidden="true" name="eye-slash" size="16" /> @@ -193,7 +186,6 @@ exports[`Confidential Issue Sidebar Block renders for confidential = true and is class="value sidebar-item-value hide-collapsed" > <gl-icon-stub - aria-hidden="true" class="sidebar-item-icon inline is-active" name="eye-slash" size="16" diff --git a/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap b/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap index 6640c0844e2..e295c587d70 100644 --- a/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap +++ b/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap @@ -4,13 +4,8 @@ exports[`SidebarTodo template renders component container element with proper da <button aria-label="Mark as done" class="btn btn-default btn-todo issuable-header-btn float-right" - data-boundary="viewport" - data-container="body" data-issuable-id="1" data-issuable-type="epic" - data-original-title="" - data-placement="left" - title="" type="button" > <gl-icon-stub diff --git a/spec/frontend/sidebar/issuable_assignees_spec.js b/spec/frontend/sidebar/issuable_assignees_spec.js index 076616de040..af4dc315aad 100644 --- a/spec/frontend/sidebar/issuable_assignees_spec.js +++ b/spec/frontend/sidebar/issuable_assignees_spec.js @@ -26,8 +26,8 @@ describe('IssuableAssignees', () => { createComponent(); }); - it('renders "None"', () => { - expect(findEmptyAssignee().text()).toBe('None'); + it('renders "None - assign yourself"', () => { + expect(findEmptyAssignee().text()).toBe('None - assign yourself'); }); }); @@ -38,4 +38,12 @@ describe('IssuableAssignees', () => { expect(findUncollapsedAssigneeList().exists()).toBe(true); }); }); + + describe('when clicking "assign yourself"', () => { + it('emits "assign-self"', () => { + createComponent(); + wrapper.find('[data-testid="assign-yourself"]').vm.$emit('click'); + expect(wrapper.emitted('assign-self')).toHaveLength(1); + }); + }); }); diff --git a/spec/frontend/sidebar/sidebar_labels_spec.js b/spec/frontend/sidebar/sidebar_labels_spec.js index 36d1e129b6a..ab08a1e65e2 100644 --- a/spec/frontend/sidebar/sidebar_labels_spec.js +++ b/spec/frontend/sidebar/sidebar_labels_spec.js @@ -3,7 +3,7 @@ import { mockLabels, mockRegularLabel, } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; -import updateIssueLabelsMutation from '~/boards/queries/issue_set_labels.mutation.graphql'; +import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; import { MutationOperationMode } from '~/graphql_shared/utils'; import { IssuableType } from '~/issue_show/constants'; import SidebarLabels from '~/sidebar/components/labels/sidebar_labels.vue'; diff --git a/spec/frontend/static_site_editor/components/edit_area_spec.js b/spec/frontend/static_site_editor/components/edit_area_spec.js index 247aff57c1a..ed33f93ec51 100644 --- a/spec/frontend/static_site_editor/components/edit_area_spec.js +++ b/spec/frontend/static_site_editor/components/edit_area_spec.js @@ -250,4 +250,17 @@ describe('~/static_site_editor/components/edit_area.vue', () => { expect(wrapper.emitted('submit').length).toBe(1); }); }); + + describe('when RichContentEditor component triggers load event', () => { + it('stores formatted markdown provided in the event data', () => { + const data = { formattedMarkdown: 'formatted markdown' }; + + findRichContentEditor().vm.$emit('load', data); + + // We can access the formatted markdown when submitting changes + findPublishToolbar().vm.$emit('submit'); + + expect(wrapper.emitted('submit')[0][0]).toMatchObject(data); + }); + }); }); diff --git a/spec/frontend/static_site_editor/pages/home_spec.js b/spec/frontend/static_site_editor/pages/home_spec.js index d0b72ad0cf0..3e488a950dc 100644 --- a/spec/frontend/static_site_editor/pages/home_spec.js +++ b/spec/frontend/static_site_editor/pages/home_spec.js @@ -235,6 +235,7 @@ describe('static_site_editor/pages/home', () => { describe('when submitting changes succeeds', () => { const newContent = `new ${content}`; + const formattedMarkdown = `formatted ${content}`; beforeEach(() => { mutateMock.mockResolvedValueOnce(hasSubmittedChangesMutationPayload).mockResolvedValueOnce({ @@ -243,7 +244,12 @@ describe('static_site_editor/pages/home', () => { }, }); - buildWrapper({ content: newContent, images }); + buildWrapper(); + + findEditMetaModal().vm.show = jest.fn(); + + findEditArea().vm.$emit('submit', { content: newContent, images, formattedMarkdown }); + findEditMetaModal().vm.$emit('primary', mergeRequestMeta); return wrapper.vm.$nextTick(); @@ -266,6 +272,7 @@ describe('static_site_editor/pages/home', () => { variables: { input: { content: newContent, + formattedMarkdown, project, sourcePath, username, diff --git a/spec/frontend/static_site_editor/services/submit_content_changes_spec.js b/spec/frontend/static_site_editor/services/submit_content_changes_spec.js index 5018da7300b..6c2bff6740a 100644 --- a/spec/frontend/static_site_editor/services/submit_content_changes_spec.js +++ b/spec/frontend/static_site_editor/services/submit_content_changes_spec.js @@ -9,6 +9,10 @@ import { SUBMIT_CHANGES_MERGE_REQUEST_ERROR, TRACKING_ACTION_CREATE_COMMIT, TRACKING_ACTION_CREATE_MERGE_REQUEST, + USAGE_PING_TRACKING_ACTION_CREATE_COMMIT, + USAGE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST, + DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE, + DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION, } from '~/static_site_editor/constants'; import generateBranchName from '~/static_site_editor/services/generate_branch_name'; import submitContentChanges from '~/static_site_editor/services/submit_content_changes'; @@ -79,6 +83,36 @@ describe('submitContentChanges', () => { ); }); + describe('committing markdown formatting changes', () => { + const formattedMarkdown = `formatted ${content}`; + const commitPayload = { + branch, + commit_message: `${DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE}\n\n${DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION}`, + actions: [ + { + action: 'update', + file_path: sourcePath, + content: formattedMarkdown, + }, + ], + }; + + it('commits markdown formatting changes in a separate commit', () => { + return submitContentChanges(buildPayload({ formattedMarkdown })).then(() => { + expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, commitPayload); + }); + }); + + it('does not commit markdown formatting changes when there are none', () => { + return submitContentChanges(buildPayload()).then(() => { + expect(Api.commitMultiple.mock.calls).toHaveLength(1); + expect(Api.commitMultiple.mock.calls[0][1]).not.toMatchObject({ + actions: commitPayload.actions, + }); + }); + }); + }); + it('commits the content changes to the branch when creating branch succeeds', () => { return submitContentChanges(buildPayload()).then(() => { expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, { @@ -201,4 +235,26 @@ describe('submitContentChanges', () => { ); }); }); + + describe('sends the correct Usage Ping tracking event', () => { + beforeEach(() => { + jest.spyOn(Api, 'trackRedisCounterEvent').mockResolvedValue({ data: '' }); + }); + + it('for commiting changes', () => { + return submitContentChanges(buildPayload()).then(() => { + expect(Api.trackRedisCounterEvent).toHaveBeenCalledWith( + USAGE_PING_TRACKING_ACTION_CREATE_COMMIT, + ); + }); + }); + + it('for creating a merge request', () => { + return submitContentChanges(buildPayload()).then(() => { + expect(Api.trackRedisCounterEvent).toHaveBeenCalledWith( + USAGE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST, + ); + }); + }); + }); }); diff --git a/spec/frontend/terraform/components/states_table_actions_spec.js b/spec/frontend/terraform/components/states_table_actions_spec.js new file mode 100644 index 00000000000..264f4b7939a --- /dev/null +++ b/spec/frontend/terraform/components/states_table_actions_spec.js @@ -0,0 +1,189 @@ +import { GlDropdown, GlModal, GlSprintf } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import VueApollo from 'vue-apollo'; +import StateActions from '~/terraform/components/states_table_actions.vue'; +import lockStateMutation from '~/terraform/graphql/mutations/lock_state.mutation.graphql'; +import removeStateMutation from '~/terraform/graphql/mutations/remove_state.mutation.graphql'; +import unlockStateMutation from '~/terraform/graphql/mutations/unlock_state.mutation.graphql'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('StatesTableActions', () => { + let lockResponse; + let removeResponse; + let unlockResponse; + let wrapper; + + const defaultProps = { + state: { + id: 'gid/1', + name: 'state-1', + latestVersion: { downloadPath: '/path' }, + lockedAt: '2020-10-13T00:00:00Z', + }, + }; + + const createMockApolloProvider = () => { + lockResponse = jest.fn().mockResolvedValue({ data: { terraformStateLock: { errors: [] } } }); + + removeResponse = jest + .fn() + .mockResolvedValue({ data: { terraformStateDelete: { errors: [] } } }); + + unlockResponse = jest + .fn() + .mockResolvedValue({ data: { terraformStateUnlock: { errors: [] } } }); + + return createMockApollo([ + [lockStateMutation, lockResponse], + [removeStateMutation, removeResponse], + [unlockStateMutation, unlockResponse], + ]); + }; + + const createComponent = (propsData = defaultProps) => { + const apolloProvider = createMockApolloProvider(); + + wrapper = shallowMount(StateActions, { + apolloProvider, + localVue, + propsData, + stubs: { GlDropdown, GlModal, GlSprintf }, + }); + + return wrapper.vm.$nextTick(); + }; + + const findLockBtn = () => wrapper.find('[data-testid="terraform-state-lock"]'); + const findUnlockBtn = () => wrapper.find('[data-testid="terraform-state-unlock"]'); + const findDownloadBtn = () => wrapper.find('[data-testid="terraform-state-download"]'); + const findRemoveBtn = () => wrapper.find('[data-testid="terraform-state-remove"]'); + const findRemoveModal = () => wrapper.find(GlModal); + + beforeEach(() => { + return createComponent(); + }); + + afterEach(() => { + lockResponse = null; + removeResponse = null; + unlockResponse = null; + wrapper.destroy(); + }); + + describe('download button', () => { + it('displays a download button', () => { + expect(findDownloadBtn().text()).toBe('Download JSON'); + }); + + describe('when state does not have a latestVersion', () => { + beforeEach(() => { + return createComponent({ + state: { + id: 'gid/1', + name: 'state-1', + latestVersion: null, + }, + }); + }); + + it('does not display a download button', () => { + expect(findDownloadBtn().exists()).toBe(false); + }); + }); + }); + + describe('unlock button', () => { + it('displays an unlock button', () => { + expect(findUnlockBtn().text()).toBe('Unlock'); + expect(findLockBtn().exists()).toBe(false); + }); + + describe('when clicking the unlock button', () => { + beforeEach(() => { + findUnlockBtn().vm.$emit('click'); + return wrapper.vm.$nextTick(); + }); + + it('calls the unlock mutation', () => { + expect(unlockResponse).toHaveBeenCalledWith({ + stateID: defaultProps.state.id, + }); + }); + }); + }); + + describe('lock button', () => { + const unlockedProps = { + state: { + id: 'gid/2', + name: 'state-2', + latestVersion: null, + lockedAt: null, + }, + }; + + beforeEach(() => { + return createComponent(unlockedProps); + }); + + it('displays a lock button', () => { + expect(findLockBtn().text()).toBe('Lock'); + expect(findUnlockBtn().exists()).toBe(false); + }); + + describe('when clicking the lock button', () => { + beforeEach(() => { + findLockBtn().vm.$emit('click'); + return wrapper.vm.$nextTick(); + }); + + it('calls the lock mutation', () => { + expect(lockResponse).toHaveBeenCalledWith({ + stateID: unlockedProps.state.id, + }); + }); + }); + }); + + describe('remove button', () => { + it('displays a remove button', () => { + expect(findRemoveBtn().text()).toBe(StateActions.i18n.remove); + }); + + describe('when clicking the remove button', () => { + beforeEach(() => { + findRemoveBtn().vm.$emit('click'); + return wrapper.vm.$nextTick(); + }); + + it('displays a remove modal', () => { + expect(findRemoveModal().text()).toContain( + `You are about to remove the State file ${defaultProps.state.name}`, + ); + }); + + describe('when submitting the remove modal', () => { + it('does not call the remove mutation when state name is missing', async () => { + findRemoveModal().vm.$emit('ok'); + await wrapper.vm.$nextTick(); + + expect(removeResponse).not.toHaveBeenCalledWith(); + }); + + it('calls the remove mutation when state name is present', async () => { + await wrapper.setData({ removeConfirmText: defaultProps.state.name }); + + findRemoveModal().vm.$emit('ok'); + await wrapper.vm.$nextTick(); + + expect(removeResponse).toHaveBeenCalledWith({ + stateID: defaultProps.state.id, + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/terraform/components/states_table_spec.js b/spec/frontend/terraform/components/states_table_spec.js index 7a8cb19971e..f2b7bc00e5b 100644 --- a/spec/frontend/terraform/components/states_table_spec.js +++ b/spec/frontend/terraform/components/states_table_spec.js @@ -1,13 +1,14 @@ import { GlIcon, GlTooltip } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { useFakeDate } from 'helpers/fake_date'; +import StateActions from '~/terraform/components/states_table_actions.vue'; import StatesTable from '~/terraform/components/states_table.vue'; describe('StatesTable', () => { let wrapper; useFakeDate([2020, 10, 15]); - const propsData = { + const defaultProps = { states: [ { name: 'state-1', @@ -37,6 +38,19 @@ describe('StatesTable', () => { createdByUser: { name: 'user-3', }, + job: { + detailedStatus: { + detailsPath: '/job-path-3', + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + }, + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/3', + path: '/pipeline-path-3', + }, + }, }, }, { @@ -47,14 +61,33 @@ describe('StatesTable', () => { latestVersion: { updatedAt: '2020-10-09T00:00:00Z', createdByUser: null, + job: { + detailedStatus: { + detailsPath: '/job-path-4', + group: 'passed', + icon: 'status_success', + label: 'passed', + text: 'passed', + }, + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/4', + path: '/pipeline-path-4', + }, + }, }, }, ], }; - beforeEach(() => { + const createComponent = (propsData = defaultProps) => { wrapper = mount(StatesTable, { propsData }); return wrapper.vm.$nextTick(); + }; + + const findActions = () => wrapper.findAll(StateActions); + + beforeEach(() => { + return createComponent(); }); afterEach(() => { @@ -99,4 +132,38 @@ describe('StatesTable', () => { expect(state.text()).toMatchInterpolatedText(updateTime); }); + + it.each` + pipelineText | toolTipAdded | lineNumber + ${''} | ${false} | ${0} + ${''} | ${false} | ${1} + ${'#3 failed Job status'} | ${true} | ${2} + ${'#4 passed Job status'} | ${true} | ${3} + `( + 'displays the pipeline information for line "$lineNumber"', + ({ pipelineText, toolTipAdded, lineNumber }) => { + const states = wrapper.findAll('[data-testid="terraform-states-table-pipeline"]'); + const state = states.at(lineNumber); + + expect(state.find(GlTooltip).exists()).toBe(toolTipAdded); + expect(state.text()).toMatchInterpolatedText(pipelineText); + }, + ); + + it('displays no actions dropdown', () => { + expect(findActions().length).toEqual(0); + }); + + describe('when user is a terraform administrator', () => { + beforeEach(() => { + return createComponent({ + terraformAdmin: true, + ...defaultProps, + }); + }); + + it('displays an actions dropdown for each state', () => { + expect(findActions().length).toEqual(defaultProps.states.length); + }); + }); }); diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js index eebec7de9d4..2a3eddf7b4e 100644 --- a/spec/frontend/test_setup.js +++ b/spec/frontend/test_setup.js @@ -25,7 +25,7 @@ afterEach(() => }), ); -initializeTestTimeout(process.env.CI ? 6000 : 500); +initializeTestTimeout(process.env.CI ? 6000 : 5000); Vue.config.devtools = false; Vue.config.productionTip = false; diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js index 266c906ba60..f9b6ac721d2 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js @@ -164,9 +164,7 @@ describe('MRWidgetHeader', () => { it('renders checkout branch button with modal trigger', () => { const button = vm.$el.querySelector('.js-check-out-branch'); - expect(button.textContent.trim()).toEqual('Check out branch'); - expect(button.getAttribute('data-target')).toEqual('#modal_merge_info'); - expect(button.getAttribute('data-toggle')).toEqual('modal'); + expect(button.textContent.trim()).toBe('Check out branch'); }); it('renders web ide button', () => { diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_merge_help_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_merge_help_spec.js index 00e79a22485..53a74bf7456 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_merge_help_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_merge_help_spec.js @@ -1,69 +1,45 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import mergeHelpComponent from '~/vue_merge_request_widget/components/mr_widget_merge_help.vue'; +import { shallowMount } from '@vue/test-utils'; +import MergeHelpComponent from '~/vue_merge_request_widget/components/mr_widget_merge_help.vue'; describe('MRWidgetMergeHelp', () => { - let vm; - let Component; + let wrapper; - beforeEach(() => { - Component = Vue.extend(mergeHelpComponent); - }); + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(MergeHelpComponent, { + propsData: { + missingBranch: 'this-is-not-the-branch-you-are-looking-for', + ...props, + }, + }); + }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + wrapper = null; }); describe('with missing branch', () => { beforeEach(() => { - vm = mountComponent(Component, { - missingBranch: 'this-is-not-the-branch-you-are-looking-for', - }); + createComponent(); }); it('renders missing branch information', () => { - expect( - vm.$el.textContent - .trim() - .replace(/[\r\n]+/g, ' ') - .replace(/\s\s+/g, ' '), - ).toEqual( - 'If the this-is-not-the-branch-you-are-looking-for branch exists in your local repository, you can merge this merge request manually using the command line', - ); - }); - - it('renders button to open help modal', () => { - expect(vm.$el.querySelector('.js-open-modal-help').getAttribute('data-target')).toEqual( - '#modal_merge_info', - ); - - expect(vm.$el.querySelector('.js-open-modal-help').getAttribute('data-toggle')).toEqual( - 'modal', + expect(wrapper.find('.mr-widget-help').text()).toContain( + 'If the this-is-not-the-branch-you-are-looking-for branch exists in your local repository', ); }); }); describe('without missing branch', () => { beforeEach(() => { - vm = mountComponent(Component); + createComponent({ + props: { missingBranch: '' }, + }); }); it('renders information about how to merge manually', () => { - expect( - vm.$el.textContent - .trim() - .replace(/[\r\n]+/g, ' ') - .replace(/\s\s+/g, ' '), - ).toEqual('You can merge this merge request manually using the command line'); - }); - - it('renders element to open a modal', () => { - expect(vm.$el.querySelector('.js-open-modal-help').getAttribute('data-target')).toEqual( - '#modal_merge_info', - ); - - expect(vm.$el.querySelector('.js-open-modal-help').getAttribute('data-toggle')).toEqual( - 'modal', + expect(wrapper.find('.mr-widget-help').text()).toContain( + 'You can merge this merge request manually', ); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js index 19f8a67d066..ad21e6e6f4f 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js @@ -6,6 +6,7 @@ import ConflictsComponent from '~/vue_merge_request_widget/components/states/mr_ describe('MRWidgetConflicts', () => { let vm; + let mergeRequestWidgetGraphql = null; const path = '/conflicts'; function createComponent(propsData = {}) { @@ -13,7 +14,35 @@ describe('MRWidgetConflicts', () => { vm = shallowMount(localVue.extend(ConflictsComponent), { propsData, + provide: { + glFeatures: { + mergeRequestWidgetGraphql, + }, + }, + mocks: { + $apollo: { + queries: { + userPermissions: { loading: false }, + stateData: { loading: false }, + }, + }, + }, }); + + if (mergeRequestWidgetGraphql) { + vm.setData({ + userPermissions: { + canMerge: propsData.mr.canMerge, + pushToSourceBranch: propsData.mr.canPushToSourceBranch, + }, + stateData: { + shouldBeRebased: propsData.mr.shouldBeRebased, + sourceBranchProtected: propsData.mr.sourceBranchProtected, + }, + }); + } + + return vm.vm.$nextTick(); } beforeEach(() => { @@ -21,206 +50,215 @@ describe('MRWidgetConflicts', () => { }); afterEach(() => { + mergeRequestWidgetGraphql = null; vm.destroy(); }); - // There are two permissions we need to consider: - // - // 1. Is the user allowed to merge to the target branch? - // 2. Is the user allowed to push to the source branch? - // - // This yields 4 possible permutations that we need to test, and - // we test them below. A user who can push to the source - // branch should be allowed to resolve conflicts. This is - // consistent with what the backend does. - describe('when allowed to merge but not allowed to push to source branch', () => { - beforeEach(() => { - createComponent({ - mr: { - canMerge: true, - canPushToSourceBranch: false, - conflictResolutionPath: path, - conflictsDocsPath: '', - }, + [false, true].forEach(featureEnabled => { + describe(`with GraphQL feature flag ${featureEnabled ? 'enabled' : 'disabled'}`, () => { + beforeEach(() => { + mergeRequestWidgetGraphql = featureEnabled; }); - }); - - it('should tell you about conflicts without bothering other people', () => { - expect(vm.text()).toContain('There are merge conflicts'); - expect(vm.text()).not.toContain('ask someone with write access'); - }); - - it('should not allow you to resolve the conflicts', () => { - expect(vm.text()).not.toContain('Resolve conflicts'); - }); - - it('should have merge buttons', () => { - const mergeLocallyButton = vm.find('.js-merge-locally-button'); - - expect(mergeLocallyButton.text()).toContain('Merge locally'); - }); - }); - describe('when not allowed to merge but allowed to push to source branch', () => { - beforeEach(() => { - createComponent({ - mr: { - canMerge: false, - canPushToSourceBranch: true, - conflictResolutionPath: path, - conflictsDocsPath: '', - }, - }); - }); - - it('should tell you about conflicts', () => { - expect(vm.text()).toContain('There are merge conflicts'); - expect(vm.text()).toContain('ask someone with write access'); - }); - - it('should allow you to resolve the conflicts', () => { - const resolveButton = vm.find('.js-resolve-conflicts-button'); - - expect(resolveButton.text()).toContain('Resolve conflicts'); - expect(resolveButton.attributes('href')).toEqual(path); - }); - - it('should not have merge buttons', () => { - expect(vm.text()).not.toContain('Merge locally'); - }); - }); - - describe('when allowed to merge and push to source branch', () => { - beforeEach(() => { - createComponent({ - mr: { - canMerge: true, - canPushToSourceBranch: true, - conflictResolutionPath: path, - conflictsDocsPath: '', - }, + // There are two permissions we need to consider: + // + // 1. Is the user allowed to merge to the target branch? + // 2. Is the user allowed to push to the source branch? + // + // This yields 4 possible permutations that we need to test, and + // we test them below. A user who can push to the source + // branch should be allowed to resolve conflicts. This is + // consistent with what the backend does. + describe('when allowed to merge but not allowed to push to source branch', () => { + beforeEach(async () => { + await createComponent({ + mr: { + canMerge: true, + canPushToSourceBranch: false, + conflictResolutionPath: path, + conflictsDocsPath: '', + }, + }); + }); + + it('should tell you about conflicts without bothering other people', () => { + expect(vm.text()).toContain('There are merge conflicts'); + expect(vm.text()).not.toContain('ask someone with write access'); + }); + + it('should not allow you to resolve the conflicts', () => { + expect(vm.text()).not.toContain('Resolve conflicts'); + }); + + it('should have merge buttons', () => { + const mergeLocallyButton = vm.find('.js-merge-locally-button'); + + expect(mergeLocallyButton.text()).toContain('Merge locally'); + }); }); - }); - - it('should tell you about conflicts without bothering other people', () => { - expect(vm.text()).toContain('There are merge conflicts'); - expect(vm.text()).not.toContain('ask someone with write access'); - }); - it('should allow you to resolve the conflicts', () => { - const resolveButton = vm.find('.js-resolve-conflicts-button'); - - expect(resolveButton.text()).toContain('Resolve conflicts'); - expect(resolveButton.attributes('href')).toEqual(path); - }); - - it('should have merge buttons', () => { - const mergeLocallyButton = vm.find('.js-merge-locally-button'); - - expect(mergeLocallyButton.text()).toContain('Merge locally'); - }); - }); - - describe('when user does not have permission to push to source branch', () => { - it('should show proper message', () => { - createComponent({ - mr: { - canMerge: false, - canPushToSourceBranch: false, - conflictsDocsPath: '', - }, + describe('when not allowed to merge but allowed to push to source branch', () => { + beforeEach(async () => { + await createComponent({ + mr: { + canMerge: false, + canPushToSourceBranch: true, + conflictResolutionPath: path, + conflictsDocsPath: '', + }, + }); + }); + + it('should tell you about conflicts', () => { + expect(vm.text()).toContain('There are merge conflicts'); + expect(vm.text()).toContain('ask someone with write access'); + }); + + it('should allow you to resolve the conflicts', () => { + const resolveButton = vm.find('.js-resolve-conflicts-button'); + + expect(resolveButton.text()).toContain('Resolve conflicts'); + expect(resolveButton.attributes('href')).toEqual(path); + }); + + it('should not have merge buttons', () => { + expect(vm.text()).not.toContain('Merge locally'); + }); }); - expect( - vm - .text() - .trim() - .replace(/\s\s+/g, ' '), - ).toContain('ask someone with write access'); - }); - - it('should not have action buttons', () => { - createComponent({ - mr: { - canMerge: false, - canPushToSourceBranch: false, - conflictsDocsPath: '', - }, + describe('when allowed to merge and push to source branch', () => { + beforeEach(async () => { + await createComponent({ + mr: { + canMerge: true, + canPushToSourceBranch: true, + conflictResolutionPath: path, + conflictsDocsPath: '', + }, + }); + }); + + it('should tell you about conflicts without bothering other people', () => { + expect(vm.text()).toContain('There are merge conflicts'); + expect(vm.text()).not.toContain('ask someone with write access'); + }); + + it('should allow you to resolve the conflicts', () => { + const resolveButton = vm.find('.js-resolve-conflicts-button'); + + expect(resolveButton.text()).toContain('Resolve conflicts'); + expect(resolveButton.attributes('href')).toEqual(path); + }); + + it('should have merge buttons', () => { + const mergeLocallyButton = vm.find('.js-merge-locally-button'); + + expect(mergeLocallyButton.text()).toContain('Merge locally'); + }); }); - expect(vm.find('.js-resolve-conflicts-button').exists()).toBe(false); - expect(vm.find('.js-merge-locally-button').exists()).toBe(false); - }); - - it('should not have resolve button when no conflict resolution path', () => { - createComponent({ - mr: { - canMerge: true, - conflictResolutionPath: null, - conflictsDocsPath: '', - }, + describe('when user does not have permission to push to source branch', () => { + it('should show proper message', async () => { + await createComponent({ + mr: { + canMerge: false, + canPushToSourceBranch: false, + conflictsDocsPath: '', + }, + }); + + expect( + vm + .text() + .trim() + .replace(/\s\s+/g, ' '), + ).toContain('ask someone with write access'); + }); + + it('should not have action buttons', async () => { + await createComponent({ + mr: { + canMerge: false, + canPushToSourceBranch: false, + conflictsDocsPath: '', + }, + }); + + expect(vm.find('.js-resolve-conflicts-button').exists()).toBe(false); + expect(vm.find('.js-merge-locally-button').exists()).toBe(false); + }); + + it('should not have resolve button when no conflict resolution path', async () => { + await createComponent({ + mr: { + canMerge: true, + conflictResolutionPath: null, + conflictsDocsPath: '', + }, + }); + + expect(vm.find('.js-resolve-conflicts-button').exists()).toBe(false); + }); }); - expect(vm.find('.js-resolve-conflicts-button').exists()).toBe(false); - }); - }); - - describe('when fast-forward or semi-linear merge enabled', () => { - it('should tell you to rebase locally', () => { - createComponent({ - mr: { - shouldBeRebased: true, - conflictsDocsPath: '', - }, + describe('when fast-forward or semi-linear merge enabled', () => { + it('should tell you to rebase locally', async () => { + await createComponent({ + mr: { + shouldBeRebased: true, + conflictsDocsPath: '', + }, + }); + + expect(removeBreakLine(vm.text()).trim()).toContain( + 'Fast-forward merge is not possible. To merge this request, first rebase locally.', + ); + }); }); - expect(removeBreakLine(vm.text()).trim()).toContain( - 'Fast-forward merge is not possible. To merge this request, first rebase locally.', - ); - }); - }); - - describe('when source branch protected', () => { - beforeEach(() => { - createComponent({ - mr: { - canMerge: true, - canPushToSourceBranch: true, - conflictResolutionPath: TEST_HOST, - sourceBranchProtected: true, - conflictsDocsPath: '', - }, + describe('when source branch protected', () => { + beforeEach(async () => { + await createComponent({ + mr: { + canMerge: true, + canPushToSourceBranch: true, + conflictResolutionPath: TEST_HOST, + sourceBranchProtected: true, + conflictsDocsPath: '', + }, + }); + }); + + it('sets resolve button as disabled', () => { + expect(vm.find('.js-resolve-conflicts-button').attributes('disabled')).toBe('true'); + }); + + it('renders popover', () => { + expect($.fn.popover).toHaveBeenCalled(); + }); }); - }); - - it('sets resolve button as disabled', () => { - expect(vm.find('.js-resolve-conflicts-button').attributes('disabled')).toBe('disabled'); - }); - it('renders popover', () => { - expect($.fn.popover).toHaveBeenCalled(); - }); - }); - - describe('when source branch not protected', () => { - beforeEach(() => { - createComponent({ - mr: { - canMerge: true, - canPushToSourceBranch: true, - conflictResolutionPath: TEST_HOST, - sourceBranchProtected: false, - conflictsDocsPath: '', - }, + describe('when source branch not protected', () => { + beforeEach(async () => { + await createComponent({ + mr: { + canMerge: true, + canPushToSourceBranch: true, + conflictResolutionPath: TEST_HOST, + sourceBranchProtected: false, + conflictsDocsPath: '', + }, + }); + }); + + it('sets resolve button as disabled', () => { + expect(vm.find('.js-resolve-conflicts-button').attributes('disabled')).toBe(undefined); + }); + + it('renders popover', () => { + expect($.fn.popover).not.toHaveBeenCalled(); + }); }); }); - - it('sets resolve button as disabled', () => { - expect(vm.find('.js-resolve-conflicts-button').attributes('disabled')).toBe(undefined); - }); - - it('renders popover', () => { - expect($.fn.popover).not.toHaveBeenCalled(); - }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js index 3f03ebdb047..f45368bf443 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js @@ -1,40 +1,46 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import missingBranchComponent from '~/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue'; - -describe('MRWidgetMissingBranch', () => { - let vm; - - beforeEach(() => { - const Component = Vue.extend(missingBranchComponent); - vm = mountComponent(Component, { mr: { sourceBranchRemoved: true } }); - }); - - afterEach(() => { - vm.$destroy(); +import { shallowMount } from '@vue/test-utils'; +import MissingBranchComponent from '~/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue'; + +let wrapper; + +function factory(sourceBranchRemoved, mergeRequestWidgetGraphql) { + wrapper = shallowMount(MissingBranchComponent, { + propsData: { + mr: { sourceBranchRemoved }, + }, + provide: { + glFeatures: { mergeRequestWidgetGraphql }, + }, }); - describe('computed', () => { - describe('missingBranchName', () => { - it('should return proper branch name', () => { - expect(vm.missingBranchName).toEqual('source'); + if (mergeRequestWidgetGraphql) { + wrapper.setData({ state: { sourceBranchExists: !sourceBranchRemoved } }); + } - vm.mr.sourceBranchRemoved = false; + return wrapper.vm.$nextTick(); +} - expect(vm.missingBranchName).toEqual('target'); - }); - }); +describe('MRWidgetMissingBranch', () => { + afterEach(() => { + wrapper.destroy(); }); - describe('template', () => { - it('should have correct elements', () => { - const el = vm.$el; - const content = el.textContent.replace(/\n(\s)+/g, ' ').trim(); - - expect(el.classList.contains('mr-widget-body')).toBeTruthy(); - expect(el.querySelector('button').getAttribute('disabled')).toBeTruthy(); - expect(content.replace(/\s\s+/g, ' ')).toContain('source branch does not exist.'); - expect(content).toContain('Please restore it or use a different source branch'); + [true, false].forEach(mergeRequestWidgetGraphql => { + describe(`widget GraphQL feature flag is ${ + mergeRequestWidgetGraphql ? 'enabled' : 'disabled' + }`, () => { + it.each` + sourceBranchRemoved | branchName + ${true} | ${'source'} + ${false} | ${'target'} + `( + 'should set missing branch name as $branchName when sourceBranchRemoved is $sourceBranchRemoved', + async ({ sourceBranchRemoved, branchName }) => { + await factory(sourceBranchRemoved, mergeRequestWidgetGraphql); + + expect(wrapper.find('[data-testid="missingBranchName"]').text()).toContain(branchName); + }, + ); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js index 5326d63cb8a..f9490ac77ff 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js @@ -1,4 +1,5 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlFormCheckbox } from '@gitlab/ui'; import SquashBeforeMerge from '~/vue_merge_request_widget/components/states/squash_before_merge.vue'; import { SQUASH_BEFORE_MERGE } from '~/vue_merge_request_widget/i18n'; @@ -20,17 +21,15 @@ describe('Squash before merge component', () => { wrapper.destroy(); }); - const findLabel = () => wrapper.find('[data-testid="squashLabel"]'); + const findCheckbox = () => wrapper.find(GlFormCheckbox); describe('checkbox', () => { - const findCheckbox = () => wrapper.find('.js-squash-checkbox'); - it('is unchecked if passed value prop is false', () => { createComponent({ value: false, }); - expect(findCheckbox().element.checked).toBeFalsy(); + expect(findCheckbox().vm.$attrs.checked).toBe(false); }); it('is checked if passed value prop is true', () => { @@ -38,22 +37,7 @@ describe('Squash before merge component', () => { value: true, }); - expect(findCheckbox().element.checked).toBeTruthy(); - }); - - it('changes value on click', done => { - createComponent({ - value: false, - }); - - findCheckbox().element.checked = true; - - findCheckbox().trigger('change'); - - wrapper.vm.$nextTick(() => { - expect(findCheckbox().element.checked).toBeTruthy(); - done(); - }); + expect(findCheckbox().vm.$attrs.checked).toBe(true); }); it('is disabled if isDisabled prop is true', () => { @@ -62,31 +46,12 @@ describe('Squash before merge component', () => { isDisabled: true, }); - expect(findCheckbox().attributes('disabled')).toBeTruthy(); - }); - }); - - describe('label', () => { - describe.each` - isDisabled | expectation - ${true} | ${'grays out text if it is true'} - ${false} | ${'does not gray out text if it is false'} - `('isDisabled prop', ({ isDisabled, expectation }) => { - beforeEach(() => { - createComponent({ - value: false, - isDisabled, - }); - }); - - it(expectation, () => { - expect(findLabel().classes('gl-text-gray-400')).toBe(isDisabled); - }); + expect(findCheckbox().vm.$attrs.disabled).toBe(true); }); }); describe('tooltip', () => { - const tooltipTitle = () => findLabel().attributes('title'); + const tooltipTitle = () => findCheckbox().attributes('title'); it('does not render when isDisabled is false', () => { createComponent({ @@ -114,7 +79,7 @@ describe('Squash before merge component', () => { const aboutLink = wrapper.find('a'); - expect(aboutLink.exists()).toBeFalsy(); + expect(aboutLink.exists()).toBe(false); }); it('is rendered if help path is passed', () => { @@ -125,7 +90,7 @@ describe('Squash before merge component', () => { const aboutLink = wrapper.find('a'); - expect(aboutLink.exists()).toBeTruthy(); + expect(aboutLink.exists()).toBe(true); }); it('should have a correct help path if passed', () => { diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_spec.js index 17d7fcc4bff..19a5566c3b1 100644 --- a/spec/frontend/vue_mr_widget/deployment/deployment_spec.js +++ b/spec/frontend/vue_mr_widget/deployment/deployment_spec.js @@ -8,6 +8,7 @@ import { SUCCESS, FAILED, CANCELED, + SKIPPED, } from '~/vue_merge_request_widget/components/deployment/constants'; import { deploymentMockData, playDetails, retryDetails } from './deployment_mock_data'; @@ -77,6 +78,10 @@ describe('Deployment component', () => { ${CANCELED} | ${true} | ${noDetails} | ${'Canceled deployment to'} | ${defaultGroup} ${CANCELED} | ${false} | ${deployDetail} | ${'Canceled deployment to'} | ${noActions} ${CANCELED} | ${false} | ${noDetails} | ${'Canceled deployment to'} | ${noActions} + ${SKIPPED} | ${true} | ${deployDetail} | ${'Skipped deployment to'} | ${defaultGroup} + ${SKIPPED} | ${true} | ${noDetails} | ${'Skipped deployment to'} | ${defaultGroup} + ${SKIPPED} | ${false} | ${deployDetail} | ${'Skipped deployment to'} | ${noActions} + ${SKIPPED} | ${false} | ${noDetails} | ${'Skipped deployment to'} | ${noActions} `( '$status + previous: $previous + manual: $deploymentDetails.isManual', ({ status, previous, deploymentDetails, text, actionButtons }) => { diff --git a/spec/frontend/vue_mr_widget/mock_data.js b/spec/frontend/vue_mr_widget/mock_data.js index 144283dc507..8ee920f06a1 100644 --- a/spec/frontend/vue_mr_widget/mock_data.js +++ b/spec/frontend/vue_mr_widget/mock_data.js @@ -41,6 +41,7 @@ export default { user_callouts_path: 'some/callout/path', suggest_pipeline_feature_id: 'suggest_pipeline', new_project_pipeline_path: '/group2/project2/pipelines/new', + source_project_default_url: '/gitlab-org/html5-boilerplate.git', metrics: { merged_by: { name: 'Administrator', @@ -263,6 +264,8 @@ export default { merge_trains_count: 3, merge_train_index: 1, security_reports_docs_path: 'security-reports-docs-path', + sast_comparison_path: '/sast_comparison_path', + secret_scanning_comparison_path: '/secret_scanning_comparison_path', }; export const mockStore = { diff --git a/spec/frontend/vue_mr_widget/mr_widget_how_to_merge_modal_spec.js b/spec/frontend/vue_mr_widget/mr_widget_how_to_merge_modal_spec.js new file mode 100644 index 00000000000..aaaee3327a8 --- /dev/null +++ b/spec/frontend/vue_mr_widget/mr_widget_how_to_merge_modal_spec.js @@ -0,0 +1,68 @@ +import { GlModal, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import MrWidgetHowToMergeModal from '~/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue'; + +describe('MRWidgetHowToMerge', () => { + let wrapper; + + function mountComponent({ data = {}, props = {} } = {}) { + wrapper = shallowMount(MrWidgetHowToMergeModal, { + data() { + return { ...data }; + }, + propsData: { + ...props, + }, + stubs: {}, + }); + } + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + beforeEach(() => { + mountComponent(); + }); + + const findModal = () => wrapper.find(GlModal); + const findInstructionsFields = () => + wrapper.findAll('[ data-testid="how-to-merge-instructions"]'); + const findTipLink = () => wrapper.find(GlSprintf); + + it('renders a modal', () => { + expect(findModal().exists()).toBe(true); + }); + + it('renders a selection of markdown fields', () => { + expect(findInstructionsFields().length).toBe(3); + }); + + it('renders a tip including a link to docs when a valid link is present', () => { + mountComponent({ props: { reviewingDocsPath: '/gitlab-org/help' } }); + expect(findTipLink().exists()).toBe(true); + }); + + it('should not render a tip including a link to docs when a valid link is not present', () => { + expect(findTipLink().exists()).toBe(false); + }); + + it('should render different instructions based on if the user can merge', () => { + mountComponent({ props: { canMerge: true } }); + expect( + findInstructionsFields() + .at(2) + .text(), + ).toContain('git push origin'); + }); + + it('should render different instructions based on if the merge is based off a fork', () => { + mountComponent({ props: { isFork: true } }); + expect( + findInstructionsFields() + .at(0) + .text(), + ).toContain('FETCH_HEAD'); + }); +}); diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js index cb0006548d4..a20cd5b4400 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -863,7 +863,7 @@ describe('mrWidgetOptions', () => { }); }); - describe('suggestPipeline feature flag', () => { + describe('suggestPipeline', () => { beforeEach(() => { mock.onAny().reply(200); @@ -874,8 +874,6 @@ describe('mrWidgetOptions', () => { describe('given feature flag is enabled', () => { beforeEach(() => { - gon.features = { suggestPipeline: true }; - createComponent(); vm.mr.hasCI = false; @@ -905,19 +903,5 @@ describe('mrWidgetOptions', () => { expect(findSuggestPipeline()).toBeNull(); }); }); - - describe('given feature flag is not enabled', () => { - beforeEach(() => { - gon.features = { suggestPipeline: false }; - - createComponent(); - - vm.mr.hasCI = false; - }); - - it('should not suggest pipelines when none exist', () => { - expect(findSuggestPipeline()).toBeNull(); - }); - }); }); }); diff --git a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js index f73f78d6f6e..8b2c10ec50a 100644 --- a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js +++ b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js @@ -1,3 +1,4 @@ +import { convertToCamelCase } from '~/lib/utils/text_utility'; import MergeRequestStore from '~/vue_merge_request_widget/stores/mr_widget_store'; import { stateKey } from '~/vue_merge_request_widget/stores/state_maps'; import mockData from '../mock_data'; @@ -141,10 +142,29 @@ describe('MergeRequestStore', () => { expect(store.newPipelinePath).toBe('/group2/project2/pipelines/new'); }); + it('should set sourceProjectDefaultUrl', () => { + store.setPaths({ ...mockData }); + + expect(store.sourceProjectDefaultUrl).toBe('/gitlab-org/html5-boilerplate.git'); + }); + it('should set securityReportsDocsPath', () => { store.setPaths({ ...mockData }); expect(store.securityReportsDocsPath).toBe('security-reports-docs-path'); }); + + it.each(['sast_comparison_path', 'secret_scanning_comparison_path'])( + 'should set %s path', + property => { + // Ensure something is set in the mock data + expect(property in mockData).toBe(true); + const expectedValue = mockData[property]; + + store.setPaths({ ...mockData }); + + expect(store[convertToCamelCase(property)]).toBe(expectedValue); + }, + ); }); }); diff --git a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap index 04ae2a0f34d..20ea897e29c 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap @@ -5,12 +5,17 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` class="awards js-awards-block" > <button - class="btn award-control" + class="btn gl-mr-3 btn-default btn-md gl-button" data-testid="award-button" title="Ada, Leonardo, and Marie" type="button" > + <!----> + + <!----> + <span + class="award-emoji-block" data-testid="award-html" > @@ -23,18 +28,28 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> <span - class="award-control-text js-counter" + class="gl-button-text" > - 3 + + <span + class="js-counter" + > + 3 + </span> </span> </button> <button - class="btn award-control active" + class="btn gl-mr-3 btn-default btn-md gl-button selected" data-testid="award-button" title="You, Ada, and Marie" type="button" > + <!----> + + <!----> + <span + class="award-emoji-block" data-testid="award-html" > @@ -47,18 +62,28 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> <span - class="award-control-text js-counter" + class="gl-button-text" > - 3 + + <span + class="js-counter" + > + 3 + </span> </span> </button> <button - class="btn award-control" + class="btn gl-mr-3 btn-default btn-md gl-button" data-testid="award-button" title="Ada and Jane" type="button" > + <!----> + + <!----> + <span + class="award-emoji-block" data-testid="award-html" > @@ -71,18 +96,28 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> <span - class="award-control-text js-counter" + class="gl-button-text" > - 2 + + <span + class="js-counter" + > + 2 + </span> </span> </button> <button - class="btn award-control active" + class="btn gl-mr-3 btn-default btn-md gl-button selected" data-testid="award-button" title="You, Ada, Jane, and Leonardo" type="button" > + <!----> + + <!----> + <span + class="award-emoji-block" data-testid="award-html" > @@ -95,18 +130,28 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> <span - class="award-control-text js-counter" + class="gl-button-text" > - 4 + + <span + class="js-counter" + > + 4 + </span> </span> </button> <button - class="btn award-control active" + class="btn gl-mr-3 btn-default btn-md gl-button selected" data-testid="award-button" title="You" type="button" > + <!----> + + <!----> + <span + class="award-emoji-block" data-testid="award-html" > @@ -119,18 +164,28 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> <span - class="award-control-text js-counter" + class="gl-button-text" > - 1 + + <span + class="js-counter" + > + 1 + </span> </span> </button> <button - class="btn award-control" + class="btn gl-mr-3 btn-default btn-md gl-button" data-testid="award-button" title="Marie" type="button" > + <!----> + + <!----> + <span + class="award-emoji-block" data-testid="award-html" > @@ -143,18 +198,28 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> <span - class="award-control-text js-counter" + class="gl-button-text" > - 1 + + <span + class="js-counter" + > + 1 + </span> </span> </button> <button - class="btn award-control active" + class="btn gl-mr-3 btn-default btn-md gl-button selected" data-testid="award-button" title="You" type="button" > + <!----> + + <!----> + <span + class="award-emoji-block" data-testid="award-html" > @@ -167,9 +232,14 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> <span - class="award-control-text js-counter" + class="gl-button-text" > - 1 + + <span + class="js-counter" + > + 1 + </span> </span> </button> @@ -178,46 +248,59 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` > <button aria-label="Add reaction" - class="award-control btn js-add-award js-test-add-button-class" + class="btn add-reaction-button js-add-award btn-default btn-md gl-button js-test-add-button-class" title="Add reaction" type="button" > - <span - class="award-control-icon award-control-icon-neutral" - > - <gl-icon-stub - aria-hidden="true" - name="slight-smile" - size="16" - /> - </span> + <!----> + <!----> + <span - class="award-control-icon award-control-icon-positive" + class="gl-button-text" > - <gl-icon-stub - aria-hidden="true" - name="smiley" - size="16" - /> + <span + class="reaction-control-icon reaction-control-icon-neutral" + > + <svg + aria-hidden="true" + class="gl-icon s16" + data-testid="slight-smile-icon" + > + <use + href="#slight-smile" + /> + </svg> + </span> + + <span + class="reaction-control-icon reaction-control-icon-positive" + > + <svg + aria-hidden="true" + class="gl-icon s16" + data-testid="smiley-icon" + > + <use + href="#smiley" + /> + </svg> + </span> + + <span + class="reaction-control-icon reaction-control-icon-super-positive" + > + <svg + aria-hidden="true" + class="gl-icon s16" + data-testid="smile-icon" + > + <use + href="#smile" + /> + </svg> + </span> </span> - - <span - class="award-control-icon award-control-icon-super-positive" - > - <gl-icon-stub - aria-hidden="true" - name="smiley" - size="16" - /> - </span> - - <gl-loading-icon-stub - class="award-control-icon-loading" - color="dark" - label="Loading" - size="md" - /> </button> </div> </div> diff --git a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap index ec4a81054db..63d38e7587a 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap @@ -4,7 +4,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` <gl-dropdown-stub category="primary" headertext="" - right="" + right="true" size="medium" text="Clone" variant="info" diff --git a/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap index 19a649089e0..adb6c935f96 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap @@ -11,6 +11,7 @@ exports[`Expand button on click when short text is provided renders button after <!----> <svg + aria-hidden="true" class="gl-button-icon gl-icon s16" data-testid="ellipsis_h-icon" > @@ -39,6 +40,7 @@ exports[`Expand button on click when short text is provided renders button after <!----> <svg + aria-hidden="true" class="gl-button-icon gl-icon s16" data-testid="ellipsis_h-icon" > @@ -62,6 +64,7 @@ exports[`Expand button when short text is provided renders button before text 1` <!----> <svg + aria-hidden="true" class="gl-button-icon gl-icon s16" data-testid="ellipsis_h-icon" > @@ -90,6 +93,7 @@ exports[`Expand button when short text is provided renders button before text 1` <!----> <svg + aria-hidden="true" class="gl-button-icon gl-icon s16" data-testid="ellipsis_h-icon" > diff --git a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap index 8eb0e8f9550..dd88ba9a6fb 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap @@ -2,7 +2,7 @@ exports[`SplitButton renders actionItems 1`] = ` <gl-dropdown-stub - category="tertiary" + category="primary" headertext="" menu-class="" size="medium" @@ -14,6 +14,7 @@ exports[`SplitButton renders actionItems 1`] = ` avatarurl="" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischecked="true" ischeckitem="true" @@ -33,6 +34,7 @@ exports[`SplitButton renders actionItems 1`] = ` avatarurl="" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischeckitem="true" secondarytext="" diff --git a/spec/frontend/vue_shared/components/awards_list_spec.js b/spec/frontend/vue_shared/components/awards_list_spec.js index 63fc8a5749d..d20de81c446 100644 --- a/spec/frontend/vue_shared/components/awards_list_spec.js +++ b/spec/frontend/vue_shared/components/awards_list_spec.js @@ -1,4 +1,4 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import AwardsList from '~/vue_shared/components/awards_list.vue'; const createUser = (id, name) => ({ id, name }); @@ -41,6 +41,8 @@ const TEST_AWARDS = [ ]; const TEST_ADD_BUTTON_CLASS = 'js-test-add-button-class'; +const REACTION_CONTROL_CLASSES = ['btn', 'gl-mr-3', 'btn-default', 'btn-md', 'gl-button']; + describe('vue_shared/components/awards_list', () => { let wrapper; @@ -54,16 +56,16 @@ describe('vue_shared/components/awards_list', () => { throw new Error('There should only be one wrapper created per test'); } - wrapper = shallowMount(AwardsList, { propsData: props }); + wrapper = mount(AwardsList, { propsData: props }); }; const matchingEmojiTag = name => expect.stringMatching(`gl-emoji data-name="${name}"`); - const findAwardButtons = () => wrapper.findAll('[data-testid="award-button"'); + const findAwardButtons = () => wrapper.findAll('[data-testid="award-button"]'); const findAwardsData = () => findAwardButtons().wrappers.map(x => { return { classes: x.classes(), title: x.attributes('title'), - html: x.find('[data-testid="award-html"]').element.innerHTML, + html: x.find('[data-testid="award-html"]').html(), count: Number(x.find('.js-counter').text()), }; }); @@ -86,43 +88,43 @@ describe('vue_shared/components/awards_list', () => { it('shows awards in correct order', () => { expect(findAwardsData()).toEqual([ { - classes: ['btn', 'award-control'], + classes: REACTION_CONTROL_CLASSES, count: 3, html: matchingEmojiTag(EMOJI_THUMBSUP), title: 'Ada, Leonardo, and Marie', }, { - classes: ['btn', 'award-control', 'active'], + classes: [...REACTION_CONTROL_CLASSES, 'selected'], count: 3, html: matchingEmojiTag(EMOJI_THUMBSDOWN), title: 'You, Ada, and Marie', }, { - classes: ['btn', 'award-control'], + classes: REACTION_CONTROL_CLASSES, count: 2, html: matchingEmojiTag(EMOJI_SMILE), title: 'Ada and Jane', }, { - classes: ['btn', 'award-control', 'active'], + classes: [...REACTION_CONTROL_CLASSES, 'selected'], count: 4, html: matchingEmojiTag(EMOJI_OK), title: 'You, Ada, Jane, and Leonardo', }, { - classes: ['btn', 'award-control', 'active'], + classes: [...REACTION_CONTROL_CLASSES, 'selected'], count: 1, html: matchingEmojiTag(EMOJI_CACTUS), title: 'You', }, { - classes: ['btn', 'award-control'], + classes: REACTION_CONTROL_CLASSES, count: 1, html: matchingEmojiTag(EMOJI_A), title: 'Marie', }, { - classes: ['btn', 'award-control', 'active'], + classes: [...REACTION_CONTROL_CLASSES, 'selected'], count: 1, html: matchingEmojiTag(EMOJI_B), title: 'You', @@ -135,7 +137,7 @@ describe('vue_shared/components/awards_list', () => { findAwardButtons() .at(2) - .trigger('click'); + .vm.$emit('click'); expect(wrapper.emitted().award).toEqual([[EMOJI_SMILE]]); }); @@ -162,7 +164,7 @@ describe('vue_shared/components/awards_list', () => { findAwardButtons() .at(0) - .trigger('click'); + .vm.$emit('click'); expect(wrapper.emitted().award).toEqual([[Number(EMOJI_100)]]); }); @@ -225,26 +227,26 @@ describe('vue_shared/components/awards_list', () => { it('shows awards in correct order', () => { expect(findAwardsData()).toEqual([ { - classes: ['btn', 'award-control'], + classes: REACTION_CONTROL_CLASSES, count: 0, html: matchingEmojiTag(EMOJI_THUMBSUP), title: '', }, { - classes: ['btn', 'award-control'], + classes: REACTION_CONTROL_CLASSES, count: 0, html: matchingEmojiTag(EMOJI_THUMBSDOWN), title: '', }, // We expect the EMOJI_100 before the EMOJI_SMILE because it was given as a defaultAward { - classes: ['btn', 'award-control'], + classes: REACTION_CONTROL_CLASSES, count: 1, html: matchingEmojiTag(EMOJI_100), title: 'Marie', }, { - classes: ['btn', 'award-control'], + classes: REACTION_CONTROL_CLASSES, count: 1, html: matchingEmojiTag(EMOJI_SMILE), title: 'Marie', diff --git a/spec/frontend/vue_shared/components/callout_spec.js b/spec/frontend/vue_shared/components/callout_spec.js deleted file mode 100644 index 7c9bb6b4650..00000000000 --- a/spec/frontend/vue_shared/components/callout_spec.js +++ /dev/null @@ -1,63 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Callout from '~/vue_shared/components/callout.vue'; - -const TEST_MESSAGE = 'This is a callout message!'; -const TEST_SLOT = '<button>This is a callout slot!</button>'; - -describe('Callout Component', () => { - let wrapper; - - const factory = options => { - wrapper = shallowMount(Callout, { - ...options, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it('should render the appropriate variant of callout', () => { - factory({ - propsData: { - category: 'info', - message: TEST_MESSAGE, - }, - }); - - expect(wrapper.classes()).toEqual(['bs-callout', 'bs-callout-info']); - - expect(wrapper.element.tagName).toEqual('DIV'); - }); - - it('should render accessibility attributes', () => { - factory({ - propsData: { - message: TEST_MESSAGE, - }, - }); - - expect(wrapper.attributes('role')).toEqual('alert'); - expect(wrapper.attributes('aria-live')).toEqual('assertive'); - }); - - it('should render the provided message', () => { - factory({ - propsData: { - message: TEST_MESSAGE, - }, - }); - - expect(wrapper.element.innerHTML.trim()).toEqual(TEST_MESSAGE); - }); - - it('should render the provided slot', () => { - factory({ - slots: { - default: TEST_SLOT, - }, - }); - - expect(wrapper.element.innerHTML.trim()).toEqual(TEST_SLOT); - }); -}); diff --git a/spec/frontend/vue_shared/components/clipboard_button_spec.js b/spec/frontend/vue_shared/components/clipboard_button_spec.js index 51a2653befc..ac0be1537b7 100644 --- a/spec/frontend/vue_shared/components/clipboard_button_spec.js +++ b/spec/frontend/vue_shared/components/clipboard_button_spec.js @@ -1,16 +1,19 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import { GlButton } from '@gitlab/ui'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; describe('clipboard button', () => { let wrapper; - const createWrapper = propsData => { - wrapper = shallowMount(ClipboardButton, { + const createWrapper = (propsData, options = {}) => { + wrapper = mount(ClipboardButton, { propsData, + ...options, }); }; + const findButton = () => wrapper.find(GlButton); + afterEach(() => { wrapper.destroy(); wrapper = null; @@ -26,7 +29,7 @@ describe('clipboard button', () => { }); it('renders a button for clipboard', () => { - expect(wrapper.find(GlButton).exists()).toBe(true); + expect(findButton().exists()).toBe(true); expect(wrapper.attributes('data-clipboard-text')).toBe('copy me'); }); @@ -53,4 +56,35 @@ describe('clipboard button', () => { ); }); }); + + it('renders default slot as button text', () => { + createWrapper( + { + text: 'copy me', + title: 'Copy this value', + }, + { + slots: { + default: 'Foo bar', + }, + }, + ); + + expect(findButton().text()).toBe('Foo bar'); + }); + + it('re-emits button events', () => { + const onClick = jest.fn(); + createWrapper( + { + text: 'copy me', + title: 'Copy this value', + }, + { listeners: { click: onClick } }, + ); + + findButton().trigger('click'); + + expect(onClick).toHaveBeenCalled(); + }); }); diff --git a/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js b/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js new file mode 100644 index 00000000000..a50a4b742b3 --- /dev/null +++ b/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js @@ -0,0 +1,140 @@ +import { GlFormGroup, GlFormInput, GlFormInputGroup, GlLink } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; + +import ColorPicker from '~/vue_shared/components/color_picker/color_picker.vue'; + +describe('ColorPicker', () => { + let wrapper; + + const createComponent = (fn = mount, propsData = {}) => { + wrapper = fn(ColorPicker, { + propsData, + }); + }; + + const setColor = '#000000'; + const label = () => wrapper.find(GlFormGroup).attributes('label'); + const colorPreview = () => wrapper.find('[data-testid="color-preview"]'); + const colorPicker = () => wrapper.find(GlFormInput); + const colorInput = () => wrapper.find(GlFormInputGroup).find('input[type="text"]'); + const invalidFeedback = () => wrapper.find('.invalid-feedback'); + const description = () => wrapper.find(GlFormGroup).attributes('description'); + const presetColors = () => wrapper.findAll(GlLink); + + beforeEach(() => { + gon.suggested_label_colors = { + [setColor]: 'Black', + '#0033CC': 'UA blue', + '#428BCA': 'Moderate blue', + '#44AD8E': 'Lime green', + }; + + createComponent(shallowMount); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('label', () => { + it('hides the label if the label is not passed', () => { + expect(label()).toBe(''); + }); + + it('shows the label if the label is passed', () => { + createComponent(shallowMount, { label: 'test' }); + + expect(label()).toBe('test'); + }); + }); + + describe('behavior', () => { + it('by default has no values', () => { + createComponent(); + + expect(colorPreview().attributes('style')).toBe(undefined); + expect(colorPicker().attributes('value')).toBe(undefined); + expect(colorInput().props('value')).toBe(''); + }); + + it('has a color set on initialization', () => { + createComponent(shallowMount, { setColor }); + + expect(wrapper.vm.$data.selectedColor).toBe(setColor); + }); + + it('emits input event from component when a color is selected', async () => { + createComponent(); + await colorInput().setValue(setColor); + + expect(wrapper.emitted().input[0]).toEqual([setColor]); + }); + + it('trims spaces from submitted colors', async () => { + createComponent(); + await colorInput().setValue(` ${setColor} `); + + expect(wrapper.vm.$data.selectedColor).toBe(setColor); + }); + + it('shows invalid feedback when an invalid color is used', async () => { + createComponent(); + await colorInput().setValue('abcd'); + + expect(invalidFeedback().text()).toBe( + 'Please enter a valid hex (#RRGGBB or #RGB) color value', + ); + expect(wrapper.emitted().input).toBe(undefined); + }); + + it('shows an invalid feedback border on the preview when an invalid color is used', async () => { + createComponent(); + await colorInput().setValue('abcd'); + + expect(colorPreview().attributes('class')).toContain('gl-inset-border-1-red-500'); + }); + }); + + describe('inputs', () => { + it('has color input value entered', async () => { + createComponent(); + await colorInput().setValue(setColor); + + expect(wrapper.vm.$data.selectedColor).toBe(setColor); + }); + + it('has color picker value entered', async () => { + createComponent(); + await colorPicker().setValue(setColor); + + expect(wrapper.vm.$data.selectedColor).toBe(setColor); + }); + }); + + describe('preset colors', () => { + it('hides the suggested colors if they are empty', () => { + gon.suggested_label_colors = {}; + createComponent(shallowMount); + + expect(description()).toBe('Choose any color'); + expect(presetColors().exists()).toBe(false); + }); + + it('shows the suggested colors', () => { + createComponent(shallowMount); + expect(description()).toBe( + 'Choose any color. Or you can choose one of the suggested colors below', + ); + expect(presetColors()).toHaveLength(4); + }); + + it('has preset color selected', async () => { + createComponent(); + await presetColors() + .at(0) + .trigger('click'); + + expect(wrapper.vm.$data.selectedColor).toBe(setColor); + }); + }); +}); 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 64bfff3dfa1..8cc5d6775a7 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 @@ -17,11 +17,14 @@ import RecentSearchesService from '~/filtered_search/services/recent_searches_se import { mockAvailableTokens, + mockMembershipToken, + mockMembershipTokenOptionsWithoutTitles, mockSortOptions, mockHistoryItems, tokenValueAuthor, tokenValueLabel, tokenValueMilestone, + tokenValueMembership, } from './mock_data'; jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({ @@ -412,6 +415,42 @@ describe('FilteredSearchBarRoot', () => { wrapperFullMount.destroy(); }); + describe('when token options have `title` attribute defined', () => { + it('renders search history items using the provided `title` attribute', async () => { + const wrapperFullMount = createComponent({ + sortOptions: mockSortOptions, + tokens: [mockMembershipToken], + shallow: false, + }); + + wrapperFullMount.vm.recentSearchesStore.addRecentSearch([tokenValueMembership]); + + await wrapperFullMount.vm.$nextTick(); + + expect(wrapperFullMount.find(GlDropdownItem).text()).toBe('Membership := Direct'); + + wrapperFullMount.destroy(); + }); + }); + + describe('when token options have do not have `title` attribute defined', () => { + it('renders search history items using the provided `value` attribute', async () => { + const wrapperFullMount = createComponent({ + sortOptions: mockSortOptions, + tokens: [mockMembershipTokenOptionsWithoutTitles], + shallow: false, + }); + + wrapperFullMount.vm.recentSearchesStore.addRecentSearch([tokenValueMembership]); + + await wrapperFullMount.vm.$nextTick(); + + expect(wrapperFullMount.find(GlDropdownItem).text()).toBe('Membership := exclude'); + + wrapperFullMount.destroy(); + }); + }); + it('renders sort dropdown component', () => { expect(wrapper.find(GlButtonGroup).exists()).toBe(true); expect(wrapper.find(GlDropdown).exists()).toBe(true); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js index e0a3208cac9..64fbe70696d 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -1,3 +1,4 @@ +import { GlFilteredSearchToken } from '@gitlab/ui'; import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; import Api from '~/api'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; @@ -102,6 +103,21 @@ export const mockMilestoneToken = { fetchMilestones: () => Promise.resolve({ data: mockMilestones }), }; +export const mockMembershipToken = { + type: 'with_inherited_permissions', + icon: 'group', + title: 'Membership', + token: GlFilteredSearchToken, + unique: true, + operators: [{ value: '=', description: 'is' }], + options: [{ value: 'exclude', title: 'Direct' }, { value: 'only', title: 'Inherited' }], +}; + +export const mockMembershipTokenOptionsWithoutTitles = { + ...mockMembershipToken, + options: [{ value: 'exclude' }, { value: 'only' }], +}; + export const mockAvailableTokens = [mockAuthorToken, mockLabelToken, mockMilestoneToken]; export const tokenValueAuthor = { @@ -128,6 +144,14 @@ export const tokenValueMilestone = { }, }; +export const tokenValueMembership = { + type: 'with_inherited_permissions', + value: { + operator: '=', + data: 'exclude', + }, +}; + export const tokenValuePlain = { type: 'filtered-search-term', value: { data: 'foo' }, diff --git a/spec/frontend/vue_shared/components/gfm_autocomplete/__snapshots__/utils_spec.js.snap b/spec/frontend/vue_shared/components/gfm_autocomplete/__snapshots__/utils_spec.js.snap new file mode 100644 index 00000000000..d0fa2086fdc --- /dev/null +++ b/spec/frontend/vue_shared/components/gfm_autocomplete/__snapshots__/utils_spec.js.snap @@ -0,0 +1,47 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`gfm_autocomplete/utils issues config shows the iid and title in the menu item within a project context 1`] = `"<small>123456</small> Project context issue title <script>alert('hi')</script>"`; + +exports[`gfm_autocomplete/utils issues config shows the reference and title in the menu item within a group context 1`] = `"<small>gitlab#987654</small> Group context issue title <script>alert('hi')</script>"`; + +exports[`gfm_autocomplete/utils labels config shows the title in the menu item 1`] = ` +" + <span class=\\"dropdown-label-box\\" style=\\"background: #123456;\\"></span> + bug <script>alert('hi')</script>" +`; + +exports[`gfm_autocomplete/utils members config shows an avatar character, name, parent name, and count in the menu item for a group 1`] = ` +" + <div class=\\"gl-display-flex gl-align-items-center\\"> + <div class=\\"gl-avatar gl-avatar-s24 gl-flex-shrink-0 gl-rounded-small + gl-display-flex gl-align-items-center gl-justify-content-center\\" aria-hidden=\\"true\\"> + G</div> + <div class=\\"gl-font-sm gl-line-height-normal gl-ml-3\\"> + <div>1-1s <script>alert('hi')</script> (2)</div> + <div class=\\"gl-text-gray-700\\">GitLab Support Team</div> + </div> + + </div> + " +`; + +exports[`gfm_autocomplete/utils members config shows the avatar, name and username in the menu item for a user 1`] = ` +" + <div class=\\"gl-display-flex gl-align-items-center\\"> + <img class=\\"gl-avatar gl-avatar-s24 gl-flex-shrink-0 gl-avatar-circle\\" src=\\"/uploads/-/system/user/avatar/123456/avatar.png\\" alt=\\"\\" /> + <div class=\\"gl-font-sm gl-line-height-normal gl-ml-3\\"> + <div>My Name <script>alert('hi')</script></div> + <div class=\\"gl-text-gray-700\\">@myusername</div> + </div> + + </div> + " +`; + +exports[`gfm_autocomplete/utils merge requests config shows the iid and title in the menu item within a project context 1`] = `"<small>123456</small> Project context merge request title <script>alert('hi')</script>"`; + +exports[`gfm_autocomplete/utils merge requests config shows the reference and title in the menu item within a group context 1`] = `"<small>gitlab!456789</small> Group context merge request title <script>alert('hi')</script>"`; + +exports[`gfm_autocomplete/utils milestones config shows the title in the menu item 1`] = `"13.2 <script>alert('hi')</script>"`; + +exports[`gfm_autocomplete/utils snippets config shows the id and title in the menu item 1`] = `"<small>123456</small> Snippet title <script>alert('hi')</script>"`; diff --git a/spec/frontend/vue_shared/components/gl_mentions_spec.js b/spec/frontend/vue_shared/components/gfm_autocomplete/gfm_autocomplete_spec.js index 32fc055a77d..b4002fdf4ec 100644 --- a/spec/frontend/vue_shared/components/gl_mentions_spec.js +++ b/spec/frontend/vue_shared/components/gfm_autocomplete/gfm_autocomplete_spec.js @@ -1,15 +1,15 @@ +import Tribute from '@gitlab/tributejs'; import { shallowMount } from '@vue/test-utils'; -import Tribute from 'tributejs'; -import GlMentions from '~/vue_shared/components/gl_mentions.vue'; +import GfmAutocomplete from '~/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue'; -describe('GlMentions', () => { +describe('GfmAutocomplete', () => { let wrapper; - describe('Tribute', () => { + describe('tribute', () => { const mentions = '/gitlab-org/gitlab-test/-/autocomplete_sources/members?type=Issue&type_id=1'; beforeEach(() => { - wrapper = shallowMount(GlMentions, { + wrapper = shallowMount(GfmAutocomplete, { propsData: { dataSources: { mentions, diff --git a/spec/frontend/vue_shared/components/gfm_autocomplete/utils_spec.js b/spec/frontend/vue_shared/components/gfm_autocomplete/utils_spec.js new file mode 100644 index 00000000000..647f8c6e000 --- /dev/null +++ b/spec/frontend/vue_shared/components/gfm_autocomplete/utils_spec.js @@ -0,0 +1,344 @@ +import { escape, last } from 'lodash'; +import { GfmAutocompleteType, tributeConfig } from '~/vue_shared/components/gfm_autocomplete/utils'; + +describe('gfm_autocomplete/utils', () => { + describe('issues config', () => { + const issuesConfig = tributeConfig[GfmAutocompleteType.Issues].config; + const groupContextIssue = { + iid: 987654, + reference: 'gitlab#987654', + title: "Group context issue title <script>alert('hi')</script>", + }; + const projectContextIssue = { + id: null, + iid: 123456, + time_estimate: 0, + title: "Project context issue title <script>alert('hi')</script>", + }; + + it('uses # as the trigger', () => { + expect(issuesConfig.trigger).toBe('#'); + }); + + it('searches using both the iid and title', () => { + expect(issuesConfig.lookup(projectContextIssue)).toBe( + `${projectContextIssue.iid}${projectContextIssue.title}`, + ); + }); + + it('shows the reference and title in the menu item within a group context', () => { + expect(issuesConfig.menuItemTemplate({ original: groupContextIssue })).toMatchSnapshot(); + }); + + it('shows the iid and title in the menu item within a project context', () => { + expect(issuesConfig.menuItemTemplate({ original: projectContextIssue })).toMatchSnapshot(); + }); + + it('inserts the reference on autocomplete selection within a group context', () => { + expect(issuesConfig.selectTemplate({ original: groupContextIssue })).toBe( + groupContextIssue.reference, + ); + }); + + it('inserts the iid on autocomplete selection within a project context', () => { + expect(issuesConfig.selectTemplate({ original: projectContextIssue })).toBe( + `#${projectContextIssue.iid}`, + ); + }); + }); + + describe('labels config', () => { + const labelsConfig = tributeConfig[GfmAutocompleteType.Labels].config; + const labelsFilter = tributeConfig[GfmAutocompleteType.Labels].filterValues; + const label = { + color: '#123456', + textColor: '#FFFFFF', + title: `bug <script>alert('hi')</script>`, + type: 'GroupLabel', + }; + const singleWordLabel = { + color: '#456789', + textColor: '#DDD', + title: `bug`, + type: 'GroupLabel', + }; + const numericalLabel = { + color: '#abcdef', + textColor: '#AAA', + title: 123456, + type: 'ProjectLabel', + }; + + it('uses ~ as the trigger', () => { + expect(labelsConfig.trigger).toBe('~'); + }); + + it('searches using `title`', () => { + expect(labelsConfig.lookup).toBe('title'); + }); + + it('shows the title in the menu item', () => { + expect(labelsConfig.menuItemTemplate({ original: label })).toMatchSnapshot(); + }); + + it('inserts the title on autocomplete selection', () => { + expect(labelsConfig.selectTemplate({ original: singleWordLabel })).toBe( + `~${escape(singleWordLabel.title)}`, + ); + }); + + it('inserts the title enclosed with quotes on autocomplete selection when the title is numerical', () => { + expect(labelsConfig.selectTemplate({ original: numericalLabel })).toBe( + `~"${escape(numericalLabel.title)}"`, + ); + }); + + it('inserts the title enclosed with quotes on autocomplete selection when the title contains multiple words', () => { + expect(labelsConfig.selectTemplate({ original: label })).toBe(`~"${escape(label.title)}"`); + }); + + describe('filter', () => { + const collection = [label, singleWordLabel, { ...numericalLabel, set: true }]; + + describe('/label quick action', () => { + describe('when the line starts with `/label`', () => { + it('shows labels that are not currently selected', () => { + const fullText = '/label ~'; + const selectionStart = 8; + + expect(labelsFilter({ collection, fullText, selectionStart })).toEqual([ + collection[0], + collection[1], + ]); + }); + }); + + describe('when the line does not start with `/label`', () => { + it('shows all labels', () => { + const fullText = '~'; + const selectionStart = 1; + + expect(labelsFilter({ collection, fullText, selectionStart })).toEqual(collection); + }); + }); + }); + + describe('/unlabel quick action', () => { + describe('when the line starts with `/unlabel`', () => { + it('shows labels that are currently selected', () => { + const fullText = '/unlabel ~'; + const selectionStart = 10; + + expect(labelsFilter({ collection, fullText, selectionStart })).toEqual([collection[2]]); + }); + }); + + describe('when the line does not start with `/unlabel`', () => { + it('shows all labels', () => { + const fullText = '~'; + const selectionStart = 1; + + expect(labelsFilter({ collection, fullText, selectionStart })).toEqual(collection); + }); + }); + }); + }); + }); + + describe('members config', () => { + const membersConfig = tributeConfig[GfmAutocompleteType.Members].config; + const membersFilter = tributeConfig[GfmAutocompleteType.Members].filterValues; + const userMember = { + type: 'User', + username: 'myusername', + name: "My Name <script>alert('hi')</script>", + avatar_url: '/uploads/-/system/user/avatar/123456/avatar.png', + availability: null, + }; + const groupMember = { + type: 'Group', + username: 'gitlab-com/support/1-1s', + name: "GitLab.com / GitLab Support Team / 1-1s <script>alert('hi')</script>", + avatar_url: null, + count: 2, + mentionsDisabled: null, + }; + + it('uses @ as the trigger', () => { + expect(membersConfig.trigger).toBe('@'); + }); + + it('inserts the username on autocomplete selection', () => { + expect(membersConfig.fillAttr).toBe('username'); + }); + + it('searches using both the name and username for a user', () => { + expect(membersConfig.lookup(userMember)).toBe(`${userMember.name}${userMember.username}`); + }); + + it('searches using only its own name and not its ancestors for a group', () => { + expect(membersConfig.lookup(groupMember)).toBe(last(groupMember.name.split(' / '))); + }); + + it('shows the avatar, name and username in the menu item for a user', () => { + expect(membersConfig.menuItemTemplate({ original: userMember })).toMatchSnapshot(); + }); + + it('shows an avatar character, name, parent name, and count in the menu item for a group', () => { + expect(membersConfig.menuItemTemplate({ original: groupMember })).toMatchSnapshot(); + }); + + describe('filter', () => { + const assignees = [userMember.username]; + const collection = [userMember, groupMember]; + + describe('/assign quick action', () => { + describe('when the line starts with `/assign`', () => { + it('shows members that are not currently selected', () => { + const fullText = '/assign @'; + const selectionStart = 9; + + expect(membersFilter({ assignees, collection, fullText, selectionStart })).toEqual([ + collection[1], + ]); + }); + }); + + describe('when the line does not start with `/assign`', () => { + it('shows all labels', () => { + const fullText = '@'; + const selectionStart = 1; + + expect(membersFilter({ assignees, collection, fullText, selectionStart })).toEqual( + collection, + ); + }); + }); + }); + + describe('/unassign quick action', () => { + describe('when the line starts with `/unassign`', () => { + it('shows members that are currently selected', () => { + const fullText = '/unassign @'; + const selectionStart = 11; + + expect(membersFilter({ assignees, collection, fullText, selectionStart })).toEqual([ + collection[0], + ]); + }); + }); + + describe('when the line does not start with `/unassign`', () => { + it('shows all members', () => { + const fullText = '@'; + const selectionStart = 1; + + expect(membersFilter({ assignees, collection, fullText, selectionStart })).toEqual( + collection, + ); + }); + }); + }); + }); + }); + + describe('merge requests config', () => { + const mergeRequestsConfig = tributeConfig[GfmAutocompleteType.MergeRequests].config; + const groupContextMergeRequest = { + iid: 456789, + reference: 'gitlab!456789', + title: "Group context merge request title <script>alert('hi')</script>", + }; + const projectContextMergeRequest = { + id: null, + iid: 123456, + time_estimate: 0, + title: "Project context merge request title <script>alert('hi')</script>", + }; + + it('uses ! as the trigger', () => { + expect(mergeRequestsConfig.trigger).toBe('!'); + }); + + it('searches using both the iid and title', () => { + expect(mergeRequestsConfig.lookup(projectContextMergeRequest)).toBe( + `${projectContextMergeRequest.iid}${projectContextMergeRequest.title}`, + ); + }); + + it('shows the reference and title in the menu item within a group context', () => { + expect( + mergeRequestsConfig.menuItemTemplate({ original: groupContextMergeRequest }), + ).toMatchSnapshot(); + }); + + it('shows the iid and title in the menu item within a project context', () => { + expect( + mergeRequestsConfig.menuItemTemplate({ original: projectContextMergeRequest }), + ).toMatchSnapshot(); + }); + + it('inserts the reference on autocomplete selection within a group context', () => { + expect(mergeRequestsConfig.selectTemplate({ original: groupContextMergeRequest })).toBe( + groupContextMergeRequest.reference, + ); + }); + + it('inserts the iid on autocomplete selection within a project context', () => { + expect(mergeRequestsConfig.selectTemplate({ original: projectContextMergeRequest })).toBe( + `!${projectContextMergeRequest.iid}`, + ); + }); + }); + + describe('milestones config', () => { + const milestonesConfig = tributeConfig[GfmAutocompleteType.Milestones].config; + const milestone = { + id: null, + iid: 49, + title: "13.2 <script>alert('hi')</script>", + }; + + it('uses % as the trigger', () => { + expect(milestonesConfig.trigger).toBe('%'); + }); + + it('searches using the title', () => { + expect(milestonesConfig.lookup).toBe('title'); + }); + + it('shows the title in the menu item', () => { + expect(milestonesConfig.menuItemTemplate({ original: milestone })).toMatchSnapshot(); + }); + + it('inserts the title on autocomplete selection', () => { + expect(milestonesConfig.selectTemplate({ original: milestone })).toBe( + `%"${escape(milestone.title)}"`, + ); + }); + }); + + describe('snippets config', () => { + const snippetsConfig = tributeConfig[GfmAutocompleteType.Snippets].config; + const snippet = { + id: 123456, + title: "Snippet title <script>alert('hi')</script>", + }; + + it('uses $ as the trigger', () => { + expect(snippetsConfig.trigger).toBe('$'); + }); + + it('inserts the id on autocomplete selection', () => { + expect(snippetsConfig.fillAttr).toBe('id'); + }); + + it('searches using both the id and title', () => { + expect(snippetsConfig.lookup(snippet)).toBe(`${snippet.id}${snippet.title}`); + }); + + it('shows the id and title in the menu item', () => { + expect(snippetsConfig.menuItemTemplate({ original: snippet })).toMatchSnapshot(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js index c87d19df1f7..d1bfc180082 100644 --- a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js @@ -33,28 +33,31 @@ describe('IssueMilestoneComponent', () => { describe('computed', () => { describe('isMilestoneStarted', () => { - it('should return `false` when milestoneStart prop is not defined', () => { + it('should return `false` when milestoneStart prop is not defined', async () => { wrapper.setProps({ milestone: { ...mockMilestone, start_date: '' }, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.isMilestoneStarted).toBe(false); }); - it('should return `true` when milestone start date is past current date', () => { - wrapper.setProps({ + it('should return `true` when milestone start date is past current date', async () => { + await wrapper.setProps({ milestone: { ...mockMilestone, start_date: '1990-07-22' }, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.isMilestoneStarted).toBe(true); }); }); describe('isMilestonePastDue', () => { - it('should return `false` when milestoneDue prop is not defined', () => { + it('should return `false` when milestoneDue prop is not defined', async () => { wrapper.setProps({ milestone: { ...mockMilestone, due_date: '' }, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.isMilestonePastDue).toBe(false); }); @@ -73,41 +76,45 @@ describe('IssueMilestoneComponent', () => { expect(vm.milestoneDatesAbsolute).toBe('(December 31, 2019)'); }); - it('returns string containing absolute milestone start date when due date is not present', () => { + it('returns string containing absolute milestone start date when due date is not present', async () => { wrapper.setProps({ milestone: { ...mockMilestone, due_date: '' }, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.milestoneDatesAbsolute).toBe('(January 1, 2018)'); }); - it('returns empty string when both milestone start and due dates are not present', () => { + it('returns empty string when both milestone start and due dates are not present', async () => { wrapper.setProps({ milestone: { ...mockMilestone, start_date: '', due_date: '' }, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.milestoneDatesAbsolute).toBe(''); }); }); describe('milestoneDatesHuman', () => { - it('returns string containing milestone due date when date is yet to be due', () => { + it('returns string containing milestone due date when date is yet to be due', async () => { wrapper.setProps({ milestone: { ...mockMilestone, due_date: `${new Date().getFullYear() + 10}-01-01` }, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.milestoneDatesHuman).toContain('years remaining'); }); - it('returns string containing milestone start date when date has already started and due date is not present', () => { + it('returns string containing milestone start date when date has already started and due date is not present', async () => { wrapper.setProps({ milestone: { ...mockMilestone, start_date: '1990-07-22', due_date: '' }, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.milestoneDatesHuman).toContain('Started'); }); - it('returns string containing milestone start date when date is yet to start and due date is not present', () => { + it('returns string containing milestone start date when date is yet to start and due date is not present', async () => { wrapper.setProps({ milestone: { ...mockMilestone, @@ -115,14 +122,16 @@ describe('IssueMilestoneComponent', () => { due_date: '', }, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.milestoneDatesHuman).toContain('Starts'); }); - it('returns empty string when milestone start and due dates are not present', () => { + it('returns empty string when milestone start and due dates are not present', async () => { wrapper.setProps({ milestone: { ...mockMilestone, start_date: '', due_date: '' }, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.milestoneDatesHuman).toBe(''); }); diff --git a/spec/frontend/vue_shared/components/loading_button_spec.js b/spec/frontend/vue_shared/components/loading_button_spec.js deleted file mode 100644 index 8bcb80d140e..00000000000 --- a/spec/frontend/vue_shared/components/loading_button_spec.js +++ /dev/null @@ -1,100 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; - -const LABEL = 'Hello'; - -describe('LoadingButton', () => { - let wrapper; - - afterEach(() => { - wrapper.destroy(); - }); - - const buildWrapper = (propsData = {}) => { - wrapper = shallowMount(LoadingButton, { - propsData, - }); - }; - const findButtonLabel = () => wrapper.find('.js-loading-button-label'); - const findButtonIcon = () => wrapper.find('.js-loading-button-icon'); - - describe('loading spinner', () => { - it('shown when loading', () => { - buildWrapper({ loading: true }); - - expect(findButtonIcon().exists()).toBe(true); - }); - }); - - describe('disabled state', () => { - it('disabled when loading', () => { - buildWrapper({ loading: true }); - expect(wrapper.attributes('disabled')).toBe('disabled'); - }); - - it('not disabled when normal', () => { - buildWrapper({ loading: false }); - - expect(wrapper.attributes('disabled')).toBe(undefined); - }); - }); - - describe('label', () => { - it('shown when normal', () => { - buildWrapper({ loading: false, label: LABEL }); - expect(findButtonLabel().text()).toBe(LABEL); - }); - - it('shown when loading', () => { - buildWrapper({ loading: false, label: LABEL }); - expect(findButtonLabel().text()).toBe(LABEL); - }); - }); - - describe('container class', () => { - it('should default to btn btn-align-content', () => { - buildWrapper(); - - expect(wrapper.classes()).toContain('btn'); - expect(wrapper.classes()).toContain('btn-align-content'); - }); - - it('should be configurable through props', () => { - const containerClass = 'test-class'; - - buildWrapper({ - containerClass, - }); - - expect(wrapper.classes()).not.toContain('btn'); - expect(wrapper.classes()).not.toContain('btn-align-content'); - expect(wrapper.classes()).toContain(containerClass); - }); - }); - - describe('click callback prop', () => { - it('calls given callback when normal', () => { - buildWrapper({ - loading: false, - }); - - wrapper.trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('click')).toBeTruthy(); - }); - }); - - it('does not call given callback when disabled because of loading', () => { - buildWrapper({ - loading: true, - }); - - wrapper.trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('click')).toBeFalsy(); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js b/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js new file mode 100644 index 00000000000..0598506891b --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js @@ -0,0 +1,72 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlDropdown, GlFormTextarea, GlButton } from '@gitlab/ui'; +import ApplySuggestionComponent from '~/vue_shared/components/markdown/apply_suggestion.vue'; + +describe('Apply Suggestion component', () => { + const propsData = { fileName: 'test.js', disabled: false }; + let wrapper; + + const createWrapper = props => { + wrapper = shallowMount(ApplySuggestionComponent, { propsData: { ...propsData, ...props } }); + }; + + const findDropdown = () => wrapper.find(GlDropdown); + const findTextArea = () => wrapper.find(GlFormTextarea); + const findApplyButton = () => wrapper.find(GlButton); + + beforeEach(() => createWrapper()); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('initial template', () => { + it('renders a dropdown with the correct props', () => { + const dropdown = findDropdown(); + + expect(dropdown.exists()).toBe(true); + expect(dropdown.props('text')).toBe('Apply suggestion'); + expect(dropdown.props('headerText')).toBe('Apply suggestion commit message'); + expect(dropdown.props('disabled')).toBe(false); + }); + + it('renders a textarea with the correct props', () => { + const textArea = findTextArea(); + + expect(textArea.exists()).toBe(true); + expect(textArea.attributes('placeholder')).toBe('Apply suggestion on test.js'); + }); + + it('renders an apply button', () => { + const applyButton = findApplyButton(); + + expect(applyButton.exists()).toBe(true); + expect(applyButton.text()).toBe('Apply'); + }); + }); + + describe('disabled', () => { + it('disables the dropdown', () => { + createWrapper({ disabled: true }); + + expect(findDropdown().props('disabled')).toBe(true); + }); + }); + + describe('apply suggestion', () => { + it('emits an apply event with a default message if no message was added', () => { + findTextArea().vm.$emit('input', null); + findApplyButton().vm.$emit('click'); + + expect(wrapper.emitted('apply')).toEqual([['Apply suggestion on test.js']]); + }); + + it('emits an apply event with a user-defined message', () => { + findTextArea().vm.$emit('input', 'some text'); + findApplyButton().vm.$emit('click'); + + expect(wrapper.emitted('apply')).toEqual([['some text']]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap index ecea151fc8a..da49778f216 100644 --- a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap +++ b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap @@ -47,6 +47,7 @@ exports[`Package code instruction single line to match the default snapshot 1`] <!----> <svg + aria-hidden="true" class="gl-button-icon gl-icon s16" data-testid="copy-to-clipboard-icon" > diff --git a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap index 3990248d021..623f7d083c5 100644 --- a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap +++ b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap @@ -10,6 +10,9 @@ exports[`Resizable Skeleton Loader default setup renders the bars, labels, and g version="1.1" viewBox="0 0 400 130" > + <title> + Loading + </title> <rect clip-path="url(#null-idClip)" height="130" @@ -226,6 +229,9 @@ exports[`Resizable Skeleton Loader with custom settings renders the correct posi version="1.1" viewBox="0 0 400 130" > + <title> + Loading + </title> <rect clip-path="url(#-idClip)" height="130" diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js index d50cf2915e8..cd1157a1c2e 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { mockEditorApi } from '@toast-ui/vue-editor'; import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue'; import InsertVideoModal from '~/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue'; @@ -114,10 +115,17 @@ describe('Rich Content Editor', () => { }); describe('when editor is loaded', () => { + const formattedMarkdown = 'formatted markdown'; + beforeEach(() => { + mockEditorApi.getMarkdown.mockReturnValueOnce(formattedMarkdown); buildWrapper(); }); + afterEach(() => { + mockEditorApi.getMarkdown.mockReset(); + }); + it('adds the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { expect(addCustomEventListener).toHaveBeenCalledWith( wrapper.vm.editorApi, @@ -137,6 +145,11 @@ describe('Rich Content Editor', () => { it('registers HTML to markdown renderer', () => { expect(registerHTMLToMarkdownRenderer).toHaveBeenCalledWith(wrapper.vm.editorApi); }); + + it('emits load event with the markdown formatted by Toast UI', () => { + expect(mockEditorApi.getMarkdown).toHaveBeenCalled(); + expect(wrapper.emitted('load')[0]).toEqual([{ formattedMarkdown }]); + }); }); describe('when editor is destroyed', () => { 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 new file mode 100644 index 00000000000..1e08394dd56 --- /dev/null +++ b/spec/frontend/vue_shared/components/security_reports/__snapshots__/security_summary_spec.js.snap @@ -0,0 +1,144 @@ +// 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="text-danger-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="text-danger-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="text-danger-800 gl-pl-4" + > + + 1 Critical + + </strong> + </span> + + <span> + <strong + class="text-danger-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/help_icon_spec.js b/spec/frontend/vue_shared/components/security_reports/help_icon_spec.js new file mode 100644 index 00000000000..60203493cbd --- /dev/null +++ b/spec/frontend/vue_shared/components/security_reports/help_icon_spec.js @@ -0,0 +1,68 @@ +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.find(GlLink); + const findPopover = () => wrapper.find(GlPopover); + const findPopoverTarget = () => wrapper.find({ ref: 'discoverProjectSecurity' }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + 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 new file mode 100644 index 00000000000..e57152c3cbf --- /dev/null +++ b/spec/frontend/vue_shared/components/security_reports/security_summary_spec.js @@ -0,0 +1,38 @@ +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, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + 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/sidebar/labels_select/base_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js index 9db86fa775f..596cb22fca5 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js @@ -33,8 +33,8 @@ describe('BaseComponent', () => { expect(vm.hiddenInputName).toBe('issue[label_names][]'); }); - it('returns correct string when showCreate prop is `false`', () => { - wrapper.setProps({ showCreate: false }); + it('returns correct string when showCreate prop is `false`', async () => { + await wrapper.setProps({ showCreate: false }); expect(vm.hiddenInputName).toBe('label_id[]'); }); @@ -45,8 +45,8 @@ describe('BaseComponent', () => { expect(vm.createLabelTitle).toBe('Create project label'); }); - it('return `Create group label` when `isProject` prop is false', () => { - wrapper.setProps({ isProject: false }); + it('return `Create group label` when `isProject` prop is false', async () => { + await wrapper.setProps({ isProject: false }); expect(vm.createLabelTitle).toBe('Create group label'); }); @@ -57,8 +57,8 @@ describe('BaseComponent', () => { expect(vm.manageLabelsTitle).toBe('Manage project labels'); }); - it('return `Manage group labels` when `isProject` prop is false', () => { - wrapper.setProps({ isProject: false }); + it('return `Manage group labels` when `isProject` prop is false', async () => { + await wrapper.setProps({ isProject: false }); expect(vm.manageLabelsTitle).toBe('Manage group labels'); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js index 8c17a974b39..1206450bbeb 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js @@ -20,22 +20,24 @@ jest.mock('~/lib/utils/common_utils', () => ({ const localVue = createLocalVue(); localVue.use(Vuex); -const createComponent = (config = mockConfig, slots = {}) => - shallowMount(LabelsSelectRoot, { - localVue, - slots, - store: new Vuex.Store(labelsSelectModule()), - propsData: config, - stubs: { - 'dropdown-contents': DropdownContents, - }, - }); - describe('LabelsSelectRoot', () => { let wrapper; + let store; + + const createComponent = (config = mockConfig, slots = {}) => { + wrapper = shallowMount(LabelsSelectRoot, { + localVue, + slots, + store, + propsData: config, + stubs: { + 'dropdown-contents': DropdownContents, + }, + }); + }; beforeEach(() => { - wrapper = createComponent(); + store = new Vuex.Store(labelsSelectModule()); }); afterEach(() => { @@ -45,6 +47,7 @@ describe('LabelsSelectRoot', () => { describe('methods', () => { describe('handleVuexActionDispatch', () => { it('calls `handleDropdownClose` when params `action.type` is `toggleDropdownContents` and state has `showDropdownButton` & `showDropdownContents` props `false`', () => { + createComponent(); jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation(); wrapper.vm.handleVuexActionDispatch( @@ -67,7 +70,7 @@ describe('LabelsSelectRoot', () => { }); it('calls `handleDropdownClose` with state.labels filterd using `set` prop when dropdown variant is `embedded`', () => { - wrapper = createComponent({ + createComponent({ ...mockConfig, variant: 'embedded', }); @@ -95,6 +98,10 @@ describe('LabelsSelectRoot', () => { }); describe('handleDropdownClose', () => { + beforeEach(() => { + createComponent(); + }); + it('emits `updateSelectedLabels` & `onDropdownClose` events on component when provided `labels` param is not empty', () => { wrapper.vm.handleDropdownClose([{ id: 1 }, { id: 2 }]); @@ -112,6 +119,7 @@ describe('LabelsSelectRoot', () => { describe('handleCollapsedValueClick', () => { it('emits `toggleCollapse` event on component', () => { + createComponent(); wrapper.vm.handleCollapsedValueClick(); expect(wrapper.emitted().toggleCollapse).toBeTruthy(); @@ -121,6 +129,7 @@ describe('LabelsSelectRoot', () => { describe('template', () => { it('renders component with classes `labels-select-wrapper position-relative`', () => { + createComponent(); expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative'); }); @@ -131,7 +140,7 @@ describe('LabelsSelectRoot', () => { `( 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"', ({ variant, cssClass }) => { - wrapper = createComponent({ + createComponent({ ...mockConfig, variant, }); @@ -142,57 +151,58 @@ describe('LabelsSelectRoot', () => { }, ); - it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', () => { + it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', async () => { + createComponent(); + await wrapper.vm.$nextTick; expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true); }); - it('renders `dropdown-title` component', () => { + it('renders `dropdown-title` component', async () => { + createComponent(); + await wrapper.vm.$nextTick; expect(wrapper.find(DropdownTitle).exists()).toBe(true); }); - it('renders `dropdown-value` component', () => { - const wrapperDropdownValue = createComponent(mockConfig, { + it('renders `dropdown-value` component', async () => { + createComponent(mockConfig, { default: 'None', }); + await wrapper.vm.$nextTick; - return wrapperDropdownValue.vm.$nextTick(() => { - const valueComp = wrapperDropdownValue.find(DropdownValue); + const valueComp = wrapper.find(DropdownValue); - expect(valueComp.exists()).toBe(true); - expect(valueComp.text()).toBe('None'); - - wrapperDropdownValue.destroy(); - }); + expect(valueComp.exists()).toBe(true); + expect(valueComp.text()).toBe('None'); }); - it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', () => { + it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', async () => { + createComponent(); wrapper.vm.$store.dispatch('toggleDropdownButton'); - + await wrapper.vm.$nextTick; expect(wrapper.find(DropdownButton).exists()).toBe(true); }); - it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', () => { + it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', async () => { + createComponent(); wrapper.vm.$store.dispatch('toggleDropdownContents'); - - return wrapper.vm.$nextTick(() => { - expect(wrapper.find(DropdownContents).exists()).toBe(true); - }); + await wrapper.vm.$nextTick; + expect(wrapper.find(DropdownContents).exists()).toBe(true); }); describe('sets content direction based on viewport', () => { - it('does not set direction when `state.variant` is not "embedded"', () => { - wrapper.vm.$store.dispatch('toggleDropdownContents'); + it('does not set direction when `state.variant` is not "embedded"', async () => { + createComponent(); + wrapper.vm.$store.dispatch('toggleDropdownContents'); wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state); + await wrapper.vm.$nextTick; - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false); - }); + expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false); }); describe('when `state.variant` is "embedded"', () => { beforeEach(() => { - wrapper = createComponent({ ...mockConfig, variant: 'embedded' }); + createComponent({ ...mockConfig, variant: 'embedded' }); wrapper.vm.$store.dispatch('toggleDropdownContents'); }); @@ -216,4 +226,22 @@ describe('LabelsSelectRoot', () => { }); }); }); + + it('calls toggleDropdownContents action when isEditing prop is changing to true', async () => { + createComponent(); + + jest.spyOn(store, 'dispatch').mockResolvedValue(); + await wrapper.setProps({ isEditing: true }); + + expect(store.dispatch).toHaveBeenCalledWith('toggleDropdownContents'); + }); + + it('does not call toggleDropdownContents action when isEditing prop is changing to false', async () => { + createComponent(); + + jest.spyOn(store, 'dispatch').mockResolvedValue(); + await wrapper.setProps({ isEditing: false }); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); }); diff --git a/spec/frontend/vue_shared/components/toggle_button_spec.js b/spec/frontend/vue_shared/components/toggle_button_spec.js index f58647ff12b..2822b1999bc 100644 --- a/spec/frontend/vue_shared/components/toggle_button_spec.js +++ b/spec/frontend/vue_shared/components/toggle_button_spec.js @@ -1,101 +1,96 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import toggleButton from '~/vue_shared/components/toggle_button.vue'; +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import ToggleButton from '~/vue_shared/components/toggle_button.vue'; -describe('Toggle Button', () => { - let vm; - let Component; +describe('Toggle Button component', () => { + let wrapper; - beforeEach(() => { - Component = Vue.extend(toggleButton); - }); + function createComponent(propsData = {}) { + wrapper = shallowMount(ToggleButton, { + propsData, + }); + } + + const findInput = () => wrapper.find('input'); + const findButton = () => wrapper.find('button'); + const findToggleIcon = () => wrapper.find(GlIcon); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + wrapper = null; }); - describe('render output', () => { - beforeEach(() => { - vm = mountComponent(Component, { - value: true, - name: 'foo', - }); - }); - - it('renders input with provided name', () => { - expect(vm.$el.querySelector('input').getAttribute('name')).toEqual('foo'); + it('renders input with provided name', () => { + createComponent({ + name: 'foo', }); - it('renders input with provided value', () => { - expect(vm.$el.querySelector('input').getAttribute('value')).toEqual('true'); - }); - - it('renders input status icon', () => { - expect(vm.$el.querySelectorAll('span.toggle-icon').length).toEqual(1); - expect(vm.$el.querySelectorAll('svg.s18').length).toEqual(1); - }); + expect(findInput().attributes('name')).toBe('foo'); }); - describe('is-checked', () => { + describe.each` + value | iconName + ${true} | ${'status_success_borderless'} + ${false} | ${'status_failed_borderless'} + `('when `value` prop is `$value`', ({ value, iconName }) => { beforeEach(() => { - vm = mountComponent(Component, { - value: true, + createComponent({ + value, + name: 'foo', }); - - jest.spyOn(vm, '$emit').mockImplementation(() => {}); }); - it('renders is checked class', () => { - expect(vm.$el.querySelector('button').classList.contains('is-checked')).toEqual(true); + it('renders input with correct value attribute', () => { + expect(findInput().attributes('value')).toBe(`${value}`); }); - it('sets aria-label representing toggle state', () => { - vm.value = true; - - expect(vm.ariaLabel).toEqual('Toggle Status: ON'); - - vm.value = false; - - expect(vm.ariaLabel).toEqual('Toggle Status: OFF'); + it('renders correct icon', () => { + const icon = findToggleIcon(); + expect(icon.isVisible()).toBe(true); + expect(icon.props('name')).toBe(iconName); + expect(findButton().classes('is-checked')).toBe(value); }); - it('emits change event when clicked', () => { - vm.$el.querySelector('button').click(); + describe('when clicked', () => { + it('emits `change` event with correct event', async () => { + findButton().trigger('click'); + await wrapper.vm.$nextTick(); - expect(vm.$emit).toHaveBeenCalledWith('change', false); + expect(wrapper.emitted('change')).toStrictEqual([[!value]]); + }); }); }); - describe('is-disabled', () => { + describe('when `disabledInput` prop is `true`', () => { beforeEach(() => { - vm = mountComponent(Component, { + createComponent({ value: true, disabledInput: true, }); - jest.spyOn(vm, '$emit').mockImplementation(() => {}); }); it('renders disabled button', () => { - expect(vm.$el.querySelector('button').classList.contains('is-disabled')).toEqual(true); + expect(findButton().classes()).toContain('is-disabled'); }); - it('does not emit change event when clicked', () => { - vm.$el.querySelector('button').click(); + it('does not emit change event when clicked', async () => { + findButton().trigger('click'); + await wrapper.vm.$nextTick(); - expect(vm.$emit).not.toHaveBeenCalled(); + expect(wrapper.emitted('change')).toBeFalsy(); }); }); - describe('is-loading', () => { + describe('when `isLoading` prop is `true`', () => { beforeEach(() => { - vm = mountComponent(Component, { + createComponent({ value: true, isLoading: true, }); }); it('renders loading class', () => { - expect(vm.$el.querySelector('button').classList.contains('is-loading')).toEqual(true); + expect(findButton().classes()).toContain('is-loading'); }); }); }); diff --git a/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js new file mode 100644 index 00000000000..175abf5aae0 --- /dev/null +++ b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js @@ -0,0 +1,239 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import { hasHorizontalOverflow } from '~/lib/utils/dom_utils'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; + +const DUMMY_TEXT = 'lorem-ipsum-dolar-sit-amit-consectur-adipiscing-elit-sed-do'; + +const createChildElement = () => `<a href="#">${DUMMY_TEXT}</a>`; + +jest.mock('~/lib/utils/dom_utils', () => ({ + hasHorizontalOverflow: jest.fn(() => { + throw new Error('this needs to be mocked'); + }), +})); + +describe('TooltipOnTruncate component', () => { + let wrapper; + let parent; + + const createComponent = ({ propsData, ...options } = {}) => { + wrapper = shallowMount(TooltipOnTruncate, { + attachToDocument: true, + propsData: { + ...propsData, + }, + ...options, + }); + }; + + const createWrappedComponent = ({ propsData, ...options }) => { + // set a parent around the tested component + parent = mount( + { + props: { + title: { default: '' }, + }, + template: ` + <TooltipOnTruncate :title="title" truncate-target="child"> + <div>{{title}}</div> + </TooltipOnTruncate> + `, + components: { + TooltipOnTruncate, + }, + }, + { + propsData: { ...propsData }, + attachToDocument: true, + ...options, + }, + ); + + wrapper = parent.find(TooltipOnTruncate); + }; + + const hasTooltip = () => wrapper.classes('js-show-tooltip'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('with default target', () => { + it('renders tooltip if truncated', () => { + hasHorizontalOverflow.mockReturnValueOnce(true); + createComponent({ + propsData: { + title: DUMMY_TEXT, + }, + slots: { + default: [DUMMY_TEXT], + }, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element); + expect(hasTooltip()).toBe(true); + expect(wrapper.attributes('data-original-title')).toEqual(DUMMY_TEXT); + expect(wrapper.attributes('data-placement')).toEqual('top'); + }); + }); + + it('does not render tooltip if normal', () => { + hasHorizontalOverflow.mockReturnValueOnce(false); + createComponent({ + propsData: { + title: DUMMY_TEXT, + }, + slots: { + default: [DUMMY_TEXT], + }, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element); + expect(hasTooltip()).toBe(false); + }); + }); + }); + + describe('with child target', () => { + it('renders tooltip if truncated', () => { + hasHorizontalOverflow.mockReturnValueOnce(true); + createComponent({ + propsData: { + title: DUMMY_TEXT, + truncateTarget: 'child', + }, + slots: { + default: createChildElement(), + }, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[0]); + expect(hasTooltip()).toBe(true); + }); + }); + + it('does not render tooltip if normal', () => { + hasHorizontalOverflow.mockReturnValueOnce(false); + createComponent({ + propsData: { + truncateTarget: 'child', + }, + slots: { + default: createChildElement(), + }, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[0]); + expect(hasTooltip()).toBe(false); + }); + }); + }); + + describe('with fn target', () => { + it('renders tooltip if truncated', () => { + hasHorizontalOverflow.mockReturnValueOnce(true); + createComponent({ + propsData: { + title: DUMMY_TEXT, + truncateTarget: el => el.childNodes[1], + }, + slots: { + default: [createChildElement(), createChildElement()], + }, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[1]); + expect(hasTooltip()).toBe(true); + }); + }); + }); + + describe('placement', () => { + it('sets data-placement when tooltip is rendered', () => { + const placement = 'bottom'; + + hasHorizontalOverflow.mockReturnValueOnce(true); + createComponent({ + propsData: { + placement, + }, + slots: { + default: DUMMY_TEXT, + }, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(hasTooltip()).toBe(true); + expect(wrapper.attributes('data-placement')).toEqual(placement); + }); + }); + }); + + describe('updates when title and slot content changes', () => { + describe('is initialized with a long text', () => { + beforeEach(() => { + hasHorizontalOverflow.mockReturnValueOnce(true); + createWrappedComponent({ + propsData: { title: DUMMY_TEXT }, + }); + return parent.vm.$nextTick(); + }); + + it('renders tooltip', () => { + expect(hasTooltip()).toBe(true); + expect(wrapper.attributes('data-original-title')).toEqual(DUMMY_TEXT); + expect(wrapper.attributes('data-placement')).toEqual('top'); + }); + + it('does not render tooltip after updated to a short text', () => { + hasHorizontalOverflow.mockReturnValueOnce(false); + parent.setProps({ + title: 'new-text', + }); + + return wrapper.vm + .$nextTick() + .then(() => wrapper.vm.$nextTick()) // wait 2 times to get an updated slot + .then(() => { + expect(hasTooltip()).toBe(false); + }); + }); + }); + + describe('is initialized with a short text', () => { + beforeEach(() => { + hasHorizontalOverflow.mockReturnValueOnce(false); + createWrappedComponent({ + propsData: { title: DUMMY_TEXT }, + }); + return wrapper.vm.$nextTick(); + }); + + it('does not render tooltip', () => { + expect(hasTooltip()).toBe(false); + }); + + it('renders tooltip after text is updated', () => { + hasHorizontalOverflow.mockReturnValueOnce(true); + const newText = 'new-text'; + parent.setProps({ + title: newText, + }); + + return wrapper.vm + .$nextTick() + .then(() => wrapper.vm.$nextTick()) // wait 2 times to get an updated slot + .then(() => { + expect(hasTooltip()).toBe(true); + expect(wrapper.attributes('data-original-title')).toEqual(newText); + expect(wrapper.attributes('data-placement')).toEqual('top'); + }); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/security_reports/components/security_report_download_dropdown_spec.js b/spec/frontend/vue_shared/security_reports/components/security_report_download_dropdown_spec.js new file mode 100644 index 00000000000..7e70407655a --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/components/security_report_download_dropdown_spec.js @@ -0,0 +1,64 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue'; + +describe('SecurityReportDownloadDropdown component', () => { + let wrapper; + let artifacts; + + const createComponent = props => { + wrapper = shallowMount(SecurityReportDownloadDropdown, { + propsData: { ...props }, + }); + }; + + const findDropdown = () => wrapper.find(GlDropdown); + const findDropdownItems = () => wrapper.findAll(GlDropdownItem); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('given report artifacts', () => { + beforeEach(() => { + artifacts = [ + { + name: 'foo', + path: '/foo.json', + }, + { + name: 'bar', + path: '/bar.json', + }, + ]; + + createComponent({ artifacts }); + }); + + it('renders a dropdown', () => { + expect(findDropdown().props('loading')).toBe(false); + }); + + it('renders a dropdown items for each artifact', () => { + artifacts.forEach((artifact, i) => { + const item = findDropdownItems().at(i); + expect(item.text()).toContain(artifact.name); + expect(item.attributes()).toMatchObject({ + href: artifact.path, + download: expect.any(String), + }); + }); + }); + }); + + describe('given it is loading', () => { + beforeEach(() => { + createComponent({ artifacts: [], loading: true }); + }); + + it('renders a loading dropdown', () => { + expect(findDropdown().props('loading')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/vue_shared/security_reports/mock_data.js b/spec/frontend/vue_shared/security_reports/mock_data.js new file mode 100644 index 00000000000..e93ca8329e7 --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/mock_data.js @@ -0,0 +1,437 @@ +import { + REPORT_TYPE_SAST, + REPORT_TYPE_SECRET_DETECTION, +} from '~/vue_shared/security_reports/constants'; + +export const mockFindings = [ + { + id: null, + report_type: 'dependency_scanning', + name: 'Cross-site Scripting in serialize-javascript', + severity: 'critical', + scanner: { + external_id: 'gemnasium', + name: 'Gemnasium', + version: '1.1.1', + url: 'https://gitlab.com/gitlab-org/security-products/gemnasium', + }, + identifiers: [ + { + external_type: 'gemnasium', + external_id: '58caa017-9a9a-46d6-bab2-ec930f46833c', + name: 'Gemnasium-58caa017-9a9a-46d6-bab2-ec930f46833c', + url: + 'https://deps.sec.gitlab.com/packages/npm/serialize-javascript/versions/1.7.0/advisories', + }, + { + external_type: 'cve', + external_id: 'CVE-2019-16769', + name: 'CVE-2019-16769', + url: 'https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-16769', + }, + ], + project_fingerprint: '09df9f4d11c8deb93d81bdcc39f7667b44143298', + create_vulnerability_feedback_issue_path: '/gitlab-org/gitlab-ui/vulnerability_feedback', + create_vulnerability_feedback_merge_request_path: + '/gitlab-org/gitlab-ui/vulnerability_feedback', + create_vulnerability_feedback_dismissal_path: '/gitlab-org/gitlab-ui/vulnerability_feedback', + project: { + id: 7071551, + name: 'gitlab-ui', + full_path: '/gitlab-org/gitlab-ui', + full_name: 'GitLab.org / gitlab-ui', + }, + dismissal_feedback: null, + issue_feedback: null, + merge_request_feedback: null, + description: + 'The serialize-javascript npm package is vulnerable to Cross-site Scripting (XSS). It does not properly mitigate against unsafe characters in serialized regular expressions. If serialized data of regular expression objects are used in an environment other than Node.js, it is affected by this vulnerability.', + links: [{ url: 'https://nvd.nist.gov/vuln/detail/CVE-2019-16769' }], + location: { + file: 'yarn.lock', + dependency: { package: { name: 'serialize-javascript' }, version: '1.7.0' }, + }, + remediations: [null], + solution: 'Upgrade to version 2.1.1 or above.', + state: 'opened', + blob_path: '/gitlab-org/gitlab-ui/blob/ad137f0a8ac59af961afe47d04e5cc062c6864a9/yarn.lock', + evidence: 'Credit Card Detected: Diners Card', + }, + { + id: null, + report_type: 'dependency_scanning', + name: '3rd party CORS request may execute in jquery', + severity: 'high', + scanner: { external_id: 'retire.js', name: 'Retire.js' }, + identifiers: [ + { + external_type: 'cve', + external_id: 'CVE-2015-9251', + name: 'CVE-2015-9251', + url: 'https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2015-9251', + }, + ], + project_fingerprint: '1ecd3b214cf39c0b9ad23a0a9679778d7cf55876', + create_vulnerability_feedback_issue_path: '/gitlab-org/gitlab-ui/vulnerability_feedback', + create_vulnerability_feedback_merge_request_path: + '/gitlab-org/gitlab-ui/vulnerability_feedback', + create_vulnerability_feedback_dismissal_path: '/gitlab-org/gitlab-ui/vulnerability_feedback', + project: { + id: 7071551, + name: 'gitlab-ui', + full_path: '/gitlab-org/gitlab-ui', + full_name: 'GitLab.org / gitlab-ui', + }, + dismissal_feedback: { + id: 2528, + created_at: '2019-08-26T12:30:32.349Z', + project_id: 7071551, + author: { + id: 181229, + name: "Lukas 'Eipi' Eipert", + username: 'leipert', + state: 'active', + avatar_url: + 'https://secure.gravatar.com/avatar/19a1f1260fa70323f35bc508927921a2?s=80\u0026d=identicon', + web_url: 'https://gitlab.com/leipert', + status_tooltip_html: null, + path: '/leipert', + }, + comment_details: { + comment: 'This particular jQuery version appears in a test path of tinycolor2.\n', + comment_timestamp: '2019-08-26T12:30:37.610Z', + comment_author: { + id: 181229, + name: "Lukas 'Eipi' Eipert", + username: 'leipert', + state: 'active', + avatar_url: + 'https://secure.gravatar.com/avatar/19a1f1260fa70323f35bc508927921a2?s=80\u0026d=identicon', + web_url: 'https://gitlab.com/leipert', + status_tooltip_html: null, + path: '/leipert', + }, + }, + pipeline: { id: 78375355, path: '/gitlab-org/gitlab-ui/pipelines/78375355' }, + destroy_vulnerability_feedback_dismissal_path: + '/gitlab-org/gitlab-ui/vulnerability_feedback/2528', + category: 'dependency_scanning', + feedback_type: 'dismissal', + branch: 'leipert-dogfood-secure', + project_fingerprint: '1ecd3b214cf39c0b9ad23a0a9679778d7cf55876', + }, + issue_feedback: null, + merge_request_feedback: null, + description: null, + links: [ + { url: 'https://github.com/jquery/jquery/issues/2432' }, + { url: 'http://blog.jquery.com/2016/01/08/jquery-2-2-and-1-12-released/' }, + { url: 'https://nvd.nist.gov/vuln/detail/CVE-2015-9251' }, + { url: 'http://research.insecurelabs.org/jquery/test/' }, + ], + location: { + file: 'node_modules/tinycolor2/demo/jquery-1.9.1.js', + dependency: { package: { name: 'jquery' }, version: '1.9.1' }, + }, + remediations: [null], + solution: null, + state: 'dismissed', + blob_path: + '/gitlab-org/gitlab-ui/blob/ad137f0a8ac59af961afe47d04e5cc062c6864a9/node_modules/tinycolor2/demo/jquery-1.9.1.js', + }, + { + id: null, + report_type: 'dependency_scanning', + name: + 'jQuery before 3.4.0, as used in Drupal, Backdrop CMS, and other products, mishandles jQuery.extend(true, {}, ...) because of Object.prototype pollution in jquery', + severity: 'low', + scanner: { external_id: 'retire.js', name: 'Retire.js' }, + identifiers: [ + { + external_type: 'cve', + external_id: 'CVE-2019-11358', + name: 'CVE-2019-11358', + url: 'https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-11358', + }, + ], + project_fingerprint: 'aeb4b2442d92d0ccf7023f0c220bda8b4ba910e3', + create_vulnerability_feedback_issue_path: '/gitlab-org/gitlab-ui/vulnerability_feedback', + create_vulnerability_feedback_merge_request_path: + '/gitlab-org/gitlab-ui/vulnerability_feedback', + create_vulnerability_feedback_dismissal_path: '/gitlab-org/gitlab-ui/vulnerability_feedback', + project: { + id: 7071551, + name: 'gitlab-ui', + full_path: '/gitlab-org/gitlab-ui', + full_name: 'GitLab.org / gitlab-ui', + }, + dismissal_feedback: { + id: 4197, + created_at: '2019-11-14T11:03:18.472Z', + project_id: 7071551, + author: { + id: 181229, + name: "Lukas 'Eipi' Eipert", + username: 'leipert', + state: 'active', + avatar_url: + 'https://secure.gravatar.com/avatar/19a1f1260fa70323f35bc508927921a2?s=80\u0026d=identicon', + web_url: 'https://gitlab.com/leipert', + status_tooltip_html: null, + path: '/leipert', + }, + comment_details: { + comment: + 'This is a false positive, as it just part of some documentation assets of sass-true.', + comment_timestamp: '2019-11-14T11:03:18.464Z', + comment_author: { + id: 181229, + name: "Lukas 'Eipi' Eipert", + username: 'leipert', + state: 'active', + avatar_url: + 'https://secure.gravatar.com/avatar/19a1f1260fa70323f35bc508927921a2?s=80\u0026d=identicon', + web_url: 'https://gitlab.com/leipert', + status_tooltip_html: null, + path: '/leipert', + }, + }, + destroy_vulnerability_feedback_dismissal_path: + '/gitlab-org/gitlab-ui/vulnerability_feedback/4197', + category: 'dependency_scanning', + feedback_type: 'dismissal', + branch: null, + project_fingerprint: 'aeb4b2442d92d0ccf7023f0c220bda8b4ba910e3', + }, + issue_feedback: null, + merge_request_feedback: null, + description: null, + links: [ + { url: 'https://blog.jquery.com/2019/04/10/jquery-3-4-0-released/' }, + { url: 'https://nvd.nist.gov/vuln/detail/CVE-2019-11358' }, + { url: 'https://github.com/jquery/jquery/commit/753d591aea698e57d6db58c9f722cd0808619b1b' }, + ], + location: { + file: 'node_modules/sass-true/docs/assets/webpack/common.min.js', + dependency: { package: { name: 'jquery' }, version: '3.3.1' }, + }, + remediations: [null], + solution: null, + state: 'dismissed', + blob_path: + '/gitlab-org/gitlab-ui/blob/ad137f0a8ac59af961afe47d04e5cc062c6864a9/node_modules/sass-true/docs/assets/webpack/common.min.js', + }, + { + id: null, + report_type: 'dependency_scanning', + name: + 'jQuery before 3.4.0, as used in Drupal, Backdrop CMS, and other products, mishandles jQuery.extend(true, {}, ...) because of Object.prototype pollution in jquery', + severity: 'low', + scanner: { external_id: 'retire.js', name: 'Retire.js' }, + identifiers: [ + { + external_type: 'cve', + external_id: 'CVE-2019-11358', + name: 'CVE-2019-11358', + url: 'https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-11358', + }, + ], + project_fingerprint: 'eb86aa13eb9d897a083ead6e134aa78aa9cadd52', + create_vulnerability_feedback_issue_path: '/gitlab-org/gitlab-ui/vulnerability_feedback', + create_vulnerability_feedback_merge_request_path: + '/gitlab-org/gitlab-ui/vulnerability_feedback', + create_vulnerability_feedback_dismissal_path: '/gitlab-org/gitlab-ui/vulnerability_feedback', + project: { + id: 7071551, + name: 'gitlab-ui', + full_path: '/gitlab-org/gitlab-ui', + full_name: 'GitLab.org / gitlab-ui', + }, + dismissal_feedback: { + id: 2527, + created_at: '2019-08-26T12:29:43.624Z', + project_id: 7071551, + author: { + id: 181229, + name: "Lukas 'Eipi' Eipert", + username: 'leipert', + state: 'active', + avatar_url: + 'https://secure.gravatar.com/avatar/19a1f1260fa70323f35bc508927921a2?s=80\u0026d=identicon', + web_url: 'https://gitlab.com/leipert', + status_tooltip_html: null, + path: '/leipert', + }, + comment_details: { + comment: 'This particular jQuery version appears in a test path of tinycolor2.', + comment_timestamp: '2019-08-26T12:30:14.840Z', + comment_author: { + id: 181229, + name: "Lukas 'Eipi' Eipert", + username: 'leipert', + state: 'active', + avatar_url: + 'https://secure.gravatar.com/avatar/19a1f1260fa70323f35bc508927921a2?s=80\u0026d=identicon', + web_url: 'https://gitlab.com/leipert', + status_tooltip_html: null, + path: '/leipert', + }, + }, + pipeline: { id: 78375355, path: '/gitlab-org/gitlab-ui/pipelines/78375355' }, + destroy_vulnerability_feedback_dismissal_path: + '/gitlab-org/gitlab-ui/vulnerability_feedback/2527', + category: 'dependency_scanning', + feedback_type: 'dismissal', + branch: 'leipert-dogfood-secure', + project_fingerprint: 'eb86aa13eb9d897a083ead6e134aa78aa9cadd52', + }, + issue_feedback: null, + merge_request_feedback: null, + description: null, + links: [ + { url: 'https://blog.jquery.com/2019/04/10/jquery-3-4-0-released/' }, + { url: 'https://nvd.nist.gov/vuln/detail/CVE-2019-11358' }, + { url: 'https://github.com/jquery/jquery/commit/753d591aea698e57d6db58c9f722cd0808619b1b' }, + ], + location: { + file: 'node_modules/tinycolor2/demo/jquery-1.9.1.js', + dependency: { package: { name: 'jquery' }, version: '1.9.1' }, + }, + remediations: [null], + solution: null, + state: 'dismissed', + blob_path: + '/gitlab-org/gitlab-ui/blob/ad137f0a8ac59af961afe47d04e5cc062c6864a9/node_modules/tinycolor2/demo/jquery-1.9.1.js', + }, +]; + +export const sastDiffSuccessMock = { + added: [mockFindings[0]], + fixed: [mockFindings[1], mockFindings[2]], + existing: [mockFindings[3]], + base_report_created_at: '2020-01-01T10:00:00.000Z', + base_report_out_of_date: false, + head_report_created_at: '2020-01-10T10:00:00.000Z', +}; + +export const secretScanningDiffSuccessMock = { + added: [mockFindings[0], mockFindings[1]], + fixed: [mockFindings[2]], + base_report_created_at: '2020-01-01T10:00:00.000Z', + base_report_out_of_date: false, + head_report_created_at: '2020-01-10T10:00:00.000Z', +}; + +export const securityReportDownloadPathsQueryResponse = { + project: { + mergeRequest: { + headPipeline: { + id: 'gid://gitlab/Ci::Pipeline/176', + jobs: { + nodes: [ + { + 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', + }, + { + 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', + }, + { + 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', + }, + ], + __typename: 'CiJobConnection', + }, + __typename: 'Pipeline', + }, + __typename: 'MergeRequest', + }, + __typename: 'Project', + }, +}; + +/** + * These correspond to SAST jobs in the securityReportDownloadPathsQueryResponse above. + */ +export const sastArtifacts = [ + { + name: 'bandit-sast', + reportType: REPORT_TYPE_SAST, + path: '/gitlab-org/secrets-detection-test/-/jobs/1400/artifacts/download?file_type=sast', + }, + { + name: 'eslint-sast', + reportType: REPORT_TYPE_SAST, + path: '/gitlab-org/secrets-detection-test/-/jobs/1401/artifacts/download?file_type=sast', + }, +]; + +/** + * These correspond to Secret Detection jobs in the securityReportDownloadPathsQueryResponse above. + */ +export const secretDetectionArtifacts = [ + { + name: 'secret_detection', + reportType: REPORT_TYPE_SECRET_DETECTION, + path: + '/gitlab-org/secrets-detection-test/-/jobs/1399/artifacts/download?file_type=secret_detection', + }, +]; + +export const expectedDownloadDropdownProps = { + loading: false, + artifacts: [...secretDetectionArtifacts, ...sastArtifacts], +}; 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 index ab87d80b291..c440081a0c4 100644 --- a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js +++ b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js @@ -1,162 +1,465 @@ -import { mount } from '@vue/test-utils'; +import { mount, createLocalVue } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { merge } from 'lodash'; +import VueApollo from 'vue-apollo'; +import Vuex from 'vuex'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import { trimText } from 'helpers/text_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { + expectedDownloadDropdownProps, + securityReportDownloadPathsQueryResponse, + sastDiffSuccessMock, + secretScanningDiffSuccessMock, +} from 'jest/vue_shared/security_reports/mock_data'; import Api from '~/api'; -import Flash from '~/flash'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { + REPORT_TYPE_SAST, + REPORT_TYPE_SECRET_DETECTION, +} from '~/vue_shared/security_reports/constants'; +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 SecurityReportsApp from '~/vue_shared/security_reports/security_reports_app.vue'; +import securityReportDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_download_paths.query.graphql'; jest.mock('~/flash'); +const localVue = createLocalVue(); +localVue.use(Vuex); + +const SAST_COMPARISON_PATH = '/sast.json'; +const SECRET_SCANNING_COMPARISON_PATH = '/secret_detection.json'; + describe('Security reports app', () => { let wrapper; - let mrTabsMock; const props = { pipelineId: 123, projectId: 456, securityReportsDocsPath: '/docs', + discoverProjectSecurityPath: '/discoverProjectSecurityPath', }; - const createComponent = () => { - wrapper = mount(SecurityReportsApp, { - propsData: { ...props }, - }); + const createComponent = options => { + wrapper = mount( + SecurityReportsApp, + merge( + { + localVue, + propsData: { ...props }, + stubs: { + HelpIcon: true, + }, + }, + options, + ), + ); + }; + + const pendingHandler = () => new Promise(() => {}); + const successHandler = () => Promise.resolve({ data: securityReportDownloadPathsQueryResponse }); + const failureHandler = () => Promise.resolve({ errors: [{ message: 'some error' }] }); + const createMockApolloProvider = handler => { + localVue.use(VueApollo); + + const requestHandlers = [[securityReportDownloadPathsQuery, handler]]; + + return createMockApollo(requestHandlers); }; const anyParams = expect.any(Object); + const findDownloadDropdown = () => wrapper.find(SecurityReportDownloadDropdown); const findPipelinesTabAnchor = () => wrapper.find('[data-testid="show-pipelines"]'); - const findHelpLink = () => wrapper.find('[data-testid="help"]'); - const setupMrTabsMock = () => { - mrTabsMock = { tabShown: jest.fn() }; - window.mrTabs = mrTabsMock; - }; + const findHelpIconComponent = () => wrapper.find(HelpIcon); const setupMockJobArtifact = reportType => { jest .spyOn(Api, 'pipelineJobs') .mockResolvedValue({ data: [{ artifacts: [{ file_type: reportType }] }] }); }; + const expectPipelinesTabAnchor = () => { + const mrTabsMock = { tabShown: jest.fn() }; + window.mrTabs = mrTabsMock; + findPipelinesTabAnchor().trigger('click'); + expect(mrTabsMock.tabShown.mock.calls).toEqual([['pipelines']]); + }; afterEach(() => { wrapper.destroy(); delete window.mrTabs; }); - describe.each(SecurityReportsApp.reportTypes)('given a report type %p', reportType => { - beforeEach(() => { - window.mrTabs = { tabShown: jest.fn() }; - setupMockJobArtifact(reportType); - createComponent(); - return wrapper.vm.$nextTick(); - }); + describe.each([false, true])( + 'given the coreSecurityMrWidgetCounts feature flag is %p', + coreSecurityMrWidgetCounts => { + const createComponentWithFlag = options => + createComponent( + merge( + { + provide: { + glFeatures: { + coreSecurityMrWidgetCounts, + }, + }, + }, + options, + ), + ); - it('calls the pipelineJobs API correctly', () => { - expect(Api.pipelineJobs).toHaveBeenCalledTimes(1); - expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams); - }); + describe.each(SecurityReportsApp.reportTypes)('given a report type %p', reportType => { + beforeEach(() => { + window.mrTabs = { tabShown: jest.fn() }; + setupMockJobArtifact(reportType); + createComponentWithFlag(); + return wrapper.vm.$nextTick(); + }); - it('renders the expected message', () => { - expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun); - }); + it('calls the pipelineJobs API correctly', () => { + expect(Api.pipelineJobs).toHaveBeenCalledTimes(1); + expect(Api.pipelineJobs).toHaveBeenCalledWith( + props.projectId, + props.pipelineId, + anyParams, + ); + }); - describe('clicking the anchor to the pipelines tab', () => { - beforeEach(() => { - setupMrTabsMock(); - findPipelinesTabAnchor().trigger('click'); + it('renders the expected message', () => { + expect(wrapper.text()).toMatchInterpolatedText( + SecurityReportsApp.i18n.scansHaveRunWithDownloadGuidance, + ); + }); + + describe('clicking the anchor to the pipelines tab', () => { + it('calls the mrTabs.tabShown global', () => { + expectPipelinesTabAnchor(); + }); + }); + + it('renders a help link', () => { + expect(findHelpIconComponent().props()).toEqual({ + helpPath: props.securityReportsDocsPath, + discoverProjectSecurityPath: props.discoverProjectSecurityPath, + }); + }); + }); + + describe('given a report type "foo"', () => { + beforeEach(() => { + setupMockJobArtifact('foo'); + createComponentWithFlag(); + return wrapper.vm.$nextTick(); + }); + + it('calls the pipelineJobs API correctly', () => { + expect(Api.pipelineJobs).toHaveBeenCalledTimes(1); + expect(Api.pipelineJobs).toHaveBeenCalledWith( + props.projectId, + props.pipelineId, + anyParams, + ); + }); + + it('renders nothing', () => { + expect(wrapper.html()).toBe(''); + }); }); - it('calls the mrTabs.tabShown global', () => { - expect(mrTabsMock.tabShown.mock.calls).toEqual([['pipelines']]); + describe('security artifacts on last page of multi-page response', () => { + const numPages = 3; + + beforeEach(() => { + jest + .spyOn(Api, 'pipelineJobs') + .mockImplementation(async (projectId, pipelineId, { page }) => { + const requestedPage = parseInt(page, 10); + if (requestedPage < numPages) { + return { + // Some jobs with no relevant artifacts + data: [{}, {}], + headers: { 'x-next-page': String(requestedPage + 1) }, + }; + } else if (requestedPage === numPages) { + return { + data: [{ artifacts: [{ file_type: SecurityReportsApp.reportTypes[0] }] }], + }; + } + + throw new Error('Test failed due to request of non-existent jobs page'); + }); + + createComponentWithFlag(); + return wrapper.vm.$nextTick(); + }); + + it('fetches all pages', () => { + expect(Api.pipelineJobs).toHaveBeenCalledTimes(numPages); + }); + + it('renders the expected message', () => { + expect(wrapper.text()).toMatchInterpolatedText( + SecurityReportsApp.i18n.scansHaveRunWithDownloadGuidance, + ); + }); }); - }); - it('renders a help link', () => { - expect(findHelpLink().attributes()).toMatchObject({ - href: props.securityReportsDocsPath, + describe('given an error from the API', () => { + let error; + + beforeEach(() => { + error = new Error('an error'); + jest.spyOn(Api, 'pipelineJobs').mockRejectedValue(error); + createComponentWithFlag(); + return wrapper.vm.$nextTick(); + }); + + it('calls the pipelineJobs API correctly', () => { + expect(Api.pipelineJobs).toHaveBeenCalledTimes(1); + expect(Api.pipelineJobs).toHaveBeenCalledWith( + props.projectId, + props.pipelineId, + anyParams, + ); + }); + + it('renders nothing', () => { + expect(wrapper.html()).toBe(''); + }); + + it('calls createFlash correctly', () => { + expect(createFlash.mock.calls).toEqual([ + [ + { + message: SecurityReportsApp.i18n.apiError, + captureError: true, + error, + }, + ], + ]); + }); }); - }); - }); + }, + ); + + describe('given the coreSecurityMrWidgetCounts feature flag is enabled', () => { + let mock; + + const createComponentWithFlagEnabled = options => + createComponent( + merge(options, { + provide: { + glFeatures: { + coreSecurityMrWidgetCounts: true, + }, + }, + }), + ); - describe('given a report type "foo"', () => { beforeEach(() => { - setupMockJobArtifact('foo'); - createComponent(); - return wrapper.vm.$nextTick(); + mock = new MockAdapter(axios); }); - it('calls the pipelineJobs API correctly', () => { - expect(Api.pipelineJobs).toHaveBeenCalledTimes(1); - expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams); + afterEach(() => { + mock.restore(); }); - it('renders nothing', () => { - expect(wrapper.html()).toBe(''); - }); + const SAST_SUCCESS_MESSAGE = + 'Security scanning detected 1 potential vulnerability 1 Critical 0 High and 0 Others'; + const SECRET_SCANNING_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} | ${'secretScanningComparisonPath'} | ${SECRET_SCANNING_COMPARISON_PATH} | ${secretScanningDiffSuccessMock} | ${SECRET_SCANNING_SUCCESS_MESSAGE} + `( + 'given a $pathProp and $reportType artifact', + ({ reportType, pathProp, path, successResponse, successMessage }) => { + beforeEach(() => { + setupMockJobArtifact(reportType); + }); + + describe('when loading', () => { + beforeEach(() => { + mock = new MockAdapter(axios, { delayResponse: 1 }); + mock.onGet(path).replyOnce(200, successResponse); + + createComponentWithFlagEnabled({ + propsData: { + [pathProp]: path, + }, + }); + + return waitForPromises(); + }); + + it('should have loading message', () => { + expect(wrapper.text()).toBe('Security scanning is loading'); + }); + + it('should not render the pipeline tab anchor', () => { + expect(findPipelinesTabAnchor().exists()).toBe(false); + }); + }); + + describe('when successfully loaded', () => { + beforeEach(() => { + mock.onGet(path).replyOnce(200, successResponse); + + createComponentWithFlagEnabled({ + propsData: { + [pathProp]: path, + }, + }); + + return waitForPromises(); + }); + + it('should show counts', () => { + expect(trimText(wrapper.text())).toContain(successMessage); + }); + + it('should render the pipeline tab anchor', () => { + expectPipelinesTabAnchor(); + }); + }); + + describe('when an error occurs', () => { + beforeEach(() => { + mock.onGet(path).replyOnce(500); + + createComponentWithFlagEnabled({ + propsData: { + [pathProp]: path, + }, + }); + + return waitForPromises(); + }); + + it('should show error message', () => { + expect(trimText(wrapper.text())).toContain('Loading resulted in an error'); + }); + + it('should render the pipeline tab anchor', () => { + expectPipelinesTabAnchor(); + }); + }); + }, + ); }); - describe('security artifacts on last page of multi-page response', () => { - const numPages = 3; + describe('given coreSecurityMrWidgetDownloads feature flag is enabled', () => { + const createComponentWithFlagEnabled = options => + createComponent( + merge(options, { + provide: { + glFeatures: { + coreSecurityMrWidgetDownloads: true, + }, + }, + }), + ); - beforeEach(() => { - jest - .spyOn(Api, 'pipelineJobs') - .mockImplementation(async (projectId, pipelineId, { page }) => { - const requestedPage = parseInt(page, 10); - if (requestedPage < numPages) { - return { - // Some jobs with no relevant artifacts - data: [{}, {}], - headers: { 'x-next-page': String(requestedPage + 1) }, - }; - } else if (requestedPage === numPages) { - return { - data: [{ artifacts: [{ file_type: SecurityReportsApp.reportTypes[0] }] }], - }; - } - - throw new Error('Test failed due to request of non-existent jobs page'); - }); - - createComponent(); - return wrapper.vm.$nextTick(); + describe('given the query is loading', () => { + beforeEach(() => { + createComponentWithFlagEnabled({ + apolloProvider: createMockApolloProvider(pendingHandler), + }); + }); + + // TODO: Remove this assertion as part of + // https://gitlab.com/gitlab-org/gitlab/-/issues/273431 + it('initially renders nothing', () => { + expect(wrapper.isEmpty()).toBe(true); + }); }); - it('fetches all pages', () => { - expect(Api.pipelineJobs).toHaveBeenCalledTimes(numPages); + describe('given the query loads successfully', () => { + beforeEach(() => { + createComponentWithFlagEnabled({ + apolloProvider: createMockApolloProvider(successHandler), + }); + }); + + it('renders the download dropdown', () => { + expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownProps); + }); + + it('renders the expected message', () => { + const text = wrapper.text(); + expect(text).not.toContain(SecurityReportsApp.i18n.scansHaveRunWithDownloadGuidance); + expect(text).toContain(SecurityReportsApp.i18n.scansHaveRun); + }); + + it('should not render the pipeline tab anchor', () => { + expect(findPipelinesTabAnchor().exists()).toBe(false); + }); }); - it('renders the expected message', () => { - expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun); + describe('given the query fails', () => { + beforeEach(() => { + createComponentWithFlagEnabled({ + apolloProvider: createMockApolloProvider(failureHandler), + }); + }); + + it('calls createFlash correctly', () => { + expect(createFlash).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.isEmpty()).toBe(true); + }); }); }); - describe('given an error from the API', () => { - let error; + describe('given coreSecurityMrWidgetCounts and coreSecurityMrWidgetDownloads feature flags are enabled', () => { + let mock; beforeEach(() => { - error = new Error('an error'); - jest.spyOn(Api, 'pipelineJobs').mockRejectedValue(error); - createComponent(); - return wrapper.vm.$nextTick(); + mock = new MockAdapter(axios); + mock.onGet(SAST_COMPARISON_PATH).replyOnce(200, sastDiffSuccessMock); + mock.onGet(SECRET_SCANNING_COMPARISON_PATH).replyOnce(200, secretScanningDiffSuccessMock); + createComponent({ + propsData: { + sastComparisonPath: SAST_COMPARISON_PATH, + secretScanningComparisonPath: SECRET_SCANNING_COMPARISON_PATH, + }, + provide: { + glFeatures: { + coreSecurityMrWidgetCounts: true, + coreSecurityMrWidgetDownloads: true, + }, + }, + apolloProvider: createMockApolloProvider(successHandler), + }); + + return waitForPromises(); }); - it('calls the pipelineJobs API correctly', () => { - expect(Api.pipelineJobs).toHaveBeenCalledTimes(1); - expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams); + afterEach(() => { + mock.restore(); }); - it('renders nothing', () => { - expect(wrapper.html()).toBe(''); + it('renders the download dropdown', () => { + expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownProps); }); - it('calls Flash correctly', () => { - expect(Flash.mock.calls).toEqual([ - [ - { - message: SecurityReportsApp.i18n.apiError, - captureError: true, - error, - }, - ], - ]); + it('renders the expected counts message', () => { + expect(trimText(wrapper.text())).toContain( + 'Security scanning detected 3 potential vulnerabilities 2 Critical 1 High and 0 Others', + ); + }); + + it('should not render the pipeline tab anchor', () => { + expect(findPipelinesTabAnchor().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/vue_shared/security_reports/store/getters_spec.js b/spec/frontend/vue_shared/security_reports/store/getters_spec.js new file mode 100644 index 00000000000..8de704be455 --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/store/getters_spec.js @@ -0,0 +1,182 @@ +import createState from '~/vue_shared/security_reports/store/state'; +import createSastState from '~/vue_shared/security_reports/store/modules/sast/state'; +import createSecretScanningState from '~/vue_shared/security_reports/store/modules/secret_detection/state'; +import { groupedTextBuilder } from '~/vue_shared/security_reports/store/utils'; +import { + groupedSummaryText, + allReportsHaveError, + areReportsLoading, + anyReportHasError, + areAllReportsLoading, + anyReportHasIssues, + summaryCounts, +} from '~/vue_shared/security_reports/store/getters'; +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 = createSecretScanningState(); + }); + + 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/utils_spec.js b/spec/frontend/vue_shared/security_reports/utils_spec.js new file mode 100644 index 00000000000..ea54644796a --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/utils_spec.js @@ -0,0 +1,28 @@ +import { extractSecurityReportArtifacts } from '~/vue_shared/security_reports/utils'; +import { + REPORT_TYPE_SAST, + REPORT_TYPE_SECRET_DETECTION, +} from '~/vue_shared/security_reports/constants'; +import { + securityReportDownloadPathsQueryResponse, + sastArtifacts, + secretDetectionArtifacts, +} from './mock_data'; + +describe('extractSecurityReportArtifacts', () => { + it.each` + reportTypes | expectedArtifacts + ${[]} | ${[]} + ${['foo']} | ${[]} + ${[REPORT_TYPE_SAST]} | ${sastArtifacts} + ${[REPORT_TYPE_SECRET_DETECTION]} | ${secretDetectionArtifacts} + ${[REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION]} | ${[...secretDetectionArtifacts, ...sastArtifacts]} + `( + 'returns the expected artifacts given report types $reportTypes', + ({ reportTypes, expectedArtifacts }) => { + expect( + extractSecurityReportArtifacts(reportTypes, securityReportDownloadPathsQueryResponse), + ).toEqual(expectedArtifacts); + }, + ); +}); diff --git a/spec/frontend/whats_new/components/app_spec.js b/spec/frontend/whats_new/components/app_spec.js index cba550b19db..7a9340da87a 100644 --- a/spec/frontend/whats_new/components/app_spec.js +++ b/spec/frontend/whats_new/components/app_spec.js @@ -1,6 +1,6 @@ import { createLocalVue, mount } from '@vue/test-utils'; import Vuex from 'vuex'; -import { GlDrawer, GlInfiniteScroll } from '@gitlab/ui'; +import { GlDrawer, GlInfiniteScroll, GlTabs } from '@gitlab/ui'; import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import App from '~/whats_new/components/app.vue'; @@ -16,12 +16,18 @@ const localVue = createLocalVue(); localVue.use(Vuex); describe('App', () => { - const propsData = { storageKey: 'storage-key' }; let wrapper; let store; let actions; let state; let trackingSpy; + let gitlabDotCom = true; + + const buildProps = () => ({ + storageKey: 'storage-key', + versions: ['3.11', '3.10'], + gitlabDotCom, + }); const buildWrapper = () => { actions = { @@ -45,7 +51,7 @@ describe('App', () => { wrapper = mount(App, { localVue, store, - propsData, + propsData: buildProps(), directives: { GlResizeObserver: createMockDirective(), }, @@ -53,112 +59,171 @@ describe('App', () => { }; const findInfiniteScroll = () => wrapper.find(GlInfiniteScroll); - const emitBottomReached = () => findInfiniteScroll().vm.$emit('bottomReached'); - beforeEach(async () => { + const setup = async () => { document.body.dataset.page = 'test-page'; document.body.dataset.namespaceId = 'namespace-840'; trackingSpy = mockTracking('_category_', null, jest.spyOn); buildWrapper(); - wrapper.vm.$store.state.features = [{ title: 'Whats New Drawer', url: 'www.url.com' }]; + wrapper.vm.$store.state.features = [ + { title: 'Whats New Drawer', url: 'www.url.com', release: 3.11 }, + ]; wrapper.vm.$store.state.drawerBodyHeight = MOCK_DRAWER_BODY_HEIGHT; await wrapper.vm.$nextTick(); - }); + }; afterEach(() => { wrapper.destroy(); unmockTracking(); }); - const getDrawer = () => wrapper.find(GlDrawer); + describe('gitlab.com', () => { + beforeEach(() => { + setup(); + }); - it('contains a drawer', () => { - expect(getDrawer().exists()).toBe(true); - }); + const getDrawer = () => wrapper.find(GlDrawer); - it('dispatches openDrawer and tracking calls when mounted', () => { - expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'storage-key'); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', { - label: 'namespace_id', - value: 'namespace-840', + it('contains a drawer', () => { + expect(getDrawer().exists()).toBe(true); }); - }); - it('dispatches closeDrawer when clicking close', () => { - getDrawer().vm.$emit('close'); - expect(actions.closeDrawer).toHaveBeenCalled(); - }); + it('dispatches openDrawer and tracking calls when mounted', () => { + expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'storage-key'); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', { + label: 'namespace_id', + value: 'namespace-840', + }); + }); - it.each([true, false])('passes open property', async openState => { - wrapper.vm.$store.state.open = openState; + it('dispatches closeDrawer when clicking close', () => { + getDrawer().vm.$emit('close'); + expect(actions.closeDrawer).toHaveBeenCalled(); + }); - await wrapper.vm.$nextTick(); + it.each([true, false])('passes open property', async openState => { + wrapper.vm.$store.state.open = openState; - expect(getDrawer().props('open')).toBe(openState); - }); + await wrapper.vm.$nextTick(); - it('renders features when provided via ajax', () => { - expect(actions.fetchItems).toHaveBeenCalled(); - expect(wrapper.find('h5').text()).toBe('Whats New Drawer'); - }); + expect(getDrawer().props('open')).toBe(openState); + }); - it('send an event when feature item is clicked', () => { - trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); + it('renders features when provided via ajax', () => { + expect(actions.fetchItems).toHaveBeenCalled(); + expect(wrapper.find('[data-test-id="feature-title"]').text()).toBe('Whats New Drawer'); + }); - const link = wrapper.find('.whats-new-item-title-link'); - triggerEvent(link.element); + it('send an event when feature item is clicked', () => { + trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); - expect(trackingSpy.mock.calls[1]).toMatchObject([ - '_category_', - 'click_whats_new_item', - { - label: 'Whats New Drawer', - property: 'www.url.com', - }, - ]); - }); + const link = wrapper.find('.whats-new-item-title-link'); + triggerEvent(link.element); + + expect(trackingSpy.mock.calls[1]).toMatchObject([ + '_category_', + 'click_whats_new_item', + { + label: 'Whats New Drawer', + property: 'www.url.com', + }, + ]); + }); + + it('renders infinite scroll', () => { + const scroll = findInfiniteScroll(); + + expect(scroll.props()).toMatchObject({ + fetchedItems: wrapper.vm.$store.state.features.length, + maxListHeight: MOCK_DRAWER_BODY_HEIGHT, + }); + }); + + describe('bottomReached', () => { + const emitBottomReached = () => findInfiniteScroll().vm.$emit('bottomReached'); - it('renders infinite scroll', () => { - const scroll = findInfiniteScroll(); + beforeEach(() => { + actions.fetchItems.mockClear(); + }); - expect(scroll.props()).toMatchObject({ - fetchedItems: wrapper.vm.$store.state.features.length, - maxListHeight: MOCK_DRAWER_BODY_HEIGHT, + it('when nextPage exists it calls fetchItems', () => { + wrapper.vm.$store.state.pageInfo = { nextPage: 840 }; + emitBottomReached(); + + expect(actions.fetchItems).toHaveBeenCalledWith(expect.anything(), { page: 840 }); + }); + + it('when nextPage does not exist it does not call fetchItems', () => { + wrapper.vm.$store.state.pageInfo = { nextPage: null }; + emitBottomReached(); + + expect(actions.fetchItems).not.toHaveBeenCalled(); + }); + }); + + it('calls getDrawerBodyHeight and setDrawerBodyHeight when resize directive is triggered', () => { + const { value } = getBinding(getDrawer().element, 'gl-resize-observer'); + + value(); + + expect(getDrawerBodyHeight).toHaveBeenCalledWith(wrapper.find(GlDrawer).element); + + expect(actions.setDrawerBodyHeight).toHaveBeenCalledWith( + expect.any(Object), + MOCK_DRAWER_BODY_HEIGHT, + ); }); }); - describe('bottomReached', () => { + describe('self managed', () => { + const findTabs = () => wrapper.find(GlTabs); + + const clickSecondTab = async () => { + const secondTab = wrapper.findAll('.nav-link').at(1); + await secondTab.trigger('click'); + await new Promise(resolve => requestAnimationFrame(resolve)); + }; + beforeEach(() => { - actions.fetchItems.mockClear(); + gitlabDotCom = false; + setup(); }); - it('when nextPage exists it calls fetchItems', () => { - wrapper.vm.$store.state.pageInfo = { nextPage: 840 }; - emitBottomReached(); + it('renders tabs with drawer body height and content', () => { + const scroll = findInfiniteScroll(); + const tabs = findTabs(); - expect(actions.fetchItems).toHaveBeenCalledWith(expect.anything(), 840); + expect(scroll.exists()).toBe(false); + expect(tabs.attributes().style).toBe(`height: ${MOCK_DRAWER_BODY_HEIGHT}px;`); + expect(wrapper.find('h5').text()).toBe('Whats New Drawer'); }); - it('when nextPage does not exist it does not call fetchItems', () => { - wrapper.vm.$store.state.pageInfo = { nextPage: null }; - emitBottomReached(); + describe('fetchVersion', () => { + beforeEach(() => { + actions.fetchItems.mockClear(); + }); - expect(actions.fetchItems).not.toHaveBeenCalled(); - }); - }); + it('when version isnt fetched, clicking a tab calls fetchItems', async () => { + const fetchVersionSpy = jest.spyOn(wrapper.vm, 'fetchVersion'); + await clickSecondTab(); - it('calls getDrawerBodyHeight and setDrawerBodyHeight when resize directive is triggered', () => { - const { value } = getBinding(getDrawer().element, 'gl-resize-observer'); + expect(fetchVersionSpy).toHaveBeenCalledWith('3.10'); + expect(actions.fetchItems).toHaveBeenCalledWith(expect.anything(), { version: '3.10' }); + }); - value(); + it('when version has been fetched, clicking a tab calls fetchItems', async () => { + wrapper.vm.$store.state.features.push({ title: 'GitLab Stories', release: 3.1 }); + await wrapper.vm.$nextTick(); - expect(getDrawerBodyHeight).toHaveBeenCalledWith(wrapper.find(GlDrawer).element); + const fetchVersionSpy = jest.spyOn(wrapper.vm, 'fetchVersion'); + await clickSecondTab(); - expect(actions.setDrawerBodyHeight).toHaveBeenCalledWith( - expect.any(Object), - MOCK_DRAWER_BODY_HEIGHT, - ); + expect(fetchVersionSpy).toHaveBeenCalledWith('3.10'); + expect(actions.fetchItems).not.toHaveBeenCalled(); + expect(wrapper.find('.tab-pane.active h5').text()).toBe('GitLab Stories'); + }); + }); }); }); diff --git a/spec/frontend/whats_new/store/actions_spec.js b/spec/frontend/whats_new/store/actions_spec.js index 12722b1b3b1..82f17a2726f 100644 --- a/spec/frontend/whats_new/store/actions_spec.js +++ b/spec/frontend/whats_new/store/actions_spec.js @@ -41,6 +41,23 @@ describe('whats new actions', () => { axiosMock.restore(); }); + it('passes arguments', () => { + axiosMock.reset(); + + axiosMock + .onGet('/-/whats_new', { params: { page: 8, version: 40 } }) + .replyOnce(200, [{ title: 'GitLab Stories' }]); + + testAction( + actions.fetchItems, + { page: 8, version: 40 }, + {}, + expect.arrayContaining([ + { type: types.ADD_FEATURES, payload: [{ title: 'GitLab Stories' }] }, + ]), + ); + }); + it('if already fetching, does not fetch', () => { testAction(actions.fetchItems, {}, { fetching: true }, []); }); diff --git a/spec/frontend/whats_new/utils/notification_spec.js b/spec/frontend/whats_new/utils/notification_spec.js new file mode 100644 index 00000000000..e3e390f4394 --- /dev/null +++ b/spec/frontend/whats_new/utils/notification_spec.js @@ -0,0 +1,55 @@ +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { setNotification, getStorageKey } from '~/whats_new/utils/notification'; + +describe('~/whats_new/utils/notification', () => { + useLocalStorageSpy(); + + let wrapper; + + const findNotificationEl = () => wrapper.querySelector('.header-help'); + const findNotificationCountEl = () => wrapper.querySelector('.js-whats-new-notification-count'); + const getAppEl = () => wrapper.querySelector('.app'); + + beforeEach(() => { + loadFixtures('static/whats_new_notification.html'); + wrapper = document.querySelector('.whats-new-notification-fixture-root'); + }); + + afterEach(() => { + wrapper.remove(); + }); + + describe('setNotification', () => { + const subject = () => setNotification(getAppEl()); + + it("when storage key doesn't exist it adds notifications class", () => { + const notificationEl = findNotificationEl(); + + expect(notificationEl.classList).not.toContain('with-notifications'); + + subject(); + + expect(findNotificationCountEl()).toExist(); + expect(notificationEl.classList).toContain('with-notifications'); + }); + + it('removes class and count element when storage key is true', () => { + const notificationEl = findNotificationEl(); + notificationEl.classList.add('with-notifications'); + localStorage.setItem('storage-key', 'false'); + + expect(findNotificationCountEl()).toExist(); + + subject(); + + expect(findNotificationCountEl()).not.toExist(); + expect(notificationEl.classList).not.toContain('with-notifications'); + }); + }); + + describe('getStorageKey', () => { + it('retrieves the storage key data attribute from the el', () => { + expect(getStorageKey(getAppEl())).toBe('storage-key'); + }); + }); +}); |