diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-03-12 19:26:10 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-03-12 19:26:10 +0300 |
commit | 6653ccc011dec86e5140a5d09ea3b2357eab6714 (patch) | |
tree | 897193f37bcd98152a0ac214f80a3c4cfe1047c5 /spec/frontend | |
parent | bff35a05aed6a31380a73c39113808fd262c2c37 (diff) |
Add latest changes from gitlab-org/gitlab@13-10-stable-eev13.10.0-rc41
Diffstat (limited to 'spec/frontend')
335 files changed, 12119 insertions, 5503 deletions
diff --git a/spec/frontend/.eslintrc.yml b/spec/frontend/.eslintrc.yml index d0e585e844a..145e6c8961a 100644 --- a/spec/frontend/.eslintrc.yml +++ b/spec/frontend/.eslintrc.yml @@ -14,7 +14,6 @@ settings: globals: getJSONFixture: false loadFixtures: false - preloadFixtures: false setFixtures: false rules: jest/expect-expect: diff --git a/spec/frontend/__helpers__/fake_date/fixtures.js b/spec/frontend/__helpers__/fake_date/fixtures.js new file mode 100644 index 00000000000..fcf9d4a9c64 --- /dev/null +++ b/spec/frontend/__helpers__/fake_date/fixtures.js @@ -0,0 +1,4 @@ +import { useFakeDate } from './jest'; + +// Also see spec/support/helpers/javascript_fixtures_helpers.rb +export const useFixturesFakeDate = () => useFakeDate(2015, 6, 3, 10); diff --git a/spec/frontend/__helpers__/fake_date/index.js b/spec/frontend/__helpers__/fake_date/index.js index 3d1b124ce79..9d00349bd26 100644 --- a/spec/frontend/__helpers__/fake_date/index.js +++ b/spec/frontend/__helpers__/fake_date/index.js @@ -1,2 +1,3 @@ export * from './fake_date'; export * from './jest'; +export * from './fixtures'; diff --git a/spec/frontend/__helpers__/vue_test_utils_helper.js b/spec/frontend/__helpers__/vue_test_utils_helper.js index ffccfb249c2..d6132ef84ac 100644 --- a/spec/frontend/__helpers__/vue_test_utils_helper.js +++ b/spec/frontend/__helpers__/vue_test_utils_helper.js @@ -45,9 +45,16 @@ export const extendedWrapper = (wrapper) => { return wrapper; } - return Object.defineProperty(wrapper, 'findByTestId', { - value(id) { - return this.find(`[data-testid="${id}"]`); + return Object.defineProperties(wrapper, { + findByTestId: { + value(id) { + return this.find(`[data-testid="${id}"]`); + }, + }, + findAllByTestId: { + value(id) { + return this.findAll(`[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 31c4ccd5dbb..d4f8e36c169 100644 --- a/spec/frontend/__helpers__/vue_test_utils_helper_spec.js +++ b/spec/frontend/__helpers__/vue_test_utils_helper_spec.js @@ -88,5 +88,22 @@ describe('Vue test utils helpers', () => { expect(mockComponent.findByTestId(testId).exists()).toBe(true); }); }); + + describe('findAllByTestId', () => { + const testId = 'a-component'; + let mockComponent; + + beforeEach(() => { + mockComponent = extendedWrapper( + shallowMount({ + template: `<div><div data-testid="${testId}"></div><div data-testid="${testId}"></div></div>`, + }), + ); + }); + + it('should find all components by test id', () => { + expect(mockComponent.findAllByTestId(testId)).toHaveLength(2); + }); + }); }); }); diff --git a/spec/frontend/__mocks__/@gitlab/ui.js b/spec/frontend/__mocks__/@gitlab/ui.js index ecd67247362..4c491a87fcb 100644 --- a/spec/frontend/__mocks__/@gitlab/ui.js +++ b/spec/frontend/__mocks__/@gitlab/ui.js @@ -39,7 +39,10 @@ jest.mock('@gitlab/ui/dist/components/base/popover/popover.js', () => ({ default: () => [], }, ...Object.fromEntries( - ['target', 'triggers', 'placement', 'boundary', 'container'].map((prop) => [prop, {}]), + ['title', 'target', 'triggers', 'placement', 'boundary', 'container'].map((prop) => [ + prop, + {}, + ]), ), }, render(h) { diff --git a/spec/frontend/access_tokens/components/projects_field_spec.js b/spec/frontend/access_tokens/components/projects_field_spec.js new file mode 100644 index 00000000000..a9e0799d114 --- /dev/null +++ b/spec/frontend/access_tokens/components/projects_field_spec.js @@ -0,0 +1,131 @@ +import { within, fireEvent } from '@testing-library/dom'; +import { mount } from '@vue/test-utils'; +import ProjectsField from '~/access_tokens/components/projects_field.vue'; +import ProjectsTokenSelector from '~/access_tokens/components/projects_token_selector.vue'; + +describe('ProjectsField', () => { + let wrapper; + + const createComponent = ({ inputAttrsValue = '' } = {}) => { + wrapper = mount(ProjectsField, { + propsData: { + inputAttrs: { + id: 'projects', + name: 'projects', + value: inputAttrsValue, + }, + }, + }); + }; + + const queryByLabelText = (text) => within(wrapper.element).queryByLabelText(text); + const queryByText = (text) => within(wrapper.element).queryByText(text); + const findAllProjectsRadio = () => queryByLabelText('All projects'); + const findSelectedProjectsRadio = () => queryByLabelText('Selected projects'); + const findProjectsTokenSelector = () => wrapper.findComponent(ProjectsTokenSelector); + const findHiddenInput = () => wrapper.find('input[type="hidden"]'); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders label and sub-label', () => { + createComponent(); + + expect(queryByText('Projects')).not.toBe(null); + expect(queryByText('Set access permissions for this token.')).not.toBe(null); + }); + + describe('when `inputAttrs.value` is empty', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders "All projects" radio as checked', () => { + expect(findAllProjectsRadio().checked).toBe(true); + }); + + it('renders "Selected projects" radio as unchecked', () => { + expect(findSelectedProjectsRadio().checked).toBe(false); + }); + + it('sets `projects-token-selector` `initialProjectIds` prop to an empty array', () => { + expect(findProjectsTokenSelector().props('initialProjectIds')).toEqual([]); + }); + }); + + describe('when `inputAttrs.value` is a comma separated list of project IDs', () => { + beforeEach(() => { + createComponent({ inputAttrsValue: '1,2' }); + }); + + it('renders "All projects" radio as unchecked', () => { + expect(findAllProjectsRadio().checked).toBe(false); + }); + + it('renders "Selected projects" radio as checked', () => { + expect(findSelectedProjectsRadio().checked).toBe(true); + }); + + it('sets `projects-token-selector` `initialProjectIds` prop to an array of project IDs', () => { + expect(findProjectsTokenSelector().props('initialProjectIds')).toEqual(['1', '2']); + }); + }); + + it('renders `projects-token-selector` component', () => { + createComponent(); + + expect(findProjectsTokenSelector().exists()).toBe(true); + }); + + it('renders hidden input with correct `name` and `id` attributes', () => { + createComponent(); + + expect(findHiddenInput().attributes()).toEqual( + expect.objectContaining({ + id: 'projects', + name: 'projects', + }), + ); + }); + + describe('when `projects-token-selector` is focused', () => { + beforeEach(() => { + createComponent(); + + findProjectsTokenSelector().vm.$emit('focus'); + }); + + it('auto selects the "Selected projects" radio', () => { + expect(findSelectedProjectsRadio().checked).toBe(true); + }); + + describe('when `projects-token-selector` is changed', () => { + beforeEach(() => { + findProjectsTokenSelector().vm.$emit('input', [ + { + id: 1, + }, + { + id: 2, + }, + ]); + }); + + it('updates the hidden input value to a comma separated list of project IDs', () => { + expect(findHiddenInput().attributes('value')).toBe('1,2'); + }); + + describe('when radio is changed back to "All projects"', () => { + beforeEach(() => { + fireEvent.click(findAllProjectsRadio()); + }); + + it('removes the hidden input value', () => { + expect(findHiddenInput().attributes('value')).toBe(''); + }); + }); + }); + }); +}); diff --git a/spec/frontend/access_tokens/components/projects_token_selector_spec.js b/spec/frontend/access_tokens/components/projects_token_selector_spec.js new file mode 100644 index 00000000000..09f52fe9a5f --- /dev/null +++ b/spec/frontend/access_tokens/components/projects_token_selector_spec.js @@ -0,0 +1,269 @@ +import { + GlAvatar, + GlAvatarLabeled, + GlIntersectionObserver, + GlToken, + GlTokenSelector, + GlLoadingIcon, +} from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import produce from 'immer'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; + +import { getJSONFixture } from 'helpers/fixtures'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import ProjectsTokenSelector from '~/access_tokens/components/projects_token_selector.vue'; +import getProjectsQuery from '~/access_tokens/graphql/queries/get_projects.query.graphql'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + +describe('ProjectsTokenSelector', () => { + const getProjectsQueryResponse = getJSONFixture( + 'graphql/projects/access_tokens/get_projects.query.graphql.json', + ); + const getProjectsQueryResponsePage2 = produce( + getProjectsQueryResponse, + (getProjectsQueryResponseDraft) => { + /* eslint-disable no-param-reassign */ + getProjectsQueryResponseDraft.data.projects.pageInfo.hasNextPage = false; + getProjectsQueryResponseDraft.data.projects.pageInfo.endCursor = null; + getProjectsQueryResponseDraft.data.projects.nodes.splice(1, 1); + getProjectsQueryResponseDraft.data.projects.nodes[0].id = 'gid://gitlab/Project/100'; + /* eslint-enable no-param-reassign */ + }, + ); + + const runDebounce = () => jest.runAllTimers(); + + const { pageInfo, nodes: projects } = getProjectsQueryResponse.data.projects; + const project1 = projects[0]; + const project2 = projects[1]; + + let wrapper; + + let resolveGetProjectsQuery; + let resolveGetInitialProjectsQuery; + const getProjectsQueryRequestHandler = jest.fn( + ({ ids }) => + new Promise((resolve) => { + if (ids) { + resolveGetInitialProjectsQuery = resolve; + } else { + resolveGetProjectsQuery = resolve; + } + }), + ); + + const createComponent = ({ + propsData = {}, + apolloProvider = createMockApollo([[getProjectsQuery, getProjectsQueryRequestHandler]]), + resolveQueries = true, + } = {}) => { + Vue.use(VueApollo); + + wrapper = extendedWrapper( + mount(ProjectsTokenSelector, { + apolloProvider, + propsData: { + selectedProjects: [], + initialProjectIds: [], + ...propsData, + }, + stubs: ['gl-intersection-observer'], + }), + ); + + runDebounce(); + + if (resolveQueries) { + resolveGetProjectsQuery(getProjectsQueryResponse); + + return waitForPromises(); + } + + return Promise.resolve(); + }; + + const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); + const findTokenSelectorInput = () => findTokenSelector().find('input[type="text"]'); + const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); + + it('renders dropdown items with project avatars', async () => { + await createComponent(); + + wrapper.findAllComponents(GlAvatarLabeled).wrappers.forEach((avatarLabeledWrapper, index) => { + const project = projects[index]; + + expect(avatarLabeledWrapper.attributes()).toEqual( + expect.objectContaining({ + 'entity-id': `${getIdFromGraphQLId(project.id)}`, + 'entity-name': project.name, + ...(project.avatarUrl && { src: project.avatarUrl }), + }), + ); + + expect(avatarLabeledWrapper.props()).toEqual( + expect.objectContaining({ + label: project.name, + subLabel: project.nameWithNamespace, + }), + ); + }); + }); + + it('renders tokens with project avatars', () => { + createComponent({ + propsData: { + selectedProjects: [{ ...project2, id: getIdFromGraphQLId(project2.id) }], + }, + }); + + const token = wrapper.findComponent(GlToken); + const avatar = token.findComponent(GlAvatar); + + expect(token.text()).toContain(project2.nameWithNamespace); + expect(avatar.attributes('src')).toBe(project2.avatarUrl); + expect(avatar.props()).toEqual( + expect.objectContaining({ + entityId: getIdFromGraphQLId(project2.id), + entityName: project2.name, + }), + ); + }); + + describe('when `enter` key is pressed', () => { + it('calls `preventDefault` so form is not submitted when user selects a project from the dropdown', () => { + createComponent(); + + const event = { + preventDefault: jest.fn(), + }; + + findTokenSelectorInput().trigger('keydown.enter', event); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + }); + + describe('when text input is typed in', () => { + const searchTerm = 'foo bar'; + + beforeEach(async () => { + await createComponent(); + + await findTokenSelectorInput().setValue(searchTerm); + runDebounce(); + }); + + it('makes GraphQL request with `search` variable set', async () => { + expect(getProjectsQueryRequestHandler).toHaveBeenLastCalledWith({ + search: searchTerm, + after: null, + first: 20, + ids: null, + }); + }); + + it('sets loading state while waiting for GraphQL request to resolve', async () => { + expect(findTokenSelector().props('loading')).toBe(true); + + resolveGetProjectsQuery(getProjectsQueryResponse); + await waitForPromises(); + + expect(findTokenSelector().props('loading')).toBe(false); + }); + }); + + describe('when there is a next page of projects and user scrolls to the bottom of the dropdown', () => { + beforeEach(async () => { + await createComponent(); + + findIntersectionObserver().vm.$emit('appear'); + }); + + it('makes GraphQL request with `after` variable set', async () => { + expect(getProjectsQueryRequestHandler).toHaveBeenLastCalledWith({ + after: pageInfo.endCursor, + first: 20, + search: '', + ids: null, + }); + }); + + it('displays loading icon while waiting for GraphQL request to resolve', async () => { + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + + resolveGetProjectsQuery(getProjectsQueryResponsePage2); + await waitForPromises(); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); + }); + }); + + describe('when there is not a next page of projects', () => { + it('does not render `GlIntersectionObserver`', async () => { + createComponent({ resolveQueries: false }); + + resolveGetProjectsQuery(getProjectsQueryResponsePage2); + await waitForPromises(); + + expect(findIntersectionObserver().exists()).toBe(false); + }); + }); + + describe('when `GlTokenSelector` emits `input` event', () => { + it('emits `input` event used by `v-model`', () => { + findTokenSelector().vm.$emit('input', project1); + + expect(wrapper.emitted('input')[0]).toEqual([project1]); + }); + }); + + describe('when `GlTokenSelector` emits `focus` event', () => { + it('emits `focus` event', () => { + const event = { fakeEvent: 'foo' }; + findTokenSelector().vm.$emit('focus', event); + + expect(wrapper.emitted('focus')[0]).toEqual([event]); + }); + }); + + describe('when `initialProjectIds` is an empty array', () => { + it('does not request initial projects', async () => { + await createComponent(); + + expect(getProjectsQueryRequestHandler).toHaveBeenCalledTimes(1); + expect(getProjectsQueryRequestHandler).toHaveBeenCalledWith( + expect.objectContaining({ + ids: null, + }), + ); + }); + }); + + describe('when `initialProjectIds` is an array of project IDs', () => { + it('requests those projects and emits `input` event with result', async () => { + await createComponent({ + propsData: { + initialProjectIds: [getIdFromGraphQLId(project1.id), getIdFromGraphQLId(project2.id)], + }, + }); + + resolveGetInitialProjectsQuery(getProjectsQueryResponse); + await waitForPromises(); + + expect(getProjectsQueryRequestHandler).toHaveBeenCalledWith({ + after: '', + first: null, + search: '', + ids: [project1.id, project2.id], + }); + expect(wrapper.emitted('input')[0][0]).toEqual([ + { ...project1, id: getIdFromGraphQLId(project1.id) }, + { ...project2, id: getIdFromGraphQLId(project2.id) }, + ]); + }); + }); +}); diff --git a/spec/frontend/access_tokens/index_spec.js b/spec/frontend/access_tokens/index_spec.js new file mode 100644 index 00000000000..e3f17e21739 --- /dev/null +++ b/spec/frontend/access_tokens/index_spec.js @@ -0,0 +1,74 @@ +import { createWrapper } from '@vue/test-utils'; +import Vue from 'vue'; + +import { initExpiresAtField, initProjectsField } from '~/access_tokens'; +import * as ExpiresAtField from '~/access_tokens/components/expires_at_field.vue'; +import * as ProjectsField from '~/access_tokens/components/projects_field.vue'; + +describe('access tokens', () => { + const FakeComponent = Vue.component('FakeComponent', { + props: { + inputAttrs: { + type: Object, + required: true, + }, + }, + render: () => null, + }); + + beforeEach(() => { + window.gon = { features: { personalAccessTokensScopedToProjects: true } }; + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + describe.each` + initFunction | mountSelector | expectedComponent + ${initExpiresAtField} | ${'js-access-tokens-expires-at'} | ${ExpiresAtField} + ${initProjectsField} | ${'js-access-tokens-projects'} | ${ProjectsField} + `('$initFunction', ({ initFunction, mountSelector, expectedComponent }) => { + describe('when mount element exists', () => { + beforeEach(() => { + const mountEl = document.createElement('div'); + mountEl.classList.add(mountSelector); + + const input = document.createElement('input'); + input.setAttribute('name', 'foo-bar'); + input.setAttribute('id', 'foo-bar'); + input.setAttribute('placeholder', 'Foo bar'); + input.setAttribute('value', '1,2'); + + mountEl.appendChild(input); + + document.body.appendChild(mountEl); + + // Mock component so we don't have to deal with mocking Apollo + // eslint-disable-next-line no-param-reassign + expectedComponent.default = FakeComponent; + }); + + it('mounts component and sets `inputAttrs` prop', async () => { + const vueInstance = await initFunction(); + + const wrapper = createWrapper(vueInstance); + const component = wrapper.findComponent(FakeComponent); + + expect(component.exists()).toBe(true); + expect(component.props('inputAttrs')).toEqual({ + name: 'foo-bar', + id: 'foo-bar', + value: '1,2', + placeholder: 'Foo bar', + }); + }); + }); + + describe('when mount element does not exist', () => { + it('returns `null`', () => { + expect(initFunction()).toBe(null); + }); + }); + }); +}); diff --git a/spec/frontend/admin/users/tabs_spec.js b/spec/frontend/admin/users/tabs_spec.js new file mode 100644 index 00000000000..39ba8618486 --- /dev/null +++ b/spec/frontend/admin/users/tabs_spec.js @@ -0,0 +1,37 @@ +import initTabs from '~/admin/users/tabs'; +import Api from '~/api'; + +jest.mock('~/api.js'); +jest.mock('~/lib/utils/common_utils'); + +describe('tabs', () => { + beforeEach(() => { + setFixtures(` + <div> + <div class="js-users-tab-item"> + <a href="#users" data-testid='users-tab'>Users</a> + </div> + <div class="js-users-tab-item"> + <a href="#cohorts" data-testid='cohorts-tab'>Cohorts</a> + </div> + </div`); + + initTabs(); + }); + + afterEach(() => {}); + + describe('tracking', () => { + it('tracks event when cohorts tab is clicked', () => { + document.querySelector('[data-testid="cohorts-tab"]').click(); + + expect(Api.trackRedisHllUserEvent).toHaveBeenCalledWith('i_analytics_cohorts'); + }); + + it('does not track an event when users tab is clicked', () => { + document.querySelector('[data-testid="users-tab"]').click(); + + expect(Api.trackRedisHllUserEvent).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/alert_management/components/alert_management_table_spec.js b/spec/frontend/alert_management/components/alert_management_table_spec.js index cea665aa50d..dece3dfbe5f 100644 --- a/spec/frontend/alert_management/components/alert_management_table_spec.js +++ b/spec/frontend/alert_management/components/alert_management_table_spec.js @@ -2,6 +2,8 @@ import { GlTable, GlAlert, GlLoadingIcon, GlDropdown, GlIcon, GlAvatar } from '@ import { mount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import mockAlerts from 'jest/vue_shared/alert_details/mocks/alerts.json'; import AlertManagementTable from '~/alert_management/components/alert_management_table.vue'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -18,19 +20,18 @@ describe('AlertManagementTable', () => { let wrapper; let mock; - const findAlertsTable = () => wrapper.find(GlTable); + const findAlertsTable = () => wrapper.findComponent(GlTable); const findAlerts = () => wrapper.findAll('table tbody tr'); - const findAlert = () => wrapper.find(GlAlert); - const findLoader = () => wrapper.find(GlLoadingIcon); - const findStatusDropdown = () => wrapper.find(GlDropdown); - const findDateFields = () => wrapper.findAll(TimeAgo); - const findSearch = () => wrapper.find(FilteredSearchBar); - const findSeverityColumnHeader = () => - wrapper.find('[data-testid="alert-management-severity-sort"]'); - const findFirstIDField = () => wrapper.findAll('[data-testid="idField"]').at(0); - const findAssignees = () => wrapper.findAll('[data-testid="assigneesField"]'); - const findSeverityFields = () => wrapper.findAll('[data-testid="severityField"]'); - const findIssueFields = () => wrapper.findAll('[data-testid="issueField"]'); + const findAlert = () => wrapper.findComponent(GlAlert); + const findLoader = () => wrapper.findComponent(GlLoadingIcon); + const findStatusDropdown = () => wrapper.findComponent(GlDropdown); + const findDateFields = () => wrapper.findAllComponents(TimeAgo); + const findSearch = () => wrapper.findComponent(FilteredSearchBar); + const findSeverityColumnHeader = () => wrapper.findByTestId('alert-management-severity-sort'); + const findFirstIDField = () => wrapper.findAllByTestId('idField').at(0); + const findAssignees = () => wrapper.findAllByTestId('assigneesField'); + const findSeverityFields = () => wrapper.findAllByTestId('severityField'); + const findIssueFields = () => wrapper.findAllByTestId('issueField'); const alertsCount = { open: 24, triggered: 20, @@ -40,29 +41,34 @@ describe('AlertManagementTable', () => { }; function mountComponent({ provide = {}, data = {}, loading = false, stubs = {} } = {}) { - wrapper = mount(AlertManagementTable, { - provide: { - ...defaultProvideValues, - alertManagementEnabled: true, - userCanEnableAlertManagement: true, - ...provide, - }, - data() { - return data; - }, - mocks: { - $apollo: { - mutate: jest.fn(), - query: jest.fn(), - queries: { - alerts: { - loading, + wrapper = extendedWrapper( + mount(AlertManagementTable, { + provide: { + ...defaultProvideValues, + alertManagementEnabled: true, + userCanEnableAlertManagement: true, + ...provide, + }, + data() { + return data; + }, + mocks: { + $apollo: { + mutate: jest.fn(), + query: jest.fn(), + queries: { + alerts: { + loading, + }, }, }, }, - }, - stubs, - }); + stubs, + directives: { + GlTooltip: createMockDirective(), + }, + }), + ); } beforeEach(() => { @@ -72,7 +78,6 @@ describe('AlertManagementTable', () => { afterEach(() => { if (wrapper) { wrapper.destroy(); - wrapper = null; } mock.restore(); }); @@ -241,9 +246,14 @@ describe('AlertManagementTable', () => { expect(findIssueFields().at(0).text()).toBe('None'); }); - it('renders a link when one exists', () => { - expect(findIssueFields().at(1).text()).toBe('#1'); - expect(findIssueFields().at(1).attributes('href')).toBe('/gitlab-org/gitlab/-/issues/1'); + it('renders a link when one exists with the issue state and title tooltip', () => { + const issueField = findIssueFields().at(1); + const tooltip = getBinding(issueField.element, 'gl-tooltip'); + + expect(issueField.text()).toBe(`#1 (closed)`); + expect(issueField.attributes('href')).toBe('/gitlab-org/gitlab/-/issues/incident/1'); + expect(issueField.attributes('title')).toBe('My test issue'); + expect(tooltip).not.toBe(undefined); }); }); diff --git a/spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap b/spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap index eb2b82a0211..1f8429af7dd 100644 --- a/spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap +++ b/spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap @@ -4,125 +4,151 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`] <form class="gl-mt-6" > - <h5 - class="gl-font-lg gl-my-5" - > - Add new integrations - </h5> - <div - class="form-group gl-form-group" - id="integration-type" - role="group" + class="tabs gl-tabs" + id="__BVID__6" > - <label - class="d-block col-form-label" - for="integration-type" - id="integration-type__BV_label_" - > - 1. Select integration type - </label> + <!----> <div - class="bv-no-focus-ring" + class="" > - <select - class="gl-form-select mw-100 custom-select" - id="__BVID__8" + <ul + class="nav gl-tabs-nav" + id="__BVID__6__BV_tab_controls_" + role="tablist" > - <option - value="" + <!----> + <li + class="nav-item" + role="presentation" > - Select integration type - </option> - <option - value="HTTP" + <a + aria-controls="__BVID__8" + aria-posinset="1" + aria-selected="true" + aria-setsize="3" + class="nav-link active gl-tab-nav-item gl-tab-nav-item-active gl-tab-nav-item-active-indigo" + href="#" + id="__BVID__8___BV_tab_button__" + role="tab" + target="_self" + > + Configure details + </a> + </li> + <li + class="nav-item" + role="presentation" > - HTTP Endpoint - </option> - <option - value="PROMETHEUS" + <a + aria-controls="__BVID__19" + aria-disabled="true" + aria-posinset="2" + aria-selected="false" + aria-setsize="3" + class="nav-link disabled disabled gl-tab-nav-item" + href="#" + id="__BVID__19___BV_tab_button__" + role="tab" + tabindex="-1" + target="_self" + > + View credentials + </a> + </li> + <li + class="nav-item" + role="presentation" > - External Prometheus - </option> - </select> - - <!----> - <!----> - <!----> - <!----> + <a + aria-controls="__BVID__41" + aria-disabled="true" + aria-posinset="3" + aria-selected="false" + aria-setsize="3" + class="nav-link disabled disabled gl-tab-nav-item" + href="#" + id="__BVID__41___BV_tab_button__" + role="tab" + tabindex="-1" + target="_self" + > + Send test alert + </a> + </li> + <!----> + </ul> </div> - </div> - - <transition-stub - class="gl-mt-3" - css="true" - enteractiveclass="collapsing" - enterclass="" - entertoclass="collapse show" - leaveactiveclass="collapsing" - leaveclass="collapse show" - leavetoclass="collapse" - > <div - class="collapse" - id="__BVID__10" - style="display: none;" + class="tab-content gl-tab-content" + id="__BVID__6__BV_tab_container_" > - <div> + <transition-stub + css="true" + enteractiveclass="" + enterclass="" + entertoclass="show" + leaveactiveclass="" + leaveclass="show" + leavetoclass="" + mode="out-in" + name="" + > <div - class="form-group gl-form-group" - id="name-integration" - role="group" + aria-hidden="false" + aria-labelledby="__BVID__8___BV_tab_button__" + class="tab-pane active" + id="__BVID__8" + role="tabpanel" + style="" > - <label - class="d-block col-form-label" - for="name-integration" - id="name-integration__BV_label_" - > - 2. Name integration - </label> <div - class="bv-no-focus-ring" + class="form-group gl-form-group" + id="integration-type" + role="group" > - <input - class="gl-form-input form-control" - id="__BVID__15" - placeholder="Enter integration name" - type="text" - /> - <!----> - <!----> - <!----> + <label + class="d-block col-form-label" + for="integration-type" + id="integration-type__BV_label_" + > + 1.Select integration type + </label> + <div + class="bv-no-focus-ring" + > + <select + class="gl-form-select gl-max-w-full custom-select" + id="__BVID__13" + > + <option + value="" + > + Select integration type + </option> + <option + value="HTTP" + > + HTTP Endpoint + </option> + <option + value="PROMETHEUS" + > + External Prometheus + </option> + </select> + + <!----> + <!----> + <!----> + <!----> + </div> </div> - </div> - - <div - class="form-group gl-form-group" - id="integration-webhook" - role="group" - > - <label - class="d-block col-form-label" - for="integration-webhook" - id="integration-webhook__BV_label_" - > - 3. Set up webhook - </label> + <div - class="bv-no-focus-ring" + class="gl-mt-3" > - <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 - class="gl-link gl-display-inline-block" - href="https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html" - rel="noopener noreferrer" - target="_blank" - > - 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" @@ -166,241 +192,333 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`] <!----> - <div - class="gl-my-4" + <!----> + </div> + + <div + class="gl-display-flex gl-justify-content-start gl-py-3" + > + <button + class="btn js-no-auto-disable btn-confirm btn-md gl-button" + data-testid="integration-form-submit" + type="submit" > + <!----> + + <!----> + <span - class="gl-font-weight-bold" + class="gl-button-text" > - Webhook URL - + Save integration + </span> + </button> + + <button + class="btn gl-ml-3 js-no-auto-disable btn-default btn-md gl-button" + type="reset" + > + <!----> + <!----> + + <span + class="gl-button-text" + > + Cancel and close + </span> + </button> + </div> + </div> + </transition-stub> + + <transition-stub + css="true" + enteractiveclass="" + enterclass="" + entertoclass="show" + leaveactiveclass="" + leaveclass="show" + leavetoclass="" + mode="out-in" + name="" + > + <div + aria-hidden="true" + aria-labelledby="__BVID__19___BV_tab_button__" + class="tab-pane disabled" + id="__BVID__19" + role="tabpanel" + style="display: none;" + > + <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 + class="gl-link gl-display-inline-block" + href="https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html" + rel="noopener noreferrer" + target="_blank" + > + GitLab documentation + </a> + to learn more about configuring your endpoint. + </span> + + <fieldset + class="form-group gl-form-group" + id="integration-webhook" + > + <!----> + <div + class="bv-no-focus-ring" + role="group" + tabindex="-1" + > <div - id="url" - readonly="readonly" + class="gl-my-4" > + <span + class="gl-font-weight-bold" + > + + Webhook URL + + </span> + <div - class="input-group" - role="group" + id="url" + readonly="readonly" > - <!----> - <!----> - - <input - class="gl-form-input form-control" - id="url" - readonly="readonly" - type="text" - /> - <div - class="input-group-append" + class="input-group" + role="group" > - <button - aria-label="Copy this value" - class="btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon" - data-clipboard-text="" - title="Copy" - type="button" + <!----> + <!----> + + <input + class="gl-form-input form-control" + id="url" + readonly="readonly" + type="text" + /> + + <div + class="input-group-append" > - <!----> - - <svg - aria-hidden="true" - class="gl-button-icon gl-icon s16" - data-testid="copy-to-clipboard-icon" + <button + aria-label="Copy this value" + class="btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon" + data-clipboard-text="" + title="Copy" + type="button" > - <use - href="#copy-to-clipboard" - /> - </svg> - - <!----> - </button> + <!----> + + <svg + aria-hidden="true" + class="gl-button-icon gl-icon s16" + data-testid="copy-to-clipboard-icon" + > + <use + href="#copy-to-clipboard" + /> + </svg> + + <!----> + </button> + </div> + <!----> </div> - <!----> </div> </div> - </div> - - <div - class="gl-my-4" - > - <span - class="gl-font-weight-bold" - > - - Authorization key - - </span> <div - class="gl-mb-3" - id="authorization-key" - readonly="readonly" + class="gl-my-4" > + <span + class="gl-font-weight-bold" + > + + Authorization key + + </span> + <div - class="input-group" - role="group" + class="gl-mb-3" + id="authorization-key" + readonly="readonly" > - <!----> - <!----> - - <input - class="gl-form-input form-control" - id="authorization-key" - readonly="readonly" - type="text" - /> - <div - class="input-group-append" + class="input-group" + role="group" > - <button - aria-label="Copy this value" - class="btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon" - data-clipboard-text="" - title="Copy" - type="button" + <!----> + <!----> + + <input + class="gl-form-input form-control" + id="authorization-key" + readonly="readonly" + type="text" + /> + + <div + class="input-group-append" > - <!----> - - <svg - aria-hidden="true" - class="gl-button-icon gl-icon s16" - data-testid="copy-to-clipboard-icon" + <button + aria-label="Copy this value" + class="btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon" + data-clipboard-text="" + title="Copy" + type="button" > - <use - href="#copy-to-clipboard" - /> - </svg> - - <!----> - </button> + <!----> + + <svg + aria-hidden="true" + class="gl-button-icon gl-icon s16" + data-testid="copy-to-clipboard-icon" + > + <use + href="#copy-to-clipboard" + /> + </svg> + + <!----> + </button> + </div> + <!----> </div> - <!----> </div> </div> - - <button - class="btn btn-default btn-md disabled gl-button" - disabled="disabled" - type="button" - > - <!----> - - <!----> - - <span - class="gl-button-text" - > - - Reset Key - - </span> - </button> - + <!----> + <!----> <!----> </div> - <!----> - <!----> - <!----> - </div> - </div> - - <div - class="form-group gl-form-group" - id="test-integration" - role="group" - > - <label - class="d-block col-form-label" - for="test-integration" - id="test-integration__BV_label_" - > - 4. Sample alert payload (optional) - </label> - <div - class="bv-no-focus-ring" + </fieldset> + + <button + class="btn btn-danger btn-md disabled gl-button" + disabled="disabled" + type="button" > - <span> - Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to test the integration (optional). - </span> + <!----> - <textarea - class="gl-form-input gl-form-textarea gl-my-3 form-control is-valid" - disabled="disabled" - id="test-payload" - placeholder="{ \\"events\\": [{ \\"application\\": \\"Name of application\\" }] }" - style="resize: none; overflow-y: scroll;" - wrap="soft" - /> <!----> + + <span + class="gl-button-text" + > + + Reset Key + + </span> + </button> + + <button + class="btn gl-ml-3 js-no-auto-disable btn-default btn-md gl-button" + type="reset" + > <!----> + <!----> - </div> + + <span + class="gl-button-text" + > + Cancel and close + </span> + </button> + + <!----> </div> - - <!----> - - <!----> - </div> + </transition-stub> - <div - class="gl-display-flex gl-justify-content-start gl-py-3" + <transition-stub + css="true" + enteractiveclass="" + enterclass="" + entertoclass="show" + leaveactiveclass="" + leaveclass="show" + leavetoclass="" + mode="out-in" + name="" > - <button - class="btn js-no-auto-disable btn-success btn-md gl-button" - data-testid="integration-form-submit" - type="submit" + <div + aria-hidden="true" + aria-labelledby="__BVID__41___BV_tab_button__" + class="tab-pane disabled" + id="__BVID__41" + role="tabpanel" + style="display: none;" > - <!----> - - <!----> - - <span - class="gl-button-text" + <fieldset + class="form-group gl-form-group" + id="test-integration" > - Save integration - - </span> - </button> - - <button - class="btn gl-mx-3 js-no-auto-disable btn-success btn-md disabled gl-button btn-success-secondary" - data-testid="integration-test-and-submit" - disabled="disabled" - type="button" - > - <!----> + <!----> + <div + class="bv-no-focus-ring" + role="group" + tabindex="-1" + > + <span> + Provide an example payload from the monitoring tool you intend to integrate with. This will allow you to send an alert to an active GitLab alerting point. + </span> + + <textarea + class="gl-form-input gl-form-textarea gl-my-3 form-control is-valid" + id="test-payload" + placeholder="{ \\"events\\": [{ \\"application\\": \\"Name of application\\" }] }" + style="resize: none; overflow-y: scroll;" + wrap="soft" + /> + <!----> + <!----> + <!----> + </div> + </fieldset> - <!----> - - <span - class="gl-button-text" + <button + class="btn js-no-auto-disable btn-confirm btn-md gl-button" + data-testid="send-test-alert" + type="button" > - Save and test payload - </span> - </button> - - <button - class="btn js-no-auto-disable btn-default btn-md gl-button" - type="reset" - > - <!----> + <!----> + + <!----> + + <span + class="gl-button-text" + > + + Send + + </span> + </button> - <!----> - - <span - class="gl-button-text" + <button + class="btn gl-ml-3 js-no-auto-disable btn-default btn-md gl-button" + type="reset" > - Cancel - </span> - </button> - </div> + <!----> + + <!----> + + <span + class="gl-button-text" + > + Cancel and close + </span> + </button> + </div> + </transition-stub> + <!----> </div> - </transition-stub> + </div> </form> `; diff --git a/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js b/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js index 7e1d1acb62c..dba9c8be669 100644 --- a/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js +++ b/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js @@ -1,10 +1,10 @@ import { GlIcon, GlFormInput, GlDropdown, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import AlertMappingBuilder, { i18n } from '~/alerts_settings/components/alert_mapping_builder.vue'; -import parsedMapping from '~/alerts_settings/components/mocks/parsedMapping.json'; import * as transformationUtils from '~/alerts_settings/utils/mapping_transformations'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; -import alertFields from '../mocks/alertFields.json'; +import alertFields from '../mocks/alert_fields.json'; +import parsedMapping from '../mocks/parsed_mapping.json'; describe('AlertMappingBuilder', () => { let wrapper; @@ -12,8 +12,8 @@ describe('AlertMappingBuilder', () => { function mountComponent() { wrapper = shallowMount(AlertMappingBuilder, { propsData: { - parsedPayload: parsedMapping.samplePayload.payloadAlerFields.nodes, - savedMapping: parsedMapping.storedMapping.nodes, + parsedPayload: parsedMapping.payloadAlerFields, + savedMapping: parsedMapping.payloadAttributeMappings, alertFields, }, }); @@ -33,6 +33,15 @@ describe('AlertMappingBuilder', () => { const findColumnInRow = (row, column) => wrapper.findAll('.gl-display-table-row').at(row).findAll('.gl-display-table-cell ').at(column); + const getDropdownContent = (dropdown, types) => { + const searchBox = dropdown.findComponent(GlSearchBoxByType); + const dropdownItems = dropdown.findAllComponents(GlDropdownItem); + const mappingOptions = parsedMapping.payloadAlerFields.filter(({ type }) => + types.includes(type), + ); + return { searchBox, dropdownItems, mappingOptions }; + }; + it('renders column captions', () => { expect(findColumnInRow(0, 0).text()).toContain(i18n.columns.gitlabKeyTitle); expect(findColumnInRow(0, 2).text()).toContain(i18n.columns.payloadKeyTitle); @@ -63,10 +72,7 @@ describe('AlertMappingBuilder', () => { it('renders mapping dropdown for each field', () => { alertFields.forEach(({ types }, index) => { const dropdown = findColumnInRow(index + 1, 2).find(GlDropdown); - const searchBox = dropdown.findComponent(GlSearchBoxByType); - const dropdownItems = dropdown.findAllComponents(GlDropdownItem); - const { nodes } = parsedMapping.samplePayload.payloadAlerFields; - const mappingOptions = nodes.filter(({ type }) => types.includes(type)); + const { searchBox, dropdownItems, mappingOptions } = getDropdownContent(dropdown, types); expect(dropdown.exists()).toBe(true); expect(searchBox.exists()).toBe(true); @@ -80,11 +86,7 @@ describe('AlertMappingBuilder', () => { expect(dropdown.exists()).toBe(Boolean(numberOfFallbacks)); if (numberOfFallbacks) { - const searchBox = dropdown.findComponent(GlSearchBoxByType); - const dropdownItems = dropdown.findAllComponents(GlDropdownItem); - const { nodes } = parsedMapping.samplePayload.payloadAlerFields; - const mappingOptions = nodes.filter(({ type }) => types.includes(type)); - + const { searchBox, dropdownItems, mappingOptions } = getDropdownContent(dropdown, types); expect(searchBox.exists()).toBe(Boolean(numberOfFallbacks)); expect(dropdownItems).toHaveLength(mappingOptions.length); } diff --git a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js index 02229b3d3da..d2dcff14432 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js @@ -1,29 +1,18 @@ -import { - GlForm, - GlFormSelect, - GlCollapse, - GlFormInput, - GlToggle, - GlFormTextarea, -} from '@gitlab/ui'; +import { GlForm, GlFormSelect, GlFormInput, GlToggle, GlFormTextarea, GlTab } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; import MappingBuilder from '~/alerts_settings/components/alert_mapping_builder.vue'; import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue'; import { typeSet } from '~/alerts_settings/constants'; -import alertFields from '../mocks/alertFields.json'; +import alertFields from '../mocks/alert_fields.json'; +import parsedMapping from '../mocks/parsed_mapping.json'; import { defaultAlertSettingsConfig } from './util'; describe('AlertsSettingsForm', () => { let wrapper; const mockToastShow = jest.fn(); - const createComponent = ({ - data = {}, - props = {}, - multipleHttpIntegrationsCustomMapping = false, - multiIntegrations = true, - } = {}) => { + const createComponent = ({ data = {}, props = {}, multiIntegrations = true } = {}) => { wrapper = mount(AlertsSettingsForm, { data() { return { ...data }; @@ -35,10 +24,12 @@ describe('AlertsSettingsForm', () => { }, provide: { ...defaultAlertSettingsConfig, - glFeatures: { multipleHttpIntegrationsCustomMapping }, multiIntegrations, }, mocks: { + $apollo: { + query: jest.fn(), + }, $toast: { show: mockToastShow, }, @@ -46,20 +37,20 @@ describe('AlertsSettingsForm', () => { }); }; - const findForm = () => wrapper.find(GlForm); - const findSelect = () => wrapper.find(GlFormSelect); - const findFormSteps = () => wrapper.find(GlCollapse); - const findFormFields = () => wrapper.findAll(GlFormInput); - const findFormToggle = () => wrapper.find(GlToggle); - const findTestPayloadSection = () => wrapper.find(`[id = "test-integration"]`); + const findForm = () => wrapper.findComponent(GlForm); + const findSelect = () => wrapper.findComponent(GlFormSelect); + const findFormFields = () => wrapper.findAllComponents(GlFormInput); + const findFormToggle = () => wrapper.findComponent(GlToggle); + const findSamplePayloadSection = () => wrapper.find('[data-testid="sample-payload-section"]'); const findMappingBuilderSection = () => wrapper.find(`[id = "mapping-builder"]`); const findMappingBuilder = () => wrapper.findComponent(MappingBuilder); const findSubmitButton = () => wrapper.find(`[type = "submit"]`); const findMultiSupportText = () => wrapper.find(`[data-testid="multi-integrations-not-supported"]`); - const findJsonTestSubmit = () => wrapper.find(`[data-testid="integration-test-and-submit"]`); + const findJsonTestSubmit = () => wrapper.find(`[data-testid="send-test-alert"]`); const findJsonTextArea = () => wrapper.find(`[id = "test-payload"]`); const findActionBtn = () => wrapper.find(`[data-testid="payload-action-btn"]`); + const findTabs = () => wrapper.findAllComponents(GlTab); afterEach(() => { if (wrapper) { @@ -91,7 +82,7 @@ describe('AlertsSettingsForm', () => { expect(findForm().exists()).toBe(true); expect(findSelect().exists()).toBe(true); expect(findMultiSupportText().exists()).toBe(false); - expect(findFormSteps().attributes('visible')).toBeUndefined(); + expect(findFormFields()).toHaveLength(0); }); it('shows the rest of the form when the dropdown is used', async () => { @@ -106,37 +97,47 @@ describe('AlertsSettingsForm', () => { expect(findMultiSupportText().exists()).toBe(true); }); - it('disabled the name input when the selected value is prometheus', async () => { + it('hides the name input when the selected value is prometheus', async () => { createComponent(); await selectOptionAtIndex(2); - - expect(findFormFields().at(0).attributes('disabled')).toBe('disabled'); + expect(findFormFields().at(0).attributes('id')).not.toBe('name-integration'); }); - }); - - describe('submitting integration form', () => { - describe('HTTP', () => { - it('create', async () => { - createComponent(); - const integrationName = 'Test integration'; - await selectOptionAtIndex(1); - enableIntegration(0, integrationName); - - const submitBtn = findSubmitButton(); - expect(submitBtn.exists()).toBe(true); - expect(submitBtn.text()).toBe('Save integration'); + describe('form tabs', () => { + it('renders 3 tabs', () => { + expect(findTabs()).toHaveLength(3); + }); - findForm().trigger('submit'); + it('only first tab is enabled on integration create', () => { + createComponent({ + data: { + currentIntegration: null, + }, + }); + const tabs = findTabs(); + expect(tabs.at(0).find('[role="tabpanel"]').classes('disabled')).toBe(false); + expect(tabs.at(1).find('[role="tabpanel"]').classes('disabled')).toBe(true); + expect(tabs.at(2).find('[role="tabpanel"]').classes('disabled')).toBe(true); + }); - expect(wrapper.emitted('create-new-integration')[0]).toEqual([ - { type: typeSet.http, variables: { name: integrationName, active: true } }, - ]); + it('all tabs are enabled on integration edit', () => { + createComponent({ + data: { + currentIntegration: { id: 1 }, + }, + }); + const tabs = findTabs(); + expect(tabs.at(0).find('[role="tabpanel"]').classes('disabled')).toBe(false); + expect(tabs.at(1).find('[role="tabpanel"]').classes('disabled')).toBe(false); + expect(tabs.at(2).find('[role="tabpanel"]').classes('disabled')).toBe(false); }); + }); + }); + describe('submitting integration form', () => { + describe('HTTP', () => { it('create with custom mapping', async () => { createComponent({ - multipleHttpIntegrationsCustomMapping: true, multiIntegrations: true, props: { alertFields }, }); @@ -146,7 +147,7 @@ describe('AlertsSettingsForm', () => { enableIntegration(0, integrationName); - const sampleMapping = { field: 'test' }; + const sampleMapping = parsedMapping.payloadAttributeMappings; findMappingBuilder().vm.$emit('onMappingUpdate', sampleMapping); findForm().trigger('submit'); @@ -157,7 +158,7 @@ describe('AlertsSettingsForm', () => { name: integrationName, active: true, payloadAttributeMappings: sampleMapping, - payloadExample: null, + payloadExample: '{}', }, }, ]); @@ -182,23 +183,28 @@ describe('AlertsSettingsForm', () => { findForm().trigger('submit'); - expect(wrapper.emitted('update-integration')[0]).toEqual([ - { type: typeSet.http, variables: { name: updatedIntegrationName, active: true } }, - ]); + expect(wrapper.emitted('update-integration')[0]).toEqual( + expect.arrayContaining([ + { + type: typeSet.http, + variables: { + name: updatedIntegrationName, + active: true, + payloadAttributeMappings: [], + payloadExample: '{}', + }, + }, + ]), + ); }); }); describe('PROMETHEUS', () => { it('create', async () => { createComponent(); - await selectOptionAtIndex(2); - const apiUrl = 'https://test.com'; - enableIntegration(1, apiUrl); - - findFormToggle().trigger('click'); - + enableIntegration(0, apiUrl); const submitBtn = findSubmitButton(); expect(submitBtn.exists()).toBe(true); expect(submitBtn.text()).toBe('Save integration'); @@ -222,7 +228,7 @@ describe('AlertsSettingsForm', () => { }); const apiUrl = 'https://test-post.com'; - enableIntegration(1, apiUrl); + enableIntegration(0, apiUrl); const submitBtn = findSubmitButton(); expect(submitBtn.exists()).toBe(true); @@ -260,7 +266,7 @@ describe('AlertsSettingsForm', () => { const jsonTestSubmit = findJsonTestSubmit(); expect(jsonTestSubmit.exists()).toBe(true); - expect(jsonTestSubmit.text()).toBe('Save and test payload'); + expect(jsonTestSubmit.text()).toBe('Send'); expect(jsonTestSubmit.props('disabled')).toBe(true); }); @@ -275,56 +281,73 @@ describe('AlertsSettingsForm', () => { }); describe('Test payload section for HTTP integration', () => { + const validSamplePayload = JSON.stringify(alertFields); + const emptySamplePayload = '{}'; + beforeEach(() => { createComponent({ - multipleHttpIntegrationsCustomMapping: true, - props: { + data: { currentIntegration: { type: typeSet.http, + payloadExample: validSamplePayload, + payloadAttributeMappings: [], }, - alertFields, + active: false, + resetPayloadAndMappingConfirmed: false, }, + props: { alertFields }, }); }); describe.each` - active | resetSamplePayloadConfirmed | disabled - ${true} | ${true} | ${undefined} - ${false} | ${true} | ${'disabled'} - ${true} | ${false} | ${'disabled'} - ${false} | ${false} | ${'disabled'} - `('', ({ active, resetSamplePayloadConfirmed, disabled }) => { - const payloadResetMsg = resetSamplePayloadConfirmed ? 'was confirmed' : 'was not confirmed'; + active | resetPayloadAndMappingConfirmed | disabled + ${true} | ${true} | ${undefined} + ${false} | ${true} | ${'disabled'} + ${true} | ${false} | ${'disabled'} + ${false} | ${false} | ${'disabled'} + `('', ({ active, resetPayloadAndMappingConfirmed, disabled }) => { + const payloadResetMsg = resetPayloadAndMappingConfirmed + ? 'was confirmed' + : 'was not confirmed'; const enabledState = disabled === 'disabled' ? 'disabled' : 'enabled'; const activeState = active ? 'active' : 'not active'; it(`textarea should be ${enabledState} when payload reset ${payloadResetMsg} and current integration is ${activeState}`, async () => { wrapper.setData({ - customMapping: { samplePayload: true }, + selectedIntegration: typeSet.http, active, - resetSamplePayloadConfirmed, + resetPayloadAndMappingConfirmed, }); await wrapper.vm.$nextTick(); - expect(findTestPayloadSection().find(GlFormTextarea).attributes('disabled')).toBe(disabled); + expect(findSamplePayloadSection().find(GlFormTextarea).attributes('disabled')).toBe( + disabled, + ); }); }); describe('action buttons for sample payload', () => { describe.each` - resetSamplePayloadConfirmed | samplePayload | caption - ${false} | ${true} | ${'Edit payload'} - ${true} | ${false} | ${'Submit payload'} - ${true} | ${true} | ${'Submit payload'} - ${false} | ${false} | ${'Submit payload'} - `('', ({ resetSamplePayloadConfirmed, samplePayload, caption }) => { - const samplePayloadMsg = samplePayload ? 'was provided' : 'was not provided'; - const payloadResetMsg = resetSamplePayloadConfirmed ? 'was confirmed' : 'was not confirmed'; + resetPayloadAndMappingConfirmed | payloadExample | caption + ${false} | ${validSamplePayload} | ${'Edit payload'} + ${true} | ${emptySamplePayload} | ${'Parse payload for custom mapping'} + ${true} | ${validSamplePayload} | ${'Parse payload for custom mapping'} + ${false} | ${emptySamplePayload} | ${'Parse payload for custom mapping'} + `('', ({ resetPayloadAndMappingConfirmed, payloadExample, caption }) => { + const samplePayloadMsg = payloadExample ? 'was provided' : 'was not provided'; + const payloadResetMsg = resetPayloadAndMappingConfirmed + ? 'was confirmed' + : 'was not confirmed'; it(`shows ${caption} button when sample payload ${samplePayloadMsg} and payload reset ${payloadResetMsg}`, async () => { wrapper.setData({ selectedIntegration: typeSet.http, - customMapping: { samplePayload }, - resetSamplePayloadConfirmed, + currentIntegration: { + payloadExample, + type: typeSet.http, + active: true, + payloadAttributeMappings: [], + }, + resetPayloadAndMappingConfirmed, }); await wrapper.vm.$nextTick(); expect(findActionBtn().text()).toBe(caption); @@ -333,16 +356,20 @@ describe('AlertsSettingsForm', () => { }); describe('Parsing payload', () => { - it('displays a toast message on successful parse', async () => { - jest.useFakeTimers(); + beforeEach(() => { wrapper.setData({ selectedIntegration: typeSet.http, - customMapping: { samplePayload: false }, + resetPayloadAndMappingConfirmed: true, }); - await wrapper.vm.$nextTick(); + }); + it('displays a toast message on successful parse', async () => { + jest.spyOn(wrapper.vm.$apollo, 'query').mockResolvedValue({ + data: { + project: { alertManagementPayloadFields: [] }, + }, + }); findActionBtn().vm.$emit('click'); - jest.advanceTimersByTime(1000); await waitForPromises(); @@ -350,27 +377,33 @@ describe('AlertsSettingsForm', () => { 'Sample payload has been parsed. You can now map the fields.', ); }); + + it('displays an error message under payload field on unsuccessful parse', async () => { + const errorMessage = 'Error parsing paylod'; + jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({ message: errorMessage }); + findActionBtn().vm.$emit('click'); + + await waitForPromises(); + + expect(findSamplePayloadSection().find('.invalid-feedback').text()).toBe(errorMessage); + }); }); }); describe('Mapping builder section', () => { describe.each` - alertFieldsProvided | multiIntegrations | featureFlag | integrationOption | visible - ${true} | ${true} | ${true} | ${1} | ${true} - ${true} | ${true} | ${true} | ${2} | ${false} - ${true} | ${true} | ${false} | ${1} | ${false} - ${true} | ${true} | ${false} | ${2} | ${false} - ${true} | ${false} | ${true} | ${1} | ${false} - ${false} | ${true} | ${true} | ${1} | ${false} - `('', ({ alertFieldsProvided, multiIntegrations, featureFlag, integrationOption, visible }) => { + alertFieldsProvided | multiIntegrations | integrationOption | visible + ${true} | ${true} | ${1} | ${true} + ${true} | ${true} | ${2} | ${false} + ${true} | ${false} | ${1} | ${false} + ${false} | ${true} | ${1} | ${false} + `('', ({ alertFieldsProvided, multiIntegrations, integrationOption, visible }) => { const visibleMsg = visible ? 'is rendered' : 'is not rendered'; - const featureFlagMsg = featureFlag ? 'is enabled' : 'is disabled'; const alertFieldsMsg = alertFieldsProvided ? 'are provided' : 'are not provided'; const integrationType = integrationOption === 1 ? typeSet.http : typeSet.prometheus; - it(`${visibleMsg} when multipleHttpIntegrationsCustomMapping feature flag ${featureFlagMsg} and integration type is ${integrationType} and alert fields ${alertFieldsMsg}`, async () => { + it(`${visibleMsg} when integration type is ${integrationType} and alert fields ${alertFieldsMsg}`, async () => { createComponent({ - multipleHttpIntegrationsCustomMapping: featureFlag, multiIntegrations, props: { alertFields: alertFieldsProvided ? alertFields : [], diff --git a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js index 80293597ab6..77fac6dd022 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js @@ -2,21 +2,26 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import VueApollo from 'vue-apollo'; +import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql'; +import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; import waitForPromises from 'helpers/wait_for_promises'; import IntegrationsList from '~/alerts_settings/components/alerts_integrations_list.vue'; import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue'; -import AlertsSettingsWrapper from '~/alerts_settings/components/alerts_settings_wrapper.vue'; +import AlertsSettingsWrapper, { + i18n, +} from '~/alerts_settings/components/alerts_settings_wrapper.vue'; import { typeSet } from '~/alerts_settings/constants'; -import createHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql'; import createPrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql'; import destroyHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql'; import resetHttpTokenMutation from '~/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql'; import resetPrometheusTokenMutation from '~/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql'; -import updateHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql'; +import updateCurrentHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/update_current_http_integration.mutation.graphql'; +import updateCurrentPrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql'; import updatePrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql'; import getIntegrationsQuery from '~/alerts_settings/graphql/queries/get_integrations.query.graphql'; +import alertsUpdateService from '~/alerts_settings/services'; import { ADD_INTEGRATION_ERROR, RESET_INTEGRATION_TOKEN_ERROR, @@ -24,14 +29,15 @@ import { INTEGRATION_PAYLOAD_TEST_ERROR, DELETE_INTEGRATION_ERROR, } from '~/alerts_settings/utils/error_messages'; -import createFlash from '~/flash'; +import createFlash, { FLASH_TYPES } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { createHttpVariables, updateHttpVariables, createPrometheusVariables, updatePrometheusVariables, - ID, + HTTP_ID, + PROMETHEUS_ID, errorMsg, getIntegrationsQueryResponse, destroyIntegrationResponse, @@ -50,9 +56,33 @@ describe('AlertsSettingsWrapper', () => { let fakeApollo; let destroyIntegrationHandler; useMockIntersectionObserver(); + const httpMappingData = { + payloadExample: '{"test: : "field"}', + payloadAttributeMappings: [], + payloadAlertFields: [], + }; + const httpIntegrations = { + list: [ + { + id: mockIntegrations[0].id, + ...httpMappingData, + }, + { + id: mockIntegrations[1].id, + ...httpMappingData, + }, + { + id: mockIntegrations[2].id, + httpMappingData, + }, + ], + }; - const findLoader = () => wrapper.find(IntegrationsList).find(GlLoadingIcon); + const findLoader = () => wrapper.findComponent(IntegrationsList).findComponent(GlLoadingIcon); + const findIntegrationsList = () => wrapper.findComponent(IntegrationsList); const findIntegrations = () => wrapper.find(IntegrationsList).findAll('table tbody tr'); + const findAddIntegrationBtn = () => wrapper.find('[data-testid="add-integration-btn"]'); + const findAlertsSettingsForm = () => wrapper.findComponent(AlertsSettingsForm); async function destroyHttpIntegration(localWrapper) { await jest.runOnlyPendingTimers(); @@ -119,14 +149,37 @@ describe('AlertsSettingsWrapper', () => { wrapper = null; }); - describe('rendered via default permissions', () => { - it('renders the GraphQL alerts integrations list and new form', () => { - createComponent(); - expect(wrapper.find(IntegrationsList).exists()).toBe(true); - expect(wrapper.find(AlertsSettingsForm).exists()).toBe(true); + describe('template', () => { + beforeEach(() => { + createComponent({ + data: { + integrations: { list: mockIntegrations }, + httpIntegrations: { list: [] }, + currentIntegration: mockIntegrations[0], + }, + loading: false, + }); }); - it('uses a loading state inside the IntegrationsList table', () => { + it('renders alerts integrations list and add new integration button by default', () => { + expect(findLoader().exists()).toBe(false); + expect(findIntegrations()).toHaveLength(mockIntegrations.length); + expect(findAddIntegrationBtn().exists()).toBe(true); + }); + + it('does NOT render settings form by default', () => { + expect(findAlertsSettingsForm().exists()).toBe(false); + }); + + it('hides `add new integration` button and displays setting form on btn click', async () => { + const addNewIntegrationBtn = findAddIntegrationBtn(); + expect(addNewIntegrationBtn.exists()).toBe(true); + await addNewIntegrationBtn.trigger('click'); + expect(findAlertsSettingsForm().exists()).toBe(true); + expect(addNewIntegrationBtn.exists()).toBe(false); + }); + + it('shows loading indicator inside the IntegrationsList table', () => { createComponent({ data: { integrations: {} }, loading: true, @@ -134,26 +187,24 @@ describe('AlertsSettingsWrapper', () => { expect(wrapper.find(IntegrationsList).exists()).toBe(true); expect(findLoader().exists()).toBe(true); }); + }); - it('renders the IntegrationsList table using the API data', () => { + describe('Integration updates', () => { + beforeEach(() => { createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + data: { + integrations: { list: mockIntegrations }, + currentIntegration: mockIntegrations[0], + formVisible: true, + }, loading: false, }); - expect(findLoader().exists()).toBe(false); - expect(findIntegrations()).toHaveLength(mockIntegrations.length); }); - it('calls `$apollo.mutate` with `createHttpIntegrationMutation`', () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: { createHttpIntegrationMutation: { integration: { id: '1' } } }, }); - wrapper.find(AlertsSettingsForm).vm.$emit('create-new-integration', { + findAlertsSettingsForm().vm.$emit('create-new-integration', { type: typeSet.http, variables: createHttpVariables, }); @@ -167,15 +218,10 @@ describe('AlertsSettingsWrapper', () => { }); it('calls `$apollo.mutate` with `updateHttpIntegrationMutation`', () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: { updateHttpIntegrationMutation: { integration: { id: '1' } } }, }); - wrapper.find(AlertsSettingsForm).vm.$emit('update-integration', { + findAlertsSettingsForm().vm.$emit('update-integration', { type: typeSet.http, variables: updateHttpVariables, }); @@ -187,37 +233,27 @@ describe('AlertsSettingsWrapper', () => { }); it('calls `$apollo.mutate` with `resetHttpTokenMutation`', () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: { resetHttpTokenMutation: { integration: { id: '1' } } }, }); - wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', { + findAlertsSettingsForm().vm.$emit('reset-token', { type: typeSet.http, - variables: { id: ID }, + variables: { id: HTTP_ID }, }); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ mutation: resetHttpTokenMutation, variables: { - id: ID, + id: HTTP_ID, }, }); }); it('calls `$apollo.mutate` with `createPrometheusIntegrationMutation`', () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: { createPrometheusIntegrationMutation: { integration: { id: '2' } } }, }); - wrapper.find(AlertsSettingsForm).vm.$emit('create-new-integration', { + findAlertsSettingsForm().vm.$emit('create-new-integration', { type: typeSet.prometheus, variables: createPrometheusVariables, }); @@ -232,14 +268,18 @@ describe('AlertsSettingsWrapper', () => { it('calls `$apollo.mutate` with `updatePrometheusIntegrationMutation`', () => { createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + data: { + integrations: { list: mockIntegrations }, + currentIntegration: mockIntegrations[3], + formVisible: true, + }, loading: false, }); jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: { updatePrometheusIntegrationMutation: { integration: { id: '2' } } }, }); - wrapper.find(AlertsSettingsForm).vm.$emit('update-integration', { + findAlertsSettingsForm().vm.$emit('update-integration', { type: typeSet.prometheus, variables: updatePrometheusVariables, }); @@ -251,35 +291,25 @@ describe('AlertsSettingsWrapper', () => { }); it('calls `$apollo.mutate` with `resetPrometheusTokenMutation`', () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: { resetPrometheusTokenMutation: { integration: { id: '1' } } }, }); - wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', { + findAlertsSettingsForm().vm.$emit('reset-token', { type: typeSet.prometheus, - variables: { id: ID }, + variables: { id: PROMETHEUS_ID }, }); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ mutation: resetPrometheusTokenMutation, variables: { - id: ID, + id: PROMETHEUS_ID, }, }); }); it('shows an error alert when integration creation fails ', async () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(ADD_INTEGRATION_ERROR); - wrapper.find(AlertsSettingsForm).vm.$emit('create-new-integration', {}); + findAlertsSettingsForm().vm.$emit('create-new-integration', {}); await waitForPromises(); @@ -287,28 +317,18 @@ describe('AlertsSettingsWrapper', () => { }); it('shows an error alert when integration token reset fails ', async () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(RESET_INTEGRATION_TOKEN_ERROR); - wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', {}); + findAlertsSettingsForm().vm.$emit('reset-token', {}); await waitForPromises(); expect(createFlash).toHaveBeenCalledWith({ message: RESET_INTEGRATION_TOKEN_ERROR }); }); it('shows an error alert when integration update fails ', async () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg); - wrapper.find(AlertsSettingsForm).vm.$emit('update-integration', {}); + findAlertsSettingsForm().vm.$emit('update-integration', {}); await waitForPromises(); expect(createFlash).toHaveBeenCalledWith({ message: UPDATE_INTEGRATION_ERROR }); @@ -317,15 +337,74 @@ describe('AlertsSettingsWrapper', () => { it('shows an error alert when integration test payload fails ', async () => { const mock = new AxiosMockAdapter(axios); mock.onPost(/(.*)/).replyOnce(403); + return wrapper.vm.testAlertPayload({ endpoint: '', data: '', token: '' }).then(() => { + expect(createFlash).toHaveBeenCalledWith({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); + expect(createFlash).toHaveBeenCalledTimes(1); + mock.restore(); + }); + }); + + it('calls `$apollo.mutate` with `updateCurrentHttpIntegrationMutation` on HTTP integration edit', () => { createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + data: { + integrations: { list: mockIntegrations }, + currentIntegration: mockIntegrations[0], + httpIntegrations, + }, loading: false, }); - return wrapper.vm.validateAlertPayload({ endpoint: '', data: '', token: '' }).then(() => { - expect(createFlash).toHaveBeenCalledWith({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); - expect(createFlash).toHaveBeenCalledTimes(1); - mock.restore(); + jest.spyOn(wrapper.vm.$apollo, 'mutate'); + findIntegrationsList().vm.$emit('edit-integration', updateHttpVariables); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: updateCurrentHttpIntegrationMutation, + variables: { ...mockIntegrations[0], ...httpMappingData }, + }); + }); + + it('calls `$apollo.mutate` with `updateCurrentPrometheusIntegrationMutation` on PROMETHEUS integration edit', () => { + createComponent({ + data: { + integrations: { list: mockIntegrations }, + currentIntegration: mockIntegrations[3], + httpIntegrations, + }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate'); + findIntegrationsList().vm.$emit('edit-integration', updatePrometheusVariables); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: updateCurrentPrometheusIntegrationMutation, + variables: mockIntegrations[3], + }); + }); + + describe('Test alert', () => { + it('makes `updateTestAlert` service call', async () => { + jest.spyOn(alertsUpdateService, 'updateTestAlert').mockResolvedValueOnce(); + const testPayload = '{"title":"test"}'; + findAlertsSettingsForm().vm.$emit('test-alert-payload', testPayload); + expect(alertsUpdateService.updateTestAlert).toHaveBeenCalledWith(testPayload); + }); + + it('shows success message on successful test', async () => { + jest.spyOn(alertsUpdateService, 'updateTestAlert').mockResolvedValueOnce({}); + findAlertsSettingsForm().vm.$emit('test-alert-payload', ''); + await waitForPromises(); + expect(createFlash).toHaveBeenCalledWith({ + message: i18n.alertSent, + type: FLASH_TYPES.SUCCESS, + }); + }); + + it('shows error message when test alert fails', async () => { + jest.spyOn(alertsUpdateService, 'updateTestAlert').mockRejectedValueOnce({}); + findAlertsSettingsForm().vm.$emit('test-alert-payload', ''); + await waitForPromises(); + expect(createFlash).toHaveBeenCalledWith({ + message: INTEGRATION_PAYLOAD_TEST_ERROR, + }); }); }); }); diff --git a/spec/frontend/alerts_settings/components/mocks/apollo_mock.js b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js index e0eba1e8421..828580a436b 100644 --- a/spec/frontend/alerts_settings/components/mocks/apollo_mock.js +++ b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js @@ -1,29 +1,34 @@ const projectPath = ''; -export const ID = 'gid://gitlab/AlertManagement::HttpIntegration/7'; +export const HTTP_ID = 'gid://gitlab/AlertManagement::HttpIntegration/7'; +export const PROMETHEUS_ID = 'gid://gitlab/PrometheusService/12'; export const errorMsg = 'Something went wrong'; export const createHttpVariables = { name: 'Test Pre', active: true, projectPath, + type: 'HTTP', }; export const updateHttpVariables = { name: 'Test Pre', active: true, - id: ID, + id: HTTP_ID, + type: 'HTTP', }; export const createPrometheusVariables = { apiUrl: 'https://test-pre.com', active: true, projectPath, + type: 'PROMETHEUS', }; export const updatePrometheusVariables = { apiUrl: 'https://test-pre.com', active: true, - id: ID, + id: PROMETHEUS_ID, + type: 'PROMETHEUS', }; export const getIntegrationsQueryResponse = { @@ -99,6 +104,9 @@ export const destroyIntegrationResponse = { 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json', token: '89eb01df471d990ff5162a1c640408cf', apiUrl: null, + payloadExample: '{"field": "value"}', + payloadAttributeMappings: [], + payloadAlertFields: [], }, }, }, @@ -117,6 +125,9 @@ export const destroyIntegrationResponseWithErrors = { 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json', token: '89eb01df471d990ff5162a1c640408cf', apiUrl: null, + payloadExample: '{"field": "value"}', + payloadAttributeMappings: [], + payloadAlertFields: [], }, }, }, diff --git a/spec/frontend/alerts_settings/mocks/alertFields.json b/spec/frontend/alerts_settings/mocks/alert_fields.json index ffe59dd0c05..ffe59dd0c05 100644 --- a/spec/frontend/alerts_settings/mocks/alertFields.json +++ b/spec/frontend/alerts_settings/mocks/alert_fields.json diff --git a/spec/frontend/alerts_settings/mocks/parsed_mapping.json b/spec/frontend/alerts_settings/mocks/parsed_mapping.json new file mode 100644 index 00000000000..e985671a923 --- /dev/null +++ b/spec/frontend/alerts_settings/mocks/parsed_mapping.json @@ -0,0 +1,122 @@ +{ + "payloadAlerFields": [ + { + "path": [ + "dashboardId" + ], + "label": "Dashboard Id", + "type": "string" + }, + { + "path": [ + "evalMatches" + ], + "label": "Eval Matches", + "type": "array" + }, + { + "path": [ + "createdAt" + ], + "label": "Created At", + "type": "datetime" + }, + { + "path": [ + "imageUrl" + ], + "label": "Image Url", + "type": "string" + }, + { + "path": [ + "message" + ], + "label": "Message", + "type": "string" + }, + { + "path": [ + "orgId" + ], + "label": "Org Id", + "type": "string" + }, + { + "path": [ + "panelId" + ], + "label": "Panel Id", + "type": "string" + }, + { + "path": [ + "ruleId" + ], + "label": "Rule Id", + "type": "string" + }, + { + "path": [ + "ruleName" + ], + "label": "Rule Name", + "type": "string" + }, + { + "path": [ + "ruleUrl" + ], + "label": "Rule Url", + "type": "string" + }, + { + "path": [ + "state" + ], + "label": "State", + "type": "string" + }, + { + "path": [ + "title" + ], + "label": "Title", + "type": "string" + }, + { + "path": [ + "tags", + "tag" + ], + "label": "Tags", + "type": "string" + } + ], + "payloadAttributeMappings": [ + { + "fieldName": "title", + "label": "Title", + "type": "STRING", + "path": ["title"] + }, + { + "fieldName": "description", + "label": "description", + "type": "STRING", + "path": ["description"] + }, + { + "fieldName": "hosts", + "label": "Host", + "type": "ARRAY", + "path": ["hosts", "host"] + }, + { + "fieldName": "startTime", + "label": "Created Atd", + "type": "STRING", + "path": ["time", "createdAt"] + } + ] +} diff --git a/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js b/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js index 8c1977ffebe..62b95c6078b 100644 --- a/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js +++ b/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js @@ -1,29 +1,25 @@ -import parsedMapping from '~/alerts_settings/components/mocks/parsedMapping.json'; -import { - getMappingData, - getPayloadFields, - transformForSave, -} from '~/alerts_settings/utils/mapping_transformations'; -import alertFields from '../mocks/alertFields.json'; +import { getMappingData, transformForSave } from '~/alerts_settings/utils/mapping_transformations'; +import alertFields from '../mocks/alert_fields.json'; +import parsedMapping from '../mocks/parsed_mapping.json'; describe('Mapping Transformation Utilities', () => { const nameField = { label: 'Name', path: ['alert', 'name'], - type: 'string', + type: 'STRING', }; const dashboardField = { label: 'Dashboard Id', path: ['alert', 'dashboardId'], - type: 'string', + type: 'STRING', }; describe('getMappingData', () => { it('should return mapping data', () => { const result = getMappingData( alertFields, - getPayloadFields(parsedMapping.samplePayload.payloadAlerFields.nodes.slice(0, 3)), - parsedMapping.storedMapping.nodes.slice(0, 3), + parsedMapping.payloadAlerFields.slice(0, 3), + parsedMapping.payloadAttributeMappings.slice(0, 3), ); result.forEach((data, index) => { @@ -44,8 +40,8 @@ describe('Mapping Transformation Utilities', () => { const mockMappingData = [ { name: fieldName, - mapping: 'alert_name', - mappingFields: getPayloadFields([dashboardField, nameField]), + mapping: ['alert', 'name'], + mappingFields: [dashboardField, nameField], }, ]; const result = transformForSave(mockMappingData); @@ -61,21 +57,11 @@ describe('Mapping Transformation Utilities', () => { { name: fieldName, mapping: null, - mappingFields: getPayloadFields([nameField, dashboardField]), + mappingFields: [nameField, dashboardField], }, ]; const result = transformForSave(mockMappingData); expect(result).toEqual([]); }); }); - - describe('getPayloadFields', () => { - it('should add name field to each payload field', () => { - const result = getPayloadFields([nameField, dashboardField]); - expect(result).toEqual([ - { ...nameField, name: 'alert_name' }, - { ...dashboardField, name: 'alert_dashboardId' }, - ]); - }); - }); }); diff --git a/spec/frontend/analytics/instance_statistics/components/app_spec.js b/spec/frontend/analytics/instance_statistics/components/app_spec.js deleted file mode 100644 index b945cc20bd6..00000000000 --- a/spec/frontend/analytics/instance_statistics/components/app_spec.js +++ /dev/null @@ -1,45 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import InstanceCounts from '~/analytics/instance_statistics/components//instance_counts.vue'; -import InstanceStatisticsApp from '~/analytics/instance_statistics/components/app.vue'; -import InstanceStatisticsCountChart from '~/analytics/instance_statistics/components/instance_statistics_count_chart.vue'; -import ProjectsAndGroupsChart from '~/analytics/instance_statistics/components/projects_and_groups_chart.vue'; -import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue'; - -describe('InstanceStatisticsApp', () => { - let wrapper; - - const createComponent = () => { - wrapper = shallowMount(InstanceStatisticsApp); - }; - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('displays the instance counts component', () => { - expect(wrapper.find(InstanceCounts).exists()).toBe(true); - }); - - ['Pipelines', 'Issues & Merge Requests'].forEach((instance) => { - it(`displays the ${instance} chart`, () => { - const chartTitles = wrapper - .findAll(InstanceStatisticsCountChart) - .wrappers.map((chartComponent) => chartComponent.props('chartTitle')); - - expect(chartTitles).toContain(instance); - }); - }); - - it('displays the users chart component', () => { - expect(wrapper.find(UsersChart).exists()).toBe(true); - }); - - it('displays the projects and groups chart component', () => { - expect(wrapper.find(ProjectsAndGroupsChart).exists()).toBe(true); - }); -}); diff --git a/spec/frontend/analytics/instance_statistics/components/projects_and_groups_chart_spec.js b/spec/frontend/analytics/instance_statistics/components/projects_and_groups_chart_spec.js deleted file mode 100644 index bbfc65f19b1..00000000000 --- a/spec/frontend/analytics/instance_statistics/components/projects_and_groups_chart_spec.js +++ /dev/null @@ -1,215 +0,0 @@ -import { GlAlert } from '@gitlab/ui'; -import { GlLineChart } from '@gitlab/ui/dist/charts'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import ProjectsAndGroupChart from '~/analytics/instance_statistics/components/projects_and_groups_chart.vue'; -import groupsQuery from '~/analytics/instance_statistics/graphql/queries/groups.query.graphql'; -import projectsQuery from '~/analytics/instance_statistics/graphql/queries/projects.query.graphql'; -import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; -import { mockQueryResponse } from '../apollo_mock_data'; -import { mockCountsData2, roundedSortedCountsMonthlyChartData2 } from '../mock_data'; - -const localVue = createLocalVue(); -localVue.use(VueApollo); - -describe('ProjectsAndGroupChart', () => { - let wrapper; - let queryResponses = { projects: null, groups: null }; - const mockAdditionalData = [{ recordedAt: '2020-07-21', count: 5 }]; - - const createComponent = ({ - loadingError = false, - projects = [], - groups = [], - projectsLoading = false, - groupsLoading = false, - projectsAdditionalData = [], - groupsAdditionalData = [], - } = {}) => { - queryResponses = { - projects: mockQueryResponse({ - key: 'projects', - data: projects, - loading: projectsLoading, - additionalData: projectsAdditionalData, - }), - groups: mockQueryResponse({ - key: 'groups', - data: groups, - loading: groupsLoading, - additionalData: groupsAdditionalData, - }), - }; - - return shallowMount(ProjectsAndGroupChart, { - props: { - startDate: new Date(2020, 9, 26), - endDate: new Date(2020, 10, 1), - totalDataPoints: mockCountsData2.length, - }, - localVue, - apolloProvider: createMockApollo([ - [projectsQuery, queryResponses.projects], - [groupsQuery, queryResponses.groups], - ]), - data() { - return { loadingError }; - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - queryResponses = { - projects: null, - groups: null, - }; - }); - - const findLoader = () => wrapper.find(ChartSkeletonLoader); - const findAlert = () => wrapper.find(GlAlert); - const findChart = () => wrapper.find(GlLineChart); - - describe('while loading', () => { - beforeEach(() => { - wrapper = createComponent({ projectsLoading: true, groupsLoading: true }); - }); - - it('displays the skeleton loader', () => { - expect(findLoader().exists()).toBe(true); - }); - - it('hides the chart', () => { - expect(findChart().exists()).toBe(false); - }); - }); - - describe('while loading 1 data set', () => { - beforeEach(async () => { - wrapper = createComponent({ - projects: mockCountsData2, - groupsLoading: true, - }); - - await wrapper.vm.$nextTick(); - }); - - it('hides the skeleton loader', () => { - expect(findLoader().exists()).toBe(false); - }); - - it('renders the chart', () => { - expect(findChart().exists()).toBe(true); - }); - }); - - describe('without data', () => { - beforeEach(async () => { - wrapper = createComponent({ projects: [] }); - await wrapper.vm.$nextTick(); - }); - - it('renders a no data message', () => { - expect(findAlert().text()).toBe('No data available.'); - }); - - it('hides the skeleton loader', () => { - expect(findLoader().exists()).toBe(false); - }); - - it('does not render the chart', () => { - expect(findChart().exists()).toBe(false); - }); - }); - - describe('with data', () => { - beforeEach(async () => { - wrapper = createComponent({ projects: mockCountsData2 }); - await wrapper.vm.$nextTick(); - }); - - it('hides the skeleton loader', () => { - expect(findLoader().exists()).toBe(false); - }); - - it('renders the chart', () => { - expect(findChart().exists()).toBe(true); - }); - - it('passes the data to the line chart', () => { - expect(findChart().props('data')).toEqual([ - { data: roundedSortedCountsMonthlyChartData2, name: 'Total projects' }, - { data: [], name: 'Total groups' }, - ]); - }); - }); - - describe('with errors', () => { - beforeEach(async () => { - wrapper = createComponent({ loadingError: true }); - await wrapper.vm.$nextTick(); - }); - - it('renders an error message', () => { - expect(findAlert().text()).toBe('No data available.'); - }); - - it('hides the skeleton loader', () => { - expect(findLoader().exists()).toBe(false); - }); - - it('hides the chart', () => { - expect(findChart().exists()).toBe(false); - }); - }); - - describe.each` - metric | loadingState | newData - ${'projects'} | ${{ projectsAdditionalData: mockAdditionalData }} | ${{ projects: mockCountsData2 }} - ${'groups'} | ${{ groupsAdditionalData: mockAdditionalData }} | ${{ groups: mockCountsData2 }} - `('$metric - fetchMore', ({ metric, loadingState, newData }) => { - describe('when the fetchMore query returns data', () => { - beforeEach(async () => { - wrapper = createComponent({ - ...loadingState, - ...newData, - }); - - jest.spyOn(wrapper.vm.$apollo.queries[metric], 'fetchMore'); - await wrapper.vm.$nextTick(); - }); - - it('requests data twice', () => { - expect(queryResponses[metric]).toBeCalledTimes(2); - }); - - it('calls fetchMore', () => { - expect(wrapper.vm.$apollo.queries[metric].fetchMore).toHaveBeenCalledTimes(1); - }); - }); - - describe('when the fetchMore query throws an error', () => { - beforeEach(() => { - wrapper = createComponent({ - ...loadingState, - ...newData, - }); - - jest - .spyOn(wrapper.vm.$apollo.queries[metric], 'fetchMore') - .mockImplementation(jest.fn().mockRejectedValue()); - return wrapper.vm.$nextTick(); - }); - - it('calls fetchMore', () => { - expect(wrapper.vm.$apollo.queries[metric].fetchMore).toHaveBeenCalledTimes(1); - }); - - it('renders an error message', () => { - expect(findAlert().text()).toBe('No data available.'); - }); - }); - }); -}); diff --git a/spec/frontend/analytics/instance_statistics/apollo_mock_data.js b/spec/frontend/analytics/usage_trends/apollo_mock_data.js index 98eabd577ee..98eabd577ee 100644 --- a/spec/frontend/analytics/instance_statistics/apollo_mock_data.js +++ b/spec/frontend/analytics/usage_trends/apollo_mock_data.js diff --git a/spec/frontend/analytics/instance_statistics/components/__snapshots__/instance_statistics_count_chart_spec.js.snap b/spec/frontend/analytics/usage_trends/components/__snapshots__/usage_trends_count_chart_spec.js.snap index 29bcd5f223b..65de69c2692 100644 --- a/spec/frontend/analytics/instance_statistics/components/__snapshots__/instance_statistics_count_chart_spec.js.snap +++ b/spec/frontend/analytics/usage_trends/components/__snapshots__/usage_trends_count_chart_spec.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`InstanceStatisticsCountChart when fetching more data when the fetchMore query returns data passes the data to the line chart 1`] = ` +exports[`UsageTrendsCountChart when fetching more data when the fetchMore query returns data passes the data to the line chart 1`] = ` Array [ Object { "data": Array [ @@ -22,7 +22,7 @@ Array [ ] `; -exports[`InstanceStatisticsCountChart with data passes the data to the line chart 1`] = ` +exports[`UsageTrendsCountChart with data passes the data to the line chart 1`] = ` Array [ Object { "data": Array [ diff --git a/spec/frontend/analytics/usage_trends/components/app_spec.js b/spec/frontend/analytics/usage_trends/components/app_spec.js new file mode 100644 index 00000000000..f0306ea72e3 --- /dev/null +++ b/spec/frontend/analytics/usage_trends/components/app_spec.js @@ -0,0 +1,40 @@ +import { shallowMount } from '@vue/test-utils'; +import UsageTrendsApp from '~/analytics/usage_trends/components/app.vue'; +import UsageCounts from '~/analytics/usage_trends/components/usage_counts.vue'; +import UsageTrendsCountChart from '~/analytics/usage_trends/components/usage_trends_count_chart.vue'; +import UsersChart from '~/analytics/usage_trends/components/users_chart.vue'; + +describe('UsageTrendsApp', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(UsageTrendsApp); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('displays the usage counts component', () => { + expect(wrapper.find(UsageCounts).exists()).toBe(true); + }); + + ['Total projects & groups', 'Pipelines', 'Issues & Merge Requests'].forEach((usage) => { + it(`displays the ${usage} chart`, () => { + const chartTitles = wrapper + .findAll(UsageTrendsCountChart) + .wrappers.map((chartComponent) => chartComponent.props('chartTitle')); + + expect(chartTitles).toContain(usage); + }); + }); + + it('displays the users chart component', () => { + expect(wrapper.find(UsersChart).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js b/spec/frontend/analytics/usage_trends/components/instance_counts_spec.js index 12b5e14b9c4..707d2cc310f 100644 --- a/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js +++ b/spec/frontend/analytics/usage_trends/components/instance_counts_spec.js @@ -1,9 +1,9 @@ import { shallowMount } from '@vue/test-utils'; -import InstanceCounts from '~/analytics/instance_statistics/components/instance_counts.vue'; import MetricCard from '~/analytics/shared/components/metric_card.vue'; -import { mockInstanceCounts } from '../mock_data'; +import UsageCounts from '~/analytics/usage_trends/components/usage_counts.vue'; +import { mockUsageCounts } from '../mock_data'; -describe('InstanceCounts', () => { +describe('UsageCounts', () => { let wrapper; const createComponent = ({ loading = false, data = {} } = {}) => { @@ -15,7 +15,7 @@ describe('InstanceCounts', () => { }, }; - wrapper = shallowMount(InstanceCounts, { + wrapper = shallowMount(UsageCounts, { mocks: { $apollo }, data() { return { @@ -44,11 +44,11 @@ describe('InstanceCounts', () => { describe('with data', () => { beforeEach(() => { - createComponent({ data: { counts: mockInstanceCounts } }); + createComponent({ data: { counts: mockUsageCounts } }); }); it('passes the counts data to the metric card', () => { - expect(findMetricCard().props('metrics')).toEqual(mockInstanceCounts); + expect(findMetricCard().props('metrics')).toEqual(mockUsageCounts); }); }); }); diff --git a/spec/frontend/analytics/instance_statistics/components/instance_statistics_count_chart_spec.js b/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js index e80dcdff426..7c2df3fe8c4 100644 --- a/spec/frontend/analytics/instance_statistics/components/instance_statistics_count_chart_spec.js +++ b/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js @@ -3,8 +3,8 @@ import { GlLineChart } from '@gitlab/ui/dist/charts'; import { createLocalVue, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; -import InstanceStatisticsCountChart from '~/analytics/instance_statistics/components/instance_statistics_count_chart.vue'; -import statsQuery from '~/analytics/instance_statistics/graphql/queries/instance_count.query.graphql'; +import UsageTrendsCountChart from '~/analytics/usage_trends/components/usage_trends_count_chart.vue'; +import statsQuery from '~/analytics/usage_trends/graphql/queries/usage_count.query.graphql'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; import { mockQueryResponse, mockApolloResponse } from '../apollo_mock_data'; import { mockCountsData1 } from '../mock_data'; @@ -15,7 +15,7 @@ localVue.use(VueApollo); const loadChartErrorMessage = 'My load error message'; const noDataMessage = 'My no data message'; -const queryResponseDataKey = 'instanceStatisticsMeasurements'; +const queryResponseDataKey = 'usageTrendsMeasurements'; const identifier = 'MOCK_QUERY'; const mockQueryConfig = { identifier, @@ -33,12 +33,12 @@ const mockChartConfig = { queries: [mockQueryConfig], }; -describe('InstanceStatisticsCountChart', () => { +describe('UsageTrendsCountChart', () => { let wrapper; let queryHandler; const createComponent = ({ responseHandler }) => { - return shallowMount(InstanceStatisticsCountChart, { + return shallowMount(UsageTrendsCountChart, { localVue, apolloProvider: createMockApollo([[statsQuery, responseHandler]]), propsData: { ...mockChartConfig }, diff --git a/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js b/spec/frontend/analytics/usage_trends/components/users_chart_spec.js index d857b7fae61..6adfcca11ac 100644 --- a/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js +++ b/spec/frontend/analytics/usage_trends/components/users_chart_spec.js @@ -3,8 +3,8 @@ import { GlAreaChart } from '@gitlab/ui/dist/charts'; import { createLocalVue, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; -import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue'; -import usersQuery from '~/analytics/instance_statistics/graphql/queries/users.query.graphql'; +import UsersChart from '~/analytics/usage_trends/components/users_chart.vue'; +import usersQuery from '~/analytics/usage_trends/graphql/queries/users.query.graphql'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; import { mockQueryResponse } from '../apollo_mock_data'; import { diff --git a/spec/frontend/analytics/instance_statistics/mock_data.js b/spec/frontend/analytics/usage_trends/mock_data.js index e86e552a952..d96dfa26209 100644 --- a/spec/frontend/analytics/instance_statistics/mock_data.js +++ b/spec/frontend/analytics/usage_trends/mock_data.js @@ -1,4 +1,4 @@ -export const mockInstanceCounts = [ +export const mockUsageCounts = [ { key: 'projects', value: 10, label: 'Projects' }, { key: 'groups', value: 20, label: 'Group' }, ]; diff --git a/spec/frontend/analytics/instance_statistics/utils_spec.js b/spec/frontend/analytics/usage_trends/utils_spec.js index 3fd89c7f740..656f310dda7 100644 --- a/spec/frontend/analytics/instance_statistics/utils_spec.js +++ b/spec/frontend/analytics/usage_trends/utils_spec.js @@ -2,7 +2,7 @@ import { getAverageByMonth, getEarliestDate, generateDataKeys, -} from '~/analytics/instance_statistics/utils'; +} from '~/analytics/usage_trends/utils'; import { mockCountsData1, mockCountsData2, diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index d2522a0124a..a03aabf9e4f 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -352,6 +352,20 @@ describe('Api', () => { }); }); + describe('projectGroups', () => { + it('fetches a project group', async () => { + const options = { unused: 'option' }; + const projectId = 1; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/groups.json`; + mock.onGet(expectedUrl, { params: options }).reply(httpStatus.OK, { + name: 'test', + }); + + const { name } = await Api.projectGroups(projectId, options); + expect(name).toBe('test'); + }); + }); + describe('projectUsers', () => { it('fetches all users of a particular project', (done) => { const query = 'dummy query'; @@ -482,6 +496,30 @@ describe('Api', () => { }); }); + describe('projectShareWithGroup', () => { + it('invites a group to share access with the authenticated project', () => { + const projectId = 1; + const sharedGroupId = 99; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/share`; + const options = { + group_id: sharedGroupId, + group_access: 10, + expires_at: undefined, + }; + + jest.spyOn(axios, 'post'); + + mock.onPost(expectedUrl).reply(200, { + status: 'success', + }); + + return Api.projectShareWithGroup(projectId, options).then(({ data }) => { + expect(data.status).toBe('success'); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, options); + }); + }); + }); + describe('projectMilestones', () => { it('fetches project milestones', (done) => { const projectId = 1; @@ -638,6 +676,30 @@ describe('Api', () => { }); }); + describe('groupShareWithGroup', () => { + it('invites a group to share access with the authenticated group', () => { + const groupId = 1; + const sharedGroupId = 99; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/share`; + const options = { + group_id: sharedGroupId, + group_access: 10, + expires_at: undefined, + }; + + jest.spyOn(axios, 'post'); + + mock.onPost(expectedUrl).reply(200, { + status: 'success', + }); + + return Api.groupShareWithGroup(groupId, options).then(({ data }) => { + expect(data.status).toBe('success'); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, options); + }); + }); + }); + describe('commit', () => { const projectId = 'user/project'; const sha = 'abcd0123'; diff --git a/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap b/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap index bf33aa731ef..2691e11e616 100644 --- a/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap +++ b/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap @@ -7,7 +7,6 @@ exports[`Keep latest artifact checkbox when application keep latest artifact set <b-form-checkbox-stub checked="true" class="gl-form-checkbox" - plain="true" value="true" > <strong diff --git a/spec/frontend/authentication/u2f/authenticate_spec.js b/spec/frontend/authentication/u2f/authenticate_spec.js index bf50ee88035..153d4be56af 100644 --- a/spec/frontend/authentication/u2f/authenticate_spec.js +++ b/spec/frontend/authentication/u2f/authenticate_spec.js @@ -8,8 +8,6 @@ describe('U2FAuthenticate', () => { let container; let component; - preloadFixtures('u2f/authenticate.html'); - beforeEach(() => { loadFixtures('u2f/authenticate.html'); u2fDevice = new MockU2FDevice(); diff --git a/spec/frontend/authentication/u2f/register_spec.js b/spec/frontend/authentication/u2f/register_spec.js index 9cbadbc2fef..a814144ac7a 100644 --- a/spec/frontend/authentication/u2f/register_spec.js +++ b/spec/frontend/authentication/u2f/register_spec.js @@ -8,8 +8,6 @@ describe('U2FRegister', () => { let container; let component; - preloadFixtures('u2f/register.html'); - beforeEach((done) => { loadFixtures('u2f/register.html'); u2fDevice = new MockU2FDevice(); diff --git a/spec/frontend/authentication/webauthn/authenticate_spec.js b/spec/frontend/authentication/webauthn/authenticate_spec.js index 0a82adfd0ee..8b27560bbbe 100644 --- a/spec/frontend/authentication/webauthn/authenticate_spec.js +++ b/spec/frontend/authentication/webauthn/authenticate_spec.js @@ -13,7 +13,6 @@ const mockResponse = { }; describe('WebAuthnAuthenticate', () => { - preloadFixtures('webauthn/authenticate.html'); useMockNavigatorCredentials(); let fallbackElement; diff --git a/spec/frontend/authentication/webauthn/register_spec.js b/spec/frontend/authentication/webauthn/register_spec.js index 1de952d176d..43cd3d7ca34 100644 --- a/spec/frontend/authentication/webauthn/register_spec.js +++ b/spec/frontend/authentication/webauthn/register_spec.js @@ -5,7 +5,6 @@ import MockWebAuthnDevice from './mock_webauthn_device'; import { useMockNavigatorCredentials } from './util'; describe('WebAuthnRegister', () => { - preloadFixtures('webauthn/register.html'); useMockNavigatorCredentials(); const mockResponse = { diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js index edd17cfd810..09270174674 100644 --- a/spec/frontend/awards_handler_spec.js +++ b/spec/frontend/awards_handler_spec.js @@ -60,7 +60,6 @@ describe('AwardsHandler', () => { u: '6.0', }, }; - preloadFixtures('snippets/show.html'); const openAndWaitForEmojiMenu = (sel = '.js-add-award') => { $(sel).eq(0).click(); @@ -189,8 +188,6 @@ describe('AwardsHandler', () => { expect($thumbsUpEmoji.hasClass('active')).toBe(true); expect($thumbsDownEmoji.hasClass('active')).toBe(false); - $thumbsUpEmoji.tooltip(); - $thumbsDownEmoji.tooltip(); awardsHandler.addAward($votesBlock, awardUrl, 'thumbsdown', true); expect($thumbsUpEmoji.hasClass('active')).toBe(false); @@ -218,9 +215,8 @@ describe('AwardsHandler', () => { const $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent(); $thumbsUpEmoji.attr('data-title', 'sam, jerry, max, and andy'); awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); - $thumbsUpEmoji.tooltip(); - expect($thumbsUpEmoji.data('originalTitle')).toBe('You, sam, jerry, max, and andy'); + expect($thumbsUpEmoji.attr('title')).toBe('You, sam, jerry, max, and andy'); }); it('handles the special case where "You" is not cleanly comma separated', () => { @@ -229,9 +225,8 @@ describe('AwardsHandler', () => { const $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent(); $thumbsUpEmoji.attr('data-title', 'sam'); awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); - $thumbsUpEmoji.tooltip(); - expect($thumbsUpEmoji.data('originalTitle')).toBe('You and sam'); + expect($thumbsUpEmoji.attr('title')).toBe('You and sam'); }); }); @@ -243,9 +238,8 @@ describe('AwardsHandler', () => { $thumbsUpEmoji.attr('data-title', 'You, sam, jerry, max, and andy'); $thumbsUpEmoji.addClass('active'); awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); - $thumbsUpEmoji.tooltip(); - expect($thumbsUpEmoji.data('originalTitle')).toBe('sam, jerry, max, and andy'); + expect($thumbsUpEmoji.attr('title')).toBe('sam, jerry, max, and andy'); }); it('handles the special case where "You" is not cleanly comma separated', () => { @@ -255,9 +249,8 @@ describe('AwardsHandler', () => { $thumbsUpEmoji.attr('data-title', 'You and sam'); $thumbsUpEmoji.addClass('active'); awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); - $thumbsUpEmoji.tooltip(); - expect($thumbsUpEmoji.data('originalTitle')).toBe('sam'); + expect($thumbsUpEmoji.attr('title')).toBe('sam'); }); }); diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js index 885e02ef60f..da19265ce82 100644 --- a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js +++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js @@ -1,6 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; +import service from '~/batch_comments/services/drafts_service'; import * as actions from '~/batch_comments/stores/modules/batch_comments/actions'; import axios from '~/lib/utils/axios_utils'; @@ -201,6 +202,12 @@ describe('Batch comments store actions', () => { describe('updateDraft', () => { let getters; + service.update = jest.fn(); + service.update.mockResolvedValue({ data: { id: 1 } }); + + const commit = jest.fn(); + let context; + let params; beforeEach(() => { getters = { @@ -208,43 +215,43 @@ describe('Batch comments store actions', () => { draftsPath: TEST_HOST, }, }; - }); - it('commits RECEIVE_DRAFT_UPDATE_SUCCESS with returned data', (done) => { - const commit = jest.fn(); - const context = { + context = { getters, commit, }; res = { id: 1 }; mock.onAny().reply(200, res); + params = { note: { id: 1 }, noteText: 'test' }; + }); - actions - .updateDraft(context, { note: { id: 1 }, noteText: 'test', callback() {} }) - .then(() => { - expect(commit).toHaveBeenCalledWith('RECEIVE_DRAFT_UPDATE_SUCCESS', { id: 1 }); - }) - .then(done) - .catch(done.fail); + afterEach(() => jest.clearAllMocks()); + + it('commits RECEIVE_DRAFT_UPDATE_SUCCESS with returned data', () => { + return actions.updateDraft(context, { ...params, callback() {} }).then(() => { + expect(commit).toHaveBeenCalledWith('RECEIVE_DRAFT_UPDATE_SUCCESS', { id: 1 }); + }); }); - it('calls passed callback', (done) => { - const commit = jest.fn(); - const context = { - getters, - commit, - }; + it('calls passed callback', () => { const callback = jest.fn(); - res = { id: 1 }; - mock.onAny().reply(200, res); + return actions.updateDraft(context, { ...params, callback }).then(() => { + expect(callback).toHaveBeenCalled(); + }); + }); - actions - .updateDraft(context, { note: { id: 1 }, noteText: 'test', callback }) - .then(() => { - expect(callback).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + it('does not stringify empty position', () => { + return actions.updateDraft(context, { ...params, position: {}, callback() {} }).then(() => { + expect(service.update.mock.calls[0][1].position).toBeUndefined(); + }); + }); + + it('stringifies a non-empty position', () => { + const position = { test: true }; + const expectation = JSON.stringify(position); + return actions.updateDraft(context, { ...params, position, callback() {} }).then(() => { + expect(service.update.mock.calls[0][1].position).toBe(expectation); + }); }); }); diff --git a/spec/frontend/behaviors/quick_submit_spec.js b/spec/frontend/behaviors/quick_submit_spec.js index d3d65892aff..86a85831c6b 100644 --- a/spec/frontend/behaviors/quick_submit_spec.js +++ b/spec/frontend/behaviors/quick_submit_spec.js @@ -6,8 +6,6 @@ describe('Quick Submit behavior', () => { const keydownEvent = (options = { keyCode: 13, metaKey: true }) => $.Event('keydown', options); - preloadFixtures('snippets/show.html'); - beforeEach(() => { loadFixtures('snippets/show.html'); diff --git a/spec/frontend/behaviors/requires_input_spec.js b/spec/frontend/behaviors/requires_input_spec.js index 0f27f89d6dc..bb22133ae44 100644 --- a/spec/frontend/behaviors/requires_input_spec.js +++ b/spec/frontend/behaviors/requires_input_spec.js @@ -3,7 +3,6 @@ import '~/behaviors/requires_input'; describe('requiresInput', () => { let submitButton; - preloadFixtures('branches/new_branch.html'); beforeEach(() => { loadFixtures('branches/new_branch.html'); diff --git a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js index 94ba1615c89..26d38b115b6 100644 --- a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js +++ b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js @@ -13,8 +13,6 @@ describe('ShortcutsIssuable', () => { const snippetShowFixtureName = 'snippets/show.html'; const mrShowFixtureName = 'merge_requests/merge_request_of_current_user.html'; - preloadFixtures(snippetShowFixtureName, mrShowFixtureName); - beforeAll((done) => { initCopyAsGFM(); diff --git a/spec/frontend/blob/blob_file_dropzone_spec.js b/spec/frontend/blob/blob_file_dropzone_spec.js index cbd36abd4ff..e387ab7c18a 100644 --- a/spec/frontend/blob/blob_file_dropzone_spec.js +++ b/spec/frontend/blob/blob_file_dropzone_spec.js @@ -1,8 +1,12 @@ import $ from 'jquery'; import BlobFileDropzone from '~/blob/blob_file_dropzone'; +import { trackUploadFileFormSubmitted } from '~/projects/upload_file_experiment'; + +jest.mock('~/projects/upload_file_experiment', () => ({ + trackUploadFileFormSubmitted: jest.fn(), +})); describe('BlobFileDropzone', () => { - preloadFixtures('blob/show.html'); let dropzone; let replaceFileButton; @@ -41,5 +45,13 @@ describe('BlobFileDropzone', () => { expect(replaceFileButton.is(':disabled')).toEqual(true); expect(dropzone.processQueue).toHaveBeenCalled(); }); + + it('calls the tracking event', () => { + jest.spyOn(window, 'alert').mockImplementation(() => {}); + + replaceFileButton.click(); + + expect(trackUploadFileFormSubmitted).toHaveBeenCalled(); + }); }); }); diff --git a/spec/frontend/blob/sketch/index_spec.js b/spec/frontend/blob/sketch/index_spec.js index a24e7de9037..7424897b22c 100644 --- a/spec/frontend/blob/sketch/index_spec.js +++ b/spec/frontend/blob/sketch/index_spec.js @@ -4,8 +4,6 @@ import SketchLoader from '~/blob/sketch'; jest.mock('jszip'); describe('Sketch viewer', () => { - preloadFixtures('static/sketch_viewer.html'); - beforeEach(() => { loadFixtures('static/sketch_viewer.html'); }); diff --git a/spec/frontend/blob/viewer/index_spec.js b/spec/frontend/blob/viewer/index_spec.js index 7449de48ec0..e4f145ae81b 100644 --- a/spec/frontend/blob/viewer/index_spec.js +++ b/spec/frontend/blob/viewer/index_spec.js @@ -16,8 +16,6 @@ describe('Blob viewer', () => { setTestTimeout(2000); - preloadFixtures('blob/show_readme.html'); - beforeEach(() => { $.fn.extend(jQueryMock); mock = new MockAdapter(axios); @@ -85,9 +83,11 @@ describe('Blob viewer', () => { describe('copy blob button', () => { let copyButton; + let copyButtonTooltip; beforeEach(() => { copyButton = document.querySelector('.js-copy-blob-source-btn'); + copyButtonTooltip = document.querySelector('.js-copy-blob-source-btn-tooltip'); }); it('disabled on load', () => { @@ -95,7 +95,7 @@ describe('Blob viewer', () => { }); it('has tooltip when disabled', () => { - expect(copyButton.getAttribute('title')).toBe( + expect(copyButtonTooltip.getAttribute('title')).toBe( 'Switch to the source to copy the file contents', ); }); @@ -131,7 +131,7 @@ describe('Blob viewer', () => { document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); setImmediate(() => { - expect(copyButton.getAttribute('title')).toBe('Copy file contents'); + expect(copyButtonTooltip.getAttribute('title')).toBe('Copy file contents'); done(); }); diff --git a/spec/frontend/boards/issue_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js index b9f84fed6b3..4487fc15de6 100644 --- a/spec/frontend/boards/issue_card_inner_spec.js +++ b/spec/frontend/boards/board_card_inner_spec.js @@ -1,7 +1,7 @@ import { GlLabel } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { range } from 'lodash'; -import IssueCardInner from '~/boards/components/issue_card_inner.vue'; +import BoardCardInner from '~/boards/components/board_card_inner.vue'; import eventHub from '~/boards/eventhub'; import defaultStore from '~/boards/stores'; import { updateHistory } from '~/lib/utils/url_utility'; @@ -10,7 +10,7 @@ import { mockLabelList } from './mock_data'; jest.mock('~/lib/utils/url_utility'); jest.mock('~/boards/eventhub'); -describe('Issue card component', () => { +describe('Board card component', () => { const user = { id: 1, name: 'testing 123', @@ -31,18 +31,17 @@ describe('Issue card component', () => { let list; const createWrapper = (props = {}, store = defaultStore) => { - wrapper = mount(IssueCardInner, { + wrapper = mount(BoardCardInner, { store, propsData: { list, - issue, + item: issue, ...props, }, stubs: { GlLabel: true, }, provide: { - groupId: null, rootPath: '/', scopedLabelsAvailable: false, }, @@ -63,7 +62,7 @@ describe('Issue card component', () => { weight: 1, }; - createWrapper({ issue, list }); + createWrapper({ item: issue, list }); }); afterEach(() => { @@ -103,8 +102,8 @@ describe('Issue card component', () => { describe('confidential issue', () => { beforeEach(() => { wrapper.setProps({ - issue: { - ...wrapper.props('issue'), + item: { + ...wrapper.props('item'), confidential: true, }, }); @@ -119,8 +118,8 @@ describe('Issue card component', () => { describe('with avatar', () => { beforeEach(() => { wrapper.setProps({ - issue: { - ...wrapper.props('issue'), + item: { + ...wrapper.props('item'), assignees: [user], updateData(newData) { Object.assign(this, newData); @@ -146,8 +145,8 @@ describe('Issue card component', () => { }); it('renders the avatar using avatarUrl property', async () => { - wrapper.props('issue').updateData({ - ...wrapper.props('issue'), + wrapper.props('item').updateData({ + ...wrapper.props('item'), assignees: [ { id: '1', @@ -172,8 +171,8 @@ describe('Issue card component', () => { global.gon.default_avatar_url = 'default_avatar'; wrapper.setProps({ - issue: { - ...wrapper.props('issue'), + item: { + ...wrapper.props('item'), assignees: [ { id: 1, @@ -201,8 +200,8 @@ describe('Issue card component', () => { describe('multiple assignees', () => { beforeEach(() => { wrapper.setProps({ - issue: { - ...wrapper.props('issue'), + item: { + ...wrapper.props('item'), assignees: [ { id: 2, @@ -233,7 +232,7 @@ describe('Issue card component', () => { describe('more than three assignees', () => { beforeEach(() => { - const { assignees } = wrapper.props('issue'); + const { assignees } = wrapper.props('item'); assignees.push({ id: 5, name: 'user5', @@ -242,8 +241,8 @@ describe('Issue card component', () => { }); wrapper.setProps({ - issue: { - ...wrapper.props('issue'), + item: { + ...wrapper.props('item'), assignees, }, }); @@ -259,7 +258,7 @@ describe('Issue card component', () => { it('renders 99+ avatar counter', async () => { const assignees = [ - ...wrapper.props('issue').assignees, + ...wrapper.props('item').assignees, ...range(5, 103).map((i) => ({ id: i, name: 'name', @@ -268,8 +267,8 @@ describe('Issue card component', () => { })), ]; wrapper.setProps({ - issue: { - ...wrapper.props('issue'), + item: { + ...wrapper.props('item'), assignees, }, }); @@ -283,7 +282,7 @@ describe('Issue card component', () => { describe('labels', () => { beforeEach(() => { - wrapper.setProps({ issue: { ...issue, labels: [list.label, label1] } }); + wrapper.setProps({ item: { ...issue, labels: [list.label, label1] } }); }); it('does not render list label but renders all other labels', () => { @@ -295,7 +294,7 @@ describe('Issue card component', () => { }); it('does not render label if label does not have an ID', async () => { - wrapper.setProps({ issue: { ...issue, labels: [label1, { title: 'closed' }] } }); + wrapper.setProps({ item: { ...issue, labels: [label1, { title: 'closed' }] } }); await wrapper.vm.$nextTick(); @@ -307,8 +306,8 @@ describe('Issue card component', () => { describe('blocked', () => { beforeEach(() => { wrapper.setProps({ - issue: { - ...wrapper.props('issue'), + item: { + ...wrapper.props('item'), blocked: true, }, }); diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index 7ed20f20882..bf39c3f3e42 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -1,8 +1,9 @@ -import { createLocalVue, mount } from '@vue/test-utils'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; import BoardCard from '~/boards/components/board_card.vue'; import BoardList from '~/boards/components/board_list.vue'; +import BoardNewIssue from '~/boards/components/board_new_issue.vue'; import eventHub from '~/boards/eventhub'; import defaultState from '~/boards/stores/state'; import { mockList, mockIssuesByListId, issues, mockIssues } from './mock_data'; @@ -11,13 +12,18 @@ const localVue = createLocalVue(); localVue.use(Vuex); const actions = { - fetchIssuesForList: jest.fn(), + fetchItemsForList: jest.fn(), }; const createStore = (state = defaultState) => { return new Vuex.Store({ state, actions, + getters: { + isGroupBoard: () => false, + isProjectBoard: () => true, + isEpicBoard: () => false, + }, }); }; @@ -28,8 +34,8 @@ const createComponent = ({ state = {}, } = {}) => { const store = createStore({ - issuesByListId: mockIssuesByListId, - issues, + boardItemsByListId: mockIssuesByListId, + boardItems: issues, pageInfoByListId: { 'gid://gitlab/List/1': { hasNextPage: true }, 'gid://gitlab/List/2': {}, @@ -38,6 +44,7 @@ const createComponent = ({ 'gid://gitlab/List/1': {}, 'gid://gitlab/List/2': {}, }, + selectedBoardItems: [], ...state, }); @@ -58,12 +65,12 @@ const createComponent = ({ list.issuesCount = 1; } - const component = mount(BoardList, { + const component = shallowMount(BoardList, { localVue, propsData: { disabled: false, list, - issues: [issue], + boardItems: [issue], canAdminList: true, ...componentProps, }, @@ -74,6 +81,10 @@ const createComponent = ({ weightFeatureAvailable: false, boardWeight: null, }, + stubs: { + BoardCard, + BoardNewIssue, + }, }); return component; @@ -81,7 +92,10 @@ const createComponent = ({ describe('Board list component', () => { let wrapper; + const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); + const findIssueCountLoadingIcon = () => wrapper.find('[data-testid="count-loading-icon"]'); + useFakeRequestAnimationFrame(); afterEach(() => { @@ -111,7 +125,7 @@ describe('Board list component', () => { }); it('sets data attribute with issue id', () => { - expect(wrapper.find('.board-card').attributes('data-issue-id')).toBe('1'); + expect(wrapper.find('.board-card').attributes('data-item-id')).toBe('1'); }); it('shows new issue form', async () => { @@ -170,7 +184,7 @@ describe('Board list component', () => { it('loads more issues after scrolling', () => { wrapper.vm.listRef.dispatchEvent(new Event('scroll')); - expect(actions.fetchIssuesForList).toHaveBeenCalled(); + expect(actions.fetchItemsForList).toHaveBeenCalled(); }); it('does not load issues if already loading', () => { @@ -179,7 +193,7 @@ describe('Board list component', () => { }); wrapper.vm.listRef.dispatchEvent(new Event('scroll')); - expect(actions.fetchIssuesForList).not.toHaveBeenCalled(); + expect(actions.fetchItemsForList).not.toHaveBeenCalled(); }); it('shows loading more spinner', async () => { @@ -189,7 +203,8 @@ describe('Board list component', () => { wrapper.vm.showCount = true; await wrapper.vm.$nextTick(); - expect(wrapper.find('.board-list-count .gl-spinner').exists()).toBe(true); + + expect(findIssueCountLoadingIcon().exists()).toBe(true); }); }); @@ -243,7 +258,7 @@ describe('Board list component', () => { describe('handleDragOnEnd', () => { it('removes class `is-dragging` from document body', () => { - jest.spyOn(wrapper.vm, 'moveIssue').mockImplementation(() => {}); + jest.spyOn(wrapper.vm, 'moveItem').mockImplementation(() => {}); document.body.classList.add('is-dragging'); findByTestId('tree-root-wrapper').vm.$emit('end', { @@ -251,9 +266,9 @@ describe('Board list component', () => { newIndex: 0, item: { dataset: { - issueId: mockIssues[0].id, - issueIid: mockIssues[0].iid, - issuePath: mockIssues[0].referencePath, + itemId: mockIssues[0].id, + itemIid: mockIssues[0].iid, + itemPath: mockIssues[0].referencePath, }, }, to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } }, diff --git a/spec/frontend/boards/board_new_issue_deprecated_spec.js b/spec/frontend/boards/board_new_issue_deprecated_spec.js index 1a29f680166..3903ad201b2 100644 --- a/spec/frontend/boards/board_new_issue_deprecated_spec.js +++ b/spec/frontend/boards/board_new_issue_deprecated_spec.js @@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import Vue from 'vue'; +import Vuex from 'vuex'; import boardNewIssue from '~/boards/components/board_new_issue_deprecated.vue'; import boardsStore from '~/boards/stores/boards_store'; import axios from '~/lib/utils/axios_utils'; @@ -10,6 +11,8 @@ import axios from '~/lib/utils/axios_utils'; import '~/boards/models/list'; import { listObj, boardsMockInterceptor } from './mock_data'; +Vue.use(Vuex); + describe('Issue boards new issue form', () => { let wrapper; let vm; @@ -43,11 +46,16 @@ describe('Issue boards new issue form', () => { newIssueMock = Promise.resolve(promiseReturn); jest.spyOn(list, 'newIssue').mockImplementation(() => newIssueMock); + const store = new Vuex.Store({ + getters: { isGroupBoard: () => false }, + }); + wrapper = mount(BoardNewIssueComp, { propsData: { disabled: false, list, }, + store, provide: { groupId: null, }, diff --git a/spec/frontend/boards/components/board_add_new_column_form_spec.js b/spec/frontend/boards/components/board_add_new_column_form_spec.js new file mode 100644 index 00000000000..3702f55f17b --- /dev/null +++ b/spec/frontend/boards/components/board_add_new_column_form_spec.js @@ -0,0 +1,166 @@ +import { GlFormGroup, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue'; +import defaultState from '~/boards/stores/state'; +import { mockLabelList } from '../mock_data'; + +Vue.use(Vuex); + +describe('Board card layout', () => { + let wrapper; + + const createStore = ({ actions = {}, getters = {}, state = {} } = {}) => { + return new Vuex.Store({ + state: { + ...defaultState, + ...state, + }, + actions, + getters, + }); + }; + + const mountComponent = ({ + loading = false, + formDescription = '', + searchLabel = '', + searchPlaceholder = '', + selectedId, + actions, + slots, + } = {}) => { + wrapper = extendedWrapper( + shallowMount(BoardAddNewColumnForm, { + stubs: { + GlFormGroup: true, + }, + propsData: { + loading, + formDescription, + searchLabel, + searchPlaceholder, + selectedId, + }, + slots, + store: createStore({ + actions: { + setAddColumnFormVisibility: jest.fn(), + ...actions, + }, + }), + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const formTitle = () => wrapper.findByTestId('board-add-column-form-title').text(); + const findSearchInput = () => wrapper.find(GlSearchBoxByType); + const findSearchLabel = () => wrapper.find(GlFormGroup); + const cancelButton = () => wrapper.findByTestId('cancelAddNewColumn'); + const submitButton = () => wrapper.findByTestId('addNewColumnButton'); + + it('shows form title & search input', () => { + mountComponent(); + + expect(formTitle()).toEqual(BoardAddNewColumnForm.i18n.newList); + expect(findSearchInput().exists()).toBe(true); + }); + + it('clicking cancel hides the form', () => { + const setAddColumnFormVisibility = jest.fn(); + mountComponent({ + actions: { + setAddColumnFormVisibility, + }, + }); + + cancelButton().vm.$emit('click'); + + expect(setAddColumnFormVisibility).toHaveBeenCalledWith(expect.anything(), false); + }); + + it('sets placeholder and description from props', () => { + const props = { + formDescription: 'Some description of a list', + }; + + mountComponent(props); + + expect(wrapper.html()).toHaveText(props.formDescription); + }); + + describe('items', () => { + const mountWithItems = (loading) => + mountComponent({ + loading, + slots: { + items: '<div class="item-slot">Some kind of list</div>', + }, + }); + + it('hides items slot and shows skeleton while loading', () => { + mountWithItems(true); + + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true); + expect(wrapper.find('.item-slot').exists()).toBe(false); + }); + + it('shows items slot and hides skeleton while not loading', () => { + mountWithItems(false); + + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false); + expect(wrapper.find('.item-slot').exists()).toBe(true); + }); + }); + + describe('search box', () => { + it('sets label and placeholder text from props', () => { + const props = { + searchLabel: 'Some items', + searchPlaceholder: 'Search for an item', + }; + + mountComponent(props); + + expect(findSearchLabel().attributes('label')).toEqual(props.searchLabel); + expect(findSearchInput().attributes('placeholder')).toEqual(props.searchPlaceholder); + }); + + it('emits filter event on input', () => { + mountComponent(); + + const searchText = 'some text'; + + findSearchInput().vm.$emit('input', searchText); + + expect(wrapper.emitted('filter-items')).toEqual([[searchText]]); + }); + }); + + describe('Add list button', () => { + it('is disabled if no item is selected', () => { + mountComponent(); + + expect(submitButton().props('disabled')).toBe(true); + }); + + it('emits add-list event on click', async () => { + mountComponent({ + selectedId: mockLabelList.label.id, + }); + + await nextTick(); + + submitButton().vm.$emit('click'); + + expect(wrapper.emitted('add-list')).toEqual([[]]); + }); + }); +}); diff --git a/spec/frontend/boards/components/board_add_new_column_spec.js b/spec/frontend/boards/components/board_add_new_column_spec.js new file mode 100644 index 00000000000..60584eaf6cf --- /dev/null +++ b/spec/frontend/boards/components/board_add_new_column_spec.js @@ -0,0 +1,115 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import BoardAddNewColumn from '~/boards/components/board_add_new_column.vue'; +import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue'; +import defaultState from '~/boards/stores/state'; +import { mockLabelList } from '../mock_data'; + +Vue.use(Vuex); + +describe('Board card layout', () => { + let wrapper; + + const createStore = ({ actions = {}, getters = {}, state = {} } = {}) => { + return new Vuex.Store({ + state: { + ...defaultState, + ...state, + }, + actions, + getters, + }); + }; + + const mountComponent = ({ + selectedId, + labels = [], + getListByLabelId = jest.fn(), + actions = {}, + } = {}) => { + wrapper = extendedWrapper( + shallowMount(BoardAddNewColumn, { + data() { + return { + selectedId, + }; + }, + store: createStore({ + actions: { + fetchLabels: jest.fn(), + setAddColumnFormVisibility: jest.fn(), + ...actions, + }, + getters: { + shouldUseGraphQL: () => true, + getListByLabelId: () => getListByLabelId, + }, + state: { + labels, + labelsLoading: false, + isEpicBoard: false, + }, + }), + provide: { + scopedLabelsAvailable: true, + }, + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Add list button', () => { + it('calls addList', async () => { + const getListByLabelId = jest.fn().mockReturnValue(null); + const highlightList = jest.fn(); + const createList = jest.fn(); + + mountComponent({ + labels: [mockLabelList.label], + selectedId: mockLabelList.label.id, + getListByLabelId, + actions: { + createList, + highlightList, + }, + }); + + wrapper.findComponent(BoardAddNewColumnForm).vm.$emit('add-list'); + + await nextTick(); + + expect(highlightList).not.toHaveBeenCalled(); + expect(createList).toHaveBeenCalledWith(expect.anything(), { + labelId: mockLabelList.label.id, + }); + }); + + it('highlights existing list if trying to re-add', async () => { + const getListByLabelId = jest.fn().mockReturnValue(mockLabelList); + const highlightList = jest.fn(); + const createList = jest.fn(); + + mountComponent({ + labels: [mockLabelList.label], + selectedId: mockLabelList.label.id, + getListByLabelId, + actions: { + createList, + highlightList, + }, + }); + + wrapper.findComponent(BoardAddNewColumnForm).vm.$emit('add-list'); + + await nextTick(); + + expect(highlightList).toHaveBeenCalledWith(expect.anything(), mockLabelList.id); + expect(createList).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/boards/components/board_card_deprecated_spec.js b/spec/frontend/boards/components/board_card_deprecated_spec.js new file mode 100644 index 00000000000..266cbc7106d --- /dev/null +++ b/spec/frontend/boards/components/board_card_deprecated_spec.js @@ -0,0 +1,219 @@ +/* global List */ +/* global ListAssignee */ +/* global ListLabel */ + +import { mount } from '@vue/test-utils'; + +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import BoardCardDeprecated from '~/boards/components/board_card_deprecated.vue'; +import issueCardInner from '~/boards/components/issue_card_inner_deprecated.vue'; +import eventHub from '~/boards/eventhub'; +import store from '~/boards/stores'; +import boardsStore from '~/boards/stores/boards_store'; +import axios from '~/lib/utils/axios_utils'; + +import sidebarEventHub from '~/sidebar/event_hub'; +import '~/boards/models/label'; +import '~/boards/models/assignee'; +import '~/boards/models/list'; +import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data'; + +describe('BoardCard', () => { + let wrapper; + let mock; + let list; + + const findIssueCardInner = () => wrapper.find(issueCardInner); + const findUserAvatarLink = () => wrapper.find(userAvatarLink); + + // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized + const mountComponent = (propsData) => { + wrapper = mount(BoardCardDeprecated, { + stubs: { + issueCardInner, + }, + store, + propsData: { + list, + issue: list.issues[0], + disabled: false, + index: 0, + ...propsData, + }, + provide: { + groupId: null, + rootPath: '/', + scopedLabelsAvailable: false, + }, + }); + }; + + const setupData = async () => { + list = new List(listObj); + boardsStore.create(); + boardsStore.detail.issue = {}; + const label1 = new ListLabel({ + id: 3, + title: 'testing 123', + color: '#000cff', + text_color: 'white', + description: 'test', + }); + await waitForPromises(); + + list.issues[0].labels.push(label1); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onAny().reply(boardsMockInterceptor); + setMockEndpoints(); + return setupData(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + list = null; + mock.restore(); + }); + + it('when details issue is empty does not show the element', () => { + mountComponent(); + expect(wrapper.find('[data-testid="board_card"').classes()).not.toContain('is-active'); + }); + + it('when detailIssue is equal to card issue shows the element', () => { + [boardsStore.detail.issue] = list.issues; + mountComponent(); + + expect(wrapper.classes()).toContain('is-active'); + }); + + it('when multiSelect does not contain issue removes multi select class', () => { + mountComponent(); + expect(wrapper.classes()).not.toContain('multi-select'); + }); + + it('when multiSelect contain issue add multi select class', () => { + boardsStore.multiSelect.list = [list.issues[0]]; + mountComponent(); + + expect(wrapper.classes()).toContain('multi-select'); + }); + + it('adds user-can-drag class if not disabled', () => { + mountComponent(); + expect(wrapper.classes()).toContain('user-can-drag'); + }); + + it('does not add user-can-drag class disabled', () => { + mountComponent({ disabled: true }); + + expect(wrapper.classes()).not.toContain('user-can-drag'); + }); + + it('does not add disabled class', () => { + mountComponent(); + expect(wrapper.classes()).not.toContain('is-disabled'); + }); + + it('adds disabled class is disabled is true', () => { + mountComponent({ disabled: true }); + + expect(wrapper.classes()).toContain('is-disabled'); + }); + + describe('mouse events', () => { + it('does not set detail issue if showDetail is false', () => { + mountComponent(); + expect(boardsStore.detail.issue).toEqual({}); + }); + + it('does not set detail issue if link is clicked', () => { + mountComponent(); + findIssueCardInner().find('a').trigger('mouseup'); + + expect(boardsStore.detail.issue).toEqual({}); + }); + + it('does not set detail issue if img is clicked', () => { + mountComponent({ + issue: { + ...list.issues[0], + assignees: [ + new ListAssignee({ + id: 1, + name: 'testing 123', + username: 'test', + avatar: 'test_image', + }), + ], + }, + }); + + findUserAvatarLink().trigger('mouseup'); + + expect(boardsStore.detail.issue).toEqual({}); + }); + + it('does not set detail issue if showDetail is false after mouseup', () => { + mountComponent(); + wrapper.trigger('mouseup'); + + expect(boardsStore.detail.issue).toEqual({}); + }); + + it('sets detail issue to card issue on mouse up', () => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + mountComponent(); + + wrapper.trigger('mousedown'); + wrapper.trigger('mouseup'); + + expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', wrapper.vm.issue, false); + expect(boardsStore.detail.list).toEqual(wrapper.vm.list); + }); + + it('resets detail issue to empty if already set', () => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + const [issue] = list.issues; + boardsStore.detail.issue = issue; + mountComponent(); + + wrapper.trigger('mousedown'); + wrapper.trigger('mouseup'); + + expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', false); + }); + }); + + describe('sidebarHub events', () => { + it('closes all sidebars before showing an issue if no issues are opened', () => { + jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {}); + boardsStore.detail.issue = {}; + mountComponent(); + + // sets conditional so that event is emitted. + wrapper.trigger('mousedown'); + + wrapper.trigger('mouseup'); + + expect(sidebarEventHub.$emit).toHaveBeenCalledWith('sidebar.closeAll'); + }); + + it('it does not closes all sidebars before showing an issue if an issue is opened', () => { + jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {}); + const [issue] = list.issues; + boardsStore.detail.issue = issue; + mountComponent(); + + wrapper.trigger('mousedown'); + + expect(sidebarEventHub.$emit).not.toHaveBeenCalledWith('sidebar.closeAll'); + }); + }); +}); diff --git a/spec/frontend/boards/components/board_card_layout_deprecated_spec.js b/spec/frontend/boards/components/board_card_layout_deprecated_spec.js index 426c5289ba6..9853c9f434f 100644 --- a/spec/frontend/boards/components/board_card_layout_deprecated_spec.js +++ b/spec/frontend/boards/components/board_card_layout_deprecated_spec.js @@ -11,7 +11,7 @@ import '~/boards/models/label'; import '~/boards/models/assignee'; import '~/boards/models/list'; import BoardCardLayout from '~/boards/components/board_card_layout_deprecated.vue'; -import issueCardInner from '~/boards/components/issue_card_inner.vue'; +import issueCardInner from '~/boards/components/issue_card_inner_deprecated.vue'; import { ISSUABLE } from '~/boards/constants'; import boardsVuexStore from '~/boards/stores'; import boardsStore from '~/boards/stores/boards_store'; diff --git a/spec/frontend/boards/components/board_card_layout_spec.js b/spec/frontend/boards/components/board_card_layout_spec.js deleted file mode 100644 index 3fa8714807c..00000000000 --- a/spec/frontend/boards/components/board_card_layout_spec.js +++ /dev/null @@ -1,116 +0,0 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import Vuex from 'vuex'; - -import BoardCardLayout from '~/boards/components/board_card_layout.vue'; -import IssueCardInner from '~/boards/components/issue_card_inner.vue'; -import { ISSUABLE } from '~/boards/constants'; -import defaultState from '~/boards/stores/state'; -import { mockLabelList, mockIssue } from '../mock_data'; - -describe('Board card layout', () => { - let wrapper; - let store; - - const localVue = createLocalVue(); - localVue.use(Vuex); - - const createStore = ({ getters = {}, actions = {} } = {}) => { - store = new Vuex.Store({ - state: defaultState, - actions, - getters, - }); - }; - - // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized - const mountComponent = ({ propsData = {}, provide = {} } = {}) => { - wrapper = shallowMount(BoardCardLayout, { - localVue, - stubs: { - IssueCardInner, - }, - store, - propsData: { - list: mockLabelList, - issue: mockIssue, - disabled: false, - index: 0, - ...propsData, - }, - provide: { - groupId: null, - rootPath: '/', - scopedLabelsAvailable: false, - ...provide, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('mouse events', () => { - it('sets showDetail to true on mousedown', async () => { - createStore(); - mountComponent(); - - wrapper.trigger('mousedown'); - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.showDetail).toBe(true); - }); - - it('sets showDetail to false on mousemove', async () => { - createStore(); - mountComponent(); - wrapper.trigger('mousedown'); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.showDetail).toBe(true); - wrapper.trigger('mousemove'); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.showDetail).toBe(false); - }); - - it("calls 'setActiveId'", async () => { - const setActiveId = jest.fn(); - createStore({ - actions: { - setActiveId, - }, - }); - mountComponent(); - - wrapper.trigger('mouseup'); - await wrapper.vm.$nextTick(); - - expect(setActiveId).toHaveBeenCalledTimes(1); - expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), { - id: mockIssue.id, - sidebarType: ISSUABLE, - }); - }); - - it("calls 'setActiveId' when epic swimlanes is active", async () => { - const setActiveId = jest.fn(); - const isSwimlanesOn = () => true; - createStore({ - getters: { isSwimlanesOn }, - actions: { - setActiveId, - }, - }); - mountComponent(); - - wrapper.trigger('mouseup'); - await wrapper.vm.$nextTick(); - - expect(setActiveId).toHaveBeenCalledTimes(1); - expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), { - id: mockIssue.id, - sidebarType: ISSUABLE, - }); - }); - }); -}); diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js index 5f26ae1bb3b..022f8c05e1e 100644 --- a/spec/frontend/boards/components/board_card_spec.js +++ b/spec/frontend/boards/components/board_card_spec.js @@ -1,43 +1,50 @@ -/* global List */ -/* global ListAssignee */ -/* global ListLabel */ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; -import { mount } from '@vue/test-utils'; - -import MockAdapter from 'axios-mock-adapter'; -import waitForPromises from 'helpers/wait_for_promises'; import BoardCard from '~/boards/components/board_card.vue'; -import issueCardInner from '~/boards/components/issue_card_inner.vue'; -import eventHub from '~/boards/eventhub'; -import store from '~/boards/stores'; -import boardsStore from '~/boards/stores/boards_store'; -import axios from '~/lib/utils/axios_utils'; - -import sidebarEventHub from '~/sidebar/event_hub'; -import '~/boards/models/label'; -import '~/boards/models/assignee'; -import '~/boards/models/list'; -import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data'; - -describe('BoardCard', () => { - let wrapper; - let mock; - let list; +import BoardCardInner from '~/boards/components/board_card_inner.vue'; +import { inactiveId } from '~/boards/constants'; +import { mockLabelList, mockIssue } from '../mock_data'; - const findIssueCardInner = () => wrapper.find(issueCardInner); - const findUserAvatarLink = () => wrapper.find(userAvatarLink); +describe('Board card', () => { + let wrapper; + let store; + let mockActions; + + const localVue = createLocalVue(); + localVue.use(Vuex); + + const createStore = ({ initialState = {}, isSwimlanesOn = false } = {}) => { + mockActions = { + toggleBoardItem: jest.fn(), + toggleBoardItemMultiSelection: jest.fn(), + }; + + store = new Vuex.Store({ + state: { + activeId: inactiveId, + selectedBoardItems: [], + ...initialState, + }, + actions: mockActions, + getters: { + isSwimlanesOn: () => isSwimlanesOn, + isEpicBoard: () => false, + }, + }); + }; // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized - const mountComponent = (propsData) => { - wrapper = mount(BoardCard, { + const mountComponent = ({ propsData = {}, provide = {} } = {}) => { + wrapper = shallowMount(BoardCard, { + localVue, stubs: { - issueCardInner, + BoardCardInner, }, store, propsData: { - list, - issue: list.issues[0], + list: mockLabelList, + item: mockIssue, disabled: false, index: 0, ...propsData, @@ -46,174 +53,94 @@ describe('BoardCard', () => { groupId: null, rootPath: '/', scopedLabelsAvailable: false, + ...provide, }, }); }; - const setupData = async () => { - list = new List(listObj); - boardsStore.create(); - boardsStore.detail.issue = {}; - const label1 = new ListLabel({ - id: 3, - title: 'testing 123', - color: '#000cff', - text_color: 'white', - description: 'test', - }); - await waitForPromises(); - - list.issues[0].labels.push(label1); + const selectCard = async () => { + wrapper.trigger('mouseup'); + await wrapper.vm.$nextTick(); }; - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onAny().reply(boardsMockInterceptor); - setMockEndpoints(); - return setupData(); - }); + const multiSelectCard = async () => { + wrapper.trigger('mouseup', { ctrlKey: true }); + await wrapper.vm.$nextTick(); + }; afterEach(() => { wrapper.destroy(); wrapper = null; - list = null; - mock.restore(); - }); - - it('when details issue is empty does not show the element', () => { - mountComponent(); - expect(wrapper.find('[data-testid="board_card"').classes()).not.toContain('is-active'); - }); - - it('when detailIssue is equal to card issue shows the element', () => { - [boardsStore.detail.issue] = list.issues; - mountComponent(); - - expect(wrapper.classes()).toContain('is-active'); - }); - - it('when multiSelect does not contain issue removes multi select class', () => { - mountComponent(); - expect(wrapper.classes()).not.toContain('multi-select'); - }); - - it('when multiSelect contain issue add multi select class', () => { - boardsStore.multiSelect.list = [list.issues[0]]; - mountComponent(); - - expect(wrapper.classes()).toContain('multi-select'); - }); - - it('adds user-can-drag class if not disabled', () => { - mountComponent(); - expect(wrapper.classes()).toContain('user-can-drag'); - }); - - it('does not add user-can-drag class disabled', () => { - mountComponent({ disabled: true }); - - expect(wrapper.classes()).not.toContain('user-can-drag'); - }); - - it('does not add disabled class', () => { - mountComponent(); - expect(wrapper.classes()).not.toContain('is-disabled'); + store = null; }); - it('adds disabled class is disabled is true', () => { - mountComponent({ disabled: true }); - - expect(wrapper.classes()).toContain('is-disabled'); - }); - - describe('mouse events', () => { - it('does not set detail issue if showDetail is false', () => { + describe.each` + isSwimlanesOn + ${true} | ${false} + `('when isSwimlanesOn is $isSwimlanesOn', ({ isSwimlanesOn }) => { + it('should not highlight the card by default', async () => { + createStore({ isSwimlanesOn }); mountComponent(); - expect(boardsStore.detail.issue).toEqual({}); - }); - it('does not set detail issue if link is clicked', () => { - mountComponent(); - findIssueCardInner().find('a').trigger('mouseup'); - - expect(boardsStore.detail.issue).toEqual({}); + expect(wrapper.classes()).not.toContain('is-active'); + expect(wrapper.classes()).not.toContain('multi-select'); }); - it('does not set detail issue if img is clicked', () => { - mountComponent({ - issue: { - ...list.issues[0], - assignees: [ - new ListAssignee({ - id: 1, - name: 'testing 123', - username: 'test', - avatar: 'test_image', - }), - ], + it('should highlight the card with a correct style when selected', async () => { + createStore({ + initialState: { + activeId: mockIssue.id, }, + isSwimlanesOn, }); - - findUserAvatarLink().trigger('mouseup'); - - expect(boardsStore.detail.issue).toEqual({}); - }); - - it('does not set detail issue if showDetail is false after mouseup', () => { - mountComponent(); - wrapper.trigger('mouseup'); - - expect(boardsStore.detail.issue).toEqual({}); - }); - - it('sets detail issue to card issue on mouse up', () => { - jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - mountComponent(); - wrapper.trigger('mousedown'); - wrapper.trigger('mouseup'); - - expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', wrapper.vm.issue, false); - expect(boardsStore.detail.list).toEqual(wrapper.vm.list); + expect(wrapper.classes()).toContain('is-active'); + expect(wrapper.classes()).not.toContain('multi-select'); }); - it('resets detail issue to empty if already set', () => { - jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - const [issue] = list.issues; - boardsStore.detail.issue = issue; + it('should highlight the card with a correct style when multi-selected', async () => { + createStore({ + initialState: { + activeId: inactiveId, + selectedBoardItems: [mockIssue], + }, + isSwimlanesOn, + }); mountComponent(); - wrapper.trigger('mousedown'); - wrapper.trigger('mouseup'); - - expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', false); + expect(wrapper.classes()).toContain('multi-select'); + expect(wrapper.classes()).not.toContain('is-active'); }); - }); - - describe('sidebarHub events', () => { - it('closes all sidebars before showing an issue if no issues are opened', () => { - jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {}); - boardsStore.detail.issue = {}; - mountComponent(); - - // sets conditional so that event is emitted. - wrapper.trigger('mousedown'); - wrapper.trigger('mouseup'); + describe('when mouseup event is called on the card', () => { + beforeEach(() => { + createStore({ isSwimlanesOn }); + mountComponent(); + }); - expect(sidebarEventHub.$emit).toHaveBeenCalledWith('sidebar.closeAll'); - }); + describe('when not using multi-select', () => { + it('should call vuex action "toggleBoardItem" with correct parameters', async () => { + await selectCard(); - it('it does not closes all sidebars before showing an issue if an issue is opened', () => { - jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {}); - const [issue] = list.issues; - boardsStore.detail.issue = issue; - mountComponent(); + expect(mockActions.toggleBoardItem).toHaveBeenCalledTimes(1); + expect(mockActions.toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), { + boardItem: mockIssue, + }); + }); + }); - wrapper.trigger('mousedown'); + describe('when using multi-select', () => { + it('should call vuex action "multiSelectBoardItem" with correct parameters', async () => { + await multiSelectCard(); - expect(sidebarEventHub.$emit).not.toHaveBeenCalledWith('sidebar.closeAll'); + expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledTimes(1); + expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledWith( + expect.any(Object), + mockIssue, + ); + }); + }); }); }); }); diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index 858efea99ad..32499bd5480 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -8,6 +8,7 @@ import { formType } from '~/boards/constants'; import createBoardMutation from '~/boards/graphql/board_create.mutation.graphql'; import destroyBoardMutation from '~/boards/graphql/board_destroy.mutation.graphql'; import updateBoardMutation from '~/boards/graphql/board_update.mutation.graphql'; +import { createStore } from '~/boards/stores'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -48,6 +49,13 @@ describe('BoardForm', () => { const findDeleteConfirmation = () => wrapper.find('[data-testid="delete-confirmation-message"]'); const findInput = () => wrapper.find('#board-new-name'); + const store = createStore({ + getters: { + isGroupBoard: () => true, + isProjectBoard: () => false, + }, + }); + const createComponent = (props, data) => { wrapper = shallowMount(BoardForm, { propsData: { ...defaultProps, ...props }, @@ -64,6 +72,7 @@ describe('BoardForm', () => { mutate, }, }, + store, attachTo: document.body, }); }; diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js index f30e3792435..d2dfb4148b3 100644 --- a/spec/frontend/boards/components/board_list_header_spec.js +++ b/spec/frontend/boards/components/board_list_header_spec.js @@ -1,5 +1,6 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { mockLabelList } from 'jest/boards/mock_data'; import BoardListHeader from '~/boards/components/board_list_header.vue'; @@ -14,6 +15,7 @@ describe('Board List Header Component', () => { let store; const updateListSpy = jest.fn(); + const toggleListCollapsedSpy = jest.fn(); afterEach(() => { wrapper.destroy(); @@ -43,38 +45,39 @@ describe('Board List Header Component', () => { if (withLocalStorage) { localStorage.setItem( - `boards.${boardId}.${listMock.listType}.${listMock.id}.expanded`, - (!collapsed).toString(), + `boards.${boardId}.${listMock.listType}.${listMock.id}.collapsed`, + collapsed.toString(), ); } store = new Vuex.Store({ state: {}, - actions: { updateList: updateListSpy }, - getters: {}, + actions: { updateList: updateListSpy, toggleListCollapsed: toggleListCollapsedSpy }, + getters: { isEpicBoard: () => false }, }); - wrapper = shallowMount(BoardListHeader, { - store, - localVue, - propsData: { - disabled: false, - list: listMock, - }, - provide: { - boardId, - weightFeatureAvailable: false, - currentUserId, - }, - }); + wrapper = extendedWrapper( + shallowMount(BoardListHeader, { + store, + localVue, + propsData: { + disabled: false, + list: listMock, + }, + provide: { + boardId, + weightFeatureAvailable: false, + currentUserId, + }, + }), + ); }; 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'); + const findCaret = () => wrapper.findByTestId('board-title-caret'); describe('Add issue button', () => { const hasNoAddButton = [ListType.closed]; @@ -114,40 +117,29 @@ describe('Board List Header Component', () => { }); describe('expanding / collapsing the column', () => { - it('does not collapse when clicking the header', async () => { + it('should display collapse icon when column is expanded', async () => { createComponent(); - expect(isCollapsed()).toBe(false); - - wrapper.find('[data-testid="board-list-header"]').trigger('click'); + const icon = findCaret(); - await wrapper.vm.$nextTick(); - - expect(isCollapsed()).toBe(false); + expect(icon.props('icon')).toBe('chevron-right'); }); - it('collapses expanded Column when clicking the collapse icon', async () => { - createComponent(); - - expect(isCollapsed()).toBe(false); - - findCaret().vm.$emit('click'); + it('should display expand icon when column is collapsed', async () => { + createComponent({ collapsed: true }); - await wrapper.vm.$nextTick(); + const icon = findCaret(); - expect(isCollapsed()).toBe(true); + expect(icon.props('icon')).toBe('chevron-down'); }); - it('expands collapsed Column when clicking the expand icon', async () => { - createComponent({ collapsed: true }); - - expect(isCollapsed()).toBe(true); + it('should dispatch toggleListCollapse when clicking the collapse icon', async () => { + createComponent(); findCaret().vm.$emit('click'); await wrapper.vm.$nextTick(); - - expect(isCollapsed()).toBe(false); + expect(toggleListCollapsedSpy).toHaveBeenCalledTimes(1); }); it("when logged in it calls list update and doesn't set localStorage", async () => { @@ -157,7 +149,7 @@ describe('Board List Header Component', () => { await wrapper.vm.$nextTick(); expect(updateListSpy).toHaveBeenCalledTimes(1); - expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null); + expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.collapsed`)).toBe(null); }); it("when logged out it doesn't call list update and sets localStorage", async () => { @@ -167,7 +159,7 @@ describe('Board List Header Component', () => { await wrapper.vm.$nextTick(); expect(updateListSpy).not.toHaveBeenCalled(); - expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded())); + expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.collapsed`)).toBe(String(isCollapsed())); }); }); diff --git a/spec/frontend/boards/components/board_new_issue_spec.js b/spec/frontend/boards/components/board_new_issue_spec.js index ce8c95527e9..737a18294bc 100644 --- a/spec/frontend/boards/components/board_new_issue_spec.js +++ b/spec/frontend/boards/components/board_new_issue_spec.js @@ -2,7 +2,6 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import BoardNewIssue from '~/boards/components/board_new_issue.vue'; -import '~/boards/models/list'; import { mockList, mockGroupProjects } from '../mock_data'; const localVue = createLocalVue(); @@ -31,7 +30,7 @@ describe('Issue boards new issue form', () => { const store = new Vuex.Store({ state: { selectedProject: mockGroupProjects[0] }, actions: { addListNewIssue: addListNewIssuesSpy }, - getters: {}, + getters: { isGroupBoard: () => false, isProjectBoard: () => true }, }); wrapper = shallowMount(BoardNewIssue, { diff --git a/spec/frontend/boards/components/filtered_search_spec.js b/spec/frontend/boards/components/filtered_search_spec.js new file mode 100644 index 00000000000..7f238aa671f --- /dev/null +++ b/spec/frontend/boards/components/filtered_search_spec.js @@ -0,0 +1,65 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import FilteredSearch from '~/boards/components/filtered_search.vue'; +import { createStore } from '~/boards/stores'; +import * as commonUtils from '~/lib/utils/common_utils'; +import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('FilteredSearch', () => { + let wrapper; + let store; + + const createComponent = () => { + wrapper = shallowMount(FilteredSearch, { + localVue, + propsData: { search: '' }, + store, + attachTo: document.body, + }); + }; + + beforeEach(() => { + // this needed for actions call for performSearch + window.gon = { features: {} }; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(() => { + store = createStore(); + + jest.spyOn(store, 'dispatch'); + + createComponent(); + }); + + it('finds FilteredSearch', () => { + expect(wrapper.find(FilteredSearchBarRoot).exists()).toBe(true); + }); + + describe('when onFilter is emitted', () => { + it('calls performSearch', () => { + wrapper.find(FilteredSearchBarRoot).vm.$emit('onFilter', [{ value: { data: '' } }]); + + expect(store.dispatch).toHaveBeenCalledWith('performSearch'); + }); + + it('calls historyPushState', () => { + commonUtils.historyPushState = jest.fn(); + wrapper + .find(FilteredSearchBarRoot) + .vm.$emit('onFilter', [{ value: { data: 'searchQuery' } }]); + + expect(commonUtils.historyPushState).toHaveBeenCalledWith( + 'http://test.host/?search=searchQuery', + ); + }); + }); + }); +}); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js index 7838b5a0b2f..8fd178a0856 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js @@ -24,7 +24,7 @@ describe('~/boards/components/sidebar/board_sidebar_due_date.vue', () => { const createWrapper = ({ dueDate = null } = {}) => { store = createStore(); - store.state.issues = { [TEST_ISSUE.id]: { ...TEST_ISSUE, dueDate } }; + store.state.boardItems = { [TEST_ISSUE.id]: { ...TEST_ISSUE, dueDate } }; store.state.activeId = TEST_ISSUE.id; wrapper = shallowMount(BoardSidebarDueDate, { @@ -61,7 +61,7 @@ describe('~/boards/components/sidebar/board_sidebar_due_date.vue', () => { createWrapper(); jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => { - store.state.issues[TEST_ISSUE.id].dueDate = TEST_DUE_DATE; + store.state.boardItems[TEST_ISSUE.id].dueDate = TEST_DUE_DATE; }); findDatePicker().vm.$emit('input', TEST_PARSED_DATE); await wrapper.vm.$nextTick(); @@ -86,7 +86,7 @@ describe('~/boards/components/sidebar/board_sidebar_due_date.vue', () => { createWrapper(); jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => { - store.state.issues[TEST_ISSUE.id].dueDate = null; + store.state.boardItems[TEST_ISSUE.id].dueDate = null; }); findDatePicker().vm.$emit('clear'); await wrapper.vm.$nextTick(); @@ -104,7 +104,7 @@ describe('~/boards/components/sidebar/board_sidebar_due_date.vue', () => { createWrapper({ dueDate: TEST_DUE_DATE }); jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => { - store.state.issues[TEST_ISSUE.id].dueDate = null; + store.state.boardItems[TEST_ISSUE.id].dueDate = null; }); findResetButton().vm.$emit('click'); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_issue_title_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_issue_title_spec.js index bc7df1c76c6..723d0345f76 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_issue_title_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_issue_title_spec.js @@ -34,7 +34,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => { const createWrapper = (issue = TEST_ISSUE_A) => { store = createStore(); - store.state.issues = { [issue.id]: { ...issue } }; + store.state.boardItems = { [issue.id]: { ...issue } }; store.dispatch('setActiveId', { id: issue.id }); wrapper = shallowMount(BoardSidebarIssueTitle, { @@ -74,7 +74,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => { createWrapper(); jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => { - store.state.issues[TEST_ISSUE_A.id].title = TEST_TITLE; + store.state.boardItems[TEST_ISSUE_A.id].title = TEST_TITLE; }); findFormInput().vm.$emit('input', TEST_TITLE); findForm().vm.$emit('submit', { preventDefault: () => {} }); @@ -147,7 +147,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => { createWrapper(TEST_ISSUE_B); jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => { - store.state.issues[TEST_ISSUE_B.id].title = TEST_TITLE; + store.state.boardItems[TEST_ISSUE_B.id].title = TEST_TITLE; }); findFormInput().vm.$emit('input', TEST_TITLE); findCancelButton().vm.$emit('click'); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js index 12b873ba7d8..98ac211238c 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js @@ -25,7 +25,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { const createWrapper = ({ labels = [] } = {}) => { store = createStore(); - store.state.issues = { [TEST_ISSUE.id]: { ...TEST_ISSUE, labels } }; + store.state.boardItems = { [TEST_ISSUE.id]: { ...TEST_ISSUE, labels } }; store.state.activeId = TEST_ISSUE.id; wrapper = shallowMount(BoardSidebarLabelsSelect, { @@ -66,7 +66,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { jest.spyOn(wrapper.vm, 'setActiveIssueLabels').mockImplementation(() => TEST_LABELS); findLabelsSelect().vm.$emit('updateSelectedLabels', TEST_LABELS_PAYLOAD); - store.state.issues[TEST_ISSUE.id].labels = TEST_LABELS; + store.state.boardItems[TEST_ISSUE.id].labels = TEST_LABELS; await wrapper.vm.$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 index 8820ec7ae63..8706424a296 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js @@ -22,7 +22,7 @@ describe('~/boards/components/sidebar/board_sidebar_milestone_select.vue', () => const createWrapper = ({ milestone = null, loading = false } = {}) => { store = createStore(); - store.state.issues = { [TEST_ISSUE.id]: { ...TEST_ISSUE, milestone } }; + store.state.boardItems = { [TEST_ISSUE.id]: { ...TEST_ISSUE, milestone } }; store.state.activeId = TEST_ISSUE.id; wrapper = shallowMount(BoardSidebarMilestoneSelect, { @@ -113,7 +113,7 @@ describe('~/boards/components/sidebar/board_sidebar_milestone_select.vue', () => createWrapper(); jest.spyOn(wrapper.vm, 'setActiveIssueMilestone').mockImplementation(() => { - store.state.issues[TEST_ISSUE.id].milestone = TEST_MILESTONE; + store.state.boardItems[TEST_ISSUE.id].milestone = TEST_MILESTONE; }); findDropdownItem().vm.$emit('click'); await wrapper.vm.$nextTick(); @@ -137,7 +137,7 @@ describe('~/boards/components/sidebar/board_sidebar_milestone_select.vue', () => createWrapper({ milestone: TEST_MILESTONE }); jest.spyOn(wrapper.vm, 'setActiveIssueMilestone').mockImplementation(() => { - store.state.issues[TEST_ISSUE.id].milestone = null; + store.state.boardItems[TEST_ISSUE.id].milestone = null; }); findUnsetMilestoneItem().vm.$emit('click'); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js index 3e6b0be0267..d2d10563ae5 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js @@ -22,7 +22,7 @@ describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () = const createComponent = (activeIssue = { ...mockActiveIssue }) => { store = createStore(); - store.state.issues = { [activeIssue.id]: activeIssue }; + store.state.boardItems = { [activeIssue.id]: activeIssue }; store.state.activeId = activeIssue.id; wrapper = mount(BoardSidebarSubscription, { diff --git a/spec/frontend/boards/components/sidebar/remove_issue_spec.js b/spec/frontend/boards/components/sidebar/remove_issue_spec.js deleted file mode 100644 index 1f740c10106..00000000000 --- a/spec/frontend/boards/components/sidebar/remove_issue_spec.js +++ /dev/null @@ -1,28 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; - -import RemoveIssue from '~/boards/components/sidebar/remove_issue.vue'; - -describe('boards sidebar remove issue', () => { - let wrapper; - - const findButton = () => wrapper.find(GlButton); - - const createComponent = (propsData) => { - wrapper = shallowMount(RemoveIssue, { - propsData: { - issue: {}, - list: {}, - ...propsData, - }, - }); - }; - - beforeEach(() => { - createComponent(); - }); - - it('renders remove button', () => { - expect(findButton().exists()).toBe(true); - }); -}); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 32d0e7ae886..69d2c8977fb 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -5,7 +5,7 @@ import { formatBoardLists, formatIssueInput, } from '~/boards/boards_util'; -import { inactiveId } from '~/boards/constants'; +import { inactiveId, ISSUABLE } from '~/boards/constants'; import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql'; import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql'; import issueMoveListMutation from '~/boards/graphql/issue_move_list.mutation.graphql'; @@ -112,6 +112,15 @@ describe('setActiveId', () => { }); describe('fetchLists', () => { + it('should dispatch fetchIssueLists action', () => { + testAction({ + action: actions.fetchLists, + expectedActions: [{ type: 'fetchIssueLists' }], + }); + }); +}); + +describe('fetchIssueLists', () => { const state = { fullPath: 'gitlab-org', boardId: '1', @@ -138,7 +147,7 @@ describe('fetchLists', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); testAction( - actions.fetchLists, + actions.fetchIssueLists, {}, state, [ @@ -152,6 +161,23 @@ describe('fetchLists', () => { ); }); + it('should commit mutations RECEIVE_BOARD_LISTS_FAILURE on failure', (done) => { + jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject()); + + testAction( + actions.fetchIssueLists, + {}, + state, + [ + { + type: types.RECEIVE_BOARD_LISTS_FAILURE, + }, + ], + [], + done, + ); + }); + it('dispatch createList action when backlog list does not exist and is not hidden', (done) => { queryResponse = { data: { @@ -168,7 +194,7 @@ describe('fetchLists', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); testAction( - actions.fetchLists, + actions.fetchIssueLists, {}, state, [ @@ -184,6 +210,16 @@ describe('fetchLists', () => { }); describe('createList', () => { + it('should dispatch createIssueList action', () => { + testAction({ + action: actions.createList, + payload: { backlog: true }, + expectedActions: [{ type: 'createIssueList', payload: { backlog: true } }], + }); + }); +}); + +describe('createIssueList', () => { let commit; let dispatch; let getters; @@ -223,7 +259,7 @@ describe('createList', () => { }), ); - await actions.createList({ getters, state, commit, dispatch }, { backlog: true }); + await actions.createIssueList({ getters, state, commit, dispatch }, { backlog: true }); expect(dispatch).toHaveBeenCalledWith('addList', backlogList); }); @@ -245,7 +281,7 @@ describe('createList', () => { }, }); - await actions.createList({ getters, state, commit, dispatch }, { labelId: '4' }); + await actions.createIssueList({ getters, state, commit, dispatch }, { labelId: '4' }); expect(dispatch).toHaveBeenCalledWith('addList', list); expect(dispatch).toHaveBeenCalledWith('highlightList', list.id); @@ -257,15 +293,15 @@ describe('createList', () => { data: { boardListCreate: { list: {}, - errors: [{ foo: 'bar' }], + errors: ['foo'], }, }, }), ); - await actions.createList({ getters, state, commit, dispatch }, { backlog: true }); + await actions.createIssueList({ getters, state, commit, dispatch }, { backlog: true }); - expect(commit).toHaveBeenCalledWith(types.CREATE_LIST_FAILURE); + expect(commit).toHaveBeenCalledWith(types.CREATE_LIST_FAILURE, 'foo'); }); it('highlights list and does not re-query if it already exists', async () => { @@ -280,7 +316,7 @@ describe('createList', () => { getListByLabelId: jest.fn().mockReturnValue(existingList), }; - await actions.createList({ getters, state, commit, dispatch }, { backlog: true }); + await actions.createIssueList({ getters, state, commit, dispatch }, { backlog: true }); expect(dispatch).toHaveBeenCalledWith('highlightList', existingList.id); expect(dispatch).toHaveBeenCalledTimes(1); @@ -301,11 +337,15 @@ describe('fetchLabels', () => { }; jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); - await testAction({ - action: actions.fetchLabels, - state: { boardType: 'group' }, - expectedMutations: [{ type: types.RECEIVE_LABELS_SUCCESS, payload: labels }], - }); + const commit = jest.fn(); + const getters = { + shouldUseGraphQL: () => true, + }; + const state = { boardType: 'group' }; + + await actions.fetchLabels({ getters, state, commit }); + + expect(commit).toHaveBeenCalledWith(types.RECEIVE_LABELS_SUCCESS, labels); }); }); @@ -412,6 +452,22 @@ describe('updateList', () => { }); }); +describe('toggleListCollapsed', () => { + it('should commit TOGGLE_LIST_COLLAPSED mutation', async () => { + const payload = { listId: 'gid://gitlab/List/1', collapsed: true }; + await testAction({ + action: actions.toggleListCollapsed, + payload, + expectedMutations: [ + { + type: types.TOGGLE_LIST_COLLAPSED, + payload, + }, + ], + }); + }); +}); + describe('removeList', () => { let state; const list = mockLists[0]; @@ -490,7 +546,7 @@ describe('removeList', () => { }); }); -describe('fetchIssuesForList', () => { +describe('fetchItemsForList', () => { const listId = mockLists[0].id; const state = { @@ -533,21 +589,21 @@ describe('fetchIssuesForList', () => { [listId]: pageInfo, }; - it('should commit mutations REQUEST_ISSUES_FOR_LIST and RECEIVE_ISSUES_FOR_LIST_SUCCESS on success', (done) => { + it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_SUCCESS on success', (done) => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); testAction( - actions.fetchIssuesForList, + actions.fetchItemsForList, { listId }, state, [ { - type: types.REQUEST_ISSUES_FOR_LIST, + type: types.REQUEST_ITEMS_FOR_LIST, payload: { listId, fetchNext: false }, }, { - type: types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, - payload: { listIssues: formattedIssues, listPageInfo, listId }, + type: types.RECEIVE_ITEMS_FOR_LIST_SUCCESS, + payload: { listItems: formattedIssues, listPageInfo, listId }, }, ], [], @@ -555,19 +611,19 @@ describe('fetchIssuesForList', () => { ); }); - it('should commit mutations REQUEST_ISSUES_FOR_LIST and RECEIVE_ISSUES_FOR_LIST_FAILURE on failure', (done) => { + it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_FAILURE on failure', (done) => { jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject()); testAction( - actions.fetchIssuesForList, + actions.fetchItemsForList, { listId }, state, [ { - type: types.REQUEST_ISSUES_FOR_LIST, + type: types.REQUEST_ITEMS_FOR_LIST, payload: { listId, fetchNext: false }, }, - { type: types.RECEIVE_ISSUES_FOR_LIST_FAILURE, payload: listId }, + { type: types.RECEIVE_ITEMS_FOR_LIST_FAILURE, payload: listId }, ], [], done, @@ -581,6 +637,15 @@ describe('resetIssues', () => { }); }); +describe('moveItem', () => { + it('should dispatch moveIssue action', () => { + testAction({ + action: actions.moveItem, + expectedActions: [{ type: 'moveIssue' }], + }); + }); +}); + describe('moveIssue', () => { const listIssues = { 'gid://gitlab/List/1': [436, 437], @@ -598,8 +663,8 @@ describe('moveIssue', () => { boardType: 'group', disabled: false, boardLists: mockLists, - issuesByListId: listIssues, - issues, + boardItemsByListId: listIssues, + boardItems: issues, }; it('should commit MOVE_ISSUE mutation and MOVE_ISSUE_SUCCESS mutation when successful', (done) => { @@ -615,9 +680,9 @@ describe('moveIssue', () => { testAction( actions.moveIssue, { - issueId: '436', - issueIid: mockIssue.iid, - issuePath: mockIssue.referencePath, + itemId: '436', + itemIid: mockIssue.iid, + itemPath: mockIssue.referencePath, fromListId: 'gid://gitlab/List/1', toListId: 'gid://gitlab/List/2', }, @@ -666,9 +731,9 @@ describe('moveIssue', () => { actions.moveIssue( { state, commit: () => {} }, { - issueId: mockIssue.id, - issueIid: mockIssue.iid, - issuePath: mockIssue.referencePath, + itemId: mockIssue.id, + itemIid: mockIssue.iid, + itemPath: mockIssue.referencePath, fromListId: 'gid://gitlab/List/1', toListId: 'gid://gitlab/List/2', }, @@ -690,9 +755,9 @@ describe('moveIssue', () => { testAction( actions.moveIssue, { - issueId: '436', - issueIid: mockIssue.iid, - issuePath: mockIssue.referencePath, + itemId: '436', + itemIid: mockIssue.iid, + itemPath: mockIssue.referencePath, fromListId: 'gid://gitlab/List/1', toListId: 'gid://gitlab/List/2', }, @@ -879,7 +944,7 @@ describe('addListIssue', () => { }); describe('setActiveIssueLabels', () => { - const state = { issues: { [mockIssue.id]: mockIssue } }; + const state = { boardItems: { [mockIssue.id]: mockIssue } }; const getters = { activeIssue: mockIssue }; const testLabelIds = labels.map((label) => label.id); const input = { @@ -924,7 +989,7 @@ describe('setActiveIssueLabels', () => { }); describe('setActiveIssueDueDate', () => { - const state = { issues: { [mockIssue.id]: mockIssue } }; + const state = { boardItems: { [mockIssue.id]: mockIssue } }; const getters = { activeIssue: mockIssue }; const testDueDate = '2020-02-20'; const input = { @@ -975,7 +1040,7 @@ describe('setActiveIssueDueDate', () => { }); describe('setActiveIssueSubscribed', () => { - const state = { issues: { [mockActiveIssue.id]: mockActiveIssue } }; + const state = { boardItems: { [mockActiveIssue.id]: mockActiveIssue } }; const getters = { activeIssue: mockActiveIssue }; const subscribedState = true; const input = { @@ -1026,7 +1091,7 @@ describe('setActiveIssueSubscribed', () => { }); describe('setActiveIssueMilestone', () => { - const state = { issues: { [mockIssue.id]: mockIssue } }; + const state = { boardItems: { [mockIssue.id]: mockIssue } }; const getters = { activeIssue: mockIssue }; const testMilestone = { ...mockMilestone, @@ -1080,7 +1145,7 @@ describe('setActiveIssueMilestone', () => { }); describe('setActiveIssueTitle', () => { - const state = { issues: { [mockIssue.id]: mockIssue } }; + const state = { boardItems: { [mockIssue.id]: mockIssue } }; const getters = { activeIssue: mockIssue }; const testTitle = 'Test Title'; const input = { @@ -1220,6 +1285,7 @@ describe('setSelectedProject', () => { describe('toggleBoardItemMultiSelection', () => { const boardItem = mockIssue; + const boardItem2 = mockIssue2; it('should commit mutation ADD_BOARD_ITEM_TO_SELECTION if item is not on selection state', () => { testAction( @@ -1250,6 +1316,66 @@ describe('toggleBoardItemMultiSelection', () => { [], ); }); + + it('should additionally commit mutation ADD_BOARD_ITEM_TO_SELECTION for active issue and dispatch unsetActiveId', () => { + testAction( + actions.toggleBoardItemMultiSelection, + boardItem2, + { activeId: mockActiveIssue.id, activeIssue: mockActiveIssue, selectedBoardItems: [] }, + [ + { + type: types.ADD_BOARD_ITEM_TO_SELECTION, + payload: mockActiveIssue, + }, + { + type: types.ADD_BOARD_ITEM_TO_SELECTION, + payload: boardItem2, + }, + ], + [{ type: 'unsetActiveId' }], + ); + }); +}); + +describe('resetBoardItemMultiSelection', () => { + it('should commit mutation RESET_BOARD_ITEM_SELECTION', () => { + testAction({ + action: actions.resetBoardItemMultiSelection, + state: { selectedBoardItems: [mockIssue] }, + expectedMutations: [ + { + type: types.RESET_BOARD_ITEM_SELECTION, + }, + ], + }); + }); +}); + +describe('toggleBoardItem', () => { + it('should dispatch resetBoardItemMultiSelection and unsetActiveId when boardItem is the active item', () => { + testAction({ + action: actions.toggleBoardItem, + payload: { boardItem: mockIssue }, + state: { + activeId: mockIssue.id, + }, + expectedActions: [{ type: 'resetBoardItemMultiSelection' }, { type: 'unsetActiveId' }], + }); + }); + + it('should dispatch resetBoardItemMultiSelection and setActiveId when boardItem is not the active item', () => { + testAction({ + action: actions.toggleBoardItem, + payload: { boardItem: mockIssue }, + state: { + activeId: inactiveId, + }, + expectedActions: [ + { type: 'resetBoardItemMultiSelection' }, + { type: 'setActiveId', payload: { id: mockIssue.id, sidebarType: ISSUABLE } }, + ], + }); + }); }); describe('fetchBacklog', () => { diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js index d5a19bf613f..c0c19e9d797 100644 --- a/spec/frontend/boards/stores/getters_spec.js +++ b/spec/frontend/boards/stores/getters_spec.js @@ -10,6 +10,42 @@ import { } from '../mock_data'; describe('Boards - Getters', () => { + describe('isGroupBoard', () => { + it('returns true when boardType on state is group', () => { + const state = { + boardType: 'group', + }; + + expect(getters.isGroupBoard(state)).toBe(true); + }); + + it('returns false when boardType on state is not group', () => { + const state = { + boardType: 'project', + }; + + expect(getters.isGroupBoard(state)).toBe(false); + }); + }); + + describe('isProjectBoard', () => { + it('returns true when boardType on state is project', () => { + const state = { + boardType: 'project', + }; + + expect(getters.isProjectBoard(state)).toBe(true); + }); + + it('returns false when boardType on state is not project', () => { + const state = { + boardType: 'group', + }; + + expect(getters.isProjectBoard(state)).toBe(false); + }); + }); + describe('isSidebarOpen', () => { it('returns true when activeId is not equal to 0', () => { const state = { @@ -38,15 +74,15 @@ describe('Boards - Getters', () => { }); }); - describe('getIssueById', () => { - const state = { issues: { 1: 'issue' } }; + describe('getBoardItemById', () => { + const state = { boardItems: { 1: 'issue' } }; it.each` id | expected ${'1'} | ${'issue'} ${''} | ${{}} `('returns $expected when $id is passed to state', ({ id, expected }) => { - expect(getters.getIssueById(state)(id)).toEqual(expected); + expect(getters.getBoardItemById(state)(id)).toEqual(expected); }); }); @@ -56,7 +92,7 @@ describe('Boards - Getters', () => { ${'1'} | ${'issue'} ${''} | ${{}} `('returns $expected when $id is passed to state', ({ id, expected }) => { - const state = { issues: { 1: 'issue' }, activeId: id }; + const state = { boardItems: { 1: 'issue' }, activeId: id }; expect(getters.activeIssue(state)).toEqual(expected); }); @@ -94,17 +130,18 @@ describe('Boards - Getters', () => { }); }); - describe('getIssuesByList', () => { + describe('getBoardItemsByList', () => { const boardsState = { - issuesByListId: mockIssuesByListId, - issues, + boardItemsByListId: mockIssuesByListId, + boardItems: issues, }; it('returns issues for a given listId', () => { - const getIssueById = (issueId) => [mockIssue, mockIssue2].find(({ id }) => id === issueId); + const getBoardItemById = (issueId) => + [mockIssue, mockIssue2].find(({ id }) => id === issueId); - expect(getters.getIssuesByList(boardsState, { getIssueById })('gid://gitlab/List/2')).toEqual( - mockIssues, - ); + expect( + getters.getBoardItemsByList(boardsState, { getBoardItemById })('gid://gitlab/List/2'), + ).toEqual(mockIssues); }); }); diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index 9423f2ed583..33897cc0250 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -1,3 +1,4 @@ +import { issuableTypes } from '~/boards/constants'; import * as types from '~/boards/stores/mutation_types'; import mutations from '~/boards/stores/mutations'; import defaultState from '~/boards/stores/state'; @@ -37,6 +38,7 @@ describe('Board Store Mutations', () => { const boardConfig = { milestoneTitle: 'Milestone 1', }; + const issuableType = issuableTypes.issue; mutations[types.SET_INITIAL_BOARD_DATA](state, { boardId, @@ -44,6 +46,7 @@ describe('Board Store Mutations', () => { boardType, disabled, boardConfig, + issuableType, }); expect(state.boardId).toEqual(boardId); @@ -51,6 +54,7 @@ describe('Board Store Mutations', () => { expect(state.boardType).toEqual(boardType); expect(state.disabled).toEqual(disabled); expect(state.boardConfig).toEqual(boardConfig); + expect(state.issuableType).toEqual(issuableType); }); }); @@ -106,11 +110,31 @@ describe('Board Store Mutations', () => { }); }); + describe('RECEIVE_LABELS_REQUEST', () => { + it('sets labelsLoading on state', () => { + mutations.RECEIVE_LABELS_REQUEST(state); + + expect(state.labelsLoading).toEqual(true); + }); + }); + describe('RECEIVE_LABELS_SUCCESS', () => { it('sets labels on state', () => { mutations.RECEIVE_LABELS_SUCCESS(state, labels); expect(state.labels).toEqual(labels); + expect(state.labelsLoading).toEqual(false); + }); + }); + + describe('RECEIVE_LABELS_FAILURE', () => { + it('sets error message', () => { + mutations.RECEIVE_LABELS_FAILURE(state); + + expect(state.error).toEqual( + 'An error occurred while fetching labels. Please reload the page.', + ); + expect(state.labelsLoading).toEqual(false); }); }); @@ -179,6 +203,24 @@ describe('Board Store Mutations', () => { }); }); + describe('TOGGLE_LIST_COLLAPSED', () => { + it('updates collapsed attribute of list in boardLists state', () => { + const listId = 'gid://gitlab/List/1'; + state = { + ...state, + boardLists: { + [listId]: mockLists[0], + }, + }; + + expect(state.boardLists[listId].collapsed).toEqual(false); + + mutations.TOGGLE_LIST_COLLAPSED(state, { listId, collapsed: true }); + + expect(state.boardLists[listId].collapsed).toEqual(true); + }); + }); + describe('REMOVE_LIST', () => { it('removes list from boardLists', () => { const [list, secondList] = mockLists; @@ -219,24 +261,24 @@ describe('Board Store Mutations', () => { }); describe('RESET_ISSUES', () => { - it('should remove issues from issuesByListId state', () => { - const issuesByListId = { + it('should remove issues from boardItemsByListId state', () => { + const boardItemsByListId = { 'gid://gitlab/List/1': [mockIssue.id], }; state = { ...state, - issuesByListId, + boardItemsByListId, }; mutations[types.RESET_ISSUES](state); - expect(state.issuesByListId).toEqual({ 'gid://gitlab/List/1': [] }); + expect(state.boardItemsByListId).toEqual({ 'gid://gitlab/List/1': [] }); }); }); - describe('RECEIVE_ISSUES_FOR_LIST_SUCCESS', () => { - it('updates issuesByListId and issues on state', () => { + describe('RECEIVE_ITEMS_FOR_LIST_SUCCESS', () => { + it('updates boardItemsByListId and issues on state', () => { const listIssues = { 'gid://gitlab/List/1': [mockIssue.id], }; @@ -246,10 +288,10 @@ describe('Board Store Mutations', () => { state = { ...state, - issuesByListId: { + boardItemsByListId: { 'gid://gitlab/List/1': [], }, - issues: {}, + boardItems: {}, boardLists: initialBoardListsState, }; @@ -260,18 +302,18 @@ describe('Board Store Mutations', () => { }, }; - mutations.RECEIVE_ISSUES_FOR_LIST_SUCCESS(state, { - listIssues: { listData: listIssues, issues }, + mutations.RECEIVE_ITEMS_FOR_LIST_SUCCESS(state, { + listItems: { listData: listIssues, boardItems: issues }, listPageInfo, listId: 'gid://gitlab/List/1', }); - expect(state.issuesByListId).toEqual(listIssues); - expect(state.issues).toEqual(issues); + expect(state.boardItemsByListId).toEqual(listIssues); + expect(state.boardItems).toEqual(issues); }); }); - describe('RECEIVE_ISSUES_FOR_LIST_FAILURE', () => { + describe('RECEIVE_ITEMS_FOR_LIST_FAILURE', () => { it('sets error message', () => { state = { ...state, @@ -281,7 +323,7 @@ describe('Board Store Mutations', () => { const listId = 'gid://gitlab/List/1'; - mutations.RECEIVE_ISSUES_FOR_LIST_FAILURE(state, listId); + mutations.RECEIVE_ITEMS_FOR_LIST_FAILURE(state, listId); expect(state.error).toEqual( 'An error occurred while fetching the board issues. Please reload the page.', @@ -303,7 +345,7 @@ describe('Board Store Mutations', () => { state = { ...state, error: undefined, - issues: { + boardItems: { ...issue, }, }; @@ -317,7 +359,7 @@ describe('Board Store Mutations', () => { value, }); - expect(state.issues[issueId]).toEqual({ ...issue[issueId], id: '2' }); + expect(state.boardItems[issueId]).toEqual({ ...issue[issueId], id: '2' }); }); }); @@ -343,7 +385,7 @@ describe('Board Store Mutations', () => { }); describe('MOVE_ISSUE', () => { - it('updates issuesByListId, moving issue between lists', () => { + it('updates boardItemsByListId, moving issue between lists', () => { const listIssues = { 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id], 'gid://gitlab/List/2': [], @@ -356,9 +398,9 @@ describe('Board Store Mutations', () => { state = { ...state, - issuesByListId: listIssues, + boardItemsByListId: listIssues, boardLists: initialBoardListsState, - issues, + boardItems: issues, }; mutations.MOVE_ISSUE(state, { @@ -372,7 +414,7 @@ describe('Board Store Mutations', () => { 'gid://gitlab/List/2': [mockIssue2.id], }; - expect(state.issuesByListId).toEqual(updatedListIssues); + expect(state.boardItemsByListId).toEqual(updatedListIssues); }); }); @@ -384,19 +426,19 @@ describe('Board Store Mutations', () => { state = { ...state, - issues, + boardItems: issues, }; mutations.MOVE_ISSUE_SUCCESS(state, { issue: rawIssue, }); - expect(state.issues).toEqual({ 436: { ...mockIssue, id: 436 } }); + expect(state.boardItems).toEqual({ 436: { ...mockIssue, id: 436 } }); }); }); describe('MOVE_ISSUE_FAILURE', () => { - it('updates issuesByListId, reverting moving issue between lists, and sets error message', () => { + it('updates boardItemsByListId, reverting moving issue between lists, and sets error message', () => { const listIssues = { 'gid://gitlab/List/1': [mockIssue.id], 'gid://gitlab/List/2': [mockIssue2.id], @@ -404,7 +446,7 @@ describe('Board Store Mutations', () => { state = { ...state, - issuesByListId: listIssues, + boardItemsByListId: listIssues, boardLists: initialBoardListsState, }; @@ -420,7 +462,7 @@ describe('Board Store Mutations', () => { 'gid://gitlab/List/2': [], }; - expect(state.issuesByListId).toEqual(updatedListIssues); + expect(state.boardItemsByListId).toEqual(updatedListIssues); expect(state.error).toEqual('An error occurred while moving the issue. Please try again.'); }); }); @@ -446,7 +488,7 @@ describe('Board Store Mutations', () => { }); describe('ADD_ISSUE_TO_LIST', () => { - it('adds issue to issues state and issue id in list in issuesByListId', () => { + it('adds issue to issues state and issue id in list in boardItemsByListId', () => { const listIssues = { 'gid://gitlab/List/1': [mockIssue.id], }; @@ -456,8 +498,8 @@ describe('Board Store Mutations', () => { state = { ...state, - issuesByListId: listIssues, - issues, + boardItemsByListId: listIssues, + boardItems: issues, boardLists: initialBoardListsState, }; @@ -465,14 +507,14 @@ describe('Board Store Mutations', () => { 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.boardItemsByListId['gid://gitlab/List/1']).toContain(mockIssue2.id); + expect(state.boardItems[mockIssue2.id]).toEqual(mockIssue2); expect(state.boardLists['gid://gitlab/List/1'].issuesCount).toBe(2); }); }); describe('ADD_ISSUE_TO_LIST_FAILURE', () => { - it('removes issue id from list in issuesByListId and sets error message', () => { + it('removes issue id from list in boardItemsByListId and sets error message', () => { const listIssues = { 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id], }; @@ -483,20 +525,20 @@ describe('Board Store Mutations', () => { state = { ...state, - issuesByListId: listIssues, - issues, + boardItemsByListId: listIssues, + boardItems: issues, boardLists: initialBoardListsState, }; mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issueId: mockIssue2.id }); - expect(state.issuesByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id); + expect(state.boardItemsByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id); expect(state.error).toBe('An error occurred while creating the issue. Please try again.'); }); }); describe('REMOVE_ISSUE_FROM_LIST', () => { - it('removes issue id from list in issuesByListId and deletes issue from state', () => { + it('removes issue id from list in boardItemsByListId and deletes issue from state', () => { const listIssues = { 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id], }; @@ -507,15 +549,15 @@ describe('Board Store Mutations', () => { state = { ...state, - issuesByListId: listIssues, - issues, + boardItemsByListId: listIssues, + boardItems: issues, boardLists: initialBoardListsState, }; mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issueId: mockIssue2.id }); - expect(state.issuesByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id); - expect(state.issues).not.toContain(mockIssue2); + expect(state.boardItemsByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id); + expect(state.boardItems).not.toContain(mockIssue2); }); }); @@ -607,14 +649,21 @@ describe('Board Store Mutations', () => { describe('REMOVE_BOARD_ITEM_FROM_SELECTION', () => { it('Should remove boardItem to selectedBoardItems state', () => { - state = { - ...state, - selectedBoardItems: [mockIssue], - }; + state.selectedBoardItems = [mockIssue]; mutations[types.REMOVE_BOARD_ITEM_FROM_SELECTION](state, mockIssue); expect(state.selectedBoardItems).toEqual([]); }); }); + + describe('RESET_BOARD_ITEM_SELECTION', () => { + it('Should reset selectedBoardItems state', () => { + state.selectedBoardItems = [mockIssue]; + + mutations[types.RESET_BOARD_ITEM_SELECTION](state, mockIssue); + + expect(state.selectedBoardItems).toEqual([]); + }); + }); }); diff --git a/spec/frontend/bootstrap_linked_tabs_spec.js b/spec/frontend/bootstrap_linked_tabs_spec.js index 2d8939e6480..30fb140bc69 100644 --- a/spec/frontend/bootstrap_linked_tabs_spec.js +++ b/spec/frontend/bootstrap_linked_tabs_spec.js @@ -1,8 +1,6 @@ import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs'; describe('Linked Tabs', () => { - preloadFixtures('static/linked_tabs.html'); - beforeEach(() => { loadFixtures('static/linked_tabs.html'); }); diff --git a/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js b/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js new file mode 100644 index 00000000000..df81b78d010 --- /dev/null +++ b/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js @@ -0,0 +1,119 @@ +import MockAdapter from 'axios-mock-adapter'; + +import { registerCaptchaModalInterceptor } from '~/captcha/captcha_modal_axios_interceptor'; +import { waitForCaptchaToBeSolved } from '~/captcha/wait_for_captcha_to_be_solved'; +import axios from '~/lib/utils/axios_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; + +jest.mock('~/captcha/wait_for_captcha_to_be_solved'); + +describe('registerCaptchaModalInterceptor', () => { + const SPAM_LOG_ID = 'SPAM_LOG_ID'; + const CAPTCHA_SITE_KEY = 'CAPTCHA_SITE_KEY'; + const CAPTCHA_SUCCESS = 'CAPTCHA_SUCCESS'; + const CAPTCHA_RESPONSE = 'CAPTCHA_RESPONSE'; + const AXIOS_RESPONSE = { text: 'AXIOS_RESPONSE' }; + const NEEDS_CAPTCHA_RESPONSE = { + needs_captcha_response: true, + captcha_site_key: CAPTCHA_SITE_KEY, + spam_log_id: SPAM_LOG_ID, + }; + + const unsupportedMethods = ['delete', 'get', 'head', 'options']; + const supportedMethods = ['patch', 'post', 'put']; + + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onAny('/no-captcha').reply(200, AXIOS_RESPONSE); + mock.onAny('/error').reply(404, AXIOS_RESPONSE); + mock.onAny('/captcha').reply((config) => { + if (!supportedMethods.includes(config.method)) { + return [httpStatusCodes.METHOD_NOT_ALLOWED, { method: config.method }]; + } + + try { + const { captcha_response, spam_log_id, ...rest } = JSON.parse(config.data); + // eslint-disable-next-line babel/camelcase + if (captcha_response === CAPTCHA_RESPONSE && spam_log_id === SPAM_LOG_ID) { + return [httpStatusCodes.OK, { ...rest, method: config.method, CAPTCHA_SUCCESS }]; + } + } catch (e) { + return [httpStatusCodes.BAD_REQUEST, { method: config.method }]; + } + + return [httpStatusCodes.CONFLICT, NEEDS_CAPTCHA_RESPONSE]; + }); + + axios.interceptors.response.handlers = []; + registerCaptchaModalInterceptor(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe.each([...supportedMethods, ...unsupportedMethods])('For HTTP method %s', (method) => { + it('successful requests are passed through', async () => { + const { data, status } = await axios[method]('/no-captcha'); + + expect(status).toEqual(httpStatusCodes.OK); + expect(data).toEqual(AXIOS_RESPONSE); + expect(mock.history[method]).toHaveLength(1); + }); + + it('error requests without needs_captcha_response_errors are passed through', async () => { + await expect(() => axios[method]('/error')).rejects.toThrow( + expect.objectContaining({ + response: expect.objectContaining({ + status: httpStatusCodes.NOT_FOUND, + data: AXIOS_RESPONSE, + }), + }), + ); + expect(mock.history[method]).toHaveLength(1); + }); + }); + + describe.each(supportedMethods)('For HTTP method %s', (method) => { + describe('error requests with needs_captcha_response_errors', () => { + const submittedData = { ID: 12345 }; + + it('re-submits request if captcha was solved correctly', async () => { + waitForCaptchaToBeSolved.mockResolvedValue(CAPTCHA_RESPONSE); + const { data: returnedData } = await axios[method]('/captcha', submittedData); + + expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY); + + expect(returnedData).toEqual({ ...submittedData, CAPTCHA_SUCCESS, method }); + expect(mock.history[method]).toHaveLength(2); + }); + + it('does not re-submit request if captcha was not solved', async () => { + const error = new Error('Captcha not solved'); + waitForCaptchaToBeSolved.mockRejectedValue(error); + await expect(() => axios[method]('/captcha', submittedData)).rejects.toThrow(error); + + expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY); + expect(mock.history[method]).toHaveLength(1); + }); + }); + }); + + describe.each(unsupportedMethods)('For HTTP method %s', (method) => { + it('ignores captcha response', async () => { + await expect(() => axios[method]('/captcha')).rejects.toThrow( + expect.objectContaining({ + response: expect.objectContaining({ + status: httpStatusCodes.METHOD_NOT_ALLOWED, + data: { method }, + }), + }), + ); + + expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); + expect(mock.history[method]).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/captcha/wait_for_captcha_to_be_solved_spec.js b/spec/frontend/captcha/wait_for_captcha_to_be_solved_spec.js new file mode 100644 index 00000000000..08d031a4fa7 --- /dev/null +++ b/spec/frontend/captcha/wait_for_captcha_to_be_solved_spec.js @@ -0,0 +1,56 @@ +import CaptchaModal from '~/captcha/captcha_modal.vue'; +import { waitForCaptchaToBeSolved } from '~/captcha/wait_for_captcha_to_be_solved'; + +jest.mock('~/captcha/captcha_modal.vue', () => ({ + mounted: jest.fn(), + render(h) { + return h('div', { attrs: { id: 'mock-modal' } }); + }, +})); + +describe('waitForCaptchaToBeSolved', () => { + const response = 'CAPTCHA_RESPONSE'; + + const findModal = () => document.querySelector('#mock-modal'); + + it('opens a modal, resolves with captcha response on success', async () => { + CaptchaModal.mounted.mockImplementationOnce(function mounted() { + requestAnimationFrame(() => { + this.$emit('receivedCaptchaResponse', response); + this.$emit('hidden'); + }); + }); + + expect(findModal()).toBeNull(); + + const promise = waitForCaptchaToBeSolved('FOO'); + + expect(findModal()).not.toBeNull(); + + const result = await promise; + expect(result).toEqual(response); + + expect(findModal()).toBeNull(); + expect(document.body.innerHTML).toEqual(''); + }); + + it("opens a modal, rejects with error in case the captcha isn't solved", async () => { + CaptchaModal.mounted.mockImplementationOnce(function mounted() { + requestAnimationFrame(() => { + this.$emit('receivedCaptchaResponse', null); + this.$emit('hidden'); + }); + }); + + expect(findModal()).toBeNull(); + + const promise = waitForCaptchaToBeSolved('FOO'); + + expect(findModal()).not.toBeNull(); + + await expect(promise).rejects.toThrow(/You must solve the CAPTCHA in order to submit/); + + expect(findModal()).toBeNull(); + expect(document.body.innerHTML).toEqual(''); + }); +}); diff --git a/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js index ad1bdec1735..1bca21b1d57 100644 --- a/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js +++ b/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js @@ -4,9 +4,6 @@ import VariableList from '~/ci_variable_list/ci_variable_list'; const HIDE_CLASS = 'hide'; describe('VariableList', () => { - preloadFixtures('pipeline_schedules/edit.html'); - preloadFixtures('pipeline_schedules/edit_with_variables.html'); - let $wrapper; let variableList; diff --git a/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js index 4982b68fa81..eee1362440d 100644 --- a/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js +++ b/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js @@ -2,8 +2,6 @@ import $ from 'jquery'; import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list'; describe('NativeFormVariableList', () => { - preloadFixtures('pipeline_schedules/edit.html'); - let $wrapper; beforeEach(() => { diff --git a/spec/frontend/ci_variable_list/components/ci_environments_dropdown_spec.js b/spec/frontend/ci_variable_list/components/ci_environments_dropdown_spec.js index 75c6e8e4540..5c5ea102f12 100644 --- a/spec/frontend/ci_variable_list/components/ci_environments_dropdown_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_environments_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import CiEnvironmentsDropdown from '~/ci_variable_list/components/ci_environments_dropdown.vue'; @@ -10,6 +10,9 @@ describe('Ci environments dropdown', () => { let wrapper; let store; + const enterSearchTerm = (value) => + wrapper.find('[data-testid="ci-environment-search"]').setValue(value); + const createComponent = (term) => { store = new Vuex.Store({ getters: { @@ -24,11 +27,12 @@ describe('Ci environments dropdown', () => { value: term, }, }); + enterSearchTerm(term); }; - const findAllDropdownItems = () => wrapper.findAll(GlDropdownItem); - const findDropdownItemByIndex = (index) => wrapper.findAll(GlDropdownItem).at(index); - const findActiveIconByIndex = (index) => findDropdownItemByIndex(index).find(GlIcon); + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownItemByIndex = (index) => wrapper.findAllComponents(GlDropdownItem).at(index); + const findActiveIconByIndex = (index) => findDropdownItemByIndex(index).findComponent(GlIcon); afterEach(() => { wrapper.destroy(); @@ -68,8 +72,9 @@ describe('Ci environments dropdown', () => { }); describe('Environments found', () => { - beforeEach(() => { + beforeEach(async () => { createComponent('prod'); + await wrapper.vm.$nextTick(); }); it('renders only the environment searched for', () => { @@ -84,21 +89,29 @@ describe('Ci environments dropdown', () => { }); it('should not display empty results message', () => { - expect(wrapper.find({ ref: 'noMatchingResults' }).exists()).toBe(false); + expect(wrapper.findComponent({ ref: 'noMatchingResults' }).exists()).toBe(false); }); it('should display active checkmark if active', () => { expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(false); }); + it('should clear the search term when showing the dropdown', () => { + wrapper.findComponent(GlDropdown).trigger('click'); + + expect(wrapper.find('[data-testid="ci-environment-search"]').text()).toBe(''); + }); + describe('Custom events', () => { it('should emit selectEnvironment if an environment is clicked', () => { findDropdownItemByIndex(0).vm.$emit('click'); expect(wrapper.emitted('selectEnvironment')).toEqual([['prod']]); }); - it('should emit createClicked if an environment is clicked', () => { + it('should emit createClicked if an environment is clicked', async () => { createComponent('newscope'); + + await wrapper.vm.$nextTick(); findDropdownItemByIndex(1).vm.$emit('click'); expect(wrapper.emitted('createClicked')).toEqual([['newscope']]); }); diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js index f398d7a0965..941a3adb625 100644 --- a/spec/frontend/clusters_list/components/clusters_spec.js +++ b/spec/frontend/clusters_list/components/clusters_spec.js @@ -4,12 +4,12 @@ import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlTable, } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import Clusters from '~/clusters_list/components/clusters.vue'; import ClusterStore from '~/clusters_list/store'; import axios from '~/lib/utils/axios_utils'; -import * as Sentry from '~/sentry/wrapper'; import { apiData } from '../mock_data'; describe('Clusters', () => { diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js index 00b998166aa..b2ef3c2138a 100644 --- a/spec/frontend/clusters_list/store/actions_spec.js +++ b/spec/frontend/clusters_list/store/actions_spec.js @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/browser'; import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -7,7 +8,6 @@ import * as types from '~/clusters_list/store/mutation_types'; import { deprecatedCreateFlash as flashError } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import Poll from '~/lib/utils/poll'; -import * as Sentry from '~/sentry/wrapper'; import { apiData } from '../mock_data'; jest.mock('~/flash.js'); diff --git a/spec/frontend/collapsed_sidebar_todo_spec.js b/spec/frontend/collapsed_sidebar_todo_spec.js index ef53cc9e103..7c659822672 100644 --- a/spec/frontend/collapsed_sidebar_todo_spec.js +++ b/spec/frontend/collapsed_sidebar_todo_spec.js @@ -14,9 +14,6 @@ describe('Issuable right sidebar collapsed todo toggle', () => { const jsonFixtureName = 'todos/todos.json'; let mock; - preloadFixtures(fixtureName); - preloadFixtures(jsonFixtureName); - beforeEach(() => { const todoData = getJSONFixture(jsonFixtureName); new Sidebar(); diff --git a/spec/frontend/commit/pipelines/pipelines_spec.js b/spec/frontend/commit/pipelines/pipelines_spec.js index f8bdd00f5da..c8c8f6408b7 100644 --- a/spec/frontend/commit/pipelines/pipelines_spec.js +++ b/spec/frontend/commit/pipelines/pipelines_spec.js @@ -13,14 +13,10 @@ describe('Pipelines table in Commits and Merge requests', () => { let vm; const props = { endpoint: 'endpoint.json', - helpPagePath: 'foo', emptyStateSvgPath: 'foo', errorStateSvgPath: 'foo', - autoDevopsHelpPath: 'foo', }; - preloadFixtures(jsonFixtureName); - const findRunPipelineBtn = () => vm.$el.querySelector('[data-testid="run_pipeline_button"]'); const findRunPipelineBtnMobile = () => vm.$el.querySelector('[data-testid="run_pipeline_button_mobile"]'); diff --git a/spec/frontend/create_item_dropdown_spec.js b/spec/frontend/create_item_dropdown_spec.js index 7314eb5eee8..56c09cd731e 100644 --- a/spec/frontend/create_item_dropdown_spec.js +++ b/spec/frontend/create_item_dropdown_spec.js @@ -20,8 +20,6 @@ const DROPDOWN_ITEM_DATA = [ ]; describe('CreateItemDropdown', () => { - preloadFixtures('static/create_item_dropdown.html'); - let $wrapperEl; let createItemDropdown; diff --git a/spec/frontend/deprecated_jquery_dropdown_spec.js b/spec/frontend/deprecated_jquery_dropdown_spec.js index 6070532a1bf..7858f88f8c3 100644 --- a/spec/frontend/deprecated_jquery_dropdown_spec.js +++ b/spec/frontend/deprecated_jquery_dropdown_spec.js @@ -10,8 +10,6 @@ jest.mock('~/lib/utils/url_utility', () => ({ })); describe('deprecatedJQueryDropdown', () => { - preloadFixtures('static/deprecated_jquery_dropdown.html'); - const NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-item'; const SEARCH_INPUT_SELECTOR = '.dropdown-input-field'; diff --git a/spec/frontend/design_management/components/delete_button_spec.js b/spec/frontend/design_management/components/delete_button_spec.js index 8f7d8e0b214..f5a841d35b8 100644 --- a/spec/frontend/design_management/components/delete_button_spec.js +++ b/spec/frontend/design_management/components/delete_button_spec.js @@ -36,7 +36,7 @@ describe('Batch delete button component', () => { expect(findButton().attributes('disabled')).toBeTruthy(); }); - it('emits `deleteSelectedDesigns` event on modal ok click', () => { + it('emits `delete-selected-designs` event on modal ok click', () => { createComponent(); findButton().vm.$emit('click'); return wrapper.vm @@ -46,7 +46,7 @@ describe('Batch delete button component', () => { return wrapper.vm.$nextTick(); }) .then(() => { - expect(wrapper.emitted().deleteSelectedDesigns).toBeTruthy(); + expect(wrapper.emitted('delete-selected-designs')).toBeTruthy(); }); }); diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js index 92e188f4bcc..efadb9b717d 100644 --- a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js @@ -93,7 +93,7 @@ describe('Design discussions component', () => { }); it('does not render a checkbox in reply form', () => { - findReplyPlaceholder().vm.$emit('onClick'); + findReplyPlaceholder().vm.$emit('focus'); return wrapper.vm.$nextTick().then(() => { expect(findResolveCheckbox().exists()).toBe(false); @@ -124,7 +124,7 @@ describe('Design discussions component', () => { }); it('renders a checkbox with Resolve thread text in reply form', () => { - findReplyPlaceholder().vm.$emit('onClick'); + findReplyPlaceholder().vm.$emit('focus'); wrapper.setProps({ discussionWithOpenForm: defaultMockDiscussion.id }); return wrapper.vm.$nextTick().then(() => { @@ -193,7 +193,7 @@ describe('Design discussions component', () => { }); it('renders a checkbox with Unresolve thread text in reply form', () => { - findReplyPlaceholder().vm.$emit('onClick'); + findReplyPlaceholder().vm.$emit('focus'); wrapper.setProps({ discussionWithOpenForm: defaultMockDiscussion.id }); return wrapper.vm.$nextTick().then(() => { @@ -205,7 +205,7 @@ describe('Design discussions component', () => { it('hides reply placeholder and opens form on placeholder click', () => { createComponent(); - findReplyPlaceholder().vm.$emit('onClick'); + findReplyPlaceholder().vm.$emit('focus'); wrapper.setProps({ discussionWithOpenForm: defaultMockDiscussion.id }); return wrapper.vm.$nextTick().then(() => { @@ -307,7 +307,7 @@ describe('Design discussions component', () => { it('emits openForm event on opening the form', () => { createComponent(); - findReplyPlaceholder().vm.$emit('onClick'); + findReplyPlaceholder().vm.$emit('focus'); expect(wrapper.emitted('open-form')).toBeTruthy(); }); diff --git a/spec/frontend/design_management/components/list/item_spec.js b/spec/frontend/design_management/components/list/item_spec.js index caf0f8bb5bc..58636ece91e 100644 --- a/spec/frontend/design_management/components/list/item_spec.js +++ b/spec/frontend/design_management/components/list/item_spec.js @@ -8,7 +8,7 @@ const localVue = createLocalVue(); localVue.use(VueRouter); const router = new VueRouter(); -// Referenced from: doc/api/graphql/reference/gitlab_schema.graphql:DesignVersionEvent +// Referenced from: gitlab_schema.graphql:DesignVersionEvent const DESIGN_VERSION_EVENT = { CREATION: 'CREATION', DELETION: 'DELETION', diff --git a/spec/frontend/design_management/components/toolbar/index_spec.js b/spec/frontend/design_management/components/toolbar/index_spec.js index 44c865d976d..009ffe57744 100644 --- a/spec/frontend/design_management/components/toolbar/index_spec.js +++ b/spec/frontend/design_management/components/toolbar/index_spec.js @@ -106,11 +106,11 @@ describe('Design management toolbar component', () => { }); }); - it('emits `delete` event on deleteButton `deleteSelectedDesigns` event', () => { + it('emits `delete` event on deleteButton `delete-selected-designs` event', () => { createComponent(); return wrapper.vm.$nextTick().then(() => { - wrapper.find(DeleteButton).vm.$emit('deleteSelectedDesigns'); + wrapper.find(DeleteButton).vm.$emit('delete-selected-designs'); expect(wrapper.emitted().delete).toBeTruthy(); }); }); diff --git a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap index 2f857247303..904bb2022ca 100644 --- a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap +++ b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap @@ -19,7 +19,7 @@ exports[`Design management upload button component renders inverted upload desig <input accept="image/*" - class="hide" + class="gl-display-none" multiple="multiple" name="design_file" type="file" @@ -44,7 +44,7 @@ exports[`Design management upload button component renders upload design button <input accept="image/*" - class="hide" + class="gl-display-none" multiple="multiple" name="design_file" type="file" diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index 4f162ca8e7f..95cb1ac943c 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -97,7 +97,7 @@ describe('Design management index page', () => { let moveDesignHandler; const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox'); - const findSelectAllButton = () => wrapper.find('.js-select-all'); + const findSelectAllButton = () => wrapper.find('[data-testid="select-all-designs-button"'); const findToolbar = () => wrapper.find('.qa-selector-toolbar'); const findDesignCollectionIsCopying = () => wrapper.find('[data-testid="design-collection-is-copying"'); @@ -542,7 +542,9 @@ describe('Design management index page', () => { await nextTick(); expect(findDeleteButton().exists()).toBe(true); expect(findSelectAllButton().text()).toBe('Deselect all'); - findDeleteButton().vm.$emit('deleteSelectedDesigns'); + + findDeleteButton().vm.$emit('delete-selected-designs'); + const [{ variables }] = mutate.mock.calls[0]; expect(variables.filenames).toStrictEqual([mockDesigns[0].filename, mockDesigns[1].filename]); }); diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index d2b5338a0cc..34547238c23 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -14,9 +14,6 @@ import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue'; import NoChanges from '~/diffs/components/no_changes.vue'; import TreeList from '~/diffs/components/tree_list.vue'; -import { EVT_VIEW_FILE_BY_FILE } from '~/diffs/constants'; - -import eventHub from '~/diffs/event_hub'; import axios from '~/lib/utils/axios_utils'; import * as urlUtils from '~/lib/utils/url_utility'; import createDiffsStore from '../create_diffs_store'; @@ -699,24 +696,5 @@ 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 nextTick(); - - eventHub.$emit(EVT_VIEW_FILE_BY_FILE, { setting }); - await nextTick(); - - expect(store.state.diffs.viewDiffsFileByFile).toBe(setting); - }, - ); - }); }); }); diff --git a/spec/frontend/diffs/components/settings_dropdown_spec.js b/spec/frontend/diffs/components/settings_dropdown_spec.js index 99fa83b64f1..feac88cb802 100644 --- a/spec/frontend/diffs/components/settings_dropdown_spec.js +++ b/spec/frontend/diffs/components/settings_dropdown_spec.js @@ -1,82 +1,66 @@ -import { mount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; +import { mount } from '@vue/test-utils'; + +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; + import SettingsDropdown from '~/diffs/components/settings_dropdown.vue'; -import { - EVT_VIEW_FILE_BY_FILE, - PARALLEL_DIFF_VIEW_TYPE, - INLINE_DIFF_VIEW_TYPE, -} from '~/diffs/constants'; +import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '~/diffs/constants'; import eventHub from '~/diffs/event_hub'; -import diffModule from '~/diffs/store/modules'; -const localVue = createLocalVue(); -localVue.use(Vuex); +import createDiffsStore from '../create_diffs_store'; describe('Diff settings dropdown component', () => { let wrapper; let vm; - let actions; + let store; function createComponent(extendStore = () => {}) { - const store = new Vuex.Store({ - modules: { - diffs: { - namespaced: true, - actions, - state: diffModule().state, - getters: diffModule().getters, - }, - }, - }); + store = createDiffsStore(); extendStore(store); - wrapper = mount(SettingsDropdown, { - localVue, - store, - }); + wrapper = extendedWrapper( + mount(SettingsDropdown, { + store, + }), + ); vm = wrapper.vm; } function getFileByFileCheckbox(vueWrapper) { - return vueWrapper.find('[data-testid="file-by-file"]'); + return vueWrapper.findByTestId('file-by-file'); + } + + function setup({ storeUpdater } = {}) { + createComponent(storeUpdater); + jest.spyOn(store, 'dispatch').mockImplementation(() => {}); } beforeEach(() => { - actions = { - setInlineDiffViewType: jest.fn(), - setParallelDiffViewType: jest.fn(), - setRenderTreeList: jest.fn(), - setShowWhitespace: jest.fn(), - }; + setup(); }); afterEach(() => { + store.dispatch.mockRestore(); wrapper.destroy(); }); describe('tree view buttons', () => { it('list view button dispatches setRenderTreeList with false', () => { - createComponent(); - wrapper.find('.js-list-view').trigger('click'); - expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), false); + expect(store.dispatch).toHaveBeenCalledWith('diffs/setRenderTreeList', false); }); it('tree view button dispatches setRenderTreeList with true', () => { - createComponent(); - wrapper.find('.js-tree-view').trigger('click'); - expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), true); + expect(store.dispatch).toHaveBeenCalledWith('diffs/setRenderTreeList', true); }); it('sets list button as selected when renderTreeList is false', () => { - createComponent((store) => { - Object.assign(store.state.diffs, { - renderTreeList: false, - }); + setup({ + storeUpdater: (origStore) => + Object.assign(origStore.state.diffs, { renderTreeList: false }), }); expect(wrapper.find('.js-list-view').classes('selected')).toBe(true); @@ -84,10 +68,8 @@ describe('Diff settings dropdown component', () => { }); it('sets tree button as selected when renderTreeList is true', () => { - createComponent((store) => { - Object.assign(store.state.diffs, { - renderTreeList: true, - }); + setup({ + storeUpdater: (origStore) => Object.assign(origStore.state.diffs, { renderTreeList: true }), }); expect(wrapper.find('.js-list-view').classes('selected')).toBe(false); @@ -97,10 +79,9 @@ describe('Diff settings dropdown component', () => { describe('compare changes', () => { it('sets inline button as selected', () => { - createComponent((store) => { - Object.assign(store.state.diffs, { - diffViewType: INLINE_DIFF_VIEW_TYPE, - }); + setup({ + storeUpdater: (origStore) => + Object.assign(origStore.state.diffs, { diffViewType: INLINE_DIFF_VIEW_TYPE }), }); expect(wrapper.find('.js-inline-diff-button').classes('selected')).toBe(true); @@ -108,10 +89,9 @@ describe('Diff settings dropdown component', () => { }); it('sets parallel button as selected', () => { - createComponent((store) => { - Object.assign(store.state.diffs, { - diffViewType: PARALLEL_DIFF_VIEW_TYPE, - }); + setup({ + storeUpdater: (origStore) => + Object.assign(origStore.state.diffs, { diffViewType: PARALLEL_DIFF_VIEW_TYPE }), }); expect(wrapper.find('.js-inline-diff-button').classes('selected')).toBe(false); @@ -119,53 +99,49 @@ describe('Diff settings dropdown component', () => { }); it('calls setInlineDiffViewType when clicking inline button', () => { - createComponent(); - wrapper.find('.js-inline-diff-button').trigger('click'); - expect(actions.setInlineDiffViewType).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith('diffs/setInlineDiffViewType', expect.anything()); }); it('calls setParallelDiffViewType when clicking parallel button', () => { - createComponent(); - wrapper.find('.js-parallel-diff-button').trigger('click'); - expect(actions.setParallelDiffViewType).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith( + 'diffs/setParallelDiffViewType', + expect.anything(), + ); }); }); describe('whitespace toggle', () => { it('does not set as checked when showWhitespace is false', () => { - createComponent((store) => { - Object.assign(store.state.diffs, { - showWhitespace: false, - }); + setup({ + storeUpdater: (origStore) => + Object.assign(origStore.state.diffs, { showWhitespace: false }), }); - expect(wrapper.find('#show-whitespace').element.checked).toBe(false); + expect(wrapper.findByTestId('show-whitespace').element.checked).toBe(false); }); it('sets as checked when showWhitespace is true', () => { - createComponent((store) => { - Object.assign(store.state.diffs, { - showWhitespace: true, - }); + setup({ + storeUpdater: (origStore) => Object.assign(origStore.state.diffs, { showWhitespace: true }), }); - expect(wrapper.find('#show-whitespace').element.checked).toBe(true); + expect(wrapper.findByTestId('show-whitespace').element.checked).toBe(true); }); - it('calls setShowWhitespace on change', () => { - createComponent(); + it('calls setShowWhitespace on change', async () => { + const checkbox = wrapper.findByTestId('show-whitespace'); + const { checked } = checkbox.element; - const checkbox = wrapper.find('#show-whitespace'); + checkbox.trigger('click'); - checkbox.element.checked = true; - checkbox.trigger('change'); + await vm.$nextTick(); - expect(actions.setShowWhitespace).toHaveBeenCalledWith(expect.anything(), { - showWhitespace: true, + expect(store.dispatch).toHaveBeenCalledWith('diffs/setShowWhitespace', { + showWhitespace: !checked, pushState: true, }); }); @@ -182,39 +158,35 @@ describe('Diff settings dropdown component', () => { ${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, - }); + ({ fileByFile, checked }) => { + setup({ + storeUpdater: (origStore) => + Object.assign(origStore.state.diffs, { viewDiffsFileByFile: fileByFile }), }); - await vm.$nextTick(); - expect(getFileByFileCheckbox(wrapper).element.checked).toBe(checked); }, ); it.each` - start | emit + start | setting ${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, - }); + 'when the file by file setting starts as $start, toggling the checkbox should call setFileByFile with $setting', + async ({ start, setting }) => { + setup({ + storeUpdater: (origStore) => + Object.assign(origStore.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 }); + expect(store.dispatch).toHaveBeenCalledWith('diffs/setFileByFile', { + fileByFile: setting, + }); }, ); }); diff --git a/spec/frontend/diffs/mock_data/diff_with_commit.js b/spec/frontend/diffs/mock_data/diff_with_commit.js index d646294ee84..f3b39bd3577 100644 --- a/spec/frontend/diffs/mock_data/diff_with_commit.js +++ b/spec/frontend/diffs/mock_data/diff_with_commit.js @@ -1,7 +1,5 @@ const FIXTURE = 'merge_request_diffs/with_commit.json'; -preloadFixtures(FIXTURE); - export default function getDiffWithCommit() { return getJSONFixture(FIXTURE); } diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index dcb58f7a380..6af38590610 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -275,24 +275,28 @@ describe('DiffsStoreUtils', () => { describe('trimFirstCharOfLineContent', () => { it('trims the line when it starts with a space', () => { + // eslint-disable-next-line import/no-deprecated expect(utils.trimFirstCharOfLineContent({ rich_text: ' diff' })).toEqual({ rich_text: 'diff', }); }); it('trims the line when it starts with a +', () => { + // eslint-disable-next-line import/no-deprecated expect(utils.trimFirstCharOfLineContent({ rich_text: '+diff' })).toEqual({ rich_text: 'diff', }); }); it('trims the line when it starts with a -', () => { + // eslint-disable-next-line import/no-deprecated expect(utils.trimFirstCharOfLineContent({ rich_text: '-diff' })).toEqual({ rich_text: 'diff', }); }); it('does not trims the line when it starts with a letter', () => { + // eslint-disable-next-line import/no-deprecated expect(utils.trimFirstCharOfLineContent({ rich_text: 'diff' })).toEqual({ rich_text: 'diff', }); @@ -303,12 +307,14 @@ describe('DiffsStoreUtils', () => { rich_text: ' diff', }; + // eslint-disable-next-line import/no-deprecated utils.trimFirstCharOfLineContent(lineObj); expect(lineObj).toEqual({ rich_text: ' diff' }); }); it('handles a undefined or null parameter', () => { + // eslint-disable-next-line import/no-deprecated expect(utils.trimFirstCharOfLineContent()).toEqual({}); }); }); diff --git a/spec/frontend/diffs/utils/file_reviews_spec.js b/spec/frontend/diffs/utils/file_reviews_spec.js index a58c19a7245..230ec12409c 100644 --- a/spec/frontend/diffs/utils/file_reviews_spec.js +++ b/spec/frontend/diffs/utils/file_reviews_spec.js @@ -49,11 +49,11 @@ describe('File Review(s) utilities', () => { it.each` mrReviews | files | fileReviews - ${{}} | ${[file1, file2]} | ${[false, false]} - ${{ abc: ['123'] }} | ${[file1, file2]} | ${[true, false]} - ${{ abc: ['098'] }} | ${[file1, file2]} | ${[false, true]} - ${{ def: ['123'] }} | ${[file1, file2]} | ${[false, false]} - ${{ abc: ['123'], def: ['098'] }} | ${[]} | ${[]} + ${{}} | ${[file1, file2]} | ${{ 123: false, '098': false }} + ${{ abc: ['123'] }} | ${[file1, file2]} | ${{ 123: true, '098': false }} + ${{ abc: ['098'] }} | ${[file1, file2]} | ${{ 123: false, '098': true }} + ${{ def: ['123'] }} | ${[file1, file2]} | ${{ 123: false, '098': false }} + ${{ abc: ['123'], def: ['098'] }} | ${[]} | ${{}} `( 'returns $fileReviews based on the diff files in state and the existing reviews $reviews', ({ mrReviews, files, fileReviews }) => { diff --git a/spec/frontend/diffs/utils/preferences_spec.js b/spec/frontend/diffs/utils/preferences_spec.js index b09db2c1003..2dcc71dc188 100644 --- a/spec/frontend/diffs/utils/preferences_spec.js +++ b/spec/frontend/diffs/utils/preferences_spec.js @@ -5,32 +5,25 @@ import { DIFF_VIEW_ALL_FILES, } from '~/diffs/constants'; import { fileByFile } from '~/diffs/utils/preferences'; -import { getParameterValues } from '~/lib/utils/url_utility'; - -jest.mock('~/lib/utils/url_utility'); describe('diffs preferences', () => { describe('fileByFile', () => { + afterEach(() => { + Cookies.remove(DIFF_FILE_BY_FILE_COOKIE_NAME); + }); + 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]} + result | preference | cookie + ${true} | ${false} | ${DIFF_VIEW_FILE_BY_FILE} + ${false} | ${true} | ${DIFF_VIEW_ALL_FILES} + ${true} | ${false} | ${DIFF_VIEW_FILE_BY_FILE} + ${false} | ${true} | ${DIFF_VIEW_ALL_FILES} + ${false} | ${false} | ${DIFF_VIEW_ALL_FILES} + ${true} | ${true} | ${DIFF_VIEW_FILE_BY_FILE} `( - '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); + 'should return $result when { preference: $preference, cookie: $cookie }', + ({ result, preference, cookie }) => { + Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, cookie); expect(fileByFile(preference)).toBe(result); }, diff --git a/spec/frontend/emoji/components/category_spec.js b/spec/frontend/emoji/components/category_spec.js new file mode 100644 index 00000000000..afd36a1eb88 --- /dev/null +++ b/spec/frontend/emoji/components/category_spec.js @@ -0,0 +1,49 @@ +import { GlIntersectionObserver } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import Category from '~/emoji/components/category.vue'; +import EmojiGroup from '~/emoji/components/emoji_group.vue'; + +let wrapper; +function factory(propsData = {}) { + wrapper = shallowMount(Category, { propsData }); +} + +describe('Emoji category component', () => { + afterEach(() => { + wrapper.destroy(); + }); + + beforeEach(() => { + factory({ + category: 'Activity', + emojis: [['thumbsup'], ['thumbsdown']], + }); + }); + + it('renders emoji groups', () => { + expect(wrapper.findAll(EmojiGroup).length).toBe(2); + }); + + it('renders group', async () => { + await wrapper.setData({ renderGroup: true }); + + expect(wrapper.find(EmojiGroup).attributes('rendergroup')).toBe('true'); + }); + + it('renders group on appear', async () => { + wrapper.find(GlIntersectionObserver).vm.$emit('appear'); + + await nextTick(); + + expect(wrapper.find(EmojiGroup).attributes('rendergroup')).toBe('true'); + }); + + it('emits appear event on appear', async () => { + wrapper.find(GlIntersectionObserver).vm.$emit('appear'); + + await nextTick(); + + expect(wrapper.emitted().appear[0]).toEqual(['Activity']); + }); +}); diff --git a/spec/frontend/emoji/components/emoji_group_spec.js b/spec/frontend/emoji/components/emoji_group_spec.js new file mode 100644 index 00000000000..1aca2fbb8fc --- /dev/null +++ b/spec/frontend/emoji/components/emoji_group_spec.js @@ -0,0 +1,56 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import EmojiGroup from '~/emoji/components/emoji_group.vue'; + +Vue.config.ignoredElements = ['gl-emoji']; + +let wrapper; +function factory(propsData = {}) { + wrapper = extendedWrapper( + shallowMount(EmojiGroup, { + propsData, + }), + ); +} + +describe('Emoji group component', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('does not render any buttons', () => { + factory({ + emojis: [], + renderGroup: false, + clickEmoji: jest.fn(), + }); + + expect(wrapper.findByTestId('emoji-button').exists()).toBe(false); + }); + + it('renders emojis', () => { + factory({ + emojis: ['thumbsup', 'thumbsdown'], + renderGroup: true, + clickEmoji: jest.fn(), + }); + + expect(wrapper.findAllByTestId('emoji-button').exists()).toBe(true); + expect(wrapper.findAllByTestId('emoji-button').length).toBe(2); + }); + + it('calls clickEmoji', () => { + const clickEmoji = jest.fn(); + + factory({ + emojis: ['thumbsup', 'thumbsdown'], + renderGroup: true, + clickEmoji, + }); + + wrapper.findByTestId('emoji-button').trigger('click'); + + expect(clickEmoji).toHaveBeenCalledWith('thumbsup'); + }); +}); diff --git a/spec/frontend/emoji/components/emoji_list_spec.js b/spec/frontend/emoji/components/emoji_list_spec.js new file mode 100644 index 00000000000..9dc73ef191e --- /dev/null +++ b/spec/frontend/emoji/components/emoji_list_spec.js @@ -0,0 +1,73 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import EmojiList from '~/emoji/components/emoji_list.vue'; + +jest.mock('~/emoji', () => ({ + initEmojiMap: jest.fn(() => Promise.resolve()), + searchEmoji: jest.fn((search) => [{ emoji: { name: search } }]), + getEmojiCategoryMap: jest.fn(() => + Promise.resolve({ + activity: ['thumbsup', 'thumbsdown'], + }), + ), +})); + +let wrapper; +async function factory(render, propsData = { searchValue: '' }) { + wrapper = extendedWrapper( + shallowMount(EmojiList, { + propsData, + scopedSlots: { + default: '<div data-testid="default-slot">{{props.filteredCategories}}</div>', + }, + }), + ); + + // Wait for categories to be set + await nextTick(); + + if (render) { + wrapper.setData({ render: true }); + + // Wait for component to render + await nextTick(); + } +} + +const findDefaultSlot = () => wrapper.findByTestId('default-slot'); + +describe('Emoji list component', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('does not render until render is set', async () => { + await factory(false); + + expect(findDefaultSlot().exists()).toBe(false); + }); + + it('renders with none filtered list', async () => { + await factory(true); + + expect(JSON.parse(findDefaultSlot().text())).toEqual({ + activity: { + emojis: [['thumbsup', 'thumbsdown']], + height: expect.any(Number), + top: expect.any(Number), + }, + }); + }); + + it('renders filtered list of emojis', async () => { + await factory(true, { searchValue: 'smile' }); + + expect(JSON.parse(findDefaultSlot().text())).toEqual({ + search: { + emojis: [['smile']], + height: expect.any(Number), + }, + }); + }); +}); diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js index 50d84b19ce8..542cf58b079 100644 --- a/spec/frontend/environments/environments_app_spec.js +++ b/spec/frontend/environments/environments_app_spec.js @@ -97,13 +97,21 @@ describe('Environment', () => { jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {}); wrapper.find('.gl-pagination li:nth-child(3) .page-link').trigger('click'); - expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ scope: 'available', page: '2' }); + expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ + scope: 'available', + page: '2', + nested: true, + }); }); it('should make an API request when using tabs', () => { jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {}); findEnvironmentsTabStopped().trigger('click'); - expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ scope: 'stopped', page: '1' }); + expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ + scope: 'stopped', + page: '1', + nested: true, + }); }); it('should not make the same API request when clicking on the current scope tab', () => { diff --git a/spec/frontend/environments/folder/environments_folder_view_spec.js b/spec/frontend/environments/folder/environments_folder_view_spec.js index 3943e89c6cf..d02ed8688c6 100644 --- a/spec/frontend/environments/folder/environments_folder_view_spec.js +++ b/spec/frontend/environments/folder/environments_folder_view_spec.js @@ -103,13 +103,18 @@ describe('Environments Folder View', () => { expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ scope: wrapper.vm.scope, page: '10', + nested: true, }); }); it('should make an API request when using tabs', () => { jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {}); findEnvironmentsTabStopped().trigger('click'); - expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ scope: 'stopped', page: '1' }); + expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ + scope: 'stopped', + page: '1', + nested: true, + }); }); }); }); @@ -161,7 +166,11 @@ describe('Environments Folder View', () => { it('should set page to 1', () => { jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {}); wrapper.vm.onChangeTab('stopped'); - expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ scope: 'stopped', page: '1' }); + expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ + scope: 'stopped', + page: '1', + nested: true, + }); }); }); @@ -172,6 +181,7 @@ describe('Environments Folder View', () => { expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ scope: wrapper.vm.scope, page: '4', + nested: true, }); }); }); diff --git a/spec/frontend/experimentation/experiment_tracking_spec.js b/spec/frontend/experimentation/experiment_tracking_spec.js new file mode 100644 index 00000000000..20f45a7015a --- /dev/null +++ b/spec/frontend/experimentation/experiment_tracking_spec.js @@ -0,0 +1,80 @@ +import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; +import ExperimentTracking from '~/experimentation/experiment_tracking'; +import { getExperimentData } from '~/experimentation/utils'; +import Tracking from '~/tracking'; + +let experimentTracking; +let label; +let property; + +jest.mock('~/tracking'); +jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn() })); + +const setup = () => { + experimentTracking = new ExperimentTracking('sidebar_experiment', { label, property }); +}; + +beforeEach(() => { + document.body.dataset.page = 'issues-page'; +}); + +afterEach(() => { + label = undefined; + property = undefined; +}); + +describe('event', () => { + beforeEach(() => { + getExperimentData.mockReturnValue(undefined); + }); + + describe('when experiment data exists for experimentName', () => { + beforeEach(() => { + getExperimentData.mockReturnValue('experiment-data'); + setup(); + }); + + describe('when providing options', () => { + label = 'sidebar-drawer'; + property = 'dark-mode'; + + it('passes them to the tracking call', () => { + experimentTracking.event('click_sidebar_close'); + + expect(Tracking.event).toHaveBeenCalledTimes(1); + expect(Tracking.event).toHaveBeenCalledWith('issues-page', 'click_sidebar_close', { + label: 'sidebar-drawer', + property: 'dark-mode', + context: { + schema: TRACKING_CONTEXT_SCHEMA, + data: 'experiment-data', + }, + }); + }); + }); + + it('tracks with the correct context', () => { + experimentTracking.event('click_sidebar_trigger'); + + expect(Tracking.event).toHaveBeenCalledTimes(1); + expect(Tracking.event).toHaveBeenCalledWith('issues-page', 'click_sidebar_trigger', { + context: { + schema: TRACKING_CONTEXT_SCHEMA, + data: 'experiment-data', + }, + }); + }); + }); + + describe('when experiment data does NOT exists for the experimentName', () => { + beforeEach(() => { + setup(); + }); + + it('does not track', () => { + experimentTracking.event('click_sidebar_close'); + + expect(Tracking.event).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/experimentation/utils_spec.js b/spec/frontend/experimentation/utils_spec.js new file mode 100644 index 00000000000..87dd2d595ba --- /dev/null +++ b/spec/frontend/experimentation/utils_spec.js @@ -0,0 +1,38 @@ +import * as experimentUtils from '~/experimentation/utils'; + +const TEST_KEY = 'abc'; + +describe('experiment Utilities', () => { + const oldGon = window.gon; + + afterEach(() => { + window.gon = oldGon; + }); + + describe('getExperimentData', () => { + it.each` + gon | input | output + ${{ experiment: { [TEST_KEY]: '_data_' } }} | ${[TEST_KEY]} | ${'_data_'} + ${{}} | ${[TEST_KEY]} | ${undefined} + `('with input=$input and gon=$gon, returns $output', ({ gon, input, output }) => { + window.gon = gon; + + expect(experimentUtils.getExperimentData(...input)).toEqual(output); + }); + }); + + describe('isExperimentVariant', () => { + it.each` + gon | input | output + ${{ experiment: { [TEST_KEY]: { variant: 'control' } } }} | ${[TEST_KEY, 'control']} | ${true} + ${{ experiment: { [TEST_KEY]: { variant: '_variant_name' } } }} | ${[TEST_KEY, '_variant_name']} | ${true} + ${{ experiment: { [TEST_KEY]: { variant: '_variant_name' } } }} | ${[TEST_KEY, '_bogus_name']} | ${false} + ${{ experiment: { [TEST_KEY]: { variant: '_variant_name' } } }} | ${['boguskey', '_variant_name']} | ${false} + ${{}} | ${[TEST_KEY, '_variant_name']} | ${false} + `('with input=$input and gon=$gon, returns $output', ({ gon, input, output }) => { + window.gon = gon; + + expect(experimentUtils.isExperimentVariant(...input)).toEqual(output); + }); + }); +}); 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 84e71ffd204..27ec6a7280f 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 @@ -32,8 +32,9 @@ describe('Configure Feature Flags Modal', () => { }); }; - const findGlModal = () => wrapper.find(GlModal); + const findGlModal = () => wrapper.findComponent(GlModal); const findPrimaryAction = () => findGlModal().props('actionPrimary'); + const findSecondaryAction = () => findGlModal().props('actionSecondary'); const findProjectNameInput = () => wrapper.find('#project_name_verification'); const findDangerGlAlert = () => wrapper.findAll(GlAlert).filter((c) => c.props('variant') === 'danger'); @@ -42,18 +43,18 @@ describe('Configure Feature Flags Modal', () => { afterEach(() => wrapper.destroy()); beforeEach(factory); - it('should have Primary and Cancel actions', () => { - expect(findGlModal().props('actionCancel').text).toBe('Close'); - expect(findPrimaryAction().text).toBe('Regenerate instance ID'); + it('should have Primary and Secondary actions', () => { + expect(findPrimaryAction().text).toBe('Close'); + expect(findSecondaryAction().text).toBe('Regenerate instance ID'); }); - it('should default disable the primary action', async () => { - const [{ disabled }] = findPrimaryAction().attributes; + it('should default disable the primary action', () => { + const [{ disabled }] = findSecondaryAction().attributes; expect(disabled).toBe(true); }); it('should emit a `token` event when clicking on the Primary action', async () => { - findGlModal().vm.$emit('primary', mockEvent); + findGlModal().vm.$emit('secondary', mockEvent); await wrapper.vm.$nextTick(); expect(wrapper.emitted('token')).toEqual([[]]); expect(mockEvent.preventDefault).toHaveBeenCalled(); @@ -112,10 +113,10 @@ describe('Configure Feature Flags Modal', () => { afterEach(() => wrapper.destroy()); beforeEach(factory); - it('should enable the primary action', async () => { + it('should enable the secondary action', async () => { findProjectNameInput().vm.$emit('input', provide.projectName); await wrapper.vm.$nextTick(); - const [{ disabled }] = findPrimaryAction().attributes; + const [{ disabled }] = findSecondaryAction().attributes; expect(disabled).toBe(false); }); }); @@ -124,8 +125,8 @@ describe('Configure Feature Flags Modal', () => { afterEach(() => wrapper.destroy()); beforeEach(factory.bind(null, { canUserRotateToken: false })); - it('should not display the primary action', async () => { - expect(findPrimaryAction()).toBe(null); + it('should not display the primary action', () => { + expect(findSecondaryAction()).toBe(null); }); it('should not display regenerating instance ID', 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 e2717b98ea9..2fd8e524e7a 100644 --- a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js +++ b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js @@ -150,5 +150,12 @@ describe('Edit feature flag form', () => { label: 'feature_flag_toggle', }); }); + + it('should render the toggle with a visually hidden label', () => { + expect(wrapper.find(GlToggle).props()).toMatchObject({ + label: 'Feature flag status', + labelPosition: 'hidden', + }); + }); }); }); diff --git a/spec/frontend/filtered_search/dropdown_user_spec.js b/spec/frontend/filtered_search/dropdown_user_spec.js index 0e2d2ee6c09..961587f7146 100644 --- a/spec/frontend/filtered_search/dropdown_user_spec.js +++ b/spec/frontend/filtered_search/dropdown_user_spec.js @@ -78,7 +78,6 @@ describe('Dropdown User', () => { describe('hideCurrentUser', () => { const fixtureTemplate = 'issues/issue_list.html'; - preloadFixtures(fixtureTemplate); let dropdown; let authorFilterDropdownElement; diff --git a/spec/frontend/filtered_search/dropdown_utils_spec.js b/spec/frontend/filtered_search/dropdown_utils_spec.js index 32d1f909d0b..49e14f58630 100644 --- a/spec/frontend/filtered_search/dropdown_utils_spec.js +++ b/spec/frontend/filtered_search/dropdown_utils_spec.js @@ -5,7 +5,6 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered describe('Dropdown Utils', () => { const issueListFixture = 'issues/issue_list.html'; - preloadFixtures(issueListFixture); describe('getEscapedText', () => { it('should return same word when it has no space', () => { diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js index a2082271efe..772fa7d07ed 100644 --- a/spec/frontend/filtered_search/visual_token_value_spec.js +++ b/spec/frontend/filtered_search/visual_token_value_spec.js @@ -133,8 +133,6 @@ describe('Filtered Search Visual Tokens', () => { const jsonFixtureName = 'labels/project_labels.json'; const dummyEndpoint = '/dummy/endpoint'; - preloadFixtures(jsonFixtureName); - let labelData; beforeAll(() => { diff --git a/spec/frontend/fixtures/issues.rb b/spec/frontend/fixtures/issues.rb index a027247bd0d..d6f6ed97626 100644 --- a/spec/frontend/fixtures/issues.rb +++ b/spec/frontend/fixtures/issues.rb @@ -16,6 +16,8 @@ RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', type: :contr end before do + stub_feature_flags(boards_filtered_search: false) + project.add_maintainer(user) sign_in(user) end diff --git a/spec/frontend/fixtures/pipelines.rb b/spec/frontend/fixtures/pipelines.rb index b4b7f0e332f..2a538352abe 100644 --- a/spec/frontend/fixtures/pipelines.rb +++ b/spec/frontend/fixtures/pipelines.rb @@ -5,16 +5,22 @@ require 'spec_helper' RSpec.describe Projects::PipelinesController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers - let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} - let(:project) { create(:project, :repository, namespace: namespace, path: 'pipelines-project') } - let(:commit) { create(:commit, project: project) } - let(:commit_without_author) { RepoHelpers.another_sample_commit } - let!(:user) { create(:user, developer_projects: [project], email: commit.author_email) } - let!(:pipeline) { create(:ci_pipeline, project: project, sha: commit.id, user: user) } + let_it_be(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'pipelines-project') } + + let_it_be(:commit_without_author) { RepoHelpers.another_sample_commit } let!(:pipeline_without_author) { create(:ci_pipeline, project: project, sha: commit_without_author.id) } - let!(:pipeline_without_commit) { create(:ci_pipeline, status: :success, project: project, sha: '0000') } + let!(:build_pipeline_without_author) { create(:ci_build, pipeline: pipeline_without_author, stage: 'test') } - render_views + let_it_be(:pipeline_without_commit) { create(:ci_pipeline, status: :success, project: project, sha: '0000') } + let!(:build_pipeline_without_commit) { create(:ci_build, pipeline: pipeline_without_commit, stage: 'test') } + + let(:commit) { create(:commit, project: project) } + let(:user) { create(:user, developer_projects: [project], email: commit.author_email) } + let!(:pipeline) { create(:ci_pipeline, :with_test_reports, project: project, sha: commit.id, user: user) } + let!(:build_success) { create(:ci_build, pipeline: pipeline, stage: 'build') } + let!(:build_test) { create(:ci_build, pipeline: pipeline, stage: 'test') } + let!(:build_deploy_failed) { create(:ci_build, status: :failed, pipeline: pipeline, stage: 'deploy') } before(:all) do clean_frontend_fixtures('pipelines/') @@ -32,4 +38,14 @@ RSpec.describe Projects::PipelinesController, '(JavaScript fixtures)', type: :co expect(response).to be_successful end + + it "pipelines/test_report.json" do + get :test_report, params: { + namespace_id: namespace, + project_id: project, + id: pipeline.id + }, format: :json + + expect(response).to be_successful + end end diff --git a/spec/frontend/fixtures/projects.rb b/spec/frontend/fixtures/projects.rb index aa2f7dbed36..778ae218160 100644 --- a/spec/frontend/fixtures/projects.rb +++ b/spec/frontend/fixtures/projects.rb @@ -3,13 +3,14 @@ require 'spec_helper' RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do + include ApiHelpers include JavaScriptFixturesHelpers runners_token = 'runnerstoken:intabulasreferre' 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) { create(:project, namespace: namespace, path: 'builds-project', runners_token: runners_token, avatar: fixture_file_upload('spec/fixtures/dk.png', 'image/png')) } + let(:project_with_repo) { create(:project, :repository, description: 'Code and stuff', avatar: fixture_file_upload('spec/fixtures/dk.png', 'image/png')) } let(:project_variable_populated) { create(:project, namespace: namespace, path: 'builds-project2', runners_token: runners_token) } let(:user) { project.owner } @@ -22,7 +23,6 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do before do project_with_repo.add_maintainer(user) sign_in(user) - allow(SecureRandom).to receive(:hex).and_return('securerandomhex:thereisnospoon') end after do @@ -48,4 +48,31 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do expect(response).to be_successful end end + + describe GraphQL::Query, type: :request do + include GraphqlHelpers + + context 'access token projects query' do + before do + project_variable_populated.add_maintainer(user) + end + + before(:all) do + clean_frontend_fixtures('graphql/projects/access_tokens') + end + + fragment_paths = ['graphql_shared/fragments/pageInfo.fragment.graphql'] + base_input_path = 'access_tokens/graphql/queries/' + base_output_path = 'graphql/projects/access_tokens/' + query_name = 'get_projects.query.graphql' + + it "#{base_output_path}#{query_name}.json" do + query = get_graphql_query_as_string("#{base_input_path}#{query_name}", fragment_paths) + + post_graphql(query, current_user: user, variables: { search: '', first: 2 }) + + expect_graphql_errors_to_be_empty + end + end + end end diff --git a/spec/frontend/fixtures/test_report.rb b/spec/frontend/fixtures/test_report.rb deleted file mode 100644 index 3d09078ba68..00000000000 --- a/spec/frontend/fixtures/test_report.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe Projects::PipelinesController, "(JavaScript fixtures)", type: :controller do - include JavaScriptFixturesHelpers - - let(:namespace) { create(:namespace, name: "frontend-fixtures") } - let(:project) { create(:project, :repository, namespace: namespace, path: "pipelines-project") } - let(:commit) { create(:commit, project: project) } - let(:user) { create(:user, developer_projects: [project], email: commit.author_email) } - let(:pipeline) { create(:ci_pipeline, :with_test_reports, project: project, user: user) } - - render_views - - before do - sign_in(user) - end - - it "pipelines/test_report.json" do - get :test_report, params: { - namespace_id: project.namespace, - project_id: project, - id: pipeline.id - }, format: :json - - expect(response).to be_successful - end -end diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 08368e1f2ca..13dbda9cf55 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -576,55 +576,95 @@ describe('GfmAutoComplete', () => { }); }); - describe('Members.templateFunction', () => { - it('should return html with avatarTag and username', () => { - expect( - GfmAutoComplete.Members.templateFunction({ - avatarTag: 'IMG', - username: 'my-group', - title: '', - icon: '', - availabilityStatus: '', - }), - ).toBe('<li>IMG my-group <small></small> </li>'); - }); + describe('GfmAutoComplete.Members', () => { + const member = { + name: 'Marge Simpson', + username: 'msimpson', + search: 'MargeSimpson msimpson', + }; - it('should add icon if icon is set', () => { - expect( - GfmAutoComplete.Members.templateFunction({ - avatarTag: 'IMG', - username: 'my-group', - title: '', - icon: '<i class="icon"/>', - availabilityStatus: '', - }), - ).toBe('<li>IMG my-group <small></small> <i class="icon"/></li>'); - }); + describe('templateFunction', () => { + it('should return html with avatarTag and username', () => { + expect( + GfmAutoComplete.Members.templateFunction({ + avatarTag: 'IMG', + username: 'my-group', + title: '', + icon: '', + availabilityStatus: '', + }), + ).toBe('<li>IMG my-group <small></small> </li>'); + }); - it('should add escaped title if title is set', () => { - expect( - GfmAutoComplete.Members.templateFunction({ - avatarTag: 'IMG', - username: 'my-group', - title: 'MyGroup+', - icon: '<i class="icon"/>', - availabilityStatus: '', - }), - ).toBe('<li>IMG my-group <small>MyGroup+</small> <i class="icon"/></li>'); - }); + it('should add icon if icon is set', () => { + expect( + GfmAutoComplete.Members.templateFunction({ + avatarTag: 'IMG', + username: 'my-group', + title: '', + icon: '<i class="icon"/>', + availabilityStatus: '', + }), + ).toBe('<li>IMG my-group <small></small> <i class="icon"/></li>'); + }); - it('should add user availability status if availabilityStatus is set', () => { - expect( - GfmAutoComplete.Members.templateFunction({ - avatarTag: 'IMG', - username: 'my-group', - title: '', - icon: '<i class="icon"/>', - availabilityStatus: '<span class="gl-text-gray-500"> (Busy)</span>', - }), - ).toBe( - '<li>IMG my-group <small><span class="gl-text-gray-500"> (Busy)</span></small> <i class="icon"/></li>', - ); + it('should add escaped title if title is set', () => { + expect( + GfmAutoComplete.Members.templateFunction({ + avatarTag: 'IMG', + username: 'my-group', + title: 'MyGroup+', + icon: '<i class="icon"/>', + availabilityStatus: '', + }), + ).toBe('<li>IMG my-group <small>MyGroup+</small> <i class="icon"/></li>'); + }); + + it('should add user availability status if availabilityStatus is set', () => { + expect( + GfmAutoComplete.Members.templateFunction({ + avatarTag: 'IMG', + username: 'my-group', + title: '', + icon: '<i class="icon"/>', + availabilityStatus: '<span class="gl-text-gray-500"> (Busy)</span>', + }), + ).toBe( + '<li>IMG my-group <small><span class="gl-text-gray-500"> (Busy)</span></small> <i class="icon"/></li>', + ); + }); + + describe('nameOrUsernameStartsWith', () => { + it.each` + query | result + ${'mar'} | ${true} + ${'msi'} | ${true} + ${'margesimpson'} | ${true} + ${'msimpson'} | ${true} + ${'arge'} | ${false} + ${'rgesimp'} | ${false} + ${'maria'} | ${false} + ${'homer'} | ${false} + `('returns $result for $query', ({ query, result }) => { + expect(GfmAutoComplete.Members.nameOrUsernameStartsWith(member, query)).toBe(result); + }); + }); + + describe('nameOrUsernameIncludes', () => { + it.each` + query | result + ${'mar'} | ${true} + ${'msi'} | ${true} + ${'margesimpson'} | ${true} + ${'msimpson'} | ${true} + ${'arge'} | ${true} + ${'rgesimp'} | ${true} + ${'maria'} | ${false} + ${'homer'} | ${false} + `('returns $result for $query', ({ query, result }) => { + expect(GfmAutoComplete.Members.nameOrUsernameIncludes(member, query)).toBe(result); + }); + }); }); }); diff --git a/spec/frontend/gl_field_errors_spec.js b/spec/frontend/gl_field_errors_spec.js index a1737211252..ada3b34e6b1 100644 --- a/spec/frontend/gl_field_errors_spec.js +++ b/spec/frontend/gl_field_errors_spec.js @@ -8,8 +8,6 @@ describe('GL Style Field Errors', () => { testContext = {}; }); - preloadFixtures('static/gl_field_errors.html'); - beforeEach(() => { loadFixtures('static/gl_field_errors.html'); const $form = $('form.gl-show-field-errors'); diff --git a/spec/frontend/graphql_shared/utils_spec.js b/spec/frontend/graphql_shared/utils_spec.js index d392b0f0575..56bfb02ea4a 100644 --- a/spec/frontend/graphql_shared/utils_spec.js +++ b/spec/frontend/graphql_shared/utils_spec.js @@ -2,6 +2,8 @@ import { getIdFromGraphQLId, convertToGraphQLId, convertToGraphQLIds, + convertFromGraphQLIds, + convertNodeIdsFromGraphQLIds, } from '~/graphql_shared/utils'; const mockType = 'Group'; @@ -81,3 +83,35 @@ describe('convertToGraphQLIds', () => { expect(() => convertToGraphQLIds(type, ids)).toThrow(new TypeError(message)); }); }); + +describe('convertFromGraphQLIds', () => { + it.each` + ids | expected + ${[mockGid]} | ${[mockId]} + ${[mockGid, 'invalid id']} | ${[mockId, null]} + `('converts $ids from GraphQL Ids', ({ ids, expected }) => { + expect(convertFromGraphQLIds(ids)).toEqual(expected); + }); + + it("throws TypeError if `ids` parameter isn't an array", () => { + expect(() => convertFromGraphQLIds('invalid')).toThrow( + new TypeError('ids must be an array; got string'), + ); + }); +}); + +describe('convertNodeIdsFromGraphQLIds', () => { + it.each` + nodes | expected + ${[{ id: mockGid, name: 'foo bar' }, { id: mockGid, name: 'baz' }]} | ${[{ id: mockId, name: 'foo bar' }, { id: mockId, name: 'baz' }]} + ${[{ name: 'foo bar' }]} | ${[{ name: 'foo bar' }]} + `('converts `id` properties in $nodes from GraphQL Id', ({ nodes, expected }) => { + expect(convertNodeIdsFromGraphQLIds(nodes)).toEqual(expected); + }); + + it("throws TypeError if `nodes` parameter isn't an array", () => { + expect(() => convertNodeIdsFromGraphQLIds('invalid')).toThrow( + new TypeError('nodes must be an array; got string'), + ); + }); +}); diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js index 4fcc9bafa46..5a9f640392f 100644 --- a/spec/frontend/groups/components/group_item_spec.js +++ b/spec/frontend/groups/components/group_item_spec.js @@ -188,7 +188,7 @@ describe('GroupItemComponent', () => { }); it('should render component template correctly', () => { - const visibilityIconEl = vm.$el.querySelector('.item-visibility'); + const visibilityIconEl = vm.$el.querySelector('[data-testid="group-visibility-icon"]'); expect(vm.$el.getAttribute('id')).toBe('group-55'); expect(vm.$el.classList.contains('group-row')).toBeTruthy(); @@ -209,8 +209,7 @@ describe('GroupItemComponent', () => { expect(vm.$el.querySelector('.title a.no-expand')).toBeDefined(); expect(visibilityIconEl).not.toBe(null); - expect(visibilityIconEl.title).toBe(vm.visibilityTooltip); - expect(visibilityIconEl.querySelectorAll('svg').length).toBeGreaterThan(0); + expect(visibilityIconEl.getAttribute('title')).toBe(vm.visibilityTooltip); expect(vm.$el.querySelector('.access-type')).toBeDefined(); expect(vm.$el.querySelector('.description')).toBeDefined(); diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js index 27305abfafa..4ca6d7259bd 100644 --- a/spec/frontend/header_spec.js +++ b/spec/frontend/header_spec.js @@ -15,7 +15,6 @@ describe('Header', () => { $(document).trigger('todo:toggle', newCount); } - preloadFixtures(fixtureTemplate); beforeEach(() => { initTodoToggle(); loadFixtures(fixtureTemplate); diff --git a/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap b/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap index efa58a4a47b..194a619c4aa 100644 --- a/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap +++ b/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap @@ -10,7 +10,6 @@ exports[`IDE pipelines list when loaded renders empty state when no latestPipeli cansetci="true" class="mb-auto mt-auto" emptystatesvgpath="http://test.host" - helppagepath="http://test.host" /> </div> `; diff --git a/spec/frontend/ide/components/pipelines/list_spec.js b/spec/frontend/ide/components/pipelines/list_spec.js index 58d8c0629fb..a917f4c0230 100644 --- a/spec/frontend/ide/components/pipelines/list_spec.js +++ b/spec/frontend/ide/components/pipelines/list_spec.js @@ -19,7 +19,6 @@ describe('IDE pipelines list', () => { let wrapper; const defaultState = { - links: { ciHelpPagePath: TEST_HOST }, pipelinesEmptyStateSvgPath: TEST_HOST, }; const defaultPipelinesState = { diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index 1985feb1615..a3b327343e5 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -1,11 +1,15 @@ +import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import { Range } from 'monaco-editor'; +import { editor as monacoEditor, Range } from 'monaco-editor'; import Vue from 'vue'; import Vuex from 'vuex'; import '~/behaviors/markdown/render_gfm'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; import waitForPromises from 'helpers/wait_for_promises'; import waitUsingRealTimer from 'helpers/wait_using_real_timer'; +import { exampleConfigs, exampleFiles } from 'jest/ide/lib/editorconfig/mock_data'; +import { EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN } from '~/editor/constants'; +import EditorLite from '~/editor/editor_lite'; +import { EditorWebIdeExtension } from '~/editor/extensions/editor_lite_webide_ext'; import RepoEditor from '~/ide/components/repo_editor.vue'; import { leftSidebarViews, @@ -13,733 +17,723 @@ import { FILE_VIEW_MODE_PREVIEW, viewerTypes, } from '~/ide/constants'; -import Editor from '~/ide/lib/editor'; +import ModelManager from '~/ide/lib/common/model_manager'; import service from '~/ide/services'; import { createStoreOptions } from '~/ide/stores'; import axios from '~/lib/utils/axios_utils'; +import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import { file } from '../helpers'; -import { exampleConfigs, exampleFiles } from '../lib/editorconfig/mock_data'; + +const defaultFileProps = { + ...file('file.txt'), + content: 'hello world', + active: true, + tempFile: true, +}; +const createActiveFile = (props) => { + return { + ...defaultFileProps, + ...props, + }; +}; + +const dummyFile = { + markdown: (() => + createActiveFile({ + projectId: 'namespace/project', + path: 'sample.md', + name: 'sample.md', + }))(), + binary: (() => + createActiveFile({ + name: 'file.dat', + content: '🐱', // non-ascii binary content, + }))(), + empty: (() => + createActiveFile({ + tempFile: false, + content: '', + raw: '', + }))(), +}; + +const prepareStore = (state, activeFile) => { + const localState = { + openFiles: [activeFile], + projects: { + 'gitlab-org/gitlab': { + branches: { + master: { + name: 'master', + commit: { + id: 'abcdefgh', + }, + }, + }, + }, + }, + currentProjectId: 'gitlab-org/gitlab', + currentBranchId: 'master', + entries: { + [activeFile.path]: activeFile, + }, + }; + const storeOptions = createStoreOptions(); + return new Vuex.Store({ + ...createStoreOptions(), + state: { + ...storeOptions.state, + ...localState, + ...state, + }, + }); +}; describe('RepoEditor', () => { + let wrapper; let vm; - let store; + let createInstanceSpy; + let createDiffInstanceSpy; + let createModelSpy; const waitForEditorSetup = () => new Promise((resolve) => { vm.$once('editorSetup', resolve); }); - const createComponent = () => { - if (vm) { - throw new Error('vm already exists'); - } - vm = createComponentWithStore(Vue.extend(RepoEditor), store, { - file: store.state.openFiles[0], + const createComponent = async ({ state = {}, activeFile = defaultFileProps } = {}) => { + const store = prepareStore(state, activeFile); + wrapper = shallowMount(RepoEditor, { + store, + propsData: { + file: store.state.openFiles[0], + }, + mocks: { + ContentViewer, + }, }); - + await waitForPromises(); + vm = wrapper.vm; jest.spyOn(vm, 'getFileData').mockResolvedValue(); jest.spyOn(vm, 'getRawFileData').mockResolvedValue(); - - vm.$mount(); }; - const createOpenFile = (path) => { - const origFile = store.state.openFiles[0]; - const newFile = { ...origFile, path, key: path, name: 'myfile.txt', content: 'hello world' }; - - store.state.entries[path] = newFile; - - store.state.openFiles = [newFile]; - }; + const findEditor = () => wrapper.find('[data-testid="editor-container"]'); + const findTabs = () => wrapper.findAll('.ide-mode-tabs .nav-links li'); + const findPreviewTab = () => wrapper.find('[data-testid="preview-tab"]'); beforeEach(() => { - const f = { - ...file('file.txt'), - content: 'hello world', - }; - - const storeOptions = createStoreOptions(); - store = new Vuex.Store(storeOptions); - - f.active = true; - f.tempFile = true; - - store.state.openFiles.push(f); - store.state.projects = { - 'gitlab-org/gitlab': { - branches: { - master: { - name: 'master', - commit: { - id: 'abcdefgh', - }, - }, - }, - }, - }; - store.state.currentProjectId = 'gitlab-org/gitlab'; - store.state.currentBranchId = 'master'; - - Vue.set(store.state.entries, f.path, f); + createInstanceSpy = jest.spyOn(EditorLite.prototype, EDITOR_CODE_INSTANCE_FN); + createDiffInstanceSpy = jest.spyOn(EditorLite.prototype, EDITOR_DIFF_INSTANCE_FN); + createModelSpy = jest.spyOn(monacoEditor, 'createModel'); + jest.spyOn(service, 'getFileData').mockResolvedValue(); + jest.spyOn(service, 'getRawFileData').mockResolvedValue(); }); afterEach(() => { - vm.$destroy(); - vm = null; - - Editor.editorInstance.dispose(); + jest.clearAllMocks(); + // create a new model each time, otherwise tests conflict with each other + // because of same model being used in multiple tests + // eslint-disable-next-line no-undef + monaco.editor.getModels().forEach((model) => model.dispose()); + wrapper.destroy(); + wrapper = null; }); - const findEditor = () => vm.$el.querySelector('.multi-file-editor-holder'); - const changeViewMode = (viewMode) => - store.dispatch('editor/updateFileEditor', { path: vm.file.path, data: { viewMode } }); - describe('default', () => { - beforeEach(() => { - createComponent(); - - return waitForEditorSetup(); + it.each` + boolVal | textVal + ${true} | ${'all'} + ${false} | ${'none'} + `('sets renderWhitespace to "$textVal"', async ({ boolVal, textVal } = {}) => { + await createComponent({ + state: { + renderWhitespaceInCode: boolVal, + }, + }); + expect(vm.editorOptions.renderWhitespace).toEqual(textVal); }); - it('sets renderWhitespace to `all`', () => { - vm.$store.state.renderWhitespaceInCode = true; - - expect(vm.editorOptions.renderWhitespace).toEqual('all'); + it('renders an ide container', async () => { + await createComponent(); + expect(findEditor().isVisible()).toBe(true); }); - it('sets renderWhitespace to `none`', () => { - vm.$store.state.renderWhitespaceInCode = false; + it('renders only an edit tab', async () => { + await createComponent(); + const tabs = findTabs(); - expect(vm.editorOptions.renderWhitespace).toEqual('none'); + expect(tabs).toHaveLength(1); + expect(tabs.at(0).text()).toBe('Edit'); }); + }); - it('renders an ide container', () => { - expect(vm.shouldHideEditor).toBeFalsy(); - expect(vm.showEditor).toBe(true); - expect(findEditor()).not.toHaveCss({ display: 'none' }); - }); + describe('when file is markdown', () => { + let mock; + let activeFile; - it('renders only an edit tab', (done) => { - Vue.nextTick(() => { - const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li'); + beforeEach(() => { + activeFile = dummyFile.markdown; - expect(tabs.length).toBe(1); - expect(tabs[0].textContent.trim()).toBe('Edit'); + mock = new MockAdapter(axios); - done(); + mock.onPost(/(.*)\/preview_markdown/).reply(200, { + body: `<p>${defaultFileProps.content}</p>`, }); }); - describe('when file is markdown', () => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - - mock.onPost(/(.*)\/preview_markdown/).reply(200, { - body: '<p>testing 123</p>', - }); - - Vue.set(vm, 'file', { - ...vm.file, - projectId: 'namespace/project', - path: 'sample.md', - name: 'sample.md', - content: 'testing 123', - }); - - vm.$store.state.entries[vm.file.path] = vm.file; + afterEach(() => { + mock.restore(); + }); - return vm.$nextTick(); - }); + it('renders an Edit and a Preview Tab', async () => { + await createComponent({ activeFile }); + const tabs = findTabs(); - afterEach(() => { - mock.restore(); - }); + expect(tabs).toHaveLength(2); + expect(tabs.at(0).text()).toBe('Edit'); + expect(tabs.at(1).text()).toBe('Preview Markdown'); + }); - it('renders an Edit and a Preview Tab', (done) => { - Vue.nextTick(() => { - const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li'); + it('renders markdown for tempFile', async () => { + // by default files created in the spec are temp: no need for explicitly sending the param + await createComponent({ activeFile }); - expect(tabs.length).toBe(2); - expect(tabs[0].textContent.trim()).toBe('Edit'); - expect(tabs[1].textContent.trim()).toBe('Preview Markdown'); + findPreviewTab().trigger('click'); + await waitForPromises(); + expect(wrapper.find(ContentViewer).html()).toContain(defaultFileProps.content); + }); - done(); - }); + it('shows no tabs when not in Edit mode', async () => { + await createComponent({ + state: { + currentActivityView: leftSidebarViews.review.name, + }, + activeFile, }); + expect(findTabs()).toHaveLength(0); + }); + }); - it('renders markdown for tempFile', (done) => { - vm.file.tempFile = true; - - vm.$nextTick() - .then(() => { - vm.$el.querySelectorAll('.ide-mode-tabs .nav-links a')[1].click(); - }) - .then(waitForPromises) - .then(() => { - expect(vm.$el.querySelector('.preview-container').innerHTML).toContain( - '<p>testing 123</p>', - ); - }) - .then(done) - .catch(done.fail); - }); + describe('when file is binary and not raw', () => { + beforeEach(async () => { + const activeFile = dummyFile.binary; + await createComponent({ activeFile }); + }); - describe('when not in edit mode', () => { - beforeEach(async () => { - await vm.$nextTick(); + it('does not render the IDE', () => { + expect(findEditor().isVisible()).toBe(false); + }); - vm.$store.state.currentActivityView = leftSidebarViews.review.name; + it('does not create an instance', () => { + expect(createInstanceSpy).not.toHaveBeenCalled(); + expect(createDiffInstanceSpy).not.toHaveBeenCalled(); + }); + }); - return vm.$nextTick(); + describe('createEditorInstance', () => { + it.each` + viewer | diffInstance + ${viewerTypes.edit} | ${undefined} + ${viewerTypes.diff} | ${true} + ${viewerTypes.mr} | ${true} + `( + 'creates instance of correct type when viewer is $viewer', + async ({ viewer, diffInstance }) => { + await createComponent({ + state: { viewer }, }); + const isDiff = () => { + return diffInstance ? { isDiff: true } : {}; + }; + expect(createInstanceSpy).toHaveBeenCalledWith(expect.objectContaining(isDiff())); + expect(createDiffInstanceSpy).toHaveBeenCalledTimes((diffInstance && 1) || 0); + }, + ); - it('shows no tabs', () => { - expect(vm.$el.querySelectorAll('.ide-mode-tabs .nav-links a')).toHaveLength(0); + it('installs the WebIDE extension', async () => { + const extensionSpy = jest.spyOn(EditorLite, 'instanceApplyExtension'); + await createComponent(); + expect(extensionSpy).toHaveBeenCalled(); + Reflect.ownKeys(EditorWebIdeExtension.prototype) + .filter((fn) => fn !== 'constructor') + .forEach((fn) => { + expect(vm.editor[fn]).toBe(EditorWebIdeExtension.prototype[fn]); }); - }); }); + }); - describe('when open file is binary and not raw', () => { - beforeEach((done) => { - vm.file.name = 'file.dat'; - vm.file.content = '🐱'; // non-ascii binary content - jest.spyOn(vm.editor, 'createInstance').mockImplementation(); - jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation(); - - vm.$nextTick(done); - }); - - it('does not render the IDE', () => { - expect(vm.shouldHideEditor).toBeTruthy(); - }); - - it('does not call createInstance', async () => { - // Mirror the act's in the `createEditorInstance` - vm.createEditorInstance(); - - await vm.$nextTick(); + describe('setupEditor', () => { + beforeEach(async () => { + await createComponent(); + }); - expect(vm.editor.createInstance).not.toHaveBeenCalled(); - expect(vm.editor.createDiffInstance).not.toHaveBeenCalled(); - }); + it('creates new model on load', () => { + // We always create two models per file to be able to build a diff of changes + expect(createModelSpy).toHaveBeenCalledTimes(2); + // The model with the most recent changes is the last one + const [content] = createModelSpy.mock.calls[1]; + expect(content).toBe(defaultFileProps.content); }); - describe('createEditorInstance', () => { - it('calls createInstance when viewer is editor', (done) => { - jest.spyOn(vm.editor, 'createInstance').mockImplementation(); + it('does not create a new model on subsequent calls to setupEditor and re-uses the already-existing model', () => { + const existingModel = vm.model; + createModelSpy.mockClear(); - vm.createEditorInstance(); + vm.setupEditor(); - vm.$nextTick(() => { - expect(vm.editor.createInstance).toHaveBeenCalled(); + expect(createModelSpy).not.toHaveBeenCalled(); + expect(vm.model).toBe(existingModel); + }); - done(); - }); - }); + it('adds callback methods', () => { + jest.spyOn(vm.editor, 'onPositionChange'); + jest.spyOn(vm.model, 'onChange'); + jest.spyOn(vm.model, 'updateOptions'); - it('calls createDiffInstance when viewer is diff', (done) => { - vm.$store.state.viewer = 'diff'; + vm.setupEditor(); - jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation(); + expect(vm.editor.onPositionChange).toHaveBeenCalledTimes(1); + expect(vm.model.onChange).toHaveBeenCalledTimes(1); + expect(vm.model.updateOptions).toHaveBeenCalledWith(vm.rules); + }); - vm.createEditorInstance(); + it('updates state with the value of the model', () => { + const newContent = 'As Gregor Samsa\n awoke one morning\n'; + vm.model.setValue(newContent); - vm.$nextTick(() => { - expect(vm.editor.createDiffInstance).toHaveBeenCalled(); + vm.setupEditor(); - done(); - }); - }); + expect(vm.file.content).toBe(newContent); + }); - it('calls createDiffInstance when viewer is a merge request diff', (done) => { - vm.$store.state.viewer = 'mrdiff'; + it('sets head model as staged file', () => { + vm.modelManager.dispose(); + const addModelSpy = jest.spyOn(ModelManager.prototype, 'addModel'); - jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation(); + vm.$store.state.stagedFiles.push({ ...vm.file, key: 'staged' }); + vm.file.staged = true; + vm.file.key = `unstaged-${vm.file.key}`; - vm.createEditorInstance(); + vm.setupEditor(); - vm.$nextTick(() => { - expect(vm.editor.createDiffInstance).toHaveBeenCalled(); + expect(addModelSpy).toHaveBeenCalledWith(vm.file, vm.$store.state.stagedFiles[0]); + }); + }); - done(); - }); - }); + describe('editor updateDimensions', () => { + let updateDimensionsSpy; + let updateDiffViewSpy; + beforeEach(async () => { + await createComponent(); + updateDimensionsSpy = jest.spyOn(vm.editor, 'updateDimensions'); + updateDiffViewSpy = jest.spyOn(vm.editor, 'updateDiffView').mockImplementation(); }); - describe('setupEditor', () => { - it('creates new model', () => { - jest.spyOn(vm.editor, 'createModel'); + it('calls updateDimensions only when panelResizing is false', async () => { + expect(updateDimensionsSpy).not.toHaveBeenCalled(); + expect(updateDiffViewSpy).not.toHaveBeenCalled(); + expect(vm.$store.state.panelResizing).toBe(false); // default value - Editor.editorInstance.modelManager.dispose(); + vm.$store.state.panelResizing = true; + await vm.$nextTick(); - vm.setupEditor(); + expect(updateDimensionsSpy).not.toHaveBeenCalled(); + expect(updateDiffViewSpy).not.toHaveBeenCalled(); - expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file, null); - expect(vm.model).not.toBeNull(); - }); + vm.$store.state.panelResizing = false; + await vm.$nextTick(); - it('attaches model to editor', () => { - jest.spyOn(vm.editor, 'attachModel'); + expect(updateDimensionsSpy).toHaveBeenCalledTimes(1); + expect(updateDiffViewSpy).toHaveBeenCalledTimes(1); - Editor.editorInstance.modelManager.dispose(); + vm.$store.state.panelResizing = true; + await vm.$nextTick(); - vm.setupEditor(); + expect(updateDimensionsSpy).toHaveBeenCalledTimes(1); + expect(updateDiffViewSpy).toHaveBeenCalledTimes(1); + }); - expect(vm.editor.attachModel).toHaveBeenCalledWith(vm.model); - }); + it('calls updateDimensions when rightPane is toggled', async () => { + expect(updateDimensionsSpy).not.toHaveBeenCalled(); + expect(updateDiffViewSpy).not.toHaveBeenCalled(); + expect(vm.$store.state.rightPane.isOpen).toBe(false); // default value - it('attaches model to merge request editor', () => { - vm.$store.state.viewer = 'mrdiff'; - vm.file.mrChange = true; - jest.spyOn(vm.editor, 'attachMergeRequestModel').mockImplementation(); + vm.$store.state.rightPane.isOpen = true; + await vm.$nextTick(); - Editor.editorInstance.modelManager.dispose(); + expect(updateDimensionsSpy).toHaveBeenCalledTimes(1); + expect(updateDiffViewSpy).toHaveBeenCalledTimes(1); - vm.setupEditor(); + vm.$store.state.rightPane.isOpen = false; + await vm.$nextTick(); - expect(vm.editor.attachMergeRequestModel).toHaveBeenCalledWith(vm.model); - }); + expect(updateDimensionsSpy).toHaveBeenCalledTimes(2); + expect(updateDiffViewSpy).toHaveBeenCalledTimes(2); + }); + }); - it('does not attach model to merge request editor when not a MR change', () => { - vm.$store.state.viewer = 'mrdiff'; - vm.file.mrChange = false; - jest.spyOn(vm.editor, 'attachMergeRequestModel').mockImplementation(); + describe('editor tabs', () => { + beforeEach(async () => { + await createComponent(); + }); - Editor.editorInstance.modelManager.dispose(); + it.each` + mode | isVisible + ${'edit'} | ${true} + ${'review'} | ${false} + ${'commit'} | ${false} + `('tabs in $mode are $isVisible', async ({ mode, isVisible } = {}) => { + vm.$store.state.currentActivityView = leftSidebarViews[mode].name; - vm.setupEditor(); + await vm.$nextTick(); + expect(wrapper.find('.nav-links').exists()).toBe(isVisible); + }); + }); - expect(vm.editor.attachMergeRequestModel).not.toHaveBeenCalledWith(vm.model); + describe('files in preview mode', () => { + let updateDimensionsSpy; + const changeViewMode = (viewMode) => + vm.$store.dispatch('editor/updateFileEditor', { + path: vm.file.path, + data: { viewMode }, }); - it('adds callback methods', () => { - jest.spyOn(vm.editor, 'onPositionChange'); - - Editor.editorInstance.modelManager.dispose(); - - vm.setupEditor(); - - expect(vm.editor.onPositionChange).toHaveBeenCalled(); - expect(vm.model.events.size).toBe(2); + beforeEach(async () => { + await createComponent({ + activeFile: dummyFile.markdown, }); - it('updates state with the value of the model', () => { - vm.model.setValue('testing 1234\n'); - - vm.setupEditor(); - - expect(vm.file.content).toBe('testing 1234\n'); - }); + updateDimensionsSpy = jest.spyOn(vm.editor, 'updateDimensions'); - it('sets head model as staged file', () => { - jest.spyOn(vm.editor, 'createModel'); + changeViewMode(FILE_VIEW_MODE_PREVIEW); + await vm.$nextTick(); + }); - Editor.editorInstance.modelManager.dispose(); + it('do not show the editor', () => { + expect(vm.showEditor).toBe(false); + expect(findEditor().isVisible()).toBe(false); + }); - vm.$store.state.stagedFiles.push({ ...vm.file, key: 'staged' }); - vm.file.staged = true; - vm.file.key = `unstaged-${vm.file.key}`; + it('updates dimensions when switching view back to edit', async () => { + expect(updateDimensionsSpy).not.toHaveBeenCalled(); - vm.setupEditor(); + changeViewMode(FILE_VIEW_MODE_EDITOR); + await vm.$nextTick(); - expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file, vm.$store.state.stagedFiles[0]); - }); + expect(updateDimensionsSpy).toHaveBeenCalled(); }); + }); - describe('editor updateDimensions', () => { - beforeEach(() => { - jest.spyOn(vm.editor, 'updateDimensions'); - jest.spyOn(vm.editor, 'updateDiffView').mockImplementation(); - }); - - it('calls updateDimensions when panelResizing is false', (done) => { - vm.$store.state.panelResizing = true; - - vm.$nextTick() - .then(() => { - vm.$store.state.panelResizing = false; - }) - .then(vm.$nextTick) - .then(() => { - expect(vm.editor.updateDimensions).toHaveBeenCalled(); - expect(vm.editor.updateDiffView).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); - }); - - it('does not call updateDimensions when panelResizing is true', (done) => { - vm.$store.state.panelResizing = true; + describe('initEditor', () => { + const hideEditorAndRunFn = async () => { + jest.clearAllMocks(); + jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true); - vm.$nextTick(() => { - expect(vm.editor.updateDimensions).not.toHaveBeenCalled(); - expect(vm.editor.updateDiffView).not.toHaveBeenCalled(); + vm.initEditor(); + await vm.$nextTick(); + }; - done(); - }); + it('does not fetch file information for temp entries', async () => { + await createComponent({ + activeFile: createActiveFile(), }); - it('calls updateDimensions when rightPane is opened', (done) => { - vm.$store.state.rightPane.isOpen = true; - - vm.$nextTick(() => { - expect(vm.editor.updateDimensions).toHaveBeenCalled(); - expect(vm.editor.updateDiffView).toHaveBeenCalled(); - - done(); - }); - }); + expect(vm.getFileData).not.toHaveBeenCalled(); }); - describe('show tabs', () => { - it('shows tabs in edit mode', () => { - expect(vm.$el.querySelector('.nav-links')).not.toBe(null); + it('is being initialised for files without content even if shouldHideEditor is `true`', async () => { + await createComponent({ + activeFile: dummyFile.empty, }); - it('hides tabs in review mode', (done) => { - vm.$store.state.currentActivityView = leftSidebarViews.review.name; + await hideEditorAndRunFn(); - vm.$nextTick(() => { - expect(vm.$el.querySelector('.nav-links')).toBe(null); + expect(vm.getFileData).toHaveBeenCalled(); + expect(vm.getRawFileData).toHaveBeenCalled(); + }); - done(); - }); + it('does not initialize editor for files already with content when shouldHideEditor is `true`', async () => { + await createComponent({ + activeFile: createActiveFile(), }); - it('hides tabs in commit mode', (done) => { - vm.$store.state.currentActivityView = leftSidebarViews.commit.name; + await hideEditorAndRunFn(); - vm.$nextTick(() => { - expect(vm.$el.querySelector('.nav-links')).toBe(null); + expect(vm.getFileData).not.toHaveBeenCalled(); + expect(vm.getRawFileData).not.toHaveBeenCalled(); + expect(createInstanceSpy).not.toHaveBeenCalled(); + }); + }); - done(); - }); + describe('updates on file changes', () => { + beforeEach(async () => { + await createComponent({ + activeFile: createActiveFile({ + content: 'foo', // need to prevent full cycle of initEditor + }), }); + jest.spyOn(vm, 'initEditor').mockImplementation(); }); - describe('when files view mode is preview', () => { - beforeEach((done) => { - jest.spyOn(vm.editor, 'updateDimensions').mockImplementation(); - changeViewMode(FILE_VIEW_MODE_PREVIEW); - vm.file.name = 'myfile.md'; - vm.file.content = 'hello world'; + it('calls removePendingTab when old file is pending', async () => { + jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true); + jest.spyOn(vm, 'removePendingTab').mockImplementation(); - vm.$nextTick(done); - }); + const origFile = vm.file; + vm.file.pending = true; + await vm.$nextTick(); - it('should hide editor', () => { - expect(vm.showEditor).toBe(false); - expect(findEditor()).toHaveCss({ display: 'none' }); + wrapper.setProps({ + file: file('testing'), }); + vm.file.content = 'foo'; // need to prevent full cycle of initEditor + await vm.$nextTick(); - describe('when file view mode changes to editor', () => { - it('should update dimensions', () => { - changeViewMode(FILE_VIEW_MODE_EDITOR); - - return vm.$nextTick().then(() => { - expect(vm.editor.updateDimensions).toHaveBeenCalled(); - }); - }); - }); + expect(vm.removePendingTab).toHaveBeenCalledWith(origFile); }); - describe('initEditor', () => { - beforeEach(() => { - vm.file.tempFile = false; - jest.spyOn(vm.editor, 'createInstance').mockImplementation(); - jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true); - }); + it('does not call initEditor if the file did not change', async () => { + Vue.set(vm, 'file', vm.file); + await vm.$nextTick(); - it('does not fetch file information for temp entries', (done) => { - vm.file.tempFile = true; - - vm.initEditor(); - vm.$nextTick() - .then(() => { - expect(vm.getFileData).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); - }); - - it('is being initialised for files without content even if shouldHideEditor is `true`', (done) => { - vm.file.content = ''; - vm.file.raw = ''; + expect(vm.initEditor).not.toHaveBeenCalled(); + }); - vm.initEditor(); + it('calls initEditor when file key is changed', async () => { + expect(vm.initEditor).not.toHaveBeenCalled(); - vm.$nextTick() - .then(() => { - expect(vm.getFileData).toHaveBeenCalled(); - expect(vm.getRawFileData).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + wrapper.setProps({ + file: { + ...vm.file, + key: 'new', + }, }); + await vm.$nextTick(); - it('does not initialize editor for files already with content', (done) => { - vm.file.content = 'foo'; - - vm.initEditor(); - vm.$nextTick() - .then(() => { - expect(vm.getFileData).not.toHaveBeenCalled(); - expect(vm.getRawFileData).not.toHaveBeenCalled(); - expect(vm.editor.createInstance).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); - }); + expect(vm.initEditor).toHaveBeenCalled(); + }); + }); + + describe('populates editor with the fetched content', () => { + const createRemoteFile = (name) => ({ + ...file(name), + tmpFile: false, }); - describe('updates on file changes', () => { - beforeEach(() => { - jest.spyOn(vm, 'initEditor').mockImplementation(); - }); + beforeEach(async () => { + await createComponent(); + vm.getRawFileData.mockRestore(); + }); - it('calls removePendingTab when old file is pending', (done) => { - jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true); - jest.spyOn(vm, 'removePendingTab').mockImplementation(); + it('after switching viewer from edit to diff', async () => { + const f = createRemoteFile('newFile'); + Vue.set(vm.$store.state.entries, f.path, f); - vm.file.pending = true; + jest.spyOn(service, 'getRawFileData').mockImplementation(async () => { + expect(vm.file.loading).toBe(true); - vm.$nextTick() - .then(() => { - vm.file = file('testing'); - vm.file.content = 'foo'; // need to prevent full cycle of initEditor + // switching from edit to diff mode usually triggers editor initialization + vm.$store.state.viewer = viewerTypes.diff; - return vm.$nextTick(); - }) - .then(() => { - expect(vm.removePendingTab).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + // we delay returning the file to make sure editor doesn't initialize before we fetch file content + await waitUsingRealTimer(30); + return 'rawFileData123\n'; }); - it('does not call initEditor if the file did not change', (done) => { - Vue.set(vm, 'file', vm.file); - - vm.$nextTick() - .then(() => { - expect(vm.initEditor).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + wrapper.setProps({ + file: f, }); - it('calls initEditor when file key is changed', (done) => { - expect(vm.initEditor).not.toHaveBeenCalled(); + await waitForEditorSetup(); + expect(vm.model.getModel().getValue()).toBe('rawFileData123\n'); + }); - Vue.set(vm, 'file', { - ...vm.file, - key: 'new', + it('after opening multiple files at the same time', async () => { + const fileA = createRemoteFile('fileA'); + const aContent = 'fileA-rawContent\n'; + const bContent = 'fileB-rawContent\n'; + const fileB = createRemoteFile('fileB'); + Vue.set(vm.$store.state.entries, fileA.path, fileA); + Vue.set(vm.$store.state.entries, fileB.path, fileB); + + jest + .spyOn(service, 'getRawFileData') + .mockImplementation(async () => { + // opening fileB while the content of fileA is still being fetched + wrapper.setProps({ + file: fileB, + }); + return aContent; + }) + .mockImplementationOnce(async () => { + // we delay returning fileB content to make sure the editor doesn't initialize prematurely + await waitUsingRealTimer(30); + return bContent; }); - vm.$nextTick() - .then(() => { - expect(vm.initEditor).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + wrapper.setProps({ + file: fileA, }); - }); - describe('populates editor with the fetched content', () => { - beforeEach(() => { - vm.getRawFileData.mockRestore(); - }); + await waitForEditorSetup(); + expect(vm.model.getModel().getValue()).toBe(bContent); + }); + }); - const createRemoteFile = (name) => ({ - ...file(name), - tmpFile: false, + describe('onPaste', () => { + const setFileName = (name) => + createActiveFile({ + content: 'hello world\n', + name, + path: `foo/${name}`, + key: 'new', }); - it('after switching viewer from edit to diff', async () => { - jest.spyOn(service, 'getRawFileData').mockImplementation(async () => { - expect(vm.file.loading).toBe(true); - - // switching from edit to diff mode usually triggers editor initialization - store.state.viewer = viewerTypes.diff; + const pasteImage = () => { + window.dispatchEvent( + Object.assign(new Event('paste'), { + clipboardData: { + files: [new File(['foo'], 'foo.png', { type: 'image/png' })], + }, + }), + ); + }; - // we delay returning the file to make sure editor doesn't initialize before we fetch file content - await waitUsingRealTimer(30); - return 'rawFileData123\n'; + const watchState = (watched) => + new Promise((resolve) => { + const unwatch = vm.$store.watch(watched, () => { + unwatch(); + resolve(); }); - - const f = createRemoteFile('newFile'); - Vue.set(store.state.entries, f.path, f); - - vm.file = f; - - await waitForEditorSetup(); - expect(vm.model.getModel().getValue()).toBe('rawFileData123\n'); }); - it('after opening multiple files at the same time', async () => { - const fileA = createRemoteFile('fileA'); - const fileB = createRemoteFile('fileB'); - Vue.set(store.state.entries, fileA.path, fileA); - Vue.set(store.state.entries, fileB.path, fileB); - - jest - .spyOn(service, 'getRawFileData') - .mockImplementationOnce(async () => { - // opening fileB while the content of fileA is still being fetched - vm.file = fileB; - return 'fileA-rawContent\n'; - }) - .mockImplementationOnce(async () => { - // we delay returning fileB content to make sure the editor doesn't initialize prematurely - await waitUsingRealTimer(30); - return 'fileB-rawContent\n'; - }); + // Pasting an image does a lot of things like using the FileReader API, + // so, waitForPromises isn't very reliable (and causes a flaky spec) + // Read more about state.watch: https://vuex.vuejs.org/api/#watch + const waitForFileContentChange = () => watchState((s) => s.entries['foo/bar.md'].content); - vm.file = fileA; - - await waitForEditorSetup(); - expect(vm.model.getModel().getValue()).toBe('fileB-rawContent\n'); + beforeEach(async () => { + await createComponent({ + state: { + trees: { + 'gitlab-org/gitlab': { tree: [] }, + }, + currentProjectId: 'gitlab-org', + currentBranchId: 'gitlab', + }, + activeFile: setFileName('bar.md'), }); - }); - - describe('onPaste', () => { - const setFileName = (name) => { - Vue.set(vm, 'file', { - ...vm.file, - content: 'hello world\n', - name, - path: `foo/${name}`, - key: 'new', - }); - vm.$store.state.entries[vm.file.path] = vm.file; - }; + vm.setupEditor(); - const pasteImage = () => { - window.dispatchEvent( - Object.assign(new Event('paste'), { - clipboardData: { - files: [new File(['foo'], 'foo.png', { type: 'image/png' })], - }, - }), - ); - }; - - const watchState = (watched) => - new Promise((resolve) => { - const unwatch = vm.$store.watch(watched, () => { - unwatch(); - resolve(); - }); - }); + await waitForPromises(); + // set cursor to line 2, column 1 + vm.editor.setSelection(new Range(2, 1, 2, 1)); + vm.editor.focus(); - // Pasting an image does a lot of things like using the FileReader API, - // so, waitForPromises isn't very reliable (and causes a flaky spec) - // Read more about state.watch: https://vuex.vuejs.org/api/#watch - const waitForFileContentChange = () => watchState((s) => s.entries['foo/bar.md'].content); - - beforeEach(() => { - setFileName('bar.md'); - - vm.$store.state.trees['gitlab-org/gitlab'] = { tree: [] }; - vm.$store.state.currentProjectId = 'gitlab-org'; - vm.$store.state.currentBranchId = 'gitlab'; - - // create a new model each time, otherwise tests conflict with each other - // because of same model being used in multiple tests - Editor.editorInstance.modelManager.dispose(); - vm.setupEditor(); + jest.spyOn(vm.editor, 'hasTextFocus').mockReturnValue(true); + }); - return waitForPromises().then(() => { - // set cursor to line 2, column 1 - vm.editor.instance.setSelection(new Range(2, 1, 2, 1)); - vm.editor.instance.focus(); + it('adds an image entry to the same folder for a pasted image in a markdown file', async () => { + pasteImage(); - jest.spyOn(vm.editor.instance, 'hasTextFocus').mockReturnValue(true); - }); + await waitForFileContentChange(); + expect(vm.$store.state.entries['foo/foo.png']).toMatchObject({ + path: 'foo/foo.png', + type: 'blob', + content: 'Zm9v', + rawPath: 'data:image/png;base64,Zm9v', }); + }); - it('adds an image entry to the same folder for a pasted image in a markdown file', () => { - pasteImage(); - - return waitForFileContentChange().then(() => { - expect(vm.$store.state.entries['foo/foo.png']).toMatchObject({ - path: 'foo/foo.png', - type: 'blob', - content: 'Zm9v', - rawPath: 'data:image/png;base64,Zm9v', - }); - }); - }); + it("adds a markdown image tag to the file's contents", async () => { + pasteImage(); - it("adds a markdown image tag to the file's contents", () => { - pasteImage(); + await waitForFileContentChange(); + expect(vm.file.content).toBe('hello world\n![foo.png](./foo.png)'); + }); - return waitForFileContentChange().then(() => { - expect(vm.file.content).toBe('hello world\n![foo.png](./foo.png)'); - }); + it("does not add file to state or set markdown image syntax if the file isn't markdown", async () => { + wrapper.setProps({ + file: setFileName('myfile.txt'), }); + pasteImage(); - it("does not add file to state or set markdown image syntax if the file isn't markdown", () => { - setFileName('myfile.txt'); - pasteImage(); - - return waitForPromises().then(() => { - expect(vm.$store.state.entries['foo/foo.png']).toBeUndefined(); - expect(vm.file.content).toBe('hello world\n'); - }); - }); + await waitForPromises(); + expect(vm.$store.state.entries['foo/foo.png']).toBeUndefined(); + expect(vm.file.content).toBe('hello world\n'); }); }); describe('fetchEditorconfigRules', () => { - beforeEach(() => { - exampleConfigs.forEach(({ path, content }) => { - store.state.entries[path] = { ...file(), path, content }; - }); - }); - it.each(exampleFiles)( 'does not fetch content from remote for .editorconfig files present locally (case %#)', - ({ path, monacoRules }) => { - createOpenFile(path); - createComponent(); - - return waitForEditorSetup().then(() => { - expect(vm.rules).toEqual(monacoRules); - expect(vm.model.options).toMatchObject(monacoRules); - expect(vm.getFileData).not.toHaveBeenCalled(); - expect(vm.getRawFileData).not.toHaveBeenCalled(); + async ({ path, monacoRules }) => { + await createComponent({ + state: { + entries: (() => { + const res = {}; + exampleConfigs.forEach(({ path: configPath, content }) => { + res[configPath] = { ...file(), path: configPath, content }; + }); + return res; + })(), + }, + activeFile: createActiveFile({ + path, + key: path, + name: 'myfile.txt', + content: 'hello world', + }), }); + + expect(vm.rules).toEqual(monacoRules); + expect(vm.model.options).toMatchObject(monacoRules); + expect(vm.getFileData).not.toHaveBeenCalled(); + expect(vm.getRawFileData).not.toHaveBeenCalled(); }, ); - it('fetches content from remote for .editorconfig files not available locally', () => { - exampleConfigs.forEach(({ path }) => { - delete store.state.entries[path].content; - delete store.state.entries[path].raw; + it('fetches content from remote for .editorconfig files not available locally', async () => { + const activeFile = createActiveFile({ + path: 'foo/bar/baz/test/my_spec.js', + key: 'foo/bar/baz/test/my_spec.js', + name: 'myfile.txt', + content: 'hello world', + }); + + const expectations = [ + 'foo/bar/baz/.editorconfig', + 'foo/bar/.editorconfig', + 'foo/.editorconfig', + '.editorconfig', + ]; + + await createComponent({ + state: { + entries: (() => { + const res = { + [activeFile.path]: activeFile, + }; + exampleConfigs.forEach(({ path: configPath }) => { + const f = { ...file(), path: configPath }; + delete f.content; + delete f.raw; + res[configPath] = f; + }); + return res; + })(), + }, + activeFile, }); - // Include a "test" directory which does not exist in store. This one should be skipped. - createOpenFile('foo/bar/baz/test/my_spec.js'); - createComponent(); - - return waitForEditorSetup().then(() => { - expect(vm.getFileData.mock.calls.map(([args]) => args)).toEqual([ - { makeFileActive: false, path: 'foo/bar/baz/.editorconfig' }, - { makeFileActive: false, path: 'foo/bar/.editorconfig' }, - { makeFileActive: false, path: 'foo/.editorconfig' }, - { makeFileActive: false, path: '.editorconfig' }, - ]); - expect(vm.getRawFileData.mock.calls.map(([args]) => args)).toEqual([ - { path: 'foo/bar/baz/.editorconfig' }, - { path: 'foo/bar/.editorconfig' }, - { path: 'foo/.editorconfig' }, - { path: '.editorconfig' }, - ]); - }); + expect(service.getFileData.mock.calls.map(([args]) => args)).toEqual( + expectations.map((expectation) => expect.stringContaining(expectation)), + ); + expect(service.getRawFileData.mock.calls.map(([args]) => args)).toEqual( + expectations.map((expectation) => expect.objectContaining({ path: expectation })), + ); }); }); }); diff --git a/spec/frontend/ide/components/repo_tab_spec.js b/spec/frontend/ide/components/repo_tab_spec.js index b39a488b034..95d52e8f7a9 100644 --- a/spec/frontend/ide/components/repo_tab_spec.js +++ b/spec/frontend/ide/components/repo_tab_spec.js @@ -1,5 +1,7 @@ +import { GlTab } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import { stubComponent } from 'helpers/stub_component'; import RepoTab from '~/ide/components/repo_tab.vue'; import { createRouter } from '~/ide/ide_router'; import { createStore } from '~/ide/stores'; @@ -8,16 +10,25 @@ import { file } from '../helpers'; const localVue = createLocalVue(); localVue.use(Vuex); +const GlTabStub = stubComponent(GlTab, { + template: '<li><slot name="title" /></li>', +}); + describe('RepoTab', () => { let wrapper; let store; let router; + const findTab = () => wrapper.find(GlTabStub); + function createComponent(propsData) { wrapper = mount(RepoTab, { localVue, store, propsData, + stubs: { + GlTab: GlTabStub, + }, }); } @@ -55,7 +66,7 @@ describe('RepoTab', () => { jest.spyOn(wrapper.vm, 'openPendingTab').mockImplementation(() => {}); - await wrapper.trigger('click'); + await findTab().vm.$emit('click'); expect(wrapper.vm.openPendingTab).not.toHaveBeenCalled(); }); @@ -67,7 +78,7 @@ describe('RepoTab', () => { jest.spyOn(wrapper.vm, 'clickFile').mockImplementation(() => {}); - wrapper.trigger('click'); + findTab().vm.$emit('click'); expect(wrapper.vm.clickFile).toHaveBeenCalledWith(wrapper.vm.tab); }); @@ -91,11 +102,11 @@ describe('RepoTab', () => { tab, }); - await wrapper.trigger('mouseover'); + await findTab().vm.$emit('mouseover'); expect(wrapper.find('.file-modified').exists()).toBe(false); - await wrapper.trigger('mouseout'); + await findTab().vm.$emit('mouseout'); expect(wrapper.find('.file-modified').exists()).toBe(true); }); 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 index cdef4b1ee62..7a83136e785 100644 --- 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 @@ -1,10 +1,15 @@ -import { GlButton, GlLink, GlFormInput } from '@gitlab/ui'; +import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { STATUSES } from '~/import_entities/constants'; import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue'; -import Select2Select from '~/vue_shared/components/select2_select.vue'; +import groupQuery from '~/import_entities/import_groups/graphql/queries/group.query.graphql'; import { availableNamespacesFixture } from '../graphql/fixtures'; +Vue.use(VueApollo); + const getFakeGroup = (status) => ({ web_url: 'https://fake.host/', full_path: 'fake_group_1', @@ -17,8 +22,12 @@ const getFakeGroup = (status) => ({ status, }); +const EXISTING_GROUP_TARGET_NAMESPACE = 'existing-group'; +const EXISTING_GROUP_PATH = 'existing-path'; + describe('import table row', () => { let wrapper; + let apolloProvider; let group; const findByText = (cmp, text) => { @@ -26,12 +35,27 @@ describe('import table row', () => { }; const findImportButton = () => findByText(GlButton, 'Import'); const findNameInput = () => wrapper.find(GlFormInput); - const findNamespaceDropdown = () => wrapper.find(Select2Select); + const findNamespaceDropdown = () => wrapper.find(GlDropdown); const createComponent = (props) => { + apolloProvider = createMockApollo([ + [ + groupQuery, + ({ fullPath }) => { + const existingGroup = + fullPath === `${EXISTING_GROUP_TARGET_NAMESPACE}/${EXISTING_GROUP_PATH}` + ? { id: 1 } + : null; + return Promise.resolve({ data: { existingGroup } }); + }, + ], + ]); + wrapper = shallowMount(ImportTableRow, { + apolloProvider, propsData: { availableNamespaces: availableNamespacesFixture, + groupPathRegex: /.*/, ...props, }, }); @@ -49,15 +73,24 @@ describe('import table row', () => { }); it.each` - selector | sourceEvent | payload | event - ${findNamespaceDropdown} | ${'input'} | ${'demo'} | ${'update-target-namespace'} - ${findNameInput} | ${'input'} | ${'demo'} | ${'update-new-name'} - ${findImportButton} | ${'click'} | ${undefined} | ${'import-group'} + selector | sourceEvent | payload | event + ${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); }); + + it('emits update-target-namespace when dropdown option is clicked', () => { + const dropdownItem = findNamespaceDropdown().findAllComponents(GlDropdownItem).at(2); + const dropdownItemText = dropdownItem.text(); + + dropdownItem.vm.$emit('click'); + + expect(wrapper.emitted('update-target-namespace')).toBeDefined(); + expect(wrapper.emitted('update-target-namespace')[0][0]).toBe(dropdownItemText); + }); }); describe('when entity status is NONE', () => { @@ -75,6 +108,34 @@ describe('import table row', () => { }); }); + it('renders only no parent option if available namespaces list is empty', () => { + createComponent({ + group: getFakeGroup(STATUSES.NONE), + availableNamespaces: [], + }); + + const items = findNamespaceDropdown() + .findAllComponents(GlDropdownItem) + .wrappers.map((w) => w.text()); + + expect(items[0]).toBe('No parent'); + expect(items).toHaveLength(1); + }); + + it('renders both no parent option and available namespaces list when available namespaces list is not empty', () => { + createComponent({ + group: getFakeGroup(STATUSES.NONE), + availableNamespaces: availableNamespacesFixture, + }); + + const [firstItem, ...rest] = findNamespaceDropdown() + .findAllComponents(GlDropdownItem) + .wrappers.map((w) => w.text()); + + expect(firstItem).toBe('No parent'); + expect(rest).toHaveLength(availableNamespacesFixture.length); + }); + describe('when entity status is SCHEDULING', () => { beforeEach(() => { group = getFakeGroup(STATUSES.SCHEDULING); @@ -109,4 +170,38 @@ describe('import table row', () => { expect(findByText(GlLink, TARGET_LINK).exists()).toBe(true); }); }); + + describe('validations', () => { + it('Reports invalid group name when name is not matching regex', () => { + createComponent({ + group: { + ...getFakeGroup(STATUSES.NONE), + import_target: { + target_namespace: 'root', + new_name: 'very`bad`name', + }, + }, + groupPathRegex: /^[a-zA-Z]+$/, + }); + + expect(wrapper.text()).toContain('Please choose a group URL with no special characters.'); + }); + + it('Reports invalid group name if group already exists', async () => { + createComponent({ + group: { + ...getFakeGroup(STATUSES.NONE), + import_target: { + target_namespace: EXISTING_GROUP_TARGET_NAMESPACE, + new_name: EXISTING_GROUP_PATH, + }, + }, + }); + + jest.runOnlyPendingTimers(); + await nextTick(); + + expect(wrapper.text()).toContain('Name already exists.'); + }); + }); }); 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 index dd734782169..496c5cda7c7 100644 --- a/spec/frontend/import_entities/import_groups/components/import_table_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_table_spec.js @@ -1,7 +1,15 @@ -import { GlEmptyState, GlLoadingIcon, GlSearchBoxByClick, GlSprintf } from '@gitlab/ui'; +import { + GlEmptyState, + GlLoadingIcon, + GlSearchBoxByClick, + GlSprintf, + GlDropdown, + GlDropdownItem, +} from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import { STATUSES } from '~/import_entities/constants'; import ImportTable from '~/import_entities/import_groups/components/import_table.vue'; @@ -16,13 +24,25 @@ import { availableNamespacesFixture, generateFakeEntry } from '../graphql/fixtur const localVue = createLocalVue(); localVue.use(VueApollo); +const GlDropdownStub = stubComponent(GlDropdown, { + template: '<div><h1 ref="text"><slot name="button-content"></slot></h1><slot></slot></div>', +}); + describe('import table', () => { let wrapper; let apolloProvider; + const SOURCE_URL = 'https://demo.host'; const FAKE_GROUP = generateFakeEntry({ id: 1, status: STATUSES.NONE }); + const FAKE_GROUPS = [ + generateFakeEntry({ id: 1, status: STATUSES.NONE }), + generateFakeEntry({ id: 2, status: STATUSES.FINISHED }), + ]; const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 }; + const findPaginationDropdown = () => wrapper.findComponent(GlDropdown); + const findPaginationDropdownText = () => findPaginationDropdown().find({ ref: 'text' }).text(); + const createComponent = ({ bulkImportSourceGroups }) => { apolloProvider = createMockApollo([], { Query: { @@ -38,10 +58,12 @@ describe('import table', () => { wrapper = shallowMount(ImportTable, { propsData: { - sourceUrl: 'https://demo.host', + groupPathRegex: /.*/, + sourceUrl: SOURCE_URL, }, stubs: { GlSprintf, + GlDropdown: GlDropdownStub, }, localVue, apolloProvider, @@ -80,14 +102,10 @@ describe('import table', () => { }); await waitForPromises(); - expect(wrapper.find(GlEmptyState).props().title).toBe('No groups available for import'); + expect(wrapper.find(GlEmptyState).props().title).toBe('You have no groups to import'); }); 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: () => ({ nodes: FAKE_GROUPS, @@ -151,6 +169,20 @@ describe('import table', () => { expect(wrapper.find(PaginationLinks).props().pageInfo).toStrictEqual(FAKE_PAGE_INFO); }); + it('renders pagination dropdown', () => { + expect(findPaginationDropdown().exists()).toBe(true); + }); + + it('updates page size when selected in Dropdown', async () => { + const otherOption = wrapper.findAllComponents(GlDropdownItem).at(1); + expect(otherOption.text()).toMatchInterpolatedText('50 items per page'); + + otherOption.vm.$emit('click'); + await waitForPromises(); + + expect(findPaginationDropdownText()).toMatchInterpolatedText('50 items per page'); + }); + it('updates page when page change is requested', async () => { const REQUESTED_PAGE = 2; wrapper.find(PaginationLinks).props().change(REQUESTED_PAGE); @@ -178,7 +210,7 @@ describe('import table', () => { wrapper.find(PaginationLinks).props().change(REQUESTED_PAGE); await waitForPromises(); - expect(wrapper.text()).toContain('Showing 21-21 of 38'); + expect(wrapper.text()).toContain('Showing 21-21 of 38 groups from'); }); }); @@ -224,7 +256,7 @@ describe('import table', () => { findFilterInput().vm.$emit('submit', FILTER_VALUE); await waitForPromises(); - expect(wrapper.text()).toContain('Showing 1-1 of 40 groups matching filter "foo"'); + expect(wrapper.text()).toContain('Showing 1-1 of 40 groups matching filter "foo" from'); }); it('properly resets filter in graphql query when search box is cleared', async () => { 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 index 4d3d2c41bbe..1feff861c1e 100644 --- a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js +++ b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js @@ -2,6 +2,7 @@ import { InMemoryCache } from 'apollo-cache-inmemory'; import MockAdapter from 'axios-mock-adapter'; import { createMockClient } from 'mock-apollo-client'; import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; import { STATUSES } from '~/import_entities/constants'; import { clientTypenames, @@ -18,6 +19,7 @@ import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; import { statusEndpointFixture, availableNamespacesFixture } from './fixtures'; +jest.mock('~/flash'); jest.mock('~/import_entities/import_groups/graphql/services/status_poller', () => ({ StatusPoller: jest.fn().mockImplementation(function mock() { this.startPolling = jest.fn(); @@ -35,15 +37,19 @@ describe('Bulk import resolvers', () => { let axiosMockAdapter; let client; - beforeEach(() => { - axiosMockAdapter = new MockAdapter(axios); - client = createMockClient({ + const createClient = (extraResolverArgs) => { + return createMockClient({ cache: new InMemoryCache({ fragmentMatcher: { match: () => true }, addTypename: false, }), - resolvers: createResolvers({ endpoints: FAKE_ENDPOINTS }), + resolvers: createResolvers({ endpoints: FAKE_ENDPOINTS, ...extraResolverArgs }), }); + }; + + beforeEach(() => { + axiosMockAdapter = new MockAdapter(axios); + client = createClient(); }); afterEach(() => { @@ -82,6 +88,44 @@ describe('Bulk import resolvers', () => { .reply(httpStatus.OK, availableNamespacesFixture); }); + it('respects cached import state when provided by group manager', async () => { + const FAKE_STATUS = 'DEMO_STATUS'; + const FAKE_IMPORT_TARGET = {}; + const TARGET_INDEX = 0; + + const clientWithMockedManager = createClient({ + GroupsManager: jest.fn().mockImplementation(() => ({ + getImportStateFromStorageByGroupId(groupId) { + if (groupId === statusEndpointFixture.importable_data[TARGET_INDEX].id) { + return { + status: FAKE_STATUS, + importTarget: FAKE_IMPORT_TARGET, + }; + } + + return null; + }, + })), + }); + + const clientResponse = await clientWithMockedManager.query({ + query: bulkImportSourceGroupsQuery, + }); + const clientResults = clientResponse.data.bulkImportSourceGroups.nodes; + + expect(clientResults[TARGET_INDEX].import_target).toBe(FAKE_IMPORT_TARGET); + expect(clientResults[TARGET_INDEX].status).toBe(FAKE_STATUS); + }); + + it('populates each result instance with empty import_target when there are no available namespaces', async () => { + axiosMockAdapter.onGet(FAKE_ENDPOINTS.availableNamespaces).reply(httpStatus.OK, []); + + const response = await client.query({ query: bulkImportSourceGroupsQuery }); + results = response.data.bulkImportSourceGroups.nodes; + + expect(results.every((r) => r.import_target.target_namespace === '')).toBe(true); + }); + describe('when called', () => { beforeEach(async () => { const response = await client.query({ query: bulkImportSourceGroupsQuery }); @@ -220,14 +264,14 @@ describe('Bulk import resolvers', () => { expect(intermediateResults[0].status).toBe(STATUSES.SCHEDULING); }); - it('sets group status to STARTED when request completes', async () => { + it('sets import status to CREATED when request completes', async () => { axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK, { id: 1 }); await client.mutate({ mutation: importGroupMutation, variables: { sourceGroupId: GROUP_ID }, }); - expect(results[0].status).toBe(STATUSES.STARTED); + expect(results[0].status).toBe(STATUSES.CREATED); }); it('resets status to NONE if request fails', async () => { @@ -245,6 +289,40 @@ describe('Bulk import resolvers', () => { expect(results[0].status).toBe(STATUSES.NONE); }); + + it('shows default error message when server error is not provided', async () => { + axiosMockAdapter + .onPost(FAKE_ENDPOINTS.createBulkImport) + .reply(httpStatus.INTERNAL_SERVER_ERROR); + + client + .mutate({ + mutation: importGroupMutation, + variables: { sourceGroupId: GROUP_ID }, + }) + .catch(() => {}); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ message: 'Importing the group failed' }); + }); + + it('shows provided error message when error is included in backend response', async () => { + const CUSTOM_MESSAGE = 'custom message'; + + axiosMockAdapter + .onPost(FAKE_ENDPOINTS.createBulkImport) + .reply(httpStatus.INTERNAL_SERVER_ERROR, { error: CUSTOM_MESSAGE }); + + client + .mutate({ + mutation: importGroupMutation, + variables: { sourceGroupId: GROUP_ID }, + }) + .catch(() => {}); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ message: CUSTOM_MESSAGE }); + }); }); }); }); 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 index ca987ab3ab4..5baa201906a 100644 --- 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 @@ -1,11 +1,17 @@ import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; import { clientTypenames } from '~/import_entities/import_groups/graphql/client_factory'; import ImportSourceGroupFragment from '~/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql'; -import { SourceGroupsManager } from '~/import_entities/import_groups/graphql/services/source_groups_manager'; +import { + KEY, + SourceGroupsManager, +} from '~/import_entities/import_groups/graphql/services/source_groups_manager'; + +const FAKE_SOURCE_URL = 'http://demo.host'; describe('SourceGroupsManager', () => { let manager; let client; + let storage; const getFakeGroup = () => ({ __typename: clientTypenames.BulkImportSourceGroup, @@ -17,8 +23,53 @@ describe('SourceGroupsManager', () => { readFragment: jest.fn(), writeFragment: jest.fn(), }; + storage = { + getItem: jest.fn(), + setItem: jest.fn(), + }; + + manager = new SourceGroupsManager({ client, storage, sourceUrl: FAKE_SOURCE_URL }); + }); + + describe('storage management', () => { + const IMPORT_ID = 1; + const IMPORT_TARGET = { destination_name: 'demo', destination_namespace: 'foo' }; + const STATUS = 'FAKE_STATUS'; + const FAKE_GROUP = { id: 1, import_target: IMPORT_TARGET, status: STATUS }; + + it('loads state from storage on creation', () => { + expect(storage.getItem).toHaveBeenCalledWith(KEY); + }); + + it('saves to storage when import is starting', () => { + manager.startImport({ + importId: IMPORT_ID, + group: FAKE_GROUP, + }); + const storedObject = JSON.parse(storage.setItem.mock.calls[0][1]); + expect(Object.values(storedObject)[0]).toStrictEqual({ + id: FAKE_GROUP.id, + importTarget: IMPORT_TARGET, + status: STATUS, + }); + }); - manager = new SourceGroupsManager({ client }); + it('saves to storage when import status is updated', () => { + const CHANGED_STATUS = 'changed'; + + manager.startImport({ + importId: IMPORT_ID, + group: FAKE_GROUP, + }); + + manager.setImportStatusByImportId(IMPORT_ID, CHANGED_STATUS); + const storedObject = JSON.parse(storage.setItem.mock.calls[1][1]); + expect(Object.values(storedObject)[0]).toStrictEqual({ + id: FAKE_GROUP.id, + importTarget: IMPORT_TARGET, + status: CHANGED_STATUS, + }); + }); }); it('finds item by group id', () => { 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 index a5fc4e18a02..0d4809971ae 100644 --- 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 @@ -2,7 +2,6 @@ import MockAdapter from 'axios-mock-adapter'; import Visibility from 'visibilityjs'; import createFlash from '~/flash'; import { STATUSES } from '~/import_entities/constants'; -import { SourceGroupsManager } from '~/import_entities/import_groups/graphql/services/source_groups_manager'; import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller'; import axios from '~/lib/utils/axios_utils'; import Poll from '~/lib/utils/poll'; @@ -18,24 +17,21 @@ jest.mock('~/import_entities/import_groups/graphql/services/source_groups_manage })); const FAKE_POLL_PATH = '/fake/poll/path'; -const CLIENT_MOCK = {}; describe('Bulk import status poller', () => { let poller; let mockAdapter; + let groupManager; const getPollHistory = () => mockAdapter.history.get.filter((x) => x.url === FAKE_POLL_PATH); beforeEach(() => { mockAdapter = new MockAdapter(axios); mockAdapter.onGet(FAKE_POLL_PATH).reply(200, {}); - poller = new StatusPoller({ client: CLIENT_MOCK, pollPath: FAKE_POLL_PATH }); - }); - - it('creates source group manager with proper client', () => { - expect(SourceGroupsManager.mock.calls).toHaveLength(1); - const [[{ client }]] = SourceGroupsManager.mock.calls; - expect(client).toBe(CLIENT_MOCK); + groupManager = { + setImportStatusByImportId: jest.fn(), + }; + poller = new StatusPoller({ groupManager, pollPath: FAKE_POLL_PATH }); }); it('creates poller with proper config', () => { @@ -100,14 +96,9 @@ describe('Bulk import status poller', () => { it('when success response arrives updates relevant group status', () => { const FAKE_ID = 5; const [[pollConfig]] = Poll.mock.calls; - const [managerInstance] = SourceGroupsManager.mock.instances; - managerInstance.findByImportId.mockReturnValue({ id: FAKE_ID }); pollConfig.successCallback({ data: [{ id: FAKE_ID, status_name: STATUSES.FINISHED }] }); - expect(managerInstance.setImportStatus).toHaveBeenCalledWith( - expect.objectContaining({ id: FAKE_ID }), - STATUSES.FINISHED, - ); + expect(groupManager.setImportStatusByImportId).toHaveBeenCalledWith(FAKE_ID, STATUSES.FINISHED); }); }); diff --git a/spec/frontend/incidents/mocks/incidents.json b/spec/frontend/incidents/mocks/incidents.json index 07c87a5d43d..78783a0dce5 100644 --- a/spec/frontend/incidents/mocks/incidents.json +++ b/spec/frontend/incidents/mocks/incidents.json @@ -1,7 +1,7 @@ [ { "iid": "15", - "title": "New: Incident", + "title": "New: Alert", "createdAt": "2020-06-03T15:46:08Z", "assignees": {}, "state": "opened", 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 82d7f691efd..5796b3fa44e 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 @@ -35,7 +35,7 @@ exports[`Alert integration settings form default state should match the default Incident template (optional) <gl-link-stub - href="/help/user/project/description_templates#creating-issue-templates" + href="/help/user/project/description_templates#create-an-issue-template" target="_blank" > <gl-icon-stub @@ -78,7 +78,7 @@ exports[`Alert integration settings form default state should match the default > <gl-form-checkbox-stub> <span> - Send a separate email notification to Developers. + Send a single email notification to Owners and Maintainers for new alerts. </span> </gl-form-checkbox-stub> </gl-form-group-stub> diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index df855674804..c015fd0b9e0 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -207,27 +207,6 @@ describe('IntegrationForm', () => { expect(findJiraTriggerFields().exists()).toBe(true); }); - - describe('featureFlag jiraIssuesIntegration is false', () => { - it('does not render JiraIssuesFields', () => { - createComponent({ - customStateProps: { type: 'jira' }, - featureFlags: { jiraIssuesIntegration: false }, - }); - - expect(findJiraIssuesFields().exists()).toBe(false); - }); - }); - - describe('featureFlag jiraIssuesIntegration is true', () => { - it('renders JiraIssuesFields', () => { - createComponent({ - customStateProps: { type: 'jira' }, - featureFlags: { jiraIssuesIntegration: true }, - }); - expect(findJiraIssuesFields().exists()).toBe(true); - }); - }); }); describe('triggerEvents is present', () => { diff --git a/spec/frontend/integrations/integration_settings_form_spec.js b/spec/frontend/integrations/integration_settings_form_spec.js index 348b942703f..cbb2ef380ba 100644 --- a/spec/frontend/integrations/integration_settings_form_spec.js +++ b/spec/frontend/integrations/integration_settings_form_spec.js @@ -7,7 +7,6 @@ jest.mock('~/vue_shared/plugins/global_toast'); describe('IntegrationSettingsForm', () => { const FIXTURE = 'services/edit_service.html'; - preloadFixtures(FIXTURE); beforeEach(() => { loadFixtures(FIXTURE); diff --git a/spec/frontend/invite_members/components/group_select_spec.js b/spec/frontend/invite_members/components/group_select_spec.js new file mode 100644 index 00000000000..2a6985de136 --- /dev/null +++ b/spec/frontend/invite_members/components/group_select_spec.js @@ -0,0 +1,90 @@ +import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import Api from '~/api'; +import GroupSelect from '~/invite_members/components/group_select.vue'; + +const createComponent = () => { + return mount(GroupSelect, {}); +}; + +const group1 = { id: 1, full_name: 'Group One' }; +const group2 = { id: 2, full_name: 'Group Two' }; +const allGroups = [group1, group2]; + +describe('GroupSelect', () => { + let wrapper; + + beforeEach(() => { + jest.spyOn(Api, 'groups').mockResolvedValue(allGroups); + + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownToggle = () => findDropdown().find('button[aria-haspopup="true"]'); + const findDropdownItemByText = (text) => + wrapper + .findAllComponents(GlDropdownItem) + .wrappers.find((dropdownItemWrapper) => dropdownItemWrapper.text() === text); + + it('renders GlSearchBoxByType with default attributes', () => { + expect(findSearchBoxByType().exists()).toBe(true); + expect(findSearchBoxByType().vm.$attrs).toMatchObject({ + placeholder: 'Search groups', + }); + }); + + describe('when user types in the search input', () => { + let resolveApiRequest; + + beforeEach(() => { + jest.spyOn(Api, 'groups').mockImplementation( + () => + new Promise((resolve) => { + resolveApiRequest = resolve; + }), + ); + + findSearchBoxByType().vm.$emit('input', group1.name); + }); + + it('calls the API', () => { + resolveApiRequest({ data: allGroups }); + + expect(Api.groups).toHaveBeenCalledWith(group1.name, { + active: true, + exclude_internal: true, + }); + }); + + it('displays loading icon while waiting for API call to resolve', async () => { + expect(findSearchBoxByType().props('isLoading')).toBe(true); + + resolveApiRequest({ data: allGroups }); + await waitForPromises(); + + expect(findSearchBoxByType().props('isLoading')).toBe(false); + }); + }); + + describe('when group is selected from the dropdown', () => { + beforeEach(() => { + findDropdownItemByText(group1.full_name).vm.$emit('click'); + }); + + it('emits `input` event used by `v-model`', () => { + expect(wrapper.emitted('input')[0][0].id).toEqual(group1.id); + }); + + it('sets dropdown toggle text to selected item', () => { + expect(findDropdownToggle().text()).toBe(group1.full_name); + }); + }); +}); diff --git a/spec/frontend/invite_members/components/invite_group_trigger_spec.js b/spec/frontend/invite_members/components/invite_group_trigger_spec.js new file mode 100644 index 00000000000..cb9967ebe8c --- /dev/null +++ b/spec/frontend/invite_members/components/invite_group_trigger_spec.js @@ -0,0 +1,50 @@ +import { GlButton } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import InviteGroupTrigger from '~/invite_members/components/invite_group_trigger.vue'; +import eventHub from '~/invite_members/event_hub'; + +const displayText = 'Invite a group'; + +const createComponent = (props = {}) => { + return mount(InviteGroupTrigger, { + propsData: { + displayText, + ...props, + }, + }); +}; + +describe('InviteGroupTrigger', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findButton = () => wrapper.findComponent(GlButton); + + describe('displayText', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('includes the correct displayText for the link', () => { + expect(findButton().text()).toBe(displayText); + }); + }); + + describe('when button is clicked', () => { + beforeEach(() => { + eventHub.$emit = jest.fn(); + + wrapper = createComponent(); + + findButton().trigger('click'); + }); + + it('emits event that triggers opening the modal', () => { + expect(eventHub.$emit).toHaveBeenLastCalledWith('openModal', { inviteeType: 'group' }); + }); + }); +}); diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js index e310a00133c..5ca5d855038 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -6,10 +6,11 @@ import Api from '~/api'; import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue'; const id = '1'; -const name = 'testgroup'; +const name = 'test name'; const isProject = false; +const inviteeType = 'members'; const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }; -const defaultAccessLevel = '10'; +const defaultAccessLevel = 10; const helpLink = 'https://example.com'; const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' }; @@ -20,16 +21,19 @@ const user3 = { username: 'one_2', avatar_url: '', }; +const sharedGroup = { id: '981' }; -const createComponent = (data = {}) => { +const createComponent = (data = {}, props = {}) => { return shallowMount(InviteMembersModal, { propsData: { id, name, isProject, + inviteeType, accessLevels, defaultAccessLevel, helpLink, + ...props, }, data() { return data; @@ -46,6 +50,22 @@ const createComponent = (data = {}) => { }); }; +const createInviteMembersToProjectWrapper = () => { + return createComponent({ inviteeType: 'members' }, { isProject: true }); +}; + +const createInviteMembersToGroupWrapper = () => { + return createComponent({ inviteeType: 'members' }, { isProject: false }); +}; + +const createInviteGroupToProjectWrapper = () => { + return createComponent({ inviteeType: 'group' }, { isProject: true }); +}; + +const createInviteGroupToGroupWrapper = () => { + return createComponent({ inviteeType: 'group' }, { isProject: false }); +}; + describe('InviteMembersModal', () => { let wrapper; @@ -54,12 +74,13 @@ describe('InviteMembersModal', () => { wrapper = null; }); - const findDropdown = () => wrapper.find(GlDropdown); - const findDropdownItems = () => findDropdown().findAll(GlDropdownItem); - const findDatepicker = () => wrapper.find(GlDatepicker); - const findLink = () => wrapper.find(GlLink); - const findCancelButton = () => wrapper.find({ ref: 'cancelButton' }); - const findInviteButton = () => wrapper.find({ ref: 'inviteButton' }); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem); + const findDatepicker = () => wrapper.findComponent(GlDatepicker); + const findLink = () => wrapper.findComponent(GlLink); + const findIntroText = () => wrapper.find({ ref: 'introText' }).text(); + const findCancelButton = () => wrapper.findComponent({ ref: 'cancelButton' }); + const findInviteButton = () => wrapper.findComponent({ ref: 'inviteButton' }); const clickInviteButton = () => findInviteButton().vm.$emit('click'); describe('rendering the modal', () => { @@ -68,7 +89,7 @@ describe('InviteMembersModal', () => { }); it('renders the modal with the correct title', () => { - expect(wrapper.find(GlModal).props('title')).toBe('Invite team members'); + expect(wrapper.findComponent(GlModal).props('title')).toBe('Invite team members'); }); it('renders the Cancel button text correctly', () => { @@ -102,21 +123,60 @@ describe('InviteMembersModal', () => { }); }); + describe('displaying the correct introText', () => { + describe('when inviting to a project', () => { + describe('when inviting members', () => { + it('includes the correct invitee, type, and formatted name', () => { + wrapper = createInviteMembersToProjectWrapper(); + + expect(findIntroText()).toBe("You're inviting members to the test name project."); + }); + }); + + describe('when sharing with a group', () => { + it('includes the correct invitee, type, and formatted name', () => { + wrapper = createInviteGroupToProjectWrapper(); + + expect(findIntroText()).toBe("You're inviting a group to the test name project."); + }); + }); + }); + + describe('when inviting to a group', () => { + describe('when inviting members', () => { + it('includes the correct invitee, type, and formatted name', () => { + wrapper = createInviteMembersToGroupWrapper(); + + expect(findIntroText()).toBe("You're inviting members to the test name group."); + }); + }); + + describe('when sharing with a group', () => { + it('includes the correct invitee, type, and formatted name', () => { + wrapper = createInviteGroupToGroupWrapper(); + + expect(findIntroText()).toBe("You're inviting a group to the test name group."); + }); + }); + }); + }); + describe('submitting the invite form', () => { const apiErrorMessage = 'Member already exists'; describe('when inviting an existing user to group by user ID', () => { const postData = { user_id: '1', - access_level: '10', + access_level: defaultAccessLevel, expires_at: undefined, format: 'json', }; describe('when invites are sent successfully', () => { beforeEach(() => { - wrapper = createComponent({ newUsersToInvite: [user1] }); + wrapper = createInviteMembersToGroupWrapper(); + wrapper.setData({ newUsersToInvite: [user1] }); wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); @@ -178,7 +238,7 @@ describe('InviteMembersModal', () => { describe('when inviting a new user by email address', () => { const postData = { - access_level: '10', + access_level: defaultAccessLevel, expires_at: undefined, email: 'email@example.com', format: 'json', @@ -227,7 +287,7 @@ describe('InviteMembersModal', () => { describe('when inviting members and non-members in same click', () => { const postData = { - access_level: '10', + access_level: defaultAccessLevel, expires_at: undefined, format: 'json', }; @@ -283,5 +343,58 @@ describe('InviteMembersModal', () => { }); }); }); + + describe('when inviting a group to share', () => { + describe('when sharing the group is successful', () => { + const groupPostData = { + group_id: sharedGroup.id, + group_access: defaultAccessLevel, + expires_at: undefined, + format: 'json', + }; + + beforeEach(() => { + wrapper = createComponent({ groupToBeSharedWith: sharedGroup }); + + wrapper.setData({ inviteeType: 'group' }); + wrapper.vm.$toast = { show: jest.fn() }; + jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData }); + jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); + + clickInviteButton(); + }); + + it('calls Api groupShareWithGroup with the correct params', () => { + expect(Api.groupShareWithGroup).toHaveBeenCalledWith(id, groupPostData); + }); + + it('displays the successful toastMessage', () => { + expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); + }); + }); + + describe('when sharing the group fails', () => { + beforeEach(() => { + wrapper = createComponent({ groupToBeSharedWith: sharedGroup }); + + wrapper.setData({ inviteeType: 'group' }); + wrapper.vm.$toast = { show: jest.fn() }; + + jest + .spyOn(Api, 'groupShareWithGroup') + .mockRejectedValue({ response: { data: { success: false } } }); + + jest.spyOn(wrapper.vm, 'showToastMessageError'); + + clickInviteButton(); + }); + + it('displays the generic error toastMessage', async () => { + await waitForPromises(); + + expect(wrapper.vm.showToastMessageError).toHaveBeenCalled(); + }); + }); + }); }); }); diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js index 18d6662d2d4..f362aace1df 100644 --- a/spec/frontend/invite_members/components/invite_members_trigger_spec.js +++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js @@ -1,9 +1,8 @@ -import { GlIcon, GlLink } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; const displayText = 'Invite team members'; -const icon = 'plus'; const createComponent = (props = {}) => { return shallowMount(InviteMembersTrigger, { @@ -23,36 +22,14 @@ describe('InviteMembersTrigger', () => { }); describe('displayText', () => { - const findLink = () => wrapper.find(GlLink); + const findButton = () => wrapper.findComponent(GlButton); beforeEach(() => { wrapper = createComponent(); }); - it('includes the correct displayText for the link', () => { - expect(findLink().text()).toBe(displayText); - }); - }); - - describe('icon', () => { - const findIcon = () => wrapper.find(GlIcon); - - it('includes the correct icon when an icon is sent', () => { - wrapper = createComponent({ icon }); - - expect(findIcon().attributes('name')).toBe(icon); - }); - - it('does not include an icon when icon is not sent', () => { - wrapper = createComponent(); - - expect(findIcon().exists()).toBe(false); - }); - - it('does not include an icon when empty string is sent', () => { - wrapper = createComponent({ icon: '' }); - - expect(findIcon().exists()).toBe(false); + it('includes the correct displayText for the button', () => { + expect(findButton().text()).toBe(displayText); }); }); }); 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 a945b99bd54..f6e79d3607f 100644 --- a/spec/frontend/invite_members/components/members_token_select_spec.js +++ b/spec/frontend/invite_members/components/members_token_select_spec.js @@ -37,7 +37,7 @@ describe('MembersTokenSelect', () => { wrapper = null; }); - const findTokenSelector = () => wrapper.find(GlTokenSelector); + const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); describe('rendering the token-selector component', () => { it('renders with the correct props', () => { diff --git a/spec/frontend/issuable/components/csv_export_modal_spec.js b/spec/frontend/issuable/components/csv_export_modal_spec.js new file mode 100644 index 00000000000..f46b6f72f05 --- /dev/null +++ b/spec/frontend/issuable/components/csv_export_modal_spec.js @@ -0,0 +1,91 @@ +import { GlModal, GlIcon, GlButton } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import CsvExportModal from '~/issuable/components/csv_export_modal.vue'; + +describe('CsvExportModal', () => { + let wrapper; + + function createComponent(options = {}) { + const { injectedProperties = {}, props = {} } = options; + return extendedWrapper( + mount(CsvExportModal, { + propsData: { + modalId: 'csv-export-modal', + ...props, + }, + provide: { + issuableType: 'issues', + ...injectedProperties, + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: + '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>', + }), + }, + }), + ); + } + + afterEach(() => { + wrapper.destroy(); + }); + + const findModal = () => wrapper.findComponent(GlModal); + const findIcon = () => wrapper.findComponent(GlIcon); + const findButton = () => wrapper.findComponent(GlButton); + + describe('template', () => { + describe.each` + issuableType | modalTitle + ${'issues'} | ${'Export issues'} + ${'merge-requests'} | ${'Export merge requests'} + `('with the issuableType "$issuableType"', ({ issuableType, modalTitle }) => { + beforeEach(() => { + wrapper = createComponent({ injectedProperties: { issuableType } }); + }); + + it('displays the modal title "$modalTitle"', () => { + expect(findModal().text()).toContain(modalTitle); + }); + + it('displays the button with title "$modalTitle"', () => { + expect(findButton().text()).toBe(modalTitle); + }); + }); + + describe('issuable count info text', () => { + it('displays the info text when issuableCount is > -1', () => { + wrapper = createComponent({ injectedProperties: { issuableCount: 10 } }); + expect(wrapper.findByTestId('issuable-count-note').exists()).toBe(true); + expect(wrapper.findByTestId('issuable-count-note').text()).toContain('10 issues selected'); + expect(findIcon().exists()).toBe(true); + }); + + it("doesn't display the info text when issuableCount is -1", () => { + wrapper = createComponent({ injectedProperties: { issuableCount: -1 } }); + expect(wrapper.findByTestId('issuable-count-note').exists()).toBe(false); + }); + }); + + describe('email info text', () => { + it('displays the proper email', () => { + const email = 'admin@example.com'; + wrapper = createComponent({ injectedProperties: { email } }); + expect(findModal().text()).toContain( + `The CSV export will be created in the background. Once finished, it will be sent to ${email} in an attachment.`, + ); + }); + }); + + describe('primary button', () => { + it('passes the exportCsvPath to the button', () => { + const exportCsvPath = '/gitlab-org/gitlab-test/-/issues/export_csv'; + wrapper = createComponent({ injectedProperties: { exportCsvPath } }); + expect(findButton().attributes('href')).toBe(exportCsvPath); + }); + }); + }); +}); diff --git a/spec/frontend/issuable/components/csv_import_export_buttons_spec.js b/spec/frontend/issuable/components/csv_import_export_buttons_spec.js new file mode 100644 index 00000000000..e96409b61b9 --- /dev/null +++ b/spec/frontend/issuable/components/csv_import_export_buttons_spec.js @@ -0,0 +1,161 @@ +import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import CsvExportModal from '~/issuable/components/csv_export_modal.vue'; +import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; +import CsvImportModal from '~/issuable/components/csv_import_modal.vue'; + +describe('CsvImportExportButtons', () => { + let wrapper; + let glModalDirective; + + function createComponent(injectedProperties = {}) { + glModalDirective = jest.fn(); + return extendedWrapper( + shallowMount(CsvImportExportButtons, { + directives: { + GlTooltip: createMockDirective(), + glModal: { + bind(_, { value }) { + glModalDirective(value); + }, + }, + }, + provide: { + ...injectedProperties, + }, + }), + ); + } + + afterEach(() => { + wrapper.destroy(); + }); + + const findExportCsvButton = () => wrapper.findByTestId('export-csv-button'); + const findImportDropdown = () => wrapper.findByTestId('import-csv-dropdown'); + const findImportCsvButton = () => wrapper.findByTestId('import-csv-dropdown'); + const findImportFromJiraLink = () => wrapper.findByTestId('import-from-jira-link'); + const findExportCsvModal = () => wrapper.findComponent(CsvExportModal); + const findImportCsvModal = () => wrapper.findComponent(CsvImportModal); + + describe('template', () => { + describe('when the showExportButton=true', () => { + beforeEach(() => { + wrapper = createComponent({ showExportButton: true }); + }); + + it('displays the export button', () => { + expect(findExportCsvButton().exists()).toBe(true); + }); + + it('export button has a tooltip', () => { + const tooltip = getBinding(findExportCsvButton().element, 'gl-tooltip'); + + expect(tooltip).toBeDefined(); + expect(tooltip.value).toBe('Export as CSV'); + }); + + it('renders the export modal', () => { + expect(findExportCsvModal().exists()).toBe(true); + }); + + it('opens the export modal', () => { + findExportCsvButton().trigger('click'); + + expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.exportModalId); + }); + }); + + describe('when the showExportButton=false', () => { + beforeEach(() => { + wrapper = createComponent({ showExportButton: false }); + }); + + it('does not display the export button', () => { + expect(findExportCsvButton().exists()).toBe(false); + }); + + it('does not render the export modal', () => { + expect(findExportCsvModal().exists()).toBe(false); + }); + }); + + describe('when the showImportButton=true', () => { + beforeEach(() => { + wrapper = createComponent({ showImportButton: true }); + }); + + it('displays the import dropdown', () => { + expect(findImportDropdown().exists()).toBe(true); + }); + + it('renders the import button', () => { + expect(findImportCsvButton().exists()).toBe(true); + }); + + it('import button has a tooltip', () => { + const tooltip = getBinding(findImportDropdown().element, 'gl-tooltip'); + + expect(tooltip).toBeDefined(); + expect(tooltip.value).toBe('Import issues'); + }); + + it('renders the import modal', () => { + expect(findImportCsvModal().exists()).toBe(true); + }); + + it('opens the import modal', () => { + findImportCsvButton().trigger('click'); + + expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.importModalId); + }); + + describe('import from jira link', () => { + const projectImportJiraPath = 'gitlab-org/gitlab-test/-/import/jira'; + + beforeEach(() => { + wrapper = createComponent({ + showImportButton: true, + canEdit: true, + projectImportJiraPath, + }); + }); + + describe('when canEdit=true', () => { + it('renders the import dropdown item', () => { + expect(findImportFromJiraLink().exists()).toBe(true); + }); + + it('passes the proper path to the link', () => { + expect(findImportFromJiraLink().attributes('href')).toBe(projectImportJiraPath); + }); + }); + + describe('when canEdit=false', () => { + beforeEach(() => { + wrapper = createComponent({ showImportButton: true, canEdit: false }); + }); + + it('does not render the import dropdown item', () => { + expect(findImportFromJiraLink().exists()).toBe(false); + }); + }); + }); + }); + + describe('when the showImportButton=false', () => { + beforeEach(() => { + wrapper = createComponent({ showImportButton: false }); + }); + + it('does not display the import dropdown', () => { + expect(findImportDropdown().exists()).toBe(false); + }); + + it('does not render the import modal', () => { + expect(findImportCsvModal().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/issuable/components/csv_import_modal_spec.js b/spec/frontend/issuable/components/csv_import_modal_spec.js new file mode 100644 index 00000000000..ce9d738f77b --- /dev/null +++ b/spec/frontend/issuable/components/csv_import_modal_spec.js @@ -0,0 +1,86 @@ +import { GlModal } from '@gitlab/ui'; +import { getByRole, getByLabelText } from '@testing-library/dom'; +import { mount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import CsvImportModal from '~/issuable/components/csv_import_modal.vue'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +describe('CsvImportModal', () => { + let wrapper; + let formSubmitSpy; + + function createComponent(options = {}) { + const { injectedProperties = {}, props = {} } = options; + return extendedWrapper( + mount(CsvImportModal, { + propsData: { + modalId: 'csv-import-modal', + ...props, + }, + provide: { + issuableType: 'issues', + ...injectedProperties, + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: '<div><slot></slot><slot name="modal-footer"></slot></div>', + }), + }, + }), + ); + } + + beforeEach(() => { + formSubmitSpy = jest.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findModal = () => wrapper.findComponent(GlModal); + const findPrimaryButton = () => getByRole(wrapper.element, 'button', { name: 'Import issues' }); + const findForm = () => wrapper.findByTestId('import-csv-form'); + const findFileInput = () => getByLabelText(wrapper.element, 'Upload CSV file'); + const findAuthenticityToken = () => new FormData(findForm().element).get('authenticity_token'); + + describe('template', () => { + it('displays modal title', () => { + wrapper = createComponent(); + expect(findModal().text()).toContain('Import issues'); + }); + + it('displays a note about the maximum allowed file size', () => { + const maxAttachmentSize = 500; + wrapper = createComponent({ injectedProperties: { maxAttachmentSize } }); + expect(findModal().text()).toContain(`The maximum file size allowed is ${maxAttachmentSize}`); + }); + + describe('form', () => { + const importCsvIssuesPath = 'gitlab-org/gitlab-test/-/issues/import_csv'; + + beforeEach(() => { + wrapper = createComponent({ injectedProperties: { importCsvIssuesPath } }); + }); + + it('displays the form with the correct action and inputs', () => { + expect(findForm().exists()).toBe(true); + expect(findForm().attributes('action')).toBe(importCsvIssuesPath); + expect(findAuthenticityToken()).toBe('mock-csrf-token'); + expect(findFileInput()).toExist(); + }); + + it('displays the correct primary button action text', () => { + expect(findPrimaryButton()).toExist(); + }); + + it('submits the form when the primary action is clicked', async () => { + findPrimaryButton().click(); + + expect(formSubmitSpy).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/issuable_list/components/issuable_item_spec.js b/spec/frontend/issuable_list/components/issuable_item_spec.js index 987acf559e3..dde65ace9db 100644 --- a/spec/frontend/issuable_list/components/issuable_item_spec.js +++ b/spec/frontend/issuable_list/components/issuable_item_spec.js @@ -202,7 +202,7 @@ describe('IssuableItem', () => { describe('labelTarget', () => { it('returns target string for a provided label param when `enableLabelPermalinks` is true', () => { expect(wrapper.vm.labelTarget(mockRegularLabel)).toBe( - '?label_name%5B%5D=Documentation%20Update', + '?label_name[]=Documentation%20Update', ); }); diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js index 9e1bc8242fe..b8860e93a22 100644 --- a/spec/frontend/issue_show/components/app_spec.js +++ b/spec/frontend/issue_show/components/app_spec.js @@ -166,40 +166,6 @@ describe('Issuable output', () => { }); }); - it('opens reCAPTCHA modal if update rejected as spam', () => { - let modal; - - jest.spyOn(wrapper.vm.service, 'updateIssuable').mockResolvedValue({ - data: { - recaptcha_html: '<div class="g-recaptcha">recaptcha_html</div>', - }, - }); - - wrapper.vm.canUpdate = true; - wrapper.vm.showForm = true; - - return wrapper.vm - .$nextTick() - .then(() => { - wrapper.vm.$refs.recaptchaModal.scriptSrc = '//scriptsrc'; - return wrapper.vm.updateIssuable(); - }) - .then(() => { - modal = wrapper.find('.js-recaptcha-modal'); - expect(modal.isVisible()).toBe(true); - expect(modal.find('.g-recaptcha').text()).toEqual('recaptcha_html'); - expect(document.body.querySelector('.js-recaptcha-script').src).toMatch('//scriptsrc'); - }) - .then(() => { - modal.find('.close').trigger('click'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(modal.isVisible()).toBe(false); - expect(document.body.querySelector('.js-recaptcha-script')).toBeNull(); - }); - }); - describe('Pinned links propagated', () => { it.each` prop | value @@ -422,7 +388,18 @@ describe('Issuable output', () => { formSpy = jest.spyOn(wrapper.vm, 'updateAndShowForm'); }); - it('shows the form if template names request is successful', () => { + it('shows the form if template names as hash request is successful', () => { + const mockData = { + test: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }], + }; + mock.onGet('/issuable-templates-path').reply(() => Promise.resolve([200, mockData])); + + return wrapper.vm.requestTemplatesAndShowForm().then(() => { + expect(formSpy).toHaveBeenCalledWith(mockData); + }); + }); + + it('shows the form if template names as array request is successful', () => { const mockData = [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }]; mock.onGet('/issuable-templates-path').reply(() => Promise.resolve([200, mockData])); diff --git a/spec/frontend/issue_show/components/description_spec.js b/spec/frontend/issue_show/components/description_spec.js index d59a257a2be..70c04280675 100644 --- a/spec/frontend/issue_show/components/description_spec.js +++ b/spec/frontend/issue_show/components/description_spec.js @@ -70,36 +70,6 @@ describe('Description component', () => { }); }); - it('opens reCAPTCHA dialog if update rejected as spam', () => { - let modal; - const recaptchaChild = vm.$children.find( - // eslint-disable-next-line no-underscore-dangle - (child) => child.$options._componentTag === 'recaptcha-modal', - ); - - recaptchaChild.scriptSrc = '//scriptsrc'; - - vm.taskListUpdateSuccess({ - recaptcha_html: '<div class="g-recaptcha">recaptcha_html</div>', - }); - - return vm - .$nextTick() - .then(() => { - modal = vm.$el.querySelector('.js-recaptcha-modal'); - - expect(modal.style.display).not.toEqual('none'); - expect(modal.querySelector('.g-recaptcha').textContent).toEqual('recaptcha_html'); - expect(document.body.querySelector('.js-recaptcha-script').src).toMatch('//scriptsrc'); - }) - .then(() => modal.querySelector('.close').click()) - .then(() => vm.$nextTick()) - .then(() => { - expect(modal.style.display).toEqual('none'); - expect(document.body.querySelector('.js-recaptcha-script')).toBeNull(); - }); - }); - it('applies syntax highlighting and math when description changed', () => { const vmSpy = jest.spyOn(vm, 'renderGFM'); const prototypeSpy = jest.spyOn($.prototype, 'renderGFM'); @@ -144,7 +114,6 @@ describe('Description component', () => { dataType: 'issuableType', fieldName: 'description', selector: '.detail-page-description', - onSuccess: expect.any(Function), onError: expect.any(Function), lockVersion: 0, }); diff --git a/spec/frontend/issue_show/components/fields/description_template_spec.js b/spec/frontend/issue_show/components/fields/description_template_spec.js index 1193d4f8add..dc126c53f5e 100644 --- a/spec/frontend/issue_show/components/fields/description_template_spec.js +++ b/spec/frontend/issue_show/components/fields/description_template_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import descriptionTemplate from '~/issue_show/components/fields/description_template.vue'; -describe('Issue description template component', () => { +describe('Issue description template component with templates as hash', () => { let vm; let formState; @@ -14,7 +14,9 @@ describe('Issue description template component', () => { vm = new Component({ propsData: { formState, - issuableTemplates: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }], + issuableTemplates: { + test: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }], + }, projectId: 1, projectPath: '/', namespacePath: '/', @@ -23,9 +25,9 @@ describe('Issue description template component', () => { }).$mount(); }); - it('renders templates as JSON array in data attribute', () => { + it('renders templates as JSON hash in data attribute', () => { expect(vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data')).toBe( - '[{"name":"test","id":"test","project_path":"/","namespace_path":"/"}]', + '{"test":[{"name":"test","id":"test","project_path":"/","namespace_path":"/"}]}', ); }); @@ -41,3 +43,32 @@ describe('Issue description template component', () => { expect(vm.issuableTemplate.editor.getValue()).toBe('testing new template'); }); }); + +describe('Issue description template component with templates as array', () => { + let vm; + let formState; + + beforeEach(() => { + const Component = Vue.extend(descriptionTemplate); + formState = { + description: 'test', + }; + + vm = new Component({ + propsData: { + formState, + issuableTemplates: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }], + projectId: 1, + projectPath: '/', + namespacePath: '/', + projectNamespace: '/', + }, + }).$mount(); + }); + + it('renders templates as JSON array in data attribute', () => { + expect(vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data')).toBe( + '[{"name":"test","id":"test","project_path":"/","namespace_path":"/"}]', + ); + }); +}); diff --git a/spec/frontend/issue_show/components/form_spec.js b/spec/frontend/issue_show/components/form_spec.js index 4a8ec3cf66a..fc2e224ad92 100644 --- a/spec/frontend/issue_show/components/form_spec.js +++ b/spec/frontend/issue_show/components/form_spec.js @@ -42,7 +42,7 @@ describe('Inline edit form component', () => { expect(vm.$el.querySelector('.js-issuable-selector-wrap')).toBeNull(); }); - it('renders template selector when templates exists', () => { + it('renders template selector when templates as array exists', () => { createComponent({ issuableTemplates: [ { name: 'test', id: 'test', project_path: 'test', namespace_path: 'test' }, @@ -52,6 +52,16 @@ describe('Inline edit form component', () => { expect(vm.$el.querySelector('.js-issuable-selector-wrap')).not.toBeNull(); }); + it('renders template selector when templates as hash exists', () => { + createComponent({ + issuableTemplates: { + test: [{ name: 'test', id: 'test', project_path: 'test', namespace_path: 'test' }], + }, + }); + + expect(vm.$el.querySelector('.js-issuable-selector-wrap')).not.toBeNull(); + }); + it('hides locked warning by default', () => { createComponent(); diff --git a/spec/frontend/issue_spec.js b/spec/frontend/issue_spec.js index fb6caef41e2..952ef54d286 100644 --- a/spec/frontend/issue_spec.js +++ b/spec/frontend/issue_spec.js @@ -1,91 +1,90 @@ +import { getByText } from '@testing-library/dom'; import MockAdapter from 'axios-mock-adapter'; -import $ from 'jquery'; +import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import Issue from '~/issue'; import axios from '~/lib/utils/axios_utils'; -import '~/lib/utils/text_utility'; describe('Issue', () => { - let $boxClosed; - let $boxOpen; let testContext; + let mock; beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet(/(.*)\/related_branches$/).reply(200, {}); + testContext = {}; + testContext.issue = new Issue(); }); - preloadFixtures('issues/closed-issue.html'); - preloadFixtures('issues/open-issue.html'); - - function expectVisibility($element, shouldBeVisible) { - if (shouldBeVisible) { - expect($element).not.toHaveClass('hidden'); - } else { - expect($element).toHaveClass('hidden'); - } - } - - function expectIssueState(isIssueOpen) { - expectVisibility($boxClosed, !isIssueOpen); - expectVisibility($boxOpen, isIssueOpen); - } - - function findElements() { - $boxClosed = $('div.status-box-issue-closed'); - - expect($boxClosed).toExist(); - expect($boxClosed).toHaveText('Closed'); + afterEach(() => { + mock.restore(); + testContext.issue.dispose(); + }); - $boxOpen = $('div.status-box-open'); + const getIssueCounter = () => document.querySelector('.issue_counter'); + const getOpenStatusBox = () => + getByText(document, (_, el) => el.textContent.match(/Open/), { + selector: '.status-box-open', + }); + const getClosedStatusBox = () => + getByText(document, (_, el) => el.textContent.match(/Closed/), { + selector: '.status-box-issue-closed', + }); - expect($boxOpen).toExist(); - expect($boxOpen).toHaveText('Open'); - } + describe.each` + desc | isIssueInitiallyOpen | expectedCounterText + ${'with an initially open issue'} | ${true} | ${'1,000'} + ${'with an initially closed issue'} | ${false} | ${'1,002'} + `('$desc', ({ isIssueInitiallyOpen, expectedCounterText }) => { + beforeEach(() => { + if (isIssueInitiallyOpen) { + loadFixtures('issues/open-issue.html'); + } else { + loadFixtures('issues/closed-issue.html'); + } - [true, false].forEach((isIssueInitiallyOpen) => { - describe(`with ${isIssueInitiallyOpen ? 'open' : 'closed'} issue`, () => { - const action = isIssueInitiallyOpen ? 'close' : 'reopen'; - let mock; + testContext.issueCounter = getIssueCounter(); + testContext.statusBoxClosed = getClosedStatusBox(); + testContext.statusBoxOpen = getOpenStatusBox(); - function setup() { - testContext.issue = new Issue(); - expectIssueState(isIssueInitiallyOpen); + testContext.issueCounter.textContent = '1,001'; + }); - testContext.$projectIssuesCounter = $('.issue_counter').first(); - testContext.$projectIssuesCounter.text('1,001'); + it(`has the proper visible status box when ${isIssueInitiallyOpen ? 'open' : 'closed'}`, () => { + if (isIssueInitiallyOpen) { + expect(testContext.statusBoxClosed).toHaveClass('hidden'); + expect(testContext.statusBoxOpen).not.toHaveClass('hidden'); + } else { + expect(testContext.statusBoxClosed).not.toHaveClass('hidden'); + expect(testContext.statusBoxOpen).toHaveClass('hidden'); } + }); + describe('when vue app triggers change', () => { beforeEach(() => { - if (isIssueInitiallyOpen) { - loadFixtures('issues/open-issue.html'); - } else { - loadFixtures('issues/closed-issue.html'); - } - - mock = new MockAdapter(axios); - mock.onGet(/(.*)\/related_branches$/).reply(200, {}); - jest.spyOn(axios, 'get'); - - findElements(isIssueInitiallyOpen); - }); - - afterEach(() => { - mock.restore(); - $('div.flash-alert').remove(); - }); - - it(`${action}s the issue on dispatch of issuable_vue_app:change event`, () => { - setup(); - document.dispatchEvent( - new CustomEvent('issuable_vue_app:change', { + new CustomEvent(EVENT_ISSUABLE_VUE_APP_CHANGE, { detail: { data: { id: 1 }, isClosed: isIssueInitiallyOpen, }, }), ); + }); + + it('displays correct status box', () => { + if (isIssueInitiallyOpen) { + expect(testContext.statusBoxClosed).not.toHaveClass('hidden'); + expect(testContext.statusBoxOpen).toHaveClass('hidden'); + } else { + expect(testContext.statusBoxClosed).toHaveClass('hidden'); + expect(testContext.statusBoxOpen).not.toHaveClass('hidden'); + } + }); - expectIssueState(!isIssueInitiallyOpen); + it('updates issueCounter text', () => { + expect(testContext.issueCounter).toBeVisible(); + expect(testContext.issueCounter).toHaveText(expectedCounterText); }); }); }); diff --git a/spec/frontend/issues_list/components/issuable_spec.js b/spec/frontend/issues_list/components/issuable_spec.js index a8bf124373b..97d841c861d 100644 --- a/spec/frontend/issues_list/components/issuable_spec.js +++ b/spec/frontend/issues_list/components/issuable_spec.js @@ -1,4 +1,4 @@ -import { GlSprintf, GlLabel, GlIcon } from '@gitlab/ui'; +import { GlSprintf, GlLabel, GlIcon, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; import { trimText } from 'helpers/text_helper'; @@ -31,6 +31,7 @@ const TEST_MILESTONE = { }; const TEXT_CLOSED = 'CLOSED'; const TEST_META_COUNT = 100; +const MOCK_GITLAB_URL = 'http://0.0.0.0:3000'; describe('Issuable component', () => { let issuable; @@ -54,6 +55,7 @@ describe('Issuable component', () => { beforeEach(() => { issuable = { ...simpleIssue }; + gon.gitlab_url = MOCK_GITLAB_URL; }); afterEach(() => { @@ -190,15 +192,42 @@ describe('Issuable component', () => { expect(wrapper.classes('closed')).toBe(false); }); - it('renders fuzzy opened date and author', () => { + it('renders fuzzy created date and author', () => { expect(trimText(findOpenedAgoContainer().text())).toContain( - `opened 1 month ago by ${TEST_USER_NAME}`, + `created 1 month ago by ${TEST_USER_NAME}`, ); }); it('renders no comments', () => { expect(findNotes().classes('no-comments')).toBe(true); }); + + it.each` + gitlabWebUrl | webUrl | expectedHref | expectedTarget | isExternal + ${undefined} | ${`${MOCK_GITLAB_URL}/issue`} | ${`${MOCK_GITLAB_URL}/issue`} | ${undefined} | ${false} + ${undefined} | ${'https://jira.com/issue'} | ${'https://jira.com/issue'} | ${'_blank'} | ${true} + ${'/gitlab-org/issue'} | ${'https://jira.com/issue'} | ${'/gitlab-org/issue'} | ${undefined} | ${false} + `( + 'renders issuable title correctly when `gitlabWebUrl` is `$gitlabWebUrl` and webUrl is `$webUrl`', + async ({ webUrl, gitlabWebUrl, expectedHref, expectedTarget, isExternal }) => { + factory({ + issuable: { + ...issuable, + web_url: webUrl, + gitlab_web_url: gitlabWebUrl, + }, + }); + + const titleEl = findIssuableTitle(); + + expect(titleEl.exists()).toBe(true); + expect(titleEl.find(GlLink).attributes('href')).toBe(expectedHref); + expect(titleEl.find(GlLink).attributes('target')).toBe(expectedTarget); + expect(titleEl.find(GlLink).text()).toBe(issuable.title); + + expect(titleEl.find(GlIcon).exists()).toBe(isExternal); + }, + ); }); describe('with confidential issuable', () => { diff --git a/spec/frontend/issues_list/components/jira_issues_list_root_spec.js b/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js index eecb092a330..0c96b95a61f 100644 --- a/spec/frontend/issues_list/components/jira_issues_list_root_spec.js +++ b/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js @@ -1,9 +1,9 @@ import { GlAlert, GlLabel } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; -import JiraIssuesListRoot from '~/issues_list/components/jira_issues_list_root.vue'; +import JiraIssuesImportStatus from '~/issues_list/components/jira_issues_import_status_app.vue'; -describe('JiraIssuesListRoot', () => { +describe('JiraIssuesImportStatus', () => { const issuesPath = 'gitlab-org/gitlab-test/-/issues'; const label = { color: '#333', @@ -19,7 +19,7 @@ describe('JiraIssuesListRoot', () => { shouldShowFinishedAlert = false, shouldShowInProgressAlert = false, } = {}) => - shallowMount(JiraIssuesListRoot, { + shallowMount(JiraIssuesImportStatus, { propsData: { canEdit: true, isJiraConfigured: true, diff --git a/spec/frontend/jira_connect/components/app_spec.js b/spec/frontend/jira_connect/components/app_spec.js index d11b66b2089..e2a5cd1be9d 100644 --- a/spec/frontend/jira_connect/components/app_spec.js +++ b/spec/frontend/jira_connect/components/app_spec.js @@ -1,10 +1,12 @@ -import { GlAlert, GlButton, GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlAlert, GlButton, GlModal, GlLink } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import JiraConnectApp from '~/jira_connect/components/app.vue'; import createStore from '~/jira_connect/store'; -import { SET_ERROR_MESSAGE } from '~/jira_connect/store/mutation_types'; +import { SET_ALERT } from '~/jira_connect/store/mutation_types'; +import { persistAlert } from '~/jira_connect/utils'; +import { __ } from '~/locale'; jest.mock('~/jira_connect/api'); @@ -13,21 +15,19 @@ describe('JiraConnectApp', () => { let store; const findAlert = () => wrapper.findComponent(GlAlert); + const findAlertLink = () => findAlert().find(GlLink); const findGlButton = () => wrapper.findComponent(GlButton); const findGlModal = () => wrapper.findComponent(GlModal); const findHeader = () => wrapper.findByTestId('new-jira-connect-ui-heading'); const findHeaderText = () => findHeader().text(); - const createComponent = (options = {}) => { + const createComponent = ({ provide, mountFn = shallowMount } = {}) => { store = createStore(); wrapper = extendedWrapper( - shallowMount(JiraConnectApp, { + mountFn(JiraConnectApp, { store, - provide: { - glFeatures: { newJiraConnectUi: true }, - }, - ...options, + provide, }), ); }; @@ -49,7 +49,6 @@ describe('JiraConnectApp', () => { beforeEach(() => { createComponent({ provide: { - glFeatures: { newJiraConnectUi: true }, usersPath: '/users', }, }); @@ -72,37 +71,72 @@ describe('JiraConnectApp', () => { }); }); - describe('newJiraConnectUi is false', () => { - it('does not render new UI', () => { - createComponent({ - provide: { - glFeatures: { newJiraConnectUi: false }, - }, - }); + describe('alert', () => { + it.each` + message | variant | alertShouldRender + ${'Test error'} | ${'danger'} | ${true} + ${'Test notice'} | ${'info'} | ${true} + ${''} | ${undefined} | ${false} + ${undefined} | ${undefined} | ${false} + `( + 'renders correct alert when message is `$message` and variant is `$variant`', + async ({ message, alertShouldRender, variant }) => { + createComponent(); + + store.commit(SET_ALERT, { message, variant }); + await wrapper.vm.$nextTick(); + + const alert = findAlert(); + + expect(alert.exists()).toBe(alertShouldRender); + if (alertShouldRender) { + expect(alert.isVisible()).toBe(alertShouldRender); + expect(alert.html()).toContain(message); + expect(alert.props('variant')).toBe(variant); + expect(findAlertLink().exists()).toBe(false); + } + }, + ); + + it('hides alert on @dismiss event', async () => { + createComponent(); + + store.commit(SET_ALERT, { message: 'test message' }); + await wrapper.vm.$nextTick(); + + findAlert().vm.$emit('dismiss'); + await wrapper.vm.$nextTick(); - expect(findHeader().exists()).toBe(false); + expect(findAlert().exists()).toBe(false); }); - }); - it.each` - errorMessage | errorShouldRender - ${'Test error'} | ${true} - ${''} | ${false} - ${undefined} | ${false} - `( - 'renders correct alert when errorMessage is `$errorMessage`', - async ({ errorMessage, errorShouldRender }) => { - createComponent(); + it('renders link when `linkUrl` is set', async () => { + createComponent({ mountFn: mount }); - store.commit(SET_ERROR_MESSAGE, errorMessage); + store.commit(SET_ALERT, { + message: __('test message %{linkStart}test link%{linkEnd}'), + linkUrl: 'https://gitlab.com', + }); await wrapper.vm.$nextTick(); - expect(findAlert().exists()).toBe(errorShouldRender); - if (errorShouldRender) { - expect(findAlert().isVisible()).toBe(errorShouldRender); - expect(findAlert().html()).toContain(errorMessage); - } - }, - ); + const alertLink = findAlertLink(); + + expect(alertLink.exists()).toBe(true); + expect(alertLink.text()).toContain('test link'); + expect(alertLink.attributes('href')).toBe('https://gitlab.com'); + }); + + describe('when alert is set in localStoage', () => { + it('renders alert on mount', () => { + persistAlert({ message: 'error message' }); + createComponent(); + + const alert = findAlert(); + + expect(alert.exists()).toBe(true); + expect(alert.html()).toContain('error message'); + }); + }); + }); }); }); diff --git a/spec/frontend/jira_connect/components/groups_list_item_spec.js b/spec/frontend/jira_connect/components/groups_list_item_spec.js index bb247534aca..da16223255c 100644 --- a/spec/frontend/jira_connect/components/groups_list_item_spec.js +++ b/spec/frontend/jira_connect/components/groups_list_item_spec.js @@ -5,8 +5,11 @@ import waitForPromises from 'helpers/wait_for_promises'; import * as JiraConnectApi from '~/jira_connect/api'; import GroupsListItem from '~/jira_connect/components/groups_list_item.vue'; +import { persistAlert } from '~/jira_connect/utils'; import { mockGroup1 } from '../mock_data'; +jest.mock('~/jira_connect/utils'); + describe('GroupsListItem', () => { let wrapper; const mockSubscriptionPath = 'subscriptionPath'; @@ -85,7 +88,16 @@ describe('GroupsListItem', () => { expect(findLinkButton().props('loading')).toBe(true); + await waitForPromises(); + expect(addSubscriptionSpy).toHaveBeenCalledWith(mockSubscriptionPath, mockGroup1.full_path); + expect(persistAlert).toHaveBeenCalledWith({ + linkUrl: '/help/integration/jira_development_panel.html#usage', + message: + 'You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}', + title: 'Namespace successfully linked', + variant: 'success', + }); }); describe('when request is successful', () => { diff --git a/spec/frontend/jira_connect/store/mutations_spec.js b/spec/frontend/jira_connect/store/mutations_spec.js index d1f9d22b3de..584b17b36f7 100644 --- a/spec/frontend/jira_connect/store/mutations_spec.js +++ b/spec/frontend/jira_connect/store/mutations_spec.js @@ -8,11 +8,21 @@ describe('JiraConnect store mutations', () => { localState = state(); }); - describe('SET_ERROR_MESSAGE', () => { - it('sets error message', () => { - mutations.SET_ERROR_MESSAGE(localState, 'test error'); + describe('SET_ALERT', () => { + it('sets alert state', () => { + mutations.SET_ALERT(localState, { + message: 'test error', + variant: 'danger', + title: 'test title', + linkUrl: 'linkUrl', + }); - expect(localState.errorMessage).toBe('test error'); + expect(localState.alert).toMatchObject({ + message: 'test error', + variant: 'danger', + title: 'test title', + linkUrl: 'linkUrl', + }); }); }); }); diff --git a/spec/frontend/jira_connect/utils_spec.js b/spec/frontend/jira_connect/utils_spec.js new file mode 100644 index 00000000000..5310bce384b --- /dev/null +++ b/spec/frontend/jira_connect/utils_spec.js @@ -0,0 +1,32 @@ +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { ALERT_LOCALSTORAGE_KEY } from '~/jira_connect/constants'; +import { persistAlert, retrieveAlert } from '~/jira_connect/utils'; + +useLocalStorageSpy(); + +describe('JiraConnect utils', () => { + describe('alert utils', () => { + it.each` + arg | expectedRetrievedValue + ${{ title: 'error' }} | ${{ title: 'error' }} + ${{ title: 'error', randomKey: 'test' }} | ${{ title: 'error' }} + ${{ title: 'error', message: 'error message', linkUrl: 'link', variant: 'danger' }} | ${{ title: 'error', message: 'error message', linkUrl: 'link', variant: 'danger' }} + ${undefined} | ${{}} + `( + 'persists and retrieves alert data from localStorage when arg is $arg', + ({ arg, expectedRetrievedValue }) => { + persistAlert(arg); + + expect(localStorage.setItem).toHaveBeenCalledWith( + ALERT_LOCALSTORAGE_KEY, + JSON.stringify(expectedRetrievedValue), + ); + + const retrievedValue = retrieveAlert(); + + expect(localStorage.getItem).toHaveBeenCalledWith(ALERT_LOCALSTORAGE_KEY); + expect(retrievedValue).toEqual(expectedRetrievedValue); + }, + ); + }); +}); diff --git a/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js b/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js index 8fc5b071e54..6914b8d4fa1 100644 --- a/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js +++ b/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js @@ -54,7 +54,7 @@ describe('Job Sidebar Retry Button', () => { expect(findRetryButton().attributes()).toMatchObject({ category: 'primary', - variant: 'info', + variant: 'confirm', }); }); }); diff --git a/spec/frontend/jobs/components/jobs_container_spec.js b/spec/frontend/jobs/components/jobs_container_spec.js index 9a336489101..1cde72682a2 100644 --- a/spec/frontend/jobs/components/jobs_container_spec.js +++ b/spec/frontend/jobs/components/jobs_container_spec.js @@ -1,10 +1,10 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import component from '~/jobs/components/jobs_container.vue'; +import { GlLink } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import JobsContainer from '~/jobs/components/jobs_container.vue'; describe('Jobs List block', () => { - const Component = Vue.extend(component); - let vm; + let wrapper; const retried = { status: { @@ -52,80 +52,96 @@ describe('Jobs List block', () => { tooltip: 'build - passed', }; + const findAllJobs = () => wrapper.findAllComponents(GlLink); + const findJob = () => findAllJobs().at(0); + + const findArrowIcon = () => wrapper.findByTestId('arrow-right-icon'); + const findRetryIcon = () => wrapper.findByTestId('retry-icon'); + + const createComponent = (props) => { + wrapper = extendedWrapper( + mount(JobsContainer, { + propsData: { + ...props, + }, + }), + ); + }; + afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - it('renders list of jobs', () => { - vm = mountComponent(Component, { + it('renders a list of jobs', () => { + createComponent({ jobs: [job, retried, active], jobId: 12313, }); - expect(vm.$el.querySelectorAll('a').length).toEqual(3); + expect(findAllJobs()).toHaveLength(3); }); - it('renders arrow right when job id matches `jobId`', () => { - vm = mountComponent(Component, { + it('renders the arrow right icon when job id matches `jobId`', () => { + createComponent({ jobs: [active], jobId: active.id, }); - expect(vm.$el.querySelector('a .js-arrow-right')).not.toBeNull(); + expect(findArrowIcon().exists()).toBe(true); }); - it('does not render arrow right when job is not active', () => { - vm = mountComponent(Component, { + it('does not render the arrow right icon when the job is not active', () => { + createComponent({ jobs: [job], jobId: active.id, }); - expect(vm.$el.querySelector('a .js-arrow-right')).toBeNull(); + expect(findArrowIcon().exists()).toBe(false); }); - it('renders job name when present', () => { - vm = mountComponent(Component, { + it('renders the job name when present', () => { + createComponent({ jobs: [job], jobId: active.id, }); - expect(vm.$el.querySelector('a').textContent.trim()).toContain(job.name); - expect(vm.$el.querySelector('a').textContent.trim()).not.toContain(job.id); + expect(findJob().text()).toBe(job.name); + expect(findJob().text()).not.toContain(job.id); }); it('renders job id when job name is not available', () => { - vm = mountComponent(Component, { + createComponent({ jobs: [retried], jobId: active.id, }); - expect(vm.$el.querySelector('a').textContent.trim()).toContain(retried.id); + expect(findJob().text()).toBe(retried.id.toString()); }); it('links to the job page', () => { - vm = mountComponent(Component, { + createComponent({ jobs: [job], jobId: active.id, }); - expect(vm.$el.querySelector('a').getAttribute('href')).toEqual(job.status.details_path); + expect(findJob().attributes('href')).toBe(job.status.details_path); }); it('renders retry icon when job was retried', () => { - vm = mountComponent(Component, { + createComponent({ jobs: [retried], jobId: active.id, }); - expect(vm.$el.querySelector('.js-retry-icon')).not.toBeNull(); + expect(findRetryIcon().exists()).toBe(true); }); it('does not render retry icon when job was not retried', () => { - vm = mountComponent(Component, { + createComponent({ jobs: [job], jobId: active.id, }); - expect(vm.$el.querySelector('.js-retry-icon')).toBeNull(); + expect(findRetryIcon().exists()).toBe(false); }); }); diff --git a/spec/frontend/lib/utils/experimentation_spec.js b/spec/frontend/lib/utils/experimentation_spec.js deleted file mode 100644 index 2c5d2f89297..00000000000 --- a/spec/frontend/lib/utils/experimentation_spec.js +++ /dev/null @@ -1,20 +0,0 @@ -import * as experimentUtils from '~/lib/utils/experimentation'; - -const TEST_KEY = 'abc'; - -describe('experiment Utilities', () => { - describe('isExperimentEnabled', () => { - it.each` - experiments | value - ${{ [TEST_KEY]: true }} | ${true} - ${{ [TEST_KEY]: false }} | ${false} - ${{ def: true }} | ${false} - ${{}} | ${false} - ${null} | ${false} - `('returns correct value of $value for experiments=$experiments', ({ experiments, value }) => { - window.gon = { experiments }; - - expect(experimentUtils.isExperimentEnabled(TEST_KEY)).toEqual(value); - }); - }); -}); diff --git a/spec/frontend/lib/utils/number_utility_spec.js b/spec/frontend/lib/utils/number_utility_spec.js index 2f8f1092612..4dcd9211697 100644 --- a/spec/frontend/lib/utils/number_utility_spec.js +++ b/spec/frontend/lib/utils/number_utility_spec.js @@ -9,6 +9,7 @@ import { median, changeInPercent, formattedChangeInPercent, + isNumeric, } from '~/lib/utils/number_utils'; describe('Number Utils', () => { @@ -162,4 +163,25 @@ describe('Number Utils', () => { expect(formattedChangeInPercent(0, 1, { nonFiniteResult: '*' })).toBe('*'); }); }); + + describe('isNumeric', () => { + it.each` + value | outcome + ${0} | ${true} + ${12345} | ${true} + ${'0'} | ${true} + ${'12345'} | ${true} + ${1.0} | ${true} + ${'1.0'} | ${true} + ${'abcd'} | ${false} + ${'abcd100'} | ${false} + ${''} | ${false} + ${false} | ${false} + ${true} | ${false} + ${undefined} | ${false} + ${null} | ${false} + `('when called with $value it returns $outcome', ({ value, outcome }) => { + expect(isNumeric(value)).toBe(outcome); + }); + }); }); diff --git a/spec/frontend/lib/utils/select2_utils_spec.js b/spec/frontend/lib/utils/select2_utils_spec.js new file mode 100644 index 00000000000..6d601dd5ad1 --- /dev/null +++ b/spec/frontend/lib/utils/select2_utils_spec.js @@ -0,0 +1,100 @@ +import MockAdapter from 'axios-mock-adapter'; +import $ from 'jquery'; +import { setHTMLFixture } from 'helpers/fixtures'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import { select2AxiosTransport } from '~/lib/utils/select2_utils'; + +import 'select2/select2'; + +const TEST_URL = '/test/api/url'; +const TEST_SEARCH_DATA = { extraSearch: 'test' }; +const TEST_DATA = [{ id: 1 }]; +const TEST_SEARCH = 'FOO'; + +describe('lib/utils/select2_utils', () => { + let mock; + let resultsSpy; + + beforeEach(() => { + setHTMLFixture('<div><input id="root" /></div>'); + + mock = new MockAdapter(axios); + + resultsSpy = jest.fn().mockReturnValue({ results: [] }); + }); + + afterEach(() => { + mock.restore(); + }); + + const setupSelect2 = (input) => { + input.select2({ + ajax: { + url: TEST_URL, + quietMillis: 250, + transport: select2AxiosTransport, + data(search, page) { + return { + search, + page, + ...TEST_SEARCH_DATA, + }; + }, + results: resultsSpy, + }, + }); + }; + + const setupSelect2AndSearch = async () => { + const $input = $('#root'); + + setupSelect2($input); + + $input.select2('search', TEST_SEARCH); + + jest.runOnlyPendingTimers(); + await waitForPromises(); + }; + + describe('select2AxiosTransport', () => { + it('uses axios to make request', async () => { + // setup mock response + const replySpy = jest.fn(); + mock.onGet(TEST_URL).reply((...args) => replySpy(...args)); + + await setupSelect2AndSearch(); + + expect(replySpy).toHaveBeenCalledWith( + expect.objectContaining({ + url: TEST_URL, + method: 'get', + params: { + page: 1, + search: TEST_SEARCH, + ...TEST_SEARCH_DATA, + }, + }), + ); + }); + + it.each` + headers | pagination + ${{}} | ${{ more: false }} + ${{ 'X-PAGE': '1', 'x-next-page': 2 }} | ${{ more: true }} + `( + 'passes results and pagination to results callback, with headers=$headers', + async ({ headers, pagination }) => { + mock.onGet(TEST_URL).reply(200, TEST_DATA, headers); + + await setupSelect2AndSearch(); + + expect(resultsSpy).toHaveBeenCalledWith( + { results: TEST_DATA, pagination }, + 1, + expect.anything(), + ); + }, + ); + }); +}); diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js index 43de195c702..b538257fac0 100644 --- a/spec/frontend/lib/utils/text_markdown_spec.js +++ b/spec/frontend/lib/utils/text_markdown_spec.js @@ -171,27 +171,40 @@ describe('init markdown', () => { expect(textArea.value).toEqual(text.replace(selected, `[${selected}](url)`)); }); - it.each` - key | expected - ${'['} | ${`[${selected}]`} - ${'*'} | ${`**${selected}**`} - ${"'"} | ${`'${selected}'`} - ${'_'} | ${`_${selected}_`} - ${'`'} | ${`\`${selected}\``} - ${'"'} | ${`"${selected}"`} - ${'{'} | ${`{${selected}}`} - ${'('} | ${`(${selected})`} - ${'<'} | ${`<${selected}>`} - `('generates $expected when $key is pressed', ({ key, expected }) => { - const event = new KeyboardEvent('keydown', { key }); - - textArea.addEventListener('keydown', keypressNoteText); - textArea.dispatchEvent(event); - - expect(textArea.value).toEqual(text.replace(selected, expected)); + describe('surrounds selected text with matching character', () => { + it.each` + key | expected + ${'['} | ${`[${selected}]`} + ${'*'} | ${`**${selected}**`} + ${"'"} | ${`'${selected}'`} + ${'_'} | ${`_${selected}_`} + ${'`'} | ${`\`${selected}\``} + ${'"'} | ${`"${selected}"`} + ${'{'} | ${`{${selected}}`} + ${'('} | ${`(${selected})`} + ${'<'} | ${`<${selected}>`} + `('generates $expected when $key is pressed', ({ key, expected }) => { + const event = new KeyboardEvent('keydown', { key }); + gon.markdown_surround_selection = true; + + textArea.addEventListener('keydown', keypressNoteText); + textArea.dispatchEvent(event); + + expect(textArea.value).toEqual(text.replace(selected, expected)); + + // cursor placement should be after selection + 2 tag lengths + expect(textArea.selectionStart).toBe(selectedIndex + expected.length); + }); - // cursor placement should be after selection + 2 tag lengths - expect(textArea.selectionStart).toBe(selectedIndex + expected.length); + it('does nothing if user preference disabled', () => { + const event = new KeyboardEvent('keydown', { key: '[' }); + gon.markdown_surround_selection = false; + + textArea.addEventListener('keydown', keypressNoteText); + textArea.dispatchEvent(event); + + expect(textArea.value).toEqual(text); + }); }); describe('and text to be selected', () => { diff --git a/spec/frontend/lib/utils/unit_format/index_spec.js b/spec/frontend/lib/utils/unit_format/index_spec.js index 5b2fdf1f02b..7fd273f1b58 100644 --- a/spec/frontend/lib/utils/unit_format/index_spec.js +++ b/spec/frontend/lib/utils/unit_format/index_spec.js @@ -1,157 +1,213 @@ -import { getFormatter, SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; +import { + number, + percent, + percentHundred, + seconds, + milliseconds, + decimalBytes, + kilobytes, + megabytes, + gigabytes, + terabytes, + petabytes, + bytes, + kibibytes, + mebibytes, + gibibytes, + tebibytes, + pebibytes, + engineering, + getFormatter, + SUPPORTED_FORMATS, +} from '~/lib/utils/unit_format'; describe('unit_format', () => { - describe('when a supported format is provided, the returned function formats', () => { - it('numbers, by default', () => { - expect(getFormatter()(1)).toBe('1'); - }); - - it('numbers', () => { - const formatNumber = getFormatter(SUPPORTED_FORMATS.number); - - expect(formatNumber(1)).toBe('1'); - expect(formatNumber(100)).toBe('100'); - expect(formatNumber(1000)).toBe('1,000'); - expect(formatNumber(10000)).toBe('10,000'); - expect(formatNumber(1000000)).toBe('1,000,000'); - }); - - it('percent', () => { - const formatPercent = getFormatter(SUPPORTED_FORMATS.percent); + it('engineering', () => { + expect(engineering(1)).toBe('1'); + expect(engineering(100)).toBe('100'); + expect(engineering(1000)).toBe('1k'); + expect(engineering(10_000)).toBe('10k'); + expect(engineering(1_000_000)).toBe('1M'); + + expect(engineering(10 ** 9)).toBe('1G'); + }); - expect(formatPercent(1)).toBe('100%'); - expect(formatPercent(1, 2)).toBe('100.00%'); + it('number', () => { + expect(number(1)).toBe('1'); + expect(number(100)).toBe('100'); + expect(number(1000)).toBe('1,000'); + expect(number(10_000)).toBe('10,000'); + expect(number(1_000_000)).toBe('1,000,000'); - expect(formatPercent(0.1)).toBe('10%'); - expect(formatPercent(0.5)).toBe('50%'); + expect(number(10 ** 9)).toBe('1,000,000,000'); + }); - expect(formatPercent(0.888888)).toBe('89%'); - expect(formatPercent(0.888888, 2)).toBe('88.89%'); - expect(formatPercent(0.888888, 5)).toBe('88.88880%'); + it('percent', () => { + expect(percent(1)).toBe('100%'); + expect(percent(1, 2)).toBe('100.00%'); - expect(formatPercent(2)).toBe('200%'); - expect(formatPercent(10)).toBe('1,000%'); - }); + expect(percent(0.1)).toBe('10%'); + expect(percent(0.5)).toBe('50%'); - it('percentunit', () => { - const formatPercentHundred = getFormatter(SUPPORTED_FORMATS.percentHundred); + expect(percent(0.888888)).toBe('89%'); + expect(percent(0.888888, 2)).toBe('88.89%'); + expect(percent(0.888888, 5)).toBe('88.88880%'); - expect(formatPercentHundred(1)).toBe('1%'); - expect(formatPercentHundred(1, 2)).toBe('1.00%'); - - expect(formatPercentHundred(88.8888)).toBe('89%'); - expect(formatPercentHundred(88.8888, 2)).toBe('88.89%'); - expect(formatPercentHundred(88.8888, 5)).toBe('88.88880%'); + expect(percent(2)).toBe('200%'); + expect(percent(10)).toBe('1,000%'); + }); - expect(formatPercentHundred(100)).toBe('100%'); - expect(formatPercentHundred(100, 2)).toBe('100.00%'); + it('percentHundred', () => { + expect(percentHundred(1)).toBe('1%'); + expect(percentHundred(1, 2)).toBe('1.00%'); - expect(formatPercentHundred(200)).toBe('200%'); - expect(formatPercentHundred(1000)).toBe('1,000%'); - }); + expect(percentHundred(88.8888)).toBe('89%'); + expect(percentHundred(88.8888, 2)).toBe('88.89%'); + expect(percentHundred(88.8888, 5)).toBe('88.88880%'); - it('seconds', () => { - expect(getFormatter(SUPPORTED_FORMATS.seconds)(1)).toBe('1s'); - }); + expect(percentHundred(100)).toBe('100%'); + expect(percentHundred(100, 2)).toBe('100.00%'); - it('milliseconds', () => { - const formatMilliseconds = getFormatter(SUPPORTED_FORMATS.milliseconds); + expect(percentHundred(200)).toBe('200%'); + expect(percentHundred(1000)).toBe('1,000%'); + }); - expect(formatMilliseconds(1)).toBe('1ms'); - expect(formatMilliseconds(100)).toBe('100ms'); - expect(formatMilliseconds(1000)).toBe('1,000ms'); - expect(formatMilliseconds(10000)).toBe('10,000ms'); - expect(formatMilliseconds(1000000)).toBe('1,000,000ms'); - }); + it('seconds', () => { + expect(seconds(1)).toBe('1s'); + }); - it('decimalBytes', () => { - const formatDecimalBytes = getFormatter(SUPPORTED_FORMATS.decimalBytes); - - expect(formatDecimalBytes(1)).toBe('1B'); - expect(formatDecimalBytes(1, 1)).toBe('1.0B'); - - expect(formatDecimalBytes(10)).toBe('10B'); - expect(formatDecimalBytes(10 ** 2)).toBe('100B'); - expect(formatDecimalBytes(10 ** 3)).toBe('1kB'); - expect(formatDecimalBytes(10 ** 4)).toBe('10kB'); - expect(formatDecimalBytes(10 ** 5)).toBe('100kB'); - expect(formatDecimalBytes(10 ** 6)).toBe('1MB'); - expect(formatDecimalBytes(10 ** 7)).toBe('10MB'); - expect(formatDecimalBytes(10 ** 8)).toBe('100MB'); - expect(formatDecimalBytes(10 ** 9)).toBe('1GB'); - expect(formatDecimalBytes(10 ** 10)).toBe('10GB'); - expect(formatDecimalBytes(10 ** 11)).toBe('100GB'); - }); + it('milliseconds', () => { + expect(milliseconds(1)).toBe('1ms'); + expect(milliseconds(100)).toBe('100ms'); + expect(milliseconds(1000)).toBe('1,000ms'); + expect(milliseconds(10_000)).toBe('10,000ms'); + expect(milliseconds(1_000_000)).toBe('1,000,000ms'); + }); - it('kilobytes', () => { - expect(getFormatter(SUPPORTED_FORMATS.kilobytes)(1)).toBe('1kB'); - expect(getFormatter(SUPPORTED_FORMATS.kilobytes)(1, 1)).toBe('1.0kB'); - }); + it('decimalBytes', () => { + expect(decimalBytes(1)).toBe('1B'); + expect(decimalBytes(1, 1)).toBe('1.0B'); + + expect(decimalBytes(10)).toBe('10B'); + expect(decimalBytes(10 ** 2)).toBe('100B'); + expect(decimalBytes(10 ** 3)).toBe('1kB'); + expect(decimalBytes(10 ** 4)).toBe('10kB'); + expect(decimalBytes(10 ** 5)).toBe('100kB'); + expect(decimalBytes(10 ** 6)).toBe('1MB'); + expect(decimalBytes(10 ** 7)).toBe('10MB'); + expect(decimalBytes(10 ** 8)).toBe('100MB'); + expect(decimalBytes(10 ** 9)).toBe('1GB'); + expect(decimalBytes(10 ** 10)).toBe('10GB'); + expect(decimalBytes(10 ** 11)).toBe('100GB'); + }); - it('megabytes', () => { - expect(getFormatter(SUPPORTED_FORMATS.megabytes)(1)).toBe('1MB'); - expect(getFormatter(SUPPORTED_FORMATS.megabytes)(1, 1)).toBe('1.0MB'); - }); + it('kilobytes', () => { + expect(kilobytes(1)).toBe('1kB'); + expect(kilobytes(1, 1)).toBe('1.0kB'); + }); - it('gigabytes', () => { - expect(getFormatter(SUPPORTED_FORMATS.gigabytes)(1)).toBe('1GB'); - expect(getFormatter(SUPPORTED_FORMATS.gigabytes)(1, 1)).toBe('1.0GB'); - }); + it('megabytes', () => { + expect(megabytes(1)).toBe('1MB'); + expect(megabytes(1, 1)).toBe('1.0MB'); + }); - it('terabytes', () => { - expect(getFormatter(SUPPORTED_FORMATS.terabytes)(1)).toBe('1TB'); - expect(getFormatter(SUPPORTED_FORMATS.terabytes)(1, 1)).toBe('1.0TB'); - }); + it('gigabytes', () => { + expect(gigabytes(1)).toBe('1GB'); + expect(gigabytes(1, 1)).toBe('1.0GB'); + }); - it('petabytes', () => { - expect(getFormatter(SUPPORTED_FORMATS.petabytes)(1)).toBe('1PB'); - expect(getFormatter(SUPPORTED_FORMATS.petabytes)(1, 1)).toBe('1.0PB'); - }); + it('terabytes', () => { + expect(terabytes(1)).toBe('1TB'); + expect(terabytes(1, 1)).toBe('1.0TB'); + }); - it('bytes', () => { - const formatBytes = getFormatter(SUPPORTED_FORMATS.bytes); + it('petabytes', () => { + expect(petabytes(1)).toBe('1PB'); + expect(petabytes(1, 1)).toBe('1.0PB'); + }); - expect(formatBytes(1)).toBe('1B'); - expect(formatBytes(1, 1)).toBe('1.0B'); + it('bytes', () => { + expect(bytes(1)).toBe('1B'); + expect(bytes(1, 1)).toBe('1.0B'); - expect(formatBytes(10)).toBe('10B'); - expect(formatBytes(100)).toBe('100B'); - expect(formatBytes(1000)).toBe('1,000B'); + expect(bytes(10)).toBe('10B'); + expect(bytes(100)).toBe('100B'); + expect(bytes(1000)).toBe('1,000B'); - expect(formatBytes(1 * 1024)).toBe('1KiB'); - expect(formatBytes(1 * 1024 ** 2)).toBe('1MiB'); - expect(formatBytes(1 * 1024 ** 3)).toBe('1GiB'); - }); + expect(bytes(1 * 1024)).toBe('1KiB'); + expect(bytes(1 * 1024 ** 2)).toBe('1MiB'); + expect(bytes(1 * 1024 ** 3)).toBe('1GiB'); + }); - it('kibibytes', () => { - expect(getFormatter(SUPPORTED_FORMATS.kibibytes)(1)).toBe('1KiB'); - expect(getFormatter(SUPPORTED_FORMATS.kibibytes)(1, 1)).toBe('1.0KiB'); - }); + it('kibibytes', () => { + expect(kibibytes(1)).toBe('1KiB'); + expect(kibibytes(1, 1)).toBe('1.0KiB'); + }); - it('mebibytes', () => { - expect(getFormatter(SUPPORTED_FORMATS.mebibytes)(1)).toBe('1MiB'); - expect(getFormatter(SUPPORTED_FORMATS.mebibytes)(1, 1)).toBe('1.0MiB'); - }); + it('mebibytes', () => { + expect(mebibytes(1)).toBe('1MiB'); + expect(mebibytes(1, 1)).toBe('1.0MiB'); + }); - it('gibibytes', () => { - expect(getFormatter(SUPPORTED_FORMATS.gibibytes)(1)).toBe('1GiB'); - expect(getFormatter(SUPPORTED_FORMATS.gibibytes)(1, 1)).toBe('1.0GiB'); - }); + it('gibibytes', () => { + expect(gibibytes(1)).toBe('1GiB'); + expect(gibibytes(1, 1)).toBe('1.0GiB'); + }); - it('tebibytes', () => { - expect(getFormatter(SUPPORTED_FORMATS.tebibytes)(1)).toBe('1TiB'); - expect(getFormatter(SUPPORTED_FORMATS.tebibytes)(1, 1)).toBe('1.0TiB'); - }); + it('tebibytes', () => { + expect(tebibytes(1)).toBe('1TiB'); + expect(tebibytes(1, 1)).toBe('1.0TiB'); + }); - it('pebibytes', () => { - expect(getFormatter(SUPPORTED_FORMATS.pebibytes)(1)).toBe('1PiB'); - expect(getFormatter(SUPPORTED_FORMATS.pebibytes)(1, 1)).toBe('1.0PiB'); - }); + it('pebibytes', () => { + expect(pebibytes(1)).toBe('1PiB'); + expect(pebibytes(1, 1)).toBe('1.0PiB'); }); - describe('when get formatter format is incorrect', () => { - it('formatter fails', () => { - expect(() => getFormatter('not-supported')(1)).toThrow(); + describe('getFormatter', () => { + it.each([ + [1], + [10], + [200], + [100], + [1000], + [10_000], + [100_000], + [1_000_000], + [10 ** 6], + [10 ** 9], + [0.1], + [0.5], + [0.888888], + ])('formatting functions yield the same result as getFormatter for %d', (value) => { + expect(number(value)).toBe(getFormatter(SUPPORTED_FORMATS.number)(value)); + expect(percent(value)).toBe(getFormatter(SUPPORTED_FORMATS.percent)(value)); + expect(percentHundred(value)).toBe(getFormatter(SUPPORTED_FORMATS.percentHundred)(value)); + + expect(seconds(value)).toBe(getFormatter(SUPPORTED_FORMATS.seconds)(value)); + expect(milliseconds(value)).toBe(getFormatter(SUPPORTED_FORMATS.milliseconds)(value)); + + expect(decimalBytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.decimalBytes)(value)); + expect(kilobytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.kilobytes)(value)); + expect(megabytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.megabytes)(value)); + expect(gigabytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.gigabytes)(value)); + expect(terabytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.terabytes)(value)); + expect(petabytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.petabytes)(value)); + + expect(bytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.bytes)(value)); + expect(kibibytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.kibibytes)(value)); + expect(mebibytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.mebibytes)(value)); + expect(gibibytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.gibibytes)(value)); + expect(tebibytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.tebibytes)(value)); + expect(pebibytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.pebibytes)(value)); + + expect(engineering(value)).toBe(getFormatter(SUPPORTED_FORMATS.engineering)(value)); + }); + + describe('when get formatter format is incorrect', () => { + it('formatter fails', () => { + expect(() => getFormatter('not-supported')(1)).toThrow(); + }); }); }); }); diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index b60ddea81ee..e12cd8b0e37 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -814,6 +814,14 @@ describe('URL utility', () => { ); }); + it('decodes URI when decodeURI=true', () => { + const url = 'https://gitlab.com/test'; + + expect(urlUtils.setUrlParams({ labels: ['foo', 'bar'] }, url, false, true, true)).toEqual( + 'https://gitlab.com/test?labels[]=foo&labels[]=bar', + ); + }); + it('removes all existing URL params and sets a new param when cleanParams=true', () => { const url = 'https://gitlab.com/test?group_id=gitlab-org&project_id=my-project'; diff --git a/spec/frontend/line_highlighter_spec.js b/spec/frontend/line_highlighter_spec.js index 8318f63ab3e..b5a0adc9d49 100644 --- a/spec/frontend/line_highlighter_spec.js +++ b/spec/frontend/line_highlighter_spec.js @@ -7,7 +7,6 @@ import LineHighlighter from '~/line_highlighter'; describe('LineHighlighter', () => { const testContext = {}; - preloadFixtures('static/line_highlighter.html'); const clickLine = (number, eventData = {}) => { if ($.isEmptyObject(eventData)) { return $(`#L${number}`).click(); diff --git a/spec/frontend/locale/index_spec.js b/spec/frontend/locale/index_spec.js index d65d7c195b2..a08be502735 100644 --- a/spec/frontend/locale/index_spec.js +++ b/spec/frontend/locale/index_spec.js @@ -1,5 +1,5 @@ import { setLanguage } from 'helpers/locale_helper'; -import { createDateTimeFormat, languageCode } from '~/locale'; +import { createDateTimeFormat, formatNumber, languageCode } from '~/locale'; describe('locale', () => { afterEach(() => setLanguage(null)); @@ -27,4 +27,68 @@ describe('locale', () => { expect(dateFormat.format(new Date(2015, 6, 3))).toBe('July 3, 2015'); }); }); + + describe('formatNumber', () => { + it('formats numbers', () => { + expect(formatNumber(1)).toBe('1'); + expect(formatNumber(12345)).toBe('12,345'); + }); + + it('formats bigint numbers', () => { + expect(formatNumber(123456789123456789n)).toBe('123,456,789,123,456,789'); + }); + + it('formats numbers with options', () => { + expect(formatNumber(1, { style: 'percent' })).toBe('100%'); + expect(formatNumber(1, { style: 'currency', currency: 'USD' })).toBe('$1.00'); + }); + + it('formats localized numbers', () => { + expect(formatNumber(12345, {}, 'es')).toBe('12.345'); + }); + + it('formats NaN', () => { + expect(formatNumber(NaN)).toBe('NaN'); + }); + + it('formats infinity', () => { + expect(formatNumber(Number.POSITIVE_INFINITY)).toBe('∞'); + }); + + it('formats negative infinity', () => { + expect(formatNumber(Number.NEGATIVE_INFINITY)).toBe('-∞'); + }); + + it('formats EPSILON', () => { + expect(formatNumber(Number.EPSILON)).toBe('0'); + }); + + describe('non-number values should pass through', () => { + it('undefined', () => { + expect(formatNumber(undefined)).toBe(undefined); + }); + + it('null', () => { + expect(formatNumber(null)).toBe(null); + }); + + it('arrays', () => { + expect(formatNumber([])).toEqual([]); + }); + + it('objects', () => { + expect(formatNumber({ a: 'b' })).toEqual({ a: 'b' }); + }); + }); + + describe('when in a different locale', () => { + beforeEach(() => { + setLanguage('es'); + }); + + it('formats localized numbers', () => { + expect(formatNumber(12345)).toBe('12.345'); + }); + }); + }); }); diff --git a/spec/frontend/members/components/avatars/user_avatar_spec.js b/spec/frontend/members/components/avatars/user_avatar_spec.js index 303c82582a3..3f4d9155c5d 100644 --- a/spec/frontend/members/components/avatars/user_avatar_spec.js +++ b/spec/frontend/members/components/avatars/user_avatar_spec.js @@ -1,21 +1,31 @@ import { GlAvatarLink, GlBadge } from '@gitlab/ui'; import { within } from '@testing-library/dom'; import { mount, createWrapper } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; import UserAvatar from '~/members/components/avatars/user_avatar.vue'; -import { member as memberMock, orphanedMember } from '../../mock_data'; +import { member as memberMock, member2faEnabled, orphanedMember } from '../../mock_data'; + +Vue.use(Vuex); describe('UserAvatar', () => { let wrapper; const { user } = memberMock; - const createComponent = (propsData = {}) => { + const createComponent = (propsData = {}, state = {}) => { wrapper = mount(UserAvatar, { propsData: { member: memberMock, isCurrentUser: false, ...propsData, }, + store: new Vuex.Store({ + state: { + canManageMembers: true, + ...state, + }, + }), }); }; @@ -69,9 +79,9 @@ describe('UserAvatar', () => { describe('badges', () => { it.each` - member | badgeText - ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${'Blocked'} - ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${'2FA'} + member | badgeText + ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${'Blocked'} + ${member2faEnabled} | ${'2FA'} `('renders the "$badgeText" badge', ({ member, badgeText }) => { createComponent({ member }); @@ -83,6 +93,12 @@ describe('UserAvatar', () => { expect(getByText("It's you").exists()).toBe(true); }); + + it('does not render 2FA badge when `canManageMembers` is `false`', () => { + createComponent({ member: member2faEnabled }, { canManageMembers: false }); + + expect(within(wrapper.element).queryByText('2FA')).toBe(null); + }); }); describe('user status', () => { diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js index fa324ce1cf9..6a73b2fcf8c 100644 --- a/spec/frontend/members/mock_data.js +++ b/spec/frontend/members/mock_data.js @@ -75,3 +75,5 @@ export const membersJsonString = JSON.stringify(members); export const directMember = { ...member, isDirectMember: true }; export const inheritedMember = { ...member, isDirectMember: false }; + +export const member2faEnabled = { ...member, user: { ...member.user, twoFactorEnabled: true } }; diff --git a/spec/frontend/members/utils_spec.js b/spec/frontend/members/utils_spec.js index f447a4c4ee9..bfb5a4bc7d3 100644 --- a/spec/frontend/members/utils_spec.js +++ b/spec/frontend/members/utils_spec.js @@ -17,6 +17,7 @@ import { member as memberMock, directMember, inheritedMember, + member2faEnabled, group, invite, membersJsonString, @@ -30,7 +31,11 @@ const URL_HOST = 'https://localhost/'; describe('Members Utils', () => { describe('generateBadges', () => { it('has correct properties for each badge', () => { - const badges = generateBadges(memberMock, true); + const badges = generateBadges({ + member: memberMock, + isCurrentUser: true, + canManageMembers: true, + }); badges.forEach((badge) => { expect(badge).toEqual( @@ -44,12 +49,32 @@ describe('Members Utils', () => { }); it.each` - member | expected - ${memberMock} | ${{ show: true, text: "It's you", variant: 'success' }} - ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${{ show: true, text: 'Blocked', variant: 'danger' }} - ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${{ show: true, text: '2FA', variant: 'info' }} + member | expected + ${memberMock} | ${{ show: true, text: "It's you", variant: 'success' }} + ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${{ show: true, text: 'Blocked', variant: 'danger' }} + ${member2faEnabled} | ${{ show: true, text: '2FA', variant: 'info' }} `('returns expected output for "$expected.text" badge', ({ member, expected }) => { - expect(generateBadges(member, true)).toContainEqual(expect.objectContaining(expected)); + expect( + generateBadges({ member, isCurrentUser: true, canManageMembers: true }), + ).toContainEqual(expect.objectContaining(expected)); + }); + + describe('when `canManageMembers` argument is `false`', () => { + describe.each` + description | memberIsCurrentUser | expectedBadgeToBeShown + ${'is not the current user'} | ${false} | ${false} + ${'is the current user'} | ${true} | ${true} + `('when member is $description', ({ memberIsCurrentUser, expectedBadgeToBeShown }) => { + it(`sets 'show' to '${expectedBadgeToBeShown}' for 2FA badge`, () => { + const badges = generateBadges({ + member: member2faEnabled, + isCurrentUser: memberIsCurrentUser, + canManageMembers: false, + }); + + expect(badges.find((badge) => badge.text === '2FA').show).toBe(expectedBadgeToBeShown); + }); + }); }); }); diff --git a/spec/frontend/merge_conflicts/store/actions_spec.js b/spec/frontend/merge_conflicts/store/actions_spec.js new file mode 100644 index 00000000000..352f1783b87 --- /dev/null +++ b/spec/frontend/merge_conflicts/store/actions_spec.js @@ -0,0 +1,257 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; +import testAction from 'helpers/vuex_action_helper'; +import createFlash from '~/flash'; +import { INTERACTIVE_RESOLVE_MODE, EDIT_RESOLVE_MODE } from '~/merge_conflicts/constants'; +import * as actions from '~/merge_conflicts/store/actions'; +import * as types from '~/merge_conflicts/store/mutation_types'; +import { restoreFileLinesState, markLine, decorateFiles } from '~/merge_conflicts/utils'; + +jest.mock('~/flash.js'); +jest.mock('~/merge_conflicts/utils'); + +describe('merge conflicts actions', () => { + let mock; + + const files = [ + { + blobPath: 'a', + }, + { blobPath: 'b' }, + ]; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('fetchConflictsData', () => { + const conflictsPath = 'conflicts/path/mock'; + + it('on success dispatches setConflictsData', (done) => { + mock.onGet(conflictsPath).reply(200, {}); + testAction( + actions.fetchConflictsData, + conflictsPath, + {}, + [ + { type: types.SET_LOADING_STATE, payload: true }, + { type: types.SET_LOADING_STATE, payload: false }, + ], + [{ type: 'setConflictsData', payload: {} }], + done, + ); + }); + + it('when data has type equal to error ', (done) => { + mock.onGet(conflictsPath).reply(200, { type: 'error', message: 'error message' }); + testAction( + actions.fetchConflictsData, + conflictsPath, + {}, + [ + { type: types.SET_LOADING_STATE, payload: true }, + { type: types.SET_FAILED_REQUEST, payload: 'error message' }, + { type: types.SET_LOADING_STATE, payload: false }, + ], + [], + done, + ); + }); + + it('when request fails ', (done) => { + mock.onGet(conflictsPath).reply(400); + testAction( + actions.fetchConflictsData, + conflictsPath, + {}, + [ + { type: types.SET_LOADING_STATE, payload: true }, + { type: types.SET_FAILED_REQUEST }, + { type: types.SET_LOADING_STATE, payload: false }, + ], + [], + done, + ); + }); + }); + + describe('submitResolvedConflicts', () => { + useMockLocationHelper(); + const resolveConflictsPath = 'resolve/conflicts/path/mock'; + + it('on success reloads the page', (done) => { + mock.onPost(resolveConflictsPath).reply(200, { redirect_to: 'hrefPath' }); + testAction( + actions.submitResolvedConflicts, + resolveConflictsPath, + {}, + [{ type: types.SET_SUBMIT_STATE, payload: true }], + [], + () => { + expect(window.location.assign).toHaveBeenCalledWith('hrefPath'); + done(); + }, + ); + }); + + it('on errors shows flash', (done) => { + mock.onPost(resolveConflictsPath).reply(400); + testAction( + actions.submitResolvedConflicts, + resolveConflictsPath, + {}, + [ + { type: types.SET_SUBMIT_STATE, payload: true }, + { type: types.SET_SUBMIT_STATE, payload: false }, + ], + [], + () => { + expect(createFlash).toHaveBeenCalledWith({ + message: 'Failed to save merge conflicts resolutions. Please try again!', + }); + done(); + }, + ); + }); + }); + + describe('setConflictsData', () => { + it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => { + decorateFiles.mockReturnValue([{ bar: 'baz' }]); + testAction( + actions.setConflictsData, + { files, foo: 'bar' }, + {}, + [ + { + type: types.SET_CONFLICTS_DATA, + payload: { foo: 'bar', files: [{ bar: 'baz' }] }, + }, + ], + [], + done, + ); + }); + }); + + describe('setFileResolveMode', () => { + it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => { + testAction( + actions.setFileResolveMode, + { file: files[0], mode: INTERACTIVE_RESOLVE_MODE }, + { conflictsData: { files }, getFileIndex: () => 0 }, + [ + { + type: types.UPDATE_FILE, + payload: { + file: { ...files[0], showEditor: false, resolveMode: INTERACTIVE_RESOLVE_MODE }, + index: 0, + }, + }, + ], + [], + done, + ); + }); + + it('EDIT_RESOLVE_MODE updates the correct file ', (done) => { + restoreFileLinesState.mockReturnValue([]); + const file = { + ...files[0], + showEditor: true, + loadEditor: true, + resolutionData: {}, + resolveMode: EDIT_RESOLVE_MODE, + }; + testAction( + actions.setFileResolveMode, + { file: files[0], mode: EDIT_RESOLVE_MODE }, + { conflictsData: { files }, getFileIndex: () => 0 }, + [ + { + type: types.UPDATE_FILE, + payload: { + file, + index: 0, + }, + }, + ], + [], + () => { + expect(restoreFileLinesState).toHaveBeenCalledWith(file); + done(); + }, + ); + }); + }); + + describe('setPromptConfirmationState', () => { + it('updates the correct file ', (done) => { + testAction( + actions.setPromptConfirmationState, + { file: files[0], promptDiscardConfirmation: true }, + { conflictsData: { files }, getFileIndex: () => 0 }, + [ + { + type: types.UPDATE_FILE, + payload: { + file: { ...files[0], promptDiscardConfirmation: true }, + index: 0, + }, + }, + ], + [], + done, + ); + }); + }); + + describe('handleSelected', () => { + const file = { + ...files[0], + inlineLines: [{ id: 1, hasConflict: true }, { id: 2 }], + parallelLines: [ + [{ id: 1, hasConflict: true }, { id: 1 }], + [{ id: 2 }, { id: 3 }], + ], + }; + + it('updates the correct file ', (done) => { + const marLikeMockReturn = { foo: 'bar' }; + markLine.mockReturnValue(marLikeMockReturn); + + testAction( + actions.handleSelected, + { file, line: { id: 1, section: 'baz' } }, + { conflictsData: { files }, getFileIndex: () => 0 }, + [ + { + type: types.UPDATE_FILE, + payload: { + file: { + ...file, + resolutionData: { 1: 'baz' }, + inlineLines: [marLikeMockReturn, { id: 2 }], + parallelLines: [ + [marLikeMockReturn, marLikeMockReturn], + [{ id: 2 }, { id: 3 }], + ], + }, + index: 0, + }, + }, + ], + [], + () => { + expect(markLine).toHaveBeenCalledTimes(3); + done(); + }, + ); + }); + }); +}); diff --git a/spec/frontend/merge_request_spec.js b/spec/frontend/merge_request_spec.js index 84647a108b2..0b7ed349507 100644 --- a/spec/frontend/merge_request_spec.js +++ b/spec/frontend/merge_request_spec.js @@ -9,7 +9,6 @@ describe('MergeRequest', () => { describe('task lists', () => { let mock; - preloadFixtures('merge_requests/merge_request_with_task_list.html'); beforeEach(() => { loadFixtures('merge_requests/merge_request_with_task_list.html'); diff --git a/spec/frontend/merge_request_tabs_spec.js b/spec/frontend/merge_request_tabs_spec.js index fd2c240aff3..23e9bf8b447 100644 --- a/spec/frontend/merge_request_tabs_spec.js +++ b/spec/frontend/merge_request_tabs_spec.js @@ -21,11 +21,6 @@ describe('MergeRequestTabs', () => { $.extend(stubLocation, defaults, stubs || {}); }; - preloadFixtures( - 'merge_requests/merge_request_with_task_list.html', - 'merge_requests/diff_comment.html', - ); - beforeEach(() => { initMrPage(); diff --git a/spec/frontend/mini_pipeline_graph_dropdown_spec.js b/spec/frontend/mini_pipeline_graph_dropdown_spec.js index 3ff34c967e4..ccd5a4ea142 100644 --- a/spec/frontend/mini_pipeline_graph_dropdown_spec.js +++ b/spec/frontend/mini_pipeline_graph_dropdown_spec.js @@ -5,8 +5,6 @@ import axios from '~/lib/utils/axios_utils'; import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown'; describe('Mini Pipeline Graph Dropdown', () => { - preloadFixtures('static/mini_dropdown_graph.html'); - beforeEach(() => { loadFixtures('static/mini_dropdown_graph.html'); }); diff --git a/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js index b794d0c571e..400ac2e8f85 100644 --- a/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js +++ b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js @@ -188,7 +188,7 @@ describe('dashboard invalid url parameters', () => { }); describe('when there is an error', () => { - const mockError = 'an error ocurred!'; + const mockError = 'an error occurred!'; beforeEach(() => { store.commit(`monitoringDashboard/${types.RECEIVE_PANEL_PREVIEW_FAILURE}`, mockError); diff --git a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js index 9672f6a315a..51b4106d4b1 100644 --- a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js +++ b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js @@ -23,7 +23,7 @@ describe('DuplicateDashboardForm', () => { findByRef(ref).setValue(val); }; const setChecked = (value) => { - const input = wrapper.find(`.form-check-input[value="${value}"]`); + const input = wrapper.find(`.custom-control-input[value="${value}"]`); input.element.checked = true; input.trigger('click'); input.trigger('change'); diff --git a/spec/frontend/monitoring/requests/index_spec.js b/spec/frontend/monitoring/requests/index_spec.js index b30b1e60575..03bf5d70153 100644 --- a/spec/frontend/monitoring/requests/index_spec.js +++ b/spec/frontend/monitoring/requests/index_spec.js @@ -94,7 +94,7 @@ describe('monitoring metrics_requests', () => { it('rejects after getting an HTTP 500 error', () => { mock.onGet(prometheusEndpoint).reply(500, { status: 'error', - error: 'An error ocurred', + error: 'An error occurred', }); return getPrometheusQueryData(prometheusEndpoint, params).catch((error) => { @@ -106,7 +106,7 @@ describe('monitoring metrics_requests', () => { // Mock multiple attempts while the cache is filling up and fails mock.onGet(prometheusEndpoint).reply(statusCodes.UNAUTHORIZED, { status: 'error', - error: 'An error ocurred', + error: 'An error occurred', }); return getPrometheusQueryData(prometheusEndpoint, params).catch((error) => { @@ -120,7 +120,7 @@ describe('monitoring metrics_requests', () => { mock.onGet(prometheusEndpoint).replyOnce(statusCodes.NO_CONTENT); mock.onGet(prometheusEndpoint).reply(500, { status: 'error', - error: 'An error ocurred', + error: 'An error occurred', }); // 3rd attempt return getPrometheusQueryData(prometheusEndpoint, params).catch((error) => { diff --git a/spec/frontend/new_branch_spec.js b/spec/frontend/new_branch_spec.js index 7e6b8a78d4f..66b28a8c0dc 100644 --- a/spec/frontend/new_branch_spec.js +++ b/spec/frontend/new_branch_spec.js @@ -9,8 +9,6 @@ describe('Branch', () => { }); describe('create a new branch', () => { - preloadFixtures('branches/new_branch.html'); - function fillNameWith(value) { $('.js-branch-name').val(value).trigger('blur'); } diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 2f58f75ab70..bab90723578 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -1,7 +1,9 @@ +import { GlDropdown, GlAlert } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import Autosize from 'autosize'; import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { deprecatedCreateFlash as flash } from '~/flash'; @@ -9,7 +11,8 @@ import axios from '~/lib/utils/axios_utils'; import CommentForm from '~/notes/components/comment_form.vue'; import * as constants from '~/notes/constants'; import eventHub from '~/notes/event_hub'; -import createStore from '~/notes/stores'; +import { COMMENT_FORM } from '~/notes/i18n'; +import notesModule from '~/notes/stores/modules'; import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data'; jest.mock('autosize'); @@ -17,15 +20,45 @@ jest.mock('~/commons/nav/user_merge_requests'); jest.mock('~/flash'); jest.mock('~/gl_form'); +Vue.use(Vuex); + describe('issue_comment_form component', () => { let store; let wrapper; let axiosMock; const findCloseReopenButton = () => wrapper.findByTestId('close-reopen-button'); - const findCommentButton = () => wrapper.findByTestId('comment-button'); const findTextArea = () => wrapper.findByTestId('comment-field'); const findConfidentialNoteCheckbox = () => wrapper.findByTestId('confidential-note-checkbox'); + const findCommentGlDropdown = () => wrapper.find(GlDropdown); + const findCommentButton = () => findCommentGlDropdown().find('button'); + const findErrorAlerts = () => wrapper.findAllComponents(GlAlert).wrappers; + + async function clickCommentButton({ waitForComponent = true, waitForNetwork = true } = {}) { + findCommentButton().trigger('click'); + + if (waitForComponent || waitForNetwork) { + // Wait for the click to bubble out and trigger the handler + await nextTick(); + + if (waitForNetwork) { + // Wait for the network request promise to resolve + await nextTick(); + } + } + } + + function createStore({ actions = {} } = {}) { + const baseModule = notesModule(); + + return new Vuex.Store({ + ...baseModule, + actions: { + ...baseModule.actions, + ...actions, + }, + }); + } const createNotableDataMock = (data = {}) => { return { @@ -101,6 +134,83 @@ describe('issue_comment_form component', () => { expect(wrapper.vm.resizeTextarea).toHaveBeenCalled(); }); + it('does not report errors in the UI when the save succeeds', async () => { + mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' } }); + + jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); + + await clickCommentButton(); + + // findErrorAlerts().exists returns false if *any* wrapper is empty, + // not necessarily that there aren't any at all. + // We want to check here that there are none found, so we use the + // raw wrapper array length instead. + expect(findErrorAlerts().length).toBe(0); + }); + + it.each` + httpStatus | errors + ${400} | ${[COMMENT_FORM.GENERIC_UNSUBMITTABLE_NETWORK]} + ${422} | ${['error 1']} + ${422} | ${['error 1', 'error 2']} + ${422} | ${['error 1', 'error 2', 'error 3']} + `( + 'displays the correct errors ($errors) for a $httpStatus network response', + async ({ errors, httpStatus }) => { + store = createStore({ + actions: { + saveNote: jest.fn().mockRejectedValue({ + response: { status: httpStatus, data: { errors: { commands_only: errors } } }, + }), + }, + }); + + mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' } }); + + await clickCommentButton(); + + const errorAlerts = findErrorAlerts(); + + expect(errorAlerts.length).toBe(errors.length); + errors.forEach((msg, index) => { + const alert = errorAlerts[index]; + + expect(alert.text()).toBe(msg); + }); + }, + ); + + it('should remove the correct error from the list when it is dismissed', async () => { + const commandErrors = ['1', '2', '3']; + store = createStore({ + actions: { + saveNote: jest.fn().mockRejectedValue({ + response: { status: 422, data: { errors: { commands_only: [...commandErrors] } } }, + }), + }, + }); + + mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' } }); + + await clickCommentButton(); + + let errorAlerts = findErrorAlerts(); + + expect(errorAlerts.length).toBe(commandErrors.length); + + // dismiss the second error + extendedWrapper(errorAlerts[1]).findByTestId('close-icon').trigger('click'); + // Wait for the dismissal to bubble out of the Alert component and be handled in this component + await nextTick(); + // Refresh the list of alerts + errorAlerts = findErrorAlerts(); + + expect(errorAlerts.length).toBe(commandErrors.length - 1); + // We want to know that the *correct* error was dismissed, not just that any one is gone + expect(errorAlerts[0].text()).toBe(commandErrors[0]); + expect(errorAlerts[1].text()).toBe(commandErrors[2]); + }); + it('should toggle issue state when no note', () => { mountComponent({ mountFunction: mount }); @@ -243,7 +353,7 @@ describe('issue_comment_form component', () => { it('should render comment button as disabled', () => { mountComponent(); - expect(findCommentButton().props('disabled')).toBe(true); + expect(findCommentGlDropdown().props('disabled')).toBe(true); }); it('should enable comment button if it has note', async () => { @@ -251,7 +361,7 @@ describe('issue_comment_form component', () => { await wrapper.setData({ note: 'Foo' }); - expect(findCommentButton().props('disabled')).toBe(false); + expect(findCommentGlDropdown().props('disabled')).toBe(false); }); it('should update buttons texts when it has note', () => { @@ -437,7 +547,7 @@ describe('issue_comment_form component', () => { await wrapper.vm.$nextTick(); // submit comment - wrapper.findByTestId('comment-button').trigger('click'); + findCommentButton().trigger('click'); const [providedData] = wrapper.vm.saveNote.mock.calls[0]; expect(providedData.data.note.confidential).toBe(shouldCheckboxBeChecked); @@ -472,16 +582,4 @@ describe('issue_comment_form component', () => { expect(findTextArea().exists()).toBe(false); }); }); - - 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 } }); - - expect(findCloseReopenButton().props('variant')).toBe(expected); - }); - }); }); diff --git a/spec/frontend/notes/components/diff_discussion_header_spec.js b/spec/frontend/notes/components/diff_discussion_header_spec.js index fdc89522901..fa34a5e8d39 100644 --- a/spec/frontend/notes/components/diff_discussion_header_spec.js +++ b/spec/frontend/notes/components/diff_discussion_header_spec.js @@ -6,14 +6,10 @@ import createStore from '~/notes/stores'; import mockDiffFile from '../../diffs/mock_data/diff_discussions'; import { discussionMock } from '../mock_data'; -const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json'; - describe('diff_discussion_header component', () => { let store; let wrapper; - preloadFixtures(discussionWithTwoUnresolvedNotes); - beforeEach(() => { window.mrTabs = {}; store = createStore(); diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js index 03e5842bb0f..c6a7d7ead98 100644 --- a/spec/frontend/notes/components/discussion_actions_spec.js +++ b/spec/frontend/notes/components/discussion_actions_spec.js @@ -96,7 +96,7 @@ describe('DiscussionActions', () => { it('emits showReplyForm event when clicking on reply placeholder', () => { jest.spyOn(wrapper.vm, '$emit'); - wrapper.find(ReplyPlaceholder).find('button').trigger('click'); + wrapper.find(ReplyPlaceholder).find('textarea').trigger('focus'); expect(wrapper.vm.$emit).toHaveBeenCalledWith('showReplyForm'); }); diff --git a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js index b7b7ec08867..2a4cd0df0c7 100644 --- a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js +++ b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js @@ -1,17 +1,17 @@ import { shallowMount } from '@vue/test-utils'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; -const buttonText = 'Test Button Text'; +const placeholderText = 'Test Button Text'; describe('ReplyPlaceholder', () => { let wrapper; - const findButton = () => wrapper.find({ ref: 'button' }); + const findTextarea = () => wrapper.find({ ref: 'textarea' }); beforeEach(() => { wrapper = shallowMount(ReplyPlaceholder, { propsData: { - buttonText, + placeholderText, }, }); }); @@ -20,17 +20,17 @@ describe('ReplyPlaceholder', () => { wrapper.destroy(); }); - it('emits onClick event on button click', () => { - findButton().trigger('click'); + it('emits focus event on button click', () => { + findTextarea().trigger('focus'); return wrapper.vm.$nextTick().then(() => { expect(wrapper.emitted()).toEqual({ - onClick: [[]], + focus: [[]], }); }); }); it('should render reply button', () => { - expect(findButton().text()).toEqual(buttonText); + expect(findTextarea().attributes('placeholder')).toEqual(placeholderText); }); }); diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js index 17717ebd09a..cc41088e21e 100644 --- a/spec/frontend/notes/components/note_actions_spec.js +++ b/spec/frontend/notes/components/note_actions_spec.js @@ -6,6 +6,7 @@ import axios from '~/lib/utils/axios_utils'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import noteActions from '~/notes/components/note_actions.vue'; import createStore from '~/notes/stores'; +import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; import { userDataMock } from '../mock_data'; describe('noteActions', () => { @@ -15,6 +16,9 @@ describe('noteActions', () => { let actions; let axiosMock; + const findUserAccessRoleBadge = (idx) => wrapper.findAll(UserAccessRoleBadge).at(idx); + const findUserAccessRoleBadgeText = (idx) => findUserAccessRoleBadge(idx).text().trim(); + const mountNoteActions = (propsData, computed) => { const localVue = createLocalVue(); return mount(localVue.extend(noteActions), { @@ -44,6 +48,7 @@ describe('noteActions', () => { projectName: 'project', reportAbusePath: `${TEST_HOST}/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26`, showReply: false, + awardPath: `${TEST_HOST}/award_emoji`, }; actions = { @@ -66,11 +71,11 @@ describe('noteActions', () => { }); it('should render noteable author badge', () => { - expect(wrapper.findAll('.note-role').at(0).text().trim()).toEqual('Author'); + expect(findUserAccessRoleBadgeText(0)).toBe('Author'); }); it('should render access level badge', () => { - expect(wrapper.findAll('.note-role').at(1).text().trim()).toEqual(props.accessLevel); + expect(findUserAccessRoleBadgeText(1)).toBe(props.accessLevel); }); it('should render contributor badge', () => { @@ -80,7 +85,7 @@ describe('noteActions', () => { }); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.findAll('.note-role').at(1).text().trim()).toBe('Contributor'); + expect(findUserAccessRoleBadgeText(1)).toBe('Contributor'); }); }); diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index 7615f3b70f1..92137d3190f 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -83,7 +83,7 @@ describe('issue_note_form component', () => { }); const message = - 'This comment has changed since you started editing, please review the updated comment to ensure information is not lost.'; + 'This comment changed after you started editing it. Review the updated comment to ensure information is not lost.'; await nextTick(); diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js index 87538279c3d..dd65351ef88 100644 --- a/spec/frontend/notes/components/noteable_discussion_spec.js +++ b/spec/frontend/notes/components/noteable_discussion_spec.js @@ -24,8 +24,6 @@ describe('noteable_discussion component', () => { let wrapper; let originalGon; - preloadFixtures(discussionWithTwoUnresolvedNotes); - beforeEach(() => { window.mrTabs = {}; store = createStore(); @@ -65,7 +63,7 @@ describe('noteable_discussion component', () => { expect(wrapper.vm.isReplying).toEqual(false); const replyPlaceholder = wrapper.find(ReplyPlaceholder); - replyPlaceholder.vm.$emit('onClick'); + replyPlaceholder.vm.$emit('focus'); await nextTick(); expect(wrapper.vm.isReplying).toEqual(true); diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js index fe78e086403..112983f3ac2 100644 --- a/spec/frontend/notes/components/noteable_note_spec.js +++ b/spec/frontend/notes/components/noteable_note_spec.js @@ -1,5 +1,6 @@ import { mount, createLocalVue } from '@vue/test-utils'; import { escape } from 'lodash'; +import waitForPromises from 'helpers/wait_for_promises'; import NoteActions from '~/notes/components/note_actions.vue'; import NoteBody from '~/notes/components/note_body.vue'; import NoteHeader from '~/notes/components/note_header.vue'; @@ -13,7 +14,7 @@ describe('issue_note', () => { let wrapper; const findMultilineComment = () => wrapper.find('[data-testid="multiline-comment"]'); - beforeEach(() => { + const createWrapper = (props = {}) => { store = createStore(); store.dispatch('setNoteableData', noteableDataMock); store.dispatch('setNotesData', notesDataMock); @@ -23,6 +24,7 @@ describe('issue_note', () => { store, propsData: { note, + ...props, }, localVue, stubs: [ @@ -33,14 +35,18 @@ describe('issue_note', () => { 'multiline-comment-form', ], }); - }); + }; afterEach(() => { wrapper.destroy(); }); describe('mutiline comments', () => { - it('should render if has multiline comment', () => { + beforeEach(() => { + createWrapper(); + }); + + it('should render if has multiline comment', async () => { const position = { line_range: { start: { @@ -69,9 +75,8 @@ describe('issue_note', () => { line, }); - return wrapper.vm.$nextTick().then(() => { - expect(findMultilineComment().text()).toEqual('Comment on lines 1 to 2'); - }); + await wrapper.vm.$nextTick(); + expect(findMultilineComment().text()).toBe('Comment on lines 1 to 2'); }); it('should only render if it has everything it needs', () => { @@ -147,108 +152,151 @@ describe('issue_note', () => { }); }); - it('should render user information', () => { - const { author } = note; - const avatar = wrapper.find(UserAvatarLink); - const avatarProps = avatar.props(); + describe('rendering', () => { + beforeEach(() => { + createWrapper(); + }); - expect(avatarProps.linkHref).toBe(author.path); - expect(avatarProps.imgSrc).toBe(author.avatar_url); - expect(avatarProps.imgAlt).toBe(author.name); - expect(avatarProps.imgSize).toBe(40); - }); + it('should render user information', () => { + const { author } = note; + const avatar = wrapper.findComponent(UserAvatarLink); + const avatarProps = avatar.props(); - it('should render note header content', () => { - const noteHeader = wrapper.find(NoteHeader); - const noteHeaderProps = noteHeader.props(); + expect(avatarProps.linkHref).toBe(author.path); + expect(avatarProps.imgSrc).toBe(author.avatar_url); + expect(avatarProps.imgAlt).toBe(author.name); + expect(avatarProps.imgSize).toBe(40); + }); - expect(noteHeaderProps.author).toEqual(note.author); - expect(noteHeaderProps.createdAt).toEqual(note.created_at); - expect(noteHeaderProps.noteId).toEqual(note.id); - }); + it('should render note header content', () => { + const noteHeader = wrapper.findComponent(NoteHeader); + const noteHeaderProps = noteHeader.props(); - it('should render note actions', () => { - const { author } = note; - const noteActions = wrapper.find(NoteActions); - const noteActionsProps = noteActions.props(); - - expect(noteActionsProps.authorId).toBe(author.id); - expect(noteActionsProps.noteId).toBe(note.id); - expect(noteActionsProps.noteUrl).toBe(note.noteable_note_url); - expect(noteActionsProps.accessLevel).toBe(note.human_access); - expect(noteActionsProps.canEdit).toBe(note.current_user.can_edit); - expect(noteActionsProps.canAwardEmoji).toBe(note.current_user.can_award_emoji); - expect(noteActionsProps.canDelete).toBe(note.current_user.can_edit); - expect(noteActionsProps.canReportAsAbuse).toBe(true); - expect(noteActionsProps.canResolve).toBe(false); - expect(noteActionsProps.reportAbusePath).toBe(note.report_abuse_path); - expect(noteActionsProps.resolvable).toBe(false); - expect(noteActionsProps.isResolved).toBe(false); - expect(noteActionsProps.isResolving).toBe(false); - expect(noteActionsProps.resolvedBy).toEqual({}); - }); + expect(noteHeaderProps.author).toBe(note.author); + expect(noteHeaderProps.createdAt).toBe(note.created_at); + expect(noteHeaderProps.noteId).toBe(note.id); + }); - it('should render issue body', () => { - const noteBody = wrapper.find(NoteBody); - const noteBodyProps = noteBody.props(); + it('should render note actions', () => { + const { author } = note; + const noteActions = wrapper.findComponent(NoteActions); + const noteActionsProps = noteActions.props(); - expect(noteBodyProps.note).toEqual(note); - expect(noteBodyProps.line).toBe(null); - expect(noteBodyProps.canEdit).toBe(note.current_user.can_edit); - expect(noteBodyProps.isEditing).toBe(false); - expect(noteBodyProps.helpPagePath).toBe(''); - }); + expect(noteActionsProps.authorId).toBe(author.id); + expect(noteActionsProps.noteId).toBe(note.id); + expect(noteActionsProps.noteUrl).toBe(note.noteable_note_url); + expect(noteActionsProps.accessLevel).toBe(note.human_access); + expect(noteActionsProps.canEdit).toBe(note.current_user.can_edit); + expect(noteActionsProps.canAwardEmoji).toBe(note.current_user.can_award_emoji); + expect(noteActionsProps.canDelete).toBe(note.current_user.can_edit); + expect(noteActionsProps.canReportAsAbuse).toBe(true); + expect(noteActionsProps.canResolve).toBe(false); + expect(noteActionsProps.reportAbusePath).toBe(note.report_abuse_path); + expect(noteActionsProps.resolvable).toBe(false); + expect(noteActionsProps.isResolved).toBe(false); + expect(noteActionsProps.isResolving).toBe(false); + expect(noteActionsProps.resolvedBy).toEqual({}); + }); - it('prevents note preview xss', (done) => { - const imgSrc = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; - const noteBody = `<img src="${imgSrc}" onload="alert(1)" />`; - const alertSpy = jest.spyOn(window, 'alert'); - store.hotUpdate({ - actions: { - updateNote() {}, - setSelectedCommentPositionHover() {}, - }, + it('should render issue body', () => { + const noteBody = wrapper.findComponent(NoteBody); + const noteBodyProps = noteBody.props(); + + expect(noteBodyProps.note).toBe(note); + expect(noteBodyProps.line).toBe(null); + expect(noteBodyProps.canEdit).toBe(note.current_user.can_edit); + expect(noteBodyProps.isEditing).toBe(false); + expect(noteBodyProps.helpPagePath).toBe(''); }); - const noteBodyComponent = wrapper.find(NoteBody); - noteBodyComponent.vm.$emit('handleFormUpdate', noteBody, null, () => {}); + it('prevents note preview xss', async () => { + const noteBody = + '<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" onload="alert(1)" />'; + const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {}); + const noteBodyComponent = wrapper.findComponent(NoteBody); + + store.hotUpdate({ + actions: { + updateNote() {}, + setSelectedCommentPositionHover() {}, + }, + }); + + noteBodyComponent.vm.$emit('handleFormUpdate', noteBody, null, () => {}); - setImmediate(() => { + await waitForPromises(); expect(alertSpy).not.toHaveBeenCalled(); - expect(wrapper.vm.note.note_html).toEqual(escape(noteBody)); - done(); + expect(wrapper.vm.note.note_html).toBe(escape(noteBody)); }); }); describe('cancel edit', () => { - it('restores content of updated note', (done) => { + beforeEach(() => { + createWrapper(); + }); + + it('restores content of updated note', async () => { const updatedText = 'updated note text'; store.hotUpdate({ actions: { updateNote() {}, }, }); - const noteBody = wrapper.find(NoteBody); + const noteBody = wrapper.findComponent(NoteBody); noteBody.vm.resetAutoSave = () => {}; noteBody.vm.$emit('handleFormUpdate', updatedText, null, () => {}); - wrapper.vm - .$nextTick() - .then(() => { - const noteBodyProps = noteBody.props(); - - expect(noteBodyProps.note.note_html).toBe(updatedText); - noteBody.vm.$emit('cancelForm'); - }) - .then(() => wrapper.vm.$nextTick()) - .then(() => { - const noteBodyProps = noteBody.props(); - - expect(noteBodyProps.note.note_html).toBe(note.note_html); - }) - .then(done) - .catch(done.fail); + await wrapper.vm.$nextTick(); + let noteBodyProps = noteBody.props(); + + expect(noteBodyProps.note.note_html).toBe(updatedText); + + noteBody.vm.$emit('cancelForm'); + await wrapper.vm.$nextTick(); + + noteBodyProps = noteBody.props(); + + expect(noteBodyProps.note.note_html).toBe(note.note_html); + }); + }); + + describe('formUpdateHandler', () => { + const updateNote = jest.fn(); + const params = ['', null, jest.fn(), '']; + + const updateActions = () => { + store.hotUpdate({ + actions: { + updateNote, + setSelectedCommentPositionHover() {}, + }, + }); + }; + + afterEach(() => updateNote.mockReset()); + + it('responds to handleFormUpdate', () => { + createWrapper(); + updateActions(); + wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', ...params); + expect(wrapper.emitted('handleUpdateNote')).toBeTruthy(); + }); + + it('does not stringify empty position', () => { + createWrapper(); + updateActions(); + wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', ...params); + expect(updateNote.mock.calls[0][1].note.note.position).toBeUndefined(); + }); + + it('stringifies populated position', () => { + const position = { test: true }; + const expectation = JSON.stringify(position); + createWrapper({ note: { ...note, position } }); + updateActions(); + wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', ...params); + expect(updateNote.mock.calls[0][1].note.note.position).toBe(expectation); }); }); }); diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js index efee72dea96..163501d5ce8 100644 --- a/spec/frontend/notes/components/notes_app_spec.js +++ b/spec/frontend/notes/components/notes_app_spec.js @@ -33,6 +33,8 @@ describe('note_app', () => { let wrapper; let store; + const findCommentButton = () => wrapper.find('[data-testid="comment-button"]'); + const getComponentOrder = () => { return wrapper .findAll('#notes-list,.js-comment-form') @@ -144,7 +146,7 @@ describe('note_app', () => { }); it('should render form comment button as disabled', () => { - expect(wrapper.find('.js-note-new-discussion').attributes('disabled')).toEqual('disabled'); + expect(findCommentButton().props('disabled')).toEqual(true); }); it('updates discussions badge', () => { diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 1852108b39f..f972ff0d2e4 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -3,6 +3,7 @@ import testAction from 'helpers/vuex_action_helper'; import { TEST_HOST } from 'spec/test_constants'; import Api from '~/api'; import { deprecatedCreateFlash as Flash } from '~/flash'; +import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import axios from '~/lib/utils/axios_utils'; import * as notesConstants from '~/notes/constants'; import createStore from '~/notes/stores'; @@ -10,7 +11,6 @@ import * as actions from '~/notes/stores/actions'; import * as mutationTypes from '~/notes/stores/mutation_types'; import mutations from '~/notes/stores/mutations'; import * as utils from '~/notes/stores/utils'; -import updateIssueConfidentialMutation from '~/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql'; import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql'; import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql'; import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub'; @@ -203,7 +203,7 @@ describe('Actions Notes Store', () => { describe('emitStateChangedEvent', () => { it('emits an event on the document', () => { - document.addEventListener('issuable_vue_app:change', (event) => { + document.addEventListener(EVENT_ISSUABLE_VUE_APP_CHANGE, (event) => { expect(event.detail.data).toEqual({ id: '1', state: 'closed' }); expect(event.detail.isClosed).toEqual(false); }); @@ -1276,68 +1276,6 @@ describe('Actions Notes Store', () => { }); }); - describe('updateConfidentialityOnIssuable', () => { - state = { noteableData: { confidential: false } }; - const iid = '1'; - const projectPath = 'full/path'; - const getters = { getNoteableData: { iid } }; - const actionArgs = { fullPath: projectPath, confidential: true }; - const confidential = true; - - beforeEach(() => { - jest - .spyOn(utils.gqClient, 'mutate') - .mockResolvedValue({ data: { issueSetConfidential: { issue: { confidential } } } }); - }); - - it('calls gqClient mutation one time', () => { - actions.updateConfidentialityOnIssuable({ commit: () => {}, state, getters }, actionArgs); - - expect(utils.gqClient.mutate).toHaveBeenCalledTimes(1); - }); - - it('calls gqClient mutation with the correct values', () => { - actions.updateConfidentialityOnIssuable({ commit: () => {}, state, getters }, actionArgs); - - expect(utils.gqClient.mutate).toHaveBeenCalledWith({ - mutation: updateIssueConfidentialMutation, - variables: { input: { iid, projectPath, confidential } }, - }); - }); - - describe('on success of mutation', () => { - it('calls commit with the correct values', () => { - const commitSpy = jest.fn(); - - return actions - .updateConfidentialityOnIssuable({ commit: commitSpy, state, getters }, actionArgs) - .then(() => { - expect(Flash).not.toHaveBeenCalled(); - expect(commitSpy).toHaveBeenCalledWith( - mutationTypes.SET_ISSUE_CONFIDENTIAL, - confidential, - ); - }); - }); - }); - - describe('on user recoverable error', () => { - it('sends the error to Flash', () => { - const error = 'error'; - - jest - .spyOn(utils.gqClient, 'mutate') - .mockResolvedValue({ data: { issueSetConfidential: { errors: [error] } } }); - - return actions - .updateConfidentialityOnIssuable({ commit: () => {}, state, getters }, actionArgs) - .then(() => { - expect(Flash).toHaveBeenCalledWith(error, 'alert'); - }); - }); - }); - }); - describe.each` issuableType ${'issue'} | ${'merge_request'} diff --git a/spec/frontend/notes/stores/getters_spec.js b/spec/frontend/notes/stores/getters_spec.js index 4ebfc679310..4d2f86a1ecf 100644 --- a/spec/frontend/notes/stores/getters_spec.js +++ b/spec/frontend/notes/stores/getters_spec.js @@ -26,8 +26,6 @@ const createDiscussionNeighborParams = (discussionId, diffOrder, step) => ({ describe('Getters Notes Store', () => { let state; - preloadFixtures(discussionWithTwoUnresolvedNotes); - beforeEach(() => { state = { discussions: [individualNote], diff --git a/spec/frontend/notifications/components/custom_notifications_modal_spec.js b/spec/frontend/notifications/components/custom_notifications_modal_spec.js index 3e87f3107bd..5e4114d91f5 100644 --- a/spec/frontend/notifications/components/custom_notifications_modal_spec.js +++ b/spec/frontend/notifications/components/custom_notifications_modal_spec.js @@ -180,7 +180,7 @@ describe('CustomNotificationsModal', () => { expect( mockToastShow, ).toHaveBeenCalledWith( - 'An error occured while loading the notification settings. Please try again.', + 'An error occurred while loading the notification settings. Please try again.', { type: 'error' }, ); }); @@ -258,7 +258,7 @@ describe('CustomNotificationsModal', () => { expect( mockToastShow, ).toHaveBeenCalledWith( - 'An error occured while updating the notification settings. Please try again.', + 'An error occurred while updating the notification settings. Please try again.', { type: 'error' }, ); }); diff --git a/spec/frontend/notifications/components/notifications_dropdown_spec.js b/spec/frontend/notifications/components/notifications_dropdown_spec.js index 0673fb51a91..e90bd68d067 100644 --- a/spec/frontend/notifications/components/notifications_dropdown_spec.js +++ b/spec/frontend/notifications/components/notifications_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlButtonGroup, GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; @@ -15,14 +15,10 @@ const mockToastShow = jest.fn(); describe('NotificationsDropdown', () => { let wrapper; let mockAxios; - let glModalDirective; function createComponent(injectedProperties = {}) { - glModalDirective = jest.fn(); - return shallowMount(NotificationsDropdown, { stubs: { - GlButtonGroup, GlDropdown, GlDropdownItem, NotificationsDropdownItem, @@ -30,11 +26,6 @@ describe('NotificationsDropdown', () => { }, directives: { GlTooltip: createMockDirective(), - glModal: { - bind(_, { value }) { - glModalDirective(value); - }, - }, }, provide: { dropdownItems: mockDropdownItems, @@ -49,13 +40,12 @@ describe('NotificationsDropdown', () => { }); } - const findButtonGroup = () => wrapper.find(GlButtonGroup); - const findButton = () => wrapper.find(GlButton); const findDropdown = () => wrapper.find(GlDropdown); const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); const findAllNotificationsDropdownItems = () => wrapper.findAll(NotificationsDropdownItem); const findDropdownItemAt = (index) => findAllNotificationsDropdownItems().at(index).find(GlDropdownItem); + const findNotificationsModal = () => wrapper.find(CustomNotificationsModal); const clickDropdownItemAt = async (index) => { const dropdownItem = findDropdownItemAt(index); @@ -83,8 +73,8 @@ describe('NotificationsDropdown', () => { }); }); - it('renders a button group', () => { - expect(findButtonGroup().exists()).toBe(true); + it('renders split dropdown', () => { + expect(findDropdown().props().split).toBe(true); }); it('shows the button text when showLabel is true', () => { @@ -93,7 +83,7 @@ describe('NotificationsDropdown', () => { showLabel: true, }); - expect(findButton().text()).toBe('Custom'); + expect(findDropdown().props().text).toBe('Custom'); }); it("doesn't show the button text when showLabel is false", () => { @@ -102,7 +92,7 @@ describe('NotificationsDropdown', () => { showLabel: false, }); - expect(findButton().text()).toBe(''); + expect(findDropdown().props().text).toBe(null); }); it('opens the modal when the user clicks the button', async () => { @@ -113,9 +103,9 @@ describe('NotificationsDropdown', () => { initialNotificationLevel: 'custom', }); - findButton().vm.$emit('click'); + await findDropdown().vm.$emit('click'); - expect(glModalDirective).toHaveBeenCalled(); + expect(findNotificationsModal().props().visible).toBe(true); }); }); @@ -126,8 +116,8 @@ describe('NotificationsDropdown', () => { }); }); - it('does not render a button group', () => { - expect(findButtonGroup().exists()).toBe(false); + it('renders unified dropdown', () => { + expect(findDropdown().props().split).toBe(false); }); it('shows the button text when showLabel is true', () => { @@ -162,7 +152,7 @@ describe('NotificationsDropdown', () => { initialNotificationLevel: level, }); - const tooltipElement = findByTestId('notificationButton'); + const tooltipElement = findByTestId('notification-dropdown'); const tooltip = getBinding(tooltipElement.element, 'gl-tooltip'); expect(tooltip.value.title).toBe(`${tooltipTitlePrefix} - ${title}`); @@ -255,7 +245,7 @@ describe('NotificationsDropdown', () => { expect( mockToastShow, ).toHaveBeenCalledWith( - 'An error occured while updating the notification settings. Please try again.', + 'An error occurred while updating the notification settings. Please try again.', { type: 'error' }, ); }); @@ -264,11 +254,9 @@ describe('NotificationsDropdown', () => { mockAxios.onPut('/api/v4/notification_settings').reply(httpStatus.OK, {}); wrapper = createComponent(); - const mockModalShow = jest.spyOn(wrapper.vm.$refs.customNotificationsModal, 'open'); - await clickDropdownItemAt(5); - expect(mockModalShow).toHaveBeenCalled(); + expect(findNotificationsModal().props().visible).toBe(true); }); }); }); diff --git a/spec/frontend/oauth_remember_me_spec.js b/spec/frontend/oauth_remember_me_spec.js index 910676a97ed..70bda1d9f9e 100644 --- a/spec/frontend/oauth_remember_me_spec.js +++ b/spec/frontend/oauth_remember_me_spec.js @@ -6,8 +6,6 @@ describe('OAuthRememberMe', () => { return $(`#oauth-container .oauth-login${selector}`).parent('form').attr('action'); }; - preloadFixtures('static/oauth_remember_me.html'); - beforeEach(() => { loadFixtures('static/oauth_remember_me.html'); diff --git a/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap index a1d08f032bc..a3423e3f4d7 100644 --- a/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap +++ b/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap @@ -2,11 +2,10 @@ exports[`ConanInstallation renders all the messages 1`] = ` <div> - <h3 - class="gl-font-lg" - > - Installation - </h3> + <installation-title-stub + options="[object Object]" + packagetype="conan" + /> <code-instruction-stub copytext="Copy Conan Command" diff --git a/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap index 6d22b372d41..a6bb9e868ee 100644 --- a/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap +++ b/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap @@ -1,12 +1,38 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`MavenInstallation renders all the messages 1`] = ` +exports[`MavenInstallation gradle renders all the messages 1`] = ` <div> - <h3 - class="gl-font-lg" - > - Installation - </h3> + <installation-title-stub + options="[object Object],[object Object]" + packagetype="maven" + /> + + <code-instruction-stub + class="gl-mb-5" + copytext="Copy Gradle Groovy DSL install command" + instruction="foo/gradle/install" + label="Gradle Groovy DSL install command" + trackingaction="copy_gradle_install_command" + trackinglabel="code_instruction" + /> + + <code-instruction-stub + copytext="Copy add Gradle Groovy DSL repository command" + instruction="foo/gradle/add/source" + label="Add Gradle Groovy DSL repository command" + multiline="true" + trackingaction="copy_gradle_add_to_source_command" + trackinglabel="code_instruction" + /> +</div> +`; + +exports[`MavenInstallation maven renders all the messages 1`] = ` +<div> + <installation-title-stub + options="[object Object],[object Object]" + packagetype="maven" + /> <p> <gl-sprintf-stub @@ -17,7 +43,7 @@ exports[`MavenInstallation renders all the messages 1`] = ` <code-instruction-stub copytext="Copy Maven XML" instruction="foo/xml" - label="Maven XML" + label="" multiline="true" trackingaction="copy_maven_xml" trackinglabel="code_instruction" diff --git a/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap index b616751f75f..6903d342d6a 100644 --- a/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap +++ b/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap @@ -2,11 +2,10 @@ exports[`NpmInstallation renders all the messages 1`] = ` <div> - <h3 - class="gl-font-lg" - > - Installation - </h3> + <installation-title-stub + options="[object Object]" + packagetype="npm" + /> <code-instruction-stub copytext="Copy npm command" diff --git a/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap index 84cf5e4db49..04532743952 100644 --- a/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap +++ b/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap @@ -2,11 +2,10 @@ exports[`NugetInstallation renders all the messages 1`] = ` <div> - <h3 - class="gl-font-lg" - > - Installation - </h3> + <installation-title-stub + options="[object Object]" + packagetype="nuget" + /> <code-instruction-stub copytext="Copy NuGet Command" diff --git a/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap index 2a588f99c1d..d5bb825d8d1 100644 --- a/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap +++ b/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap @@ -2,11 +2,10 @@ exports[`PypiInstallation renders all the messages 1`] = ` <div> - <h3 - class="gl-font-lg" - > - Installation - </h3> + <installation-title-stub + options="[object Object]" + packagetype="pypi" + /> <code-instruction-stub copytext="Copy Pip command" diff --git a/spec/frontend/packages/details/components/composer_installation_spec.js b/spec/frontend/packages/details/components/composer_installation_spec.js index a1d30d0ed22..18d11c7dd57 100644 --- a/spec/frontend/packages/details/components/composer_installation_spec.js +++ b/spec/frontend/packages/details/components/composer_installation_spec.js @@ -4,6 +4,7 @@ import Vuex from 'vuex'; import { registryUrl as composerHelpPath } from 'jest/packages/details/mock_data'; import { composerPackage as packageEntity } from 'jest/packages/mock_data'; import ComposerInstallation from '~/packages/details/components/composer_installation.vue'; +import InstallationTitle from '~/packages/details/components/installation_title.vue'; import { TrackingActions } from '~/packages/details/constants'; @@ -33,6 +34,7 @@ describe('ComposerInstallation', () => { const findPackageInclude = () => wrapper.find('[data-testid="package-include"]'); const findHelpText = () => wrapper.find('[data-testid="help-text"]'); const findHelpLink = () => wrapper.find(GlLink); + const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); function createComponent() { wrapper = shallowMount(ComposerInstallation, { @@ -48,6 +50,19 @@ describe('ComposerInstallation', () => { wrapper.destroy(); }); + describe('install command switch', () => { + it('has the installation title component', () => { + createStore(); + createComponent(); + + expect(findInstallationTitle().exists()).toBe(true); + expect(findInstallationTitle().props()).toMatchObject({ + packageType: 'composer', + options: [{ value: 'composer', label: 'Show Composer commands' }], + }); + }); + }); + describe('registry include command', () => { beforeEach(() => { createStore(); diff --git a/spec/frontend/packages/details/components/conan_installation_spec.js b/spec/frontend/packages/details/components/conan_installation_spec.js index bf8a92a6350..78a7d265a21 100644 --- a/spec/frontend/packages/details/components/conan_installation_spec.js +++ b/spec/frontend/packages/details/components/conan_installation_spec.js @@ -1,6 +1,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import ConanInstallation from '~/packages/details/components/conan_installation.vue'; +import InstallationTitle from '~/packages/details/components/installation_title.vue'; import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; import { conanPackage as packageEntity } from '../../mock_data'; import { registryUrl as conanPath } from '../mock_data'; @@ -26,6 +27,7 @@ describe('ConanInstallation', () => { }); const findCodeInstructions = () => wrapper.findAll(CodeInstructions); + const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); function createComponent() { wrapper = shallowMount(ConanInstallation, { @@ -39,13 +41,23 @@ describe('ConanInstallation', () => { }); afterEach(() => { - if (wrapper) wrapper.destroy(); + wrapper.destroy(); }); it('renders all the messages', () => { expect(wrapper.element).toMatchSnapshot(); }); + describe('install command switch', () => { + it('has the installation title component', () => { + expect(findInstallationTitle().exists()).toBe(true); + expect(findInstallationTitle().props()).toMatchObject({ + packageType: 'conan', + options: [{ value: 'conan', label: 'Show Conan commands' }], + }); + }); + }); + describe('installation commands', () => { it('renders the correct command', () => { expect(findCodeInstructions().at(0).props('instruction')).toBe(conanInstallationCommandStr); diff --git a/spec/frontend/packages/details/components/installation_title_spec.js b/spec/frontend/packages/details/components/installation_title_spec.js new file mode 100644 index 00000000000..14e990d3011 --- /dev/null +++ b/spec/frontend/packages/details/components/installation_title_spec.js @@ -0,0 +1,58 @@ +import { shallowMount } from '@vue/test-utils'; + +import InstallationTitle from '~/packages/details/components/installation_title.vue'; +import PersistedDropdownSelection from '~/vue_shared/components/registry/persisted_dropdown_selection.vue'; + +describe('InstallationTitle', () => { + let wrapper; + + const defaultProps = { packageType: 'foo', options: [{ value: 'foo', label: 'bar' }] }; + + const findPersistedDropdownSelection = () => wrapper.findComponent(PersistedDropdownSelection); + const findTitle = () => wrapper.find('h3'); + + function createComponent({ props = {} } = {}) { + wrapper = shallowMount(InstallationTitle, { + propsData: { + ...defaultProps, + ...props, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('has a title', () => { + createComponent(); + + expect(findTitle().exists()).toBe(true); + expect(findTitle().text()).toBe('Installation'); + }); + + describe('persisted dropdown selection', () => { + it('exists', () => { + createComponent(); + + expect(findPersistedDropdownSelection().exists()).toBe(true); + }); + + it('has the correct props', () => { + createComponent(); + + expect(findPersistedDropdownSelection().props()).toMatchObject({ + storageKey: 'package_foo_installation_instructions', + options: defaultProps.options, + }); + }); + + it('on change event emits a change event', () => { + createComponent(); + + findPersistedDropdownSelection().vm.$emit('change', 'baz'); + + expect(wrapper.emitted('change')).toEqual([['baz']]); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/maven_installation_spec.js b/spec/frontend/packages/details/components/maven_installation_spec.js index dfeb6002186..d49a7c0b561 100644 --- a/spec/frontend/packages/details/components/maven_installation_spec.js +++ b/spec/frontend/packages/details/components/maven_installation_spec.js @@ -1,7 +1,9 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; import Vuex from 'vuex'; import { registryUrl as mavenPath } from 'jest/packages/details/mock_data'; import { mavenPackage as packageEntity } from 'jest/packages/mock_data'; +import InstallationTitle from '~/packages/details/components/installation_title.vue'; import MavenInstallation from '~/packages/details/components/maven_installation.vue'; import { TrackingActions } from '~/packages/details/constants'; import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; @@ -15,6 +17,8 @@ describe('MavenInstallation', () => { const xmlCodeBlock = 'foo/xml'; const mavenCommandStr = 'foo/command'; const mavenSetupXml = 'foo/setup'; + const gradleGroovyInstallCommandText = 'foo/gradle/install'; + const gradleGroovyAddSourceCommandText = 'foo/gradle/add/source'; const store = new Vuex.Store({ state: { @@ -25,54 +29,120 @@ describe('MavenInstallation', () => { mavenInstallationXml: () => xmlCodeBlock, mavenInstallationCommand: () => mavenCommandStr, mavenSetupXml: () => mavenSetupXml, + gradleGroovyInstalCommand: () => gradleGroovyInstallCommandText, + gradleGroovyAddSourceCommand: () => gradleGroovyAddSourceCommandText, }, }); const findCodeInstructions = () => wrapper.findAll(CodeInstructions); + const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); - function createComponent() { + function createComponent({ data = {} } = {}) { wrapper = shallowMount(MavenInstallation, { localVue, store, + data() { + return data; + }, }); } - beforeEach(() => { - createComponent(); - }); - afterEach(() => { - if (wrapper) wrapper.destroy(); + wrapper.destroy(); }); - it('renders all the messages', () => { - expect(wrapper.element).toMatchSnapshot(); + describe('install command switch', () => { + it('has the installation title component', () => { + createComponent(); + + expect(findInstallationTitle().exists()).toBe(true); + expect(findInstallationTitle().props()).toMatchObject({ + packageType: 'maven', + options: [ + { value: 'maven', label: 'Show Maven commands' }, + { value: 'groovy', label: 'Show Gradle Groovy DSL commands' }, + ], + }); + }); + + it('on change event updates the instructions to show', async () => { + createComponent(); + + expect(findCodeInstructions().at(0).props('instruction')).toBe(xmlCodeBlock); + findInstallationTitle().vm.$emit('change', 'groovy'); + + await nextTick(); + + expect(findCodeInstructions().at(0).props('instruction')).toBe( + gradleGroovyInstallCommandText, + ); + }); }); - describe('installation commands', () => { - it('renders the correct xml block', () => { - expect(findCodeInstructions().at(0).props()).toMatchObject({ - instruction: xmlCodeBlock, - multiline: true, - trackingAction: TrackingActions.COPY_MAVEN_XML, + describe('maven', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders all the messages', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('installation commands', () => { + it('renders the correct xml block', () => { + expect(findCodeInstructions().at(0).props()).toMatchObject({ + instruction: xmlCodeBlock, + multiline: true, + trackingAction: TrackingActions.COPY_MAVEN_XML, + }); + }); + + it('renders the correct maven command', () => { + expect(findCodeInstructions().at(1).props()).toMatchObject({ + instruction: mavenCommandStr, + multiline: false, + trackingAction: TrackingActions.COPY_MAVEN_COMMAND, + }); }); }); - it('renders the correct maven command', () => { - expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: mavenCommandStr, - multiline: false, - trackingAction: TrackingActions.COPY_MAVEN_COMMAND, + describe('setup commands', () => { + it('renders the correct xml block', () => { + expect(findCodeInstructions().at(2).props()).toMatchObject({ + instruction: mavenSetupXml, + multiline: true, + trackingAction: TrackingActions.COPY_MAVEN_SETUP, + }); }); }); }); - describe('setup commands', () => { - it('renders the correct xml block', () => { - expect(findCodeInstructions().at(2).props()).toMatchObject({ - instruction: mavenSetupXml, - multiline: true, - trackingAction: TrackingActions.COPY_MAVEN_SETUP, + describe('gradle', () => { + beforeEach(() => { + createComponent({ data: { instructionType: 'gradle' } }); + }); + + it('renders all the messages', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('installation commands', () => { + it('renders the gradle install command', () => { + expect(findCodeInstructions().at(0).props()).toMatchObject({ + instruction: gradleGroovyInstallCommandText, + multiline: false, + trackingAction: TrackingActions.COPY_GRADLE_INSTALL_COMMAND, + }); + }); + }); + + describe('setup commands', () => { + it('renders the correct gradle command', () => { + expect(findCodeInstructions().at(1).props()).toMatchObject({ + instruction: gradleGroovyAddSourceCommandText, + multiline: true, + trackingAction: TrackingActions.COPY_GRADLE_ADD_TO_SOURCE_COMMAND, + }); }); }); }); diff --git a/spec/frontend/packages/details/components/npm_installation_spec.js b/spec/frontend/packages/details/components/npm_installation_spec.js index df820e7e948..09afcd4fd0a 100644 --- a/spec/frontend/packages/details/components/npm_installation_spec.js +++ b/spec/frontend/packages/details/components/npm_installation_spec.js @@ -2,6 +2,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { registryUrl as nugetPath } from 'jest/packages/details/mock_data'; import { npmPackage as packageEntity } from 'jest/packages/mock_data'; +import InstallationTitle from '~/packages/details/components/installation_title.vue'; import NpmInstallation from '~/packages/details/components/npm_installation.vue'; import { TrackingActions } from '~/packages/details/constants'; import { npmInstallationCommand, npmSetupCommand } from '~/packages/details/store/getters'; @@ -14,6 +15,7 @@ describe('NpmInstallation', () => { let wrapper; const findCodeInstructions = () => wrapper.findAll(CodeInstructions); + const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); function createComponent() { const store = new Vuex.Store({ @@ -38,13 +40,23 @@ describe('NpmInstallation', () => { }); afterEach(() => { - if (wrapper) wrapper.destroy(); + wrapper.destroy(); }); it('renders all the messages', () => { expect(wrapper.element).toMatchSnapshot(); }); + describe('install command switch', () => { + it('has the installation title component', () => { + expect(findInstallationTitle().exists()).toBe(true); + expect(findInstallationTitle().props()).toMatchObject({ + packageType: 'npm', + options: [{ value: 'npm', label: 'Show NPM commands' }], + }); + }); + }); + describe('installation commands', () => { it('renders the correct npm command', () => { expect(findCodeInstructions().at(0).props()).toMatchObject({ diff --git a/spec/frontend/packages/details/components/nuget_installation_spec.js b/spec/frontend/packages/details/components/nuget_installation_spec.js index 100e369751c..8839a8f1108 100644 --- a/spec/frontend/packages/details/components/nuget_installation_spec.js +++ b/spec/frontend/packages/details/components/nuget_installation_spec.js @@ -2,6 +2,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { registryUrl as nugetPath } from 'jest/packages/details/mock_data'; import { nugetPackage as packageEntity } from 'jest/packages/mock_data'; +import InstallationTitle from '~/packages/details/components/installation_title.vue'; import NugetInstallation from '~/packages/details/components/nuget_installation.vue'; import { TrackingActions } from '~/packages/details/constants'; import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; @@ -27,6 +28,7 @@ describe('NugetInstallation', () => { }); const findCodeInstructions = () => wrapper.findAll(CodeInstructions); + const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); function createComponent() { wrapper = shallowMount(NugetInstallation, { @@ -40,13 +42,23 @@ describe('NugetInstallation', () => { }); afterEach(() => { - if (wrapper) wrapper.destroy(); + wrapper.destroy(); }); it('renders all the messages', () => { expect(wrapper.element).toMatchSnapshot(); }); + describe('install command switch', () => { + it('has the installation title component', () => { + expect(findInstallationTitle().exists()).toBe(true); + expect(findInstallationTitle().props()).toMatchObject({ + packageType: 'nuget', + options: [{ value: 'nuget', label: 'Show Nuget commands' }], + }); + }); + }); + describe('installation commands', () => { it('renders the correct command', () => { expect(findCodeInstructions().at(0).props()).toMatchObject({ diff --git a/spec/frontend/packages/details/components/pypi_installation_spec.js b/spec/frontend/packages/details/components/pypi_installation_spec.js index a6ccba71554..2cec84282d9 100644 --- a/spec/frontend/packages/details/components/pypi_installation_spec.js +++ b/spec/frontend/packages/details/components/pypi_installation_spec.js @@ -1,6 +1,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { pypiPackage as packageEntity } from 'jest/packages/mock_data'; +import InstallationTitle from '~/packages/details/components/installation_title.vue'; import PypiInstallation from '~/packages/details/components/pypi_installation.vue'; const localVue = createLocalVue(); @@ -26,6 +27,8 @@ describe('PypiInstallation', () => { const pipCommand = () => wrapper.find('[data-testid="pip-command"]'); const setupInstruction = () => wrapper.find('[data-testid="pypi-setup-content"]'); + const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); + function createComponent() { wrapper = shallowMount(PypiInstallation, { localVue, @@ -39,7 +42,16 @@ describe('PypiInstallation', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; + }); + + describe('install command switch', () => { + it('has the installation title component', () => { + expect(findInstallationTitle().exists()).toBe(true); + expect(findInstallationTitle().props()).toMatchObject({ + packageType: 'pypi', + options: [{ value: 'pypi', label: 'Show PyPi commands' }], + }); + }); }); it('renders all the messages', () => { diff --git a/spec/frontend/packages/details/store/getters_spec.js b/spec/frontend/packages/details/store/getters_spec.js index 07c120f57f7..fa7123c813d 100644 --- a/spec/frontend/packages/details/store/getters_spec.js +++ b/spec/frontend/packages/details/store/getters_spec.js @@ -17,6 +17,8 @@ import { composerRegistryInclude, composerPackageInclude, groupExists, + gradleGroovyInstalCommand, + gradleGroovyAddSourceCommand, } from '~/packages/details/store/getters'; import { conanPackage, @@ -99,7 +101,7 @@ describe('Getters PackageDetails Store', () => { packageEntity | expectedResult ${conanPackage} | ${'Conan'} ${packageWithoutBuildInfo} | ${'Maven'} - ${npmPackage} | ${'NPM'} + ${npmPackage} | ${'npm'} ${nugetPackage} | ${'NuGet'} ${pypiPackage} | ${'PyPI'} `(`package type`, ({ packageEntity, expectedResult }) => { @@ -168,13 +170,13 @@ describe('Getters PackageDetails Store', () => { }); describe('npm string getters', () => { - it('gets the correct npmInstallationCommand for NPM', () => { + it('gets the correct npmInstallationCommand for npm', () => { setupState({ packageEntity: npmPackage }); expect(npmInstallationCommand(state)(NpmManager.NPM)).toBe(npmInstallStr); }); - it('gets the correct npmSetupCommand for NPM', () => { + it('gets the correct npmSetupCommand for npm', () => { setupState({ packageEntity: npmPackage }); expect(npmSetupCommand(state)(NpmManager.NPM)).toBe(npmSetupStr); @@ -235,6 +237,26 @@ describe('Getters PackageDetails Store', () => { }); }); + describe('gradle groovy string getters', () => { + it('gets the correct gradleGroovyInstalCommand', () => { + setupState(); + + expect(gradleGroovyInstalCommand(state)).toMatchInlineSnapshot( + `"implementation 'com.test.app:test-app:1.0-SNAPSHOT'"`, + ); + }); + + it('gets the correct gradleGroovyAddSourceCommand', () => { + setupState(); + + expect(gradleGroovyAddSourceCommand(state)).toMatchInlineSnapshot(` + "gitlab { + url \\"foo/registry\\" + }" + `); + }); + }); + describe('check if group', () => { it('is set', () => { setupState({ groupListUrl: '/groups/composer/-/packages' }); diff --git a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap index 4a75deebcf9..77095f7c611 100644 --- a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap +++ b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap @@ -6,7 +6,7 @@ exports[`packages_list_row renders 1`] = ` data-qa-selector="package_row" > <div - class="gl-display-flex gl-align-items-center gl-py-3" + class="gl-display-flex gl-align-items-center gl-py-3 gl-px-5" > <!----> @@ -102,7 +102,7 @@ exports[`packages_list_row renders 1`] = ` <gl-button-stub aria-label="Remove package" buttontextclasses="" - category="primary" + category="secondary" data-testid="action-delete" icon="remove" size="medium" diff --git a/spec/frontend/packages/shared/components/package_list_row_spec.js b/spec/frontend/packages/shared/components/package_list_row_spec.js index bd122167273..1c0ef7e3539 100644 --- a/spec/frontend/packages/shared/components/package_list_row_spec.js +++ b/spec/frontend/packages/shared/components/package_list_row_spec.js @@ -60,11 +60,9 @@ describe('packages_list_row', () => { }); describe('when is is group', () => { - beforeEach(() => { + it('has a package path component', () => { mountComponent({ isGroup: true }); - }); - it('has a package path component', () => { expect(findPackagePath().exists()).toBe(true); expect(findPackagePath().props()).toMatchObject({ path: 'foo/bar/baz' }); }); @@ -92,10 +90,22 @@ describe('packages_list_row', () => { }); }); - describe('delete event', () => { - beforeEach(() => mountComponent({ packageEntity: packageWithoutTags })); + describe('delete button', () => { + it('exists and has the correct props', () => { + mountComponent({ packageEntity: packageWithoutTags }); + + expect(findDeleteButton().exists()).toBe(true); + expect(findDeleteButton().attributes()).toMatchObject({ + icon: 'remove', + category: 'secondary', + variant: 'danger', + title: 'Remove package', + }); + }); it('emits the packageToDelete event when the delete button is clicked', async () => { + mountComponent({ packageEntity: packageWithoutTags }); + findDeleteButton().vm.$emit('click'); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/packages/shared/utils_spec.js b/spec/frontend/packages/shared/utils_spec.js index 506f37f8895..4a95def1bef 100644 --- a/spec/frontend/packages/shared/utils_spec.js +++ b/spec/frontend/packages/shared/utils_spec.js @@ -35,7 +35,7 @@ describe('Packages shared utils', () => { packageType | expectedResult ${'conan'} | ${'Conan'} ${'maven'} | ${'Maven'} - ${'npm'} | ${'NPM'} + ${'npm'} | ${'npm'} ${'nuget'} | ${'NuGet'} ${'pypi'} | ${'PyPI'} ${'composer'} | ${'Composer'} diff --git a/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js b/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js index 2c76adf761f..71c9da238b4 100644 --- a/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js +++ b/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js @@ -14,8 +14,6 @@ describe('Abuse Reports', () => { const findMessage = (searchText) => $messages.filter((index, element) => element.innerText.indexOf(searchText) > -1).first(); - preloadFixtures(FIXTURE); - beforeEach(() => { loadFixtures(FIXTURE); new AbuseReports(); // eslint-disable-line no-new diff --git a/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js index 8816609d1d2..9f326dc33c0 100644 --- a/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js +++ b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js @@ -8,7 +8,6 @@ describe('AccountAndLimits', () => { const FIXTURE = 'application_settings/accounts_and_limit.html'; let $userDefaultExternal; let $userInternalRegex; - preloadFixtures(FIXTURE); beforeEach(() => { loadFixtures(FIXTURE); diff --git a/spec/frontend/pages/admin/users/new/index_spec.js b/spec/frontend/pages/admin/users/new/index_spec.js index 60482860e84..ec9fe487030 100644 --- a/spec/frontend/pages/admin/users/new/index_spec.js +++ b/spec/frontend/pages/admin/users/new/index_spec.js @@ -7,8 +7,6 @@ describe('UserInternalRegexHandler', () => { let $userEmail; let $warningMessage; - preloadFixtures(FIXTURE); - beforeEach(() => { loadFixtures(FIXTURE); // eslint-disable-next-line no-new diff --git a/spec/frontend/pages/dashboard/todos/index/todos_spec.js b/spec/frontend/pages/dashboard/todos/index/todos_spec.js index fb612f17669..de8b29d54fc 100644 --- a/spec/frontend/pages/dashboard/todos/index/todos_spec.js +++ b/spec/frontend/pages/dashboard/todos/index/todos_spec.js @@ -14,7 +14,6 @@ const TEST_COUNT_BIG = 2000; const TEST_DONE_COUNT_BIG = 7300; describe('Todos', () => { - preloadFixtures('todos/todos.html'); let todoItem; let mock; diff --git a/spec/frontend/pages/projects/forks/new/components/app_spec.js b/spec/frontend/pages/projects/forks/new/components/app_spec.js new file mode 100644 index 00000000000..e1820606704 --- /dev/null +++ b/spec/frontend/pages/projects/forks/new/components/app_spec.js @@ -0,0 +1,42 @@ +import { shallowMount } from '@vue/test-utils'; +import App from '~/pages/projects/forks/new/components/app.vue'; + +describe('App component', () => { + let wrapper; + + const DEFAULT_PROPS = { + forkIllustration: 'illustrations/project-create-new-sm.svg', + endpoint: '/some/project-full-path/-/forks/new.json', + projectFullPath: '/some/project-full-path', + projectId: '10', + projectName: 'Project Name', + projectPath: 'project-name', + projectDescription: 'some project description', + projectVisibility: 'private', + }; + + const createComponent = (props = {}) => { + wrapper = shallowMount(App, { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays the correct svg illustration', () => { + expect(wrapper.find('img').attributes('src')).toBe('illustrations/project-create-new-sm.svg'); + }); + + it('renders ForkForm component with prop', () => { + expect(wrapper.props()).toEqual(expect.objectContaining(DEFAULT_PROPS)); + }); +}); diff --git a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js new file mode 100644 index 00000000000..694a0c2b9c1 --- /dev/null +++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js @@ -0,0 +1,275 @@ +import { GlForm, GlFormInputGroup } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import createFlash from '~/flash'; +import httpStatus from '~/lib/utils/http_status'; +import * as urlUtility from '~/lib/utils/url_utility'; +import ForkForm from '~/pages/projects/forks/new/components/fork_form.vue'; + +jest.mock('~/flash'); +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +describe('ForkForm component', () => { + let wrapper; + let axiosMock; + + const GON_GITLAB_URL = 'https://gitlab.com'; + const GON_API_VERSION = 'v7'; + + const MOCK_NAMESPACES_RESPONSE = [ + { + name: 'one', + id: 1, + }, + { + name: 'two', + id: 2, + }, + ]; + + const DEFAULT_PROPS = { + endpoint: '/some/project-full-path/-/forks/new.json', + projectFullPath: '/some/project-full-path', + projectId: '10', + projectName: 'Project Name', + projectPath: 'project-name', + projectDescription: 'some project description', + projectVisibility: 'private', + }; + + const mockGetRequest = (data = {}, statusCode = httpStatus.OK) => { + axiosMock.onGet(DEFAULT_PROPS.endpoint).replyOnce(statusCode, data); + }; + + const createComponent = (props = {}, data = {}) => { + wrapper = shallowMount(ForkForm, { + provide: { + newGroupPath: 'some/groups/path', + visibilityHelpPath: 'some/visibility/help/path', + }, + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + data() { + return { + ...data, + }; + }, + stubs: { + GlFormInputGroup, + }, + }); + }; + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + window.gon = { + gitlab_url: GON_GITLAB_URL, + api_version: GON_API_VERSION, + }; + }); + + afterEach(() => { + wrapper.destroy(); + axiosMock.restore(); + }); + + const findPrivateRadio = () => wrapper.find('[data-testid="radio-private"]'); + const findInternalRadio = () => wrapper.find('[data-testid="radio-internal"]'); + const findPublicRadio = () => wrapper.find('[data-testid="radio-public"]'); + const findForkNameInput = () => wrapper.find('[data-testid="fork-name-input"]'); + const findForkUrlInput = () => wrapper.find('[data-testid="fork-url-input"]'); + const findForkSlugInput = () => wrapper.find('[data-testid="fork-slug-input"]'); + const findForkDescriptionTextarea = () => + wrapper.find('[data-testid="fork-description-textarea"]'); + const findVisibilityRadioGroup = () => + wrapper.find('[data-testid="fork-visibility-radio-group"]'); + + it('will go to projectFullPath when click cancel button', () => { + mockGetRequest(); + createComponent(); + + const { projectFullPath } = DEFAULT_PROPS; + const cancelButton = wrapper.find('[data-testid="cancel-button"]'); + + expect(cancelButton.attributes('href')).toBe(projectFullPath); + }); + + it('make POST request with project param', async () => { + jest.spyOn(axios, 'post'); + + const namespaceId = 20; + + mockGetRequest(); + createComponent( + {}, + { + selectedNamespace: { + id: namespaceId, + }, + }, + ); + + wrapper.find(GlForm).vm.$emit('submit', { preventDefault: () => {} }); + + const { + projectId, + projectDescription, + projectName, + projectPath, + projectVisibility, + } = DEFAULT_PROPS; + + const url = `/api/${GON_API_VERSION}/projects/${projectId}/fork`; + const project = { + description: projectDescription, + id: projectId, + name: projectName, + namespace_id: namespaceId, + path: projectPath, + visibility: projectVisibility, + }; + + expect(axios.post).toHaveBeenCalledWith(url, project); + }); + + it('has input with csrf token', () => { + mockGetRequest(); + createComponent(); + + expect(wrapper.find('input[name="authenticity_token"]').attributes('value')).toBe( + 'mock-csrf-token', + ); + }); + + it('pre-populate form from project props', () => { + mockGetRequest(); + createComponent(); + + expect(findForkNameInput().attributes('value')).toBe(DEFAULT_PROPS.projectName); + expect(findForkSlugInput().attributes('value')).toBe(DEFAULT_PROPS.projectPath); + expect(findForkDescriptionTextarea().attributes('value')).toBe( + DEFAULT_PROPS.projectDescription, + ); + }); + + it('sets project URL prepend text with gon.gitlab_url', () => { + mockGetRequest(); + createComponent(); + + expect(wrapper.find(GlFormInputGroup).text()).toContain(`${GON_GITLAB_URL}/`); + }); + + it('will have required attribute for required fields', () => { + mockGetRequest(); + createComponent(); + + expect(findForkNameInput().attributes('required')).not.toBeUndefined(); + expect(findForkUrlInput().attributes('required')).not.toBeUndefined(); + expect(findForkSlugInput().attributes('required')).not.toBeUndefined(); + expect(findVisibilityRadioGroup().attributes('required')).not.toBeUndefined(); + expect(findForkDescriptionTextarea().attributes('required')).toBeUndefined(); + }); + + describe('forks namespaces', () => { + beforeEach(() => { + mockGetRequest({ namespaces: MOCK_NAMESPACES_RESPONSE }); + createComponent(); + }); + + it('make GET request from endpoint', async () => { + await axios.waitForAll(); + + expect(axiosMock.history.get[0].url).toBe(DEFAULT_PROPS.endpoint); + }); + + it('generate default option', async () => { + await axios.waitForAll(); + + const optionsArray = findForkUrlInput().findAll('option'); + + expect(optionsArray.at(0).text()).toBe('Select a namespace'); + }); + + it('populate project url namespace options', async () => { + await axios.waitForAll(); + + const optionsArray = findForkUrlInput().findAll('option'); + + expect(optionsArray).toHaveLength(MOCK_NAMESPACES_RESPONSE.length + 1); + expect(optionsArray.at(1).text()).toBe(MOCK_NAMESPACES_RESPONSE[0].name); + expect(optionsArray.at(2).text()).toBe(MOCK_NAMESPACES_RESPONSE[1].name); + }); + }); + + describe('visibility level', () => { + it.each` + project | namespace | privateIsDisabled | internalIsDisabled | publicIsDisabled + ${'private'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} + ${'private'} | ${'internal'} | ${undefined} | ${'true'} | ${'true'} + ${'private'} | ${'public'} | ${undefined} | ${'true'} | ${'true'} + ${'internal'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} + ${'internal'} | ${'internal'} | ${undefined} | ${undefined} | ${'true'} + ${'internal'} | ${'public'} | ${undefined} | ${undefined} | ${'true'} + ${'public'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} + ${'public'} | ${'internal'} | ${undefined} | ${undefined} | ${'true'} + ${'public'} | ${'public'} | ${undefined} | ${undefined} | ${undefined} + `( + 'sets appropriate radio button disabled state', + async ({ project, namespace, privateIsDisabled, internalIsDisabled, publicIsDisabled }) => { + mockGetRequest(); + createComponent( + { + projectVisibility: project, + }, + { + selectedNamespace: { + visibility: namespace, + }, + }, + ); + + expect(findPrivateRadio().attributes('disabled')).toBe(privateIsDisabled); + expect(findInternalRadio().attributes('disabled')).toBe(internalIsDisabled); + expect(findPublicRadio().attributes('disabled')).toBe(publicIsDisabled); + }, + ); + }); + + describe('onSubmit', () => { + beforeEach(() => { + jest.spyOn(urlUtility, 'redirectTo').mockImplementation(); + }); + + it('redirect to POST web_url response', async () => { + const webUrl = `new/fork-project`; + + jest.spyOn(axios, 'post').mockResolvedValue({ data: { web_url: webUrl } }); + + mockGetRequest(); + createComponent(); + + await wrapper.vm.onSubmit(); + + expect(urlUtility.redirectTo).toHaveBeenCalledWith(webUrl); + }); + + it('display flash when POST is unsuccessful', async () => { + const dummyError = 'Fork project failed'; + + jest.spyOn(axios, 'post').mockRejectedValue(dummyError); + + mockGetRequest(); + createComponent(); + + await wrapper.vm.onSubmit(); + + expect(urlUtility.redirectTo).not.toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalledWith({ + message: dummyError, + }); + }); + }); +}); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap index c9141d13a46..1c1327e7a4e 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap +++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap @@ -4,7 +4,7 @@ exports[`Learn GitLab Design A should render the loading state 1`] = ` <ul> <li> <span> - Create a repository + Create or import a repository </span> </li> <li> @@ -14,7 +14,11 @@ exports[`Learn GitLab Design A should render the loading state 1`] = ` </li> <li> <span> - Set-up CI/CD + <gl-link-stub + href="http://example.com/" + > + Set up CI/CD + </gl-link-stub> </span> </li> <li> @@ -22,7 +26,7 @@ exports[`Learn GitLab Design A should render the loading state 1`] = ` <gl-link-stub href="http://example.com/" > - Start a free trial of GitLab Gold + Start a free Ultimate trial </gl-link-stub> </span> </li> @@ -40,7 +44,7 @@ exports[`Learn GitLab Design A should render the loading state 1`] = ` <gl-link-stub href="http://example.com/" > - Enable require merge approvals + Add merge request approval </gl-link-stub> </span> </li> @@ -49,7 +53,7 @@ exports[`Learn GitLab Design A should render the loading state 1`] = ` <gl-link-stub href="http://example.com/" > - Submit a merge request (MR) + Submit a merge request </gl-link-stub> </span> </li> @@ -58,7 +62,7 @@ exports[`Learn GitLab Design A should render the loading state 1`] = ` <gl-link-stub href="http://example.com/" > - Run a Security scan using CI/CD + Run a security scan </gl-link-stub> </span> </li> diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap index 85e3b675e5b..dd899b93302 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap +++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap @@ -1,66 +1,519 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Learn GitLab Design B should render the loading state 1`] = ` -<ul> - <li> - <span> - Create a repository - </span> - </li> - <li> - <span> - Invite your colleagues - </span> - </li> - <li> - <span> - Set-up CI/CD - </span> - </li> - <li> - <span> - <gl-link-stub - href="http://example.com/" +exports[`Learn GitLab Design B renders correctly 1`] = ` +<div> + <div + class="row" + > + <div + class="gl-mb-7 col-md-8 col-lg-7" + > + <h1 + class="gl-font-size-h1" > - Start a free trial of GitLab Gold - </gl-link-stub> - </span> - </li> - <li> - <span> - <gl-link-stub - href="http://example.com/" + Learn GitLab + </h1> + + <p + class="gl-text-gray-700 gl-mb-0" > - Add code owners - </gl-link-stub> - </span> - </li> - <li> - <span> - <gl-link-stub - href="http://example.com/" + Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project. + </p> + </div> + </div> + + <div + class="gl-mb-3" + > + <p + class="gl-text-gray-500 gl-mb-2" + data-testid="completion-percentage" + > + 25% completed + </p> + + <div + class="progress" + max="8" + value="2" + > + <div + aria-valuemax="8" + aria-valuemin="0" + aria-valuenow="2" + class="progress-bar" + role="progressbar" + style="width: 25%;" > - Enable require merge approvals - </gl-link-stub> - </span> - </li> - <li> - <span> - <gl-link-stub - href="http://example.com/" + <!----> + </div> + </div> + </div> + + <h2 + class="gl-font-lg gl-mb-3" + > + Set up your workspace + </h2> + + <p + class="gl-text-gray-700 gl-mb-6" + > + Complete these tasks first so you can enjoy GitLab's features to their fullest: + </p> + + <div + class="row row-cols-2 row-cols-md-3 row-cols-lg-4" + > + <div + class="col gl-mb-6" + > + <div + class="gl-card gl-pt-0" > - Submit a merge request (MR) - </gl-link-stub> - </span> - </li> - <li> - <span> - <gl-link-stub - href="http://example.com/" + <!----> + + <div + class="gl-card-body" + > + <div + class="gl-text-right gl-h-5" + > + <svg + aria-hidden="true" + class="gl-text-green-500 gl-icon s16" + data-testid="completed-icon" + > + <use + href="#check-circle-filled" + /> + </svg> + </div> + + <div + class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" + > + <img + src="http://example.com/images/illustration.svg" + /> + + <h6> + Invite your colleagues + </h6> + + <p + class="gl-font-sm gl-text-gray-700" + > + GitLab works best as a team. Invite your colleague to enjoy all features. + </p> + + <a + class="gl-link" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + Invite your colleagues + </a> + </div> + </div> + + <!----> + </div> + </div> + + <div + class="col gl-mb-6" + > + <div + class="gl-card gl-pt-0" > - Run a Security scan using CI/CD - </gl-link-stub> - </span> - </li> -</ul> + <!----> + + <div + class="gl-card-body" + > + <div + class="gl-text-right gl-h-5" + > + <svg + aria-hidden="true" + class="gl-text-green-500 gl-icon s16" + data-testid="completed-icon" + > + <use + href="#check-circle-filled" + /> + </svg> + </div> + + <div + class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" + > + <img + src="http://example.com/images/illustration.svg" + /> + + <h6> + Create or import a repository + </h6> + + <p + class="gl-font-sm gl-text-gray-700" + > + Create or import your first repository into your new project. + </p> + + <a + class="gl-link" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + Create or import a repository + </a> + </div> + </div> + + <!----> + </div> + </div> + + <div + class="col gl-mb-6" + > + <div + class="gl-card gl-pt-0" + > + <!----> + + <div + class="gl-card-body" + > + <div + class="gl-text-right gl-h-5" + > + <!----> + </div> + + <div + class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" + > + <img + src="http://example.com/images/illustration.svg" + /> + + <h6> + Set up CI/CD + </h6> + + <p + class="gl-font-sm gl-text-gray-700" + > + Save time by automating your integration and deployment tasks. + </p> + + <a + class="gl-link" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + Set-up CI/CD + </a> + </div> + </div> + + <!----> + </div> + </div> + + <div + class="col gl-mb-6" + > + <div + class="gl-card gl-pt-0" + > + <!----> + + <div + class="gl-card-body" + > + <div + class="gl-text-right gl-h-5" + > + <!----> + </div> + + <div + class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" + > + <img + src="http://example.com/images/illustration.svg" + /> + + <h6> + Start a free Ultimate trial + </h6> + + <p + class="gl-font-sm gl-text-gray-700" + > + Try all GitLab features for 30 days, no credit card required. + </p> + + <a + class="gl-link" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + Try GitLab Ultimate for free + </a> + </div> + </div> + + <!----> + </div> + </div> + + <div + class="col gl-mb-6" + > + <div + class="gl-card gl-pt-0" + > + <!----> + + <div + class="gl-card-body" + > + <div + class="gl-text-right gl-h-5" + > + <span + class="gl-text-gray-500 gl-font-sm gl-font-style-italic" + data-testid="trial-only" + > + Trial only + </span> + </div> + + <div + class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" + > + <img + src="http://example.com/images/illustration.svg" + /> + + <h6> + Add code owners + </h6> + + <p + class="gl-font-sm gl-text-gray-700" + > + Prevent unexpected changes to important assets by assigning ownership of files and paths. + </p> + + <a + class="gl-link" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + Add code owners + </a> + </div> + </div> + + <!----> + </div> + </div> + + <div + class="col gl-mb-6" + > + <div + class="gl-card gl-pt-0" + > + <!----> + + <div + class="gl-card-body" + > + <div + class="gl-text-right gl-h-5" + > + <span + class="gl-text-gray-500 gl-font-sm gl-font-style-italic" + data-testid="trial-only" + > + Trial only + </span> + </div> + + <div + class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" + > + <img + src="http://example.com/images/illustration.svg" + /> + + <h6> + Add merge request approval + </h6> + + <p + class="gl-font-sm gl-text-gray-700" + > + Route code reviews to the right reviewers, every time. + </p> + + <a + class="gl-link" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + Enable require merge approvals + </a> + </div> + </div> + + <!----> + </div> + </div> + </div> + + <h2 + class="gl-font-lg gl-mb-3" + > + Plan and execute + </h2> + + <p + class="gl-text-gray-700 gl-mb-6" + > + Create a workflow for your new workspace, and learn how GitLab features work together: + </p> + + <div + class="row row-cols-2 row-cols-md-3 row-cols-lg-4" + > + <div + class="col gl-mb-6" + > + <div + class="gl-card gl-pt-0" + > + <!----> + + <div + class="gl-card-body" + > + <div + class="gl-text-right gl-h-5" + > + <!----> + </div> + + <div + class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" + > + <img + src="http://example.com/images/illustration.svg" + /> + + <h6> + Submit a merge request + </h6> + + <p + class="gl-font-sm gl-text-gray-700" + > + Review and edit proposed changes to source code. + </p> + + <a + class="gl-link" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + Submit a merge request (MR) + </a> + </div> + </div> + + <!----> + </div> + </div> + </div> + + <h2 + class="gl-font-lg gl-mb-3" + > + Deploy + </h2> + + <p + class="gl-text-gray-700 gl-mb-6" + > + Use your new GitLab workflow to deploy your application, monitor its health, and keep it secure: + </p> + + <div + class="row row-cols-2 row-cols-lg-4 g-2 g-lg-3" + > + <div + class="col gl-mb-6" + > + <div + class="gl-card gl-pt-0" + > + <!----> + + <div + class="gl-card-body" + > + <div + class="gl-text-right gl-h-5" + > + <!----> + </div> + + <div + class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" + > + <img + src="http://example.com/images/illustration.svg" + /> + + <h6> + Run a security scan + </h6> + + <p + class="gl-font-sm gl-text-gray-700" + > + Scan your code to uncover vulnerabilities before deploying. + </p> + + <a + class="gl-link" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + Run a Security scan + </a> + </div> + </div> + + <!----> + </div> + </div> + </div> +</div> `; diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js index ddc5339e7e0..2154358de51 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js +++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js @@ -1,41 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import LearnGitlabA from '~/pages/projects/learn_gitlab/components/learn_gitlab_a.vue'; - -const TEST_ACTIONS = { - gitWrite: { - url: 'http://example.com/', - completed: true, - }, - userAdded: { - url: 'http://example.com/', - completed: true, - }, - pipelineCreated: { - url: 'http://example.com/', - completed: true, - }, - trialStarted: { - url: 'http://example.com/', - completed: false, - }, - codeOwnersEnabled: { - url: 'http://example.com/', - completed: false, - }, - requiredMrApprovalsEnabled: { - url: 'http://example.com/', - completed: false, - }, - mergeRequestCreated: { - url: 'http://example.com/', - completed: false, - }, - securityScanEnabled: { - url: 'http://example.com/', - completed: false, - }, -}; +import { testActions } from './mock_data'; describe('Learn GitLab Design A', () => { let wrapper; @@ -46,13 +11,7 @@ describe('Learn GitLab Design A', () => { }); const createWrapper = () => { - wrapper = extendedWrapper( - shallowMount(LearnGitlabA, { - propsData: { - actions: TEST_ACTIONS, - }, - }), - ); + wrapper = shallowMount(LearnGitlabA, { propsData: { actions: testActions } }); }; it('should render the loading state', () => { diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js index be4f5768402..fbb989fae32 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js +++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js @@ -1,63 +1,38 @@ -import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import LearnGitlabA from '~/pages/projects/learn_gitlab/components/learn_gitlab_a.vue'; - -const TEST_ACTIONS = { - gitWrite: { - url: 'http://example.com/', - completed: true, - }, - userAdded: { - url: 'http://example.com/', - completed: true, - }, - pipelineCreated: { - url: 'http://example.com/', - completed: true, - }, - trialStarted: { - url: 'http://example.com/', - completed: false, - }, - codeOwnersEnabled: { - url: 'http://example.com/', - completed: false, - }, - requiredMrApprovalsEnabled: { - url: 'http://example.com/', - completed: false, - }, - mergeRequestCreated: { - url: 'http://example.com/', - completed: false, - }, - securityScanEnabled: { - url: 'http://example.com/', - completed: false, - }, -}; +import { GlProgressBar } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import LearnGitlabB from '~/pages/projects/learn_gitlab/components/learn_gitlab_b.vue'; +import { testActions } from './mock_data'; describe('Learn GitLab Design B', () => { let wrapper; + const createWrapper = () => { + wrapper = mount(LearnGitlabB, { propsData: { actions: testActions } }); + }; + + beforeEach(() => { + createWrapper(); + }); + afterEach(() => { wrapper.destroy(); wrapper = null; }); - const createWrapper = () => { - wrapper = extendedWrapper( - shallowMount(LearnGitlabA, { - propsData: { - actions: TEST_ACTIONS, - }, - }), - ); - }; + it('renders correctly', () => { + expect(wrapper.element).toMatchSnapshot(); + }); - it('should render the loading state', () => { - createWrapper(); + it('renders the progress percentage', () => { + const text = wrapper.find('[data-testid="completion-percentage"]').text(); - expect(wrapper.element).toMatchSnapshot(); + expect(text).toEqual('25% completed'); + }); + + it('renders the progress bar with correct values', () => { + const progressBar = wrapper.find(GlProgressBar); + + expect(progressBar.attributes('value')).toBe('2'); + expect(progressBar.attributes('max')).toBe('8'); }); }); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_info_card_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_info_card_spec.js new file mode 100644 index 00000000000..ad4bc826a9d --- /dev/null +++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_info_card_spec.js @@ -0,0 +1,57 @@ +import { shallowMount } from '@vue/test-utils'; +import LearnGitlabInfoCard from '~/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue'; + +const defaultProps = { + title: 'Create Repository', + description: 'Some description', + actionLabel: 'Create Repository now', + url: 'https://example.com', + completed: false, + svg: 'https://example.com/illustration.svg', +}; + +describe('Learn GitLab Info Card', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const createWrapper = (props = {}) => { + wrapper = shallowMount(LearnGitlabInfoCard, { + propsData: { ...defaultProps, ...props }, + }); + }; + + it('renders no icon when not completed', () => { + createWrapper({ completed: false }); + + expect(wrapper.find('[data-testid="completed-icon"]').exists()).toBe(false); + }); + + it('renders the completion icon when completed', () => { + createWrapper({ completed: true }); + + expect(wrapper.find('[data-testid="completed-icon"]').exists()).toBe(true); + }); + + it('renders no trial only when it is not required', () => { + createWrapper(); + + expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(false); + }); + + it('renders trial only when trial is required', () => { + createWrapper({ trialRequired: true }); + + expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(true); + }); + + it('renders completion icon when completed a trial-only feature', () => { + createWrapper({ trialRequired: true, completed: true }); + + expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="completed-icon"]').exists()).toBe(true); + }); +}); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js b/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js new file mode 100644 index 00000000000..caac667e2b1 --- /dev/null +++ b/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js @@ -0,0 +1,42 @@ +export const testActions = { + gitWrite: { + url: 'http://example.com/', + completed: true, + svg: 'http://example.com/images/illustration.svg', + }, + userAdded: { + url: 'http://example.com/', + completed: true, + svg: 'http://example.com/images/illustration.svg', + }, + pipelineCreated: { + url: 'http://example.com/', + completed: false, + svg: 'http://example.com/images/illustration.svg', + }, + trialStarted: { + url: 'http://example.com/', + completed: false, + svg: 'http://example.com/images/illustration.svg', + }, + codeOwnersEnabled: { + url: 'http://example.com/', + completed: false, + svg: 'http://example.com/images/illustration.svg', + }, + requiredMrApprovalsEnabled: { + url: 'http://example.com/', + completed: false, + svg: 'http://example.com/images/illustration.svg', + }, + mergeRequestCreated: { + url: 'http://example.com/', + completed: false, + svg: 'http://example.com/images/illustration.svg', + }, + securityScanEnabled: { + url: 'http://example.com/', + completed: false, + svg: 'http://example.com/images/illustration.svg', + }, +}; diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js index de63409b181..2a3b07f95f2 100644 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js @@ -6,8 +6,6 @@ import TimezoneDropdown, { } from '~/pages/projects/pipeline_schedules/shared/components/timezone_dropdown'; describe('Timezone Dropdown', () => { - preloadFixtures('pipeline_schedules/edit.html'); - let $inputEl = null; let $dropdownEl = null; let $wrapper = null; 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 d7c754fd3cc..bee628c3a56 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 @@ -29,6 +29,7 @@ const defaultProps = { showDefaultAwardEmojis: true, allowEditingCommitMessages: false, }, + isGitlabCom: true, canDisableEmails: true, canChangeVisibilityLevel: true, allowedVisibilityOptions: [0, 10, 20], diff --git a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js index 8632c852720..e39a3904613 100644 --- a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js +++ b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js @@ -6,8 +6,6 @@ describe('preserve_url_fragment', () => { return $(`.omniauth-container ${selector}`).parent('form').attr('action'); }; - preloadFixtures('sessions/new.html'); - beforeEach(() => { loadFixtures('sessions/new.html'); }); diff --git a/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js b/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js index f04c16d2ddb..6aa725fbd7d 100644 --- a/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js +++ b/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js @@ -18,8 +18,6 @@ describe('SigninTabsMemoizer', () => { return memo; } - preloadFixtures(fixtureTemplate); - beforeEach(() => { loadFixtures(fixtureTemplate); diff --git a/spec/frontend/pages/shared/wikis/wiki_alert_spec.js b/spec/frontend/pages/shared/wikis/wiki_alert_spec.js new file mode 100644 index 00000000000..6a18473b1a7 --- /dev/null +++ b/spec/frontend/pages/shared/wikis/wiki_alert_spec.js @@ -0,0 +1,40 @@ +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import WikiAlert from '~/pages/shared/wikis/components/wiki_alert.vue'; + +describe('WikiAlert', () => { + let wrapper; + const ERROR = 'There is already a page with the same title in that path.'; + const ERROR_WITH_LINK = 'Before text %{wikiLinkStart}the page%{wikiLinkEnd} after text.'; + const PATH = '/test'; + + function createWrapper(propsData = {}, stubs = {}) { + wrapper = shallowMount(WikiAlert, { + propsData: { wikiPagePath: PATH, ...propsData }, + stubs, + }); + } + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findGlAlert = () => wrapper.findComponent(GlAlert); + const findGlLink = () => wrapper.findComponent(GlLink); + const findGlSprintf = () => wrapper.findComponent(GlSprintf); + + describe('Wiki Alert', () => { + it('shows an alert when there is an error', () => { + createWrapper({ error: ERROR }); + expect(findGlAlert().exists()).toBe(true); + expect(findGlSprintf().exists()).toBe(true); + expect(findGlSprintf().attributes('message')).toBe(ERROR); + }); + + it('shows a the link to the help path', () => { + createWrapper({ error: ERROR_WITH_LINK }, { GlAlert, GlSprintf }); + expect(findGlLink().attributes('href')).toBe(PATH); + }); + }); +}); diff --git a/spec/frontend/performance_bar/components/performance_bar_app_spec.js b/spec/frontend/performance_bar/components/performance_bar_app_spec.js index 417a655093c..67a4259a8e3 100644 --- a/spec/frontend/performance_bar/components/performance_bar_app_spec.js +++ b/spec/frontend/performance_bar/components/performance_bar_app_spec.js @@ -9,6 +9,7 @@ describe('performance bar app', () => { store, env: 'development', requestId: '123', + statsUrl: 'https://log.gprd.gitlab.net/app/dashboards#/view/', peekUrl: '/-/peek/results', profileUrl: '?lineprofiler=true', }, diff --git a/spec/frontend/performance_bar/index_spec.js b/spec/frontend/performance_bar/index_spec.js index 8d9c32b7f12..819b2bcbacf 100644 --- a/spec/frontend/performance_bar/index_spec.js +++ b/spec/frontend/performance_bar/index_spec.js @@ -19,6 +19,7 @@ describe('performance bar wrapper', () => { peekWrapper.setAttribute('data-env', 'development'); peekWrapper.setAttribute('data-request-id', '123'); peekWrapper.setAttribute('data-peek-url', '/-/peek/results'); + peekWrapper.setAttribute('data-stats-url', 'https://log.gprd.gitlab.net/app/dashboards#/view/'); peekWrapper.setAttribute('data-profile-url', '?lineprofiler=true'); mock = new MockAdapter(axios); diff --git a/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js b/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js index 5dae77a4626..8040c9d701c 100644 --- a/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js +++ b/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js @@ -12,7 +12,7 @@ describe('Pipeline Editor | Commit Form', () => { wrapper = mountFn(CommitForm, { propsData: { defaultMessage: mockCommitMessage, - defaultBranch: mockDefaultBranch, + currentBranch: mockDefaultBranch, ...props, }, @@ -41,7 +41,7 @@ describe('Pipeline Editor | Commit Form', () => { expect(findCommitTextarea().attributes('value')).toBe(mockCommitMessage); }); - it('shows a default branch', () => { + it('shows current branch', () => { expect(findBranchInput().attributes('value')).toBe(mockDefaultBranch); }); @@ -66,7 +66,7 @@ describe('Pipeline Editor | Commit Form', () => { expect(wrapper.emitted('submit')[0]).toEqual([ { message: mockCommitMessage, - branch: mockDefaultBranch, + targetBranch: mockDefaultBranch, openMergeRequest: false, }, ]); @@ -101,7 +101,7 @@ describe('Pipeline Editor | Commit Form', () => { expect(wrapper.emitted('submit')[0]).toEqual([ { message: anotherMessage, - branch: anotherBranch, + targetBranch: anotherBranch, openMergeRequest: true, }, ]); diff --git a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js index b87ff6ec0de..aa62144736c 100644 --- a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js +++ b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js @@ -35,7 +35,6 @@ const mockVariables = { const mockProvide = { ciConfigPath: mockCiConfigPath, - defaultBranch: mockDefaultBranch, projectFullPath: mockProjectFullPath, newMergeRequestPath: mockNewMergeRequestPath, }; @@ -64,6 +63,7 @@ describe('Pipeline Editor | Commit section', () => { data() { return { commitSha: mockCommitSha, + currentBranch: mockDefaultBranch, }; }, mocks: { @@ -116,7 +116,7 @@ describe('Pipeline Editor | Commit section', () => { await submitCommit(); }); - it('calls the mutation with the default branch', () => { + it('calls the mutation with the current branch', () => { expect(mockMutate).toHaveBeenCalledTimes(1); expect(mockMutate).toHaveBeenCalledWith({ mutation: commitCreate, diff --git a/spec/frontend/pipeline_editor/components/header/pipeline_editor_header_spec.js b/spec/frontend/pipeline_editor/components/header/pipeline_editor_header_spec.js index df15a6c8e7f..ef8ca574e59 100644 --- a/spec/frontend/pipeline_editor/components/header/pipeline_editor_header_spec.js +++ b/spec/frontend/pipeline_editor/components/header/pipeline_editor_header_spec.js @@ -1,21 +1,33 @@ import { shallowMount } from '@vue/test-utils'; import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue'; +import PipelineStatus from '~/pipeline_editor/components/header/pipeline_status.vue'; import ValidationSegment from '~/pipeline_editor/components/header/validation_segment.vue'; -import { mockLintResponse } from '../../mock_data'; +import { mockCiYml, mockLintResponse } from '../../mock_data'; describe('Pipeline editor header', () => { let wrapper; + const mockProvide = { + glFeatures: { + pipelineStatusForPipelineEditor: true, + }, + }; - const createComponent = () => { + const createComponent = ({ provide = {} } = {}) => { wrapper = shallowMount(PipelineEditorHeader, { - props: { + provide: { + ...mockProvide, + ...provide, + }, + propsData: { ciConfigData: mockLintResponse, + ciFileContent: mockCiYml, isCiConfigDataLoading: false, }, }); }; + const findPipelineStatus = () => wrapper.findComponent(PipelineStatus); const findValidationSegment = () => wrapper.findComponent(ValidationSegment); afterEach(() => { @@ -27,8 +39,27 @@ describe('Pipeline editor header', () => { beforeEach(() => { createComponent(); }); + + it('renders the pipeline status', () => { + expect(findPipelineStatus().exists()).toBe(true); + }); + it('renders the validation segment', () => { expect(findValidationSegment().exists()).toBe(true); }); }); + + describe('with pipeline status feature flag off', () => { + beforeEach(() => { + createComponent({ + provide: { + glFeatures: { pipelineStatusForPipelineEditor: false }, + }, + }); + }); + + it('does not render the pipeline status', () => { + expect(findPipelineStatus().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js new file mode 100644 index 00000000000..de6e112866b --- /dev/null +++ b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js @@ -0,0 +1,150 @@ +import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import PipelineStatus, { i18n } from '~/pipeline_editor/components/header/pipeline_status.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { mockCommitSha, mockProjectPipeline, mockProjectFullPath } from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +const mockProvide = { + projectFullPath: mockProjectFullPath, +}; + +describe('Pipeline Status', () => { + let wrapper; + let mockApollo; + let mockPipelineQuery; + + const createComponent = ({ hasPipeline = true, isQueryLoading = false }) => { + const pipeline = hasPipeline + ? { loading: isQueryLoading, ...mockProjectPipeline.pipeline } + : { loading: isQueryLoading }; + + wrapper = shallowMount(PipelineStatus, { + provide: mockProvide, + stubs: { GlLink, GlSprintf }, + data: () => (hasPipeline ? { pipeline } : {}), + mocks: { + $apollo: { + queries: { + pipeline, + }, + }, + }, + }); + }; + + const createComponentWithApollo = () => { + const resolvers = { + Query: { + project: mockPipelineQuery, + }, + }; + mockApollo = createMockApollo([], resolvers); + + wrapper = shallowMount(PipelineStatus, { + localVue, + apolloProvider: mockApollo, + provide: mockProvide, + stubs: { GlLink, GlSprintf }, + data() { + return { + commitSha: mockCommitSha, + }; + }, + }); + }; + + const findIcon = () => wrapper.findComponent(GlIcon); + const findCiIcon = () => wrapper.findComponent(CiIcon); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findPipelineId = () => wrapper.find('[data-testid="pipeline-id"]'); + const findPipelineCommit = () => wrapper.find('[data-testid="pipeline-commit"]'); + const findPipelineErrorMsg = () => wrapper.find('[data-testid="pipeline-error-msg"]'); + const findPipelineLoadingMsg = () => wrapper.find('[data-testid="pipeline-loading-msg"]'); + + beforeEach(() => { + mockPipelineQuery = jest.fn(); + }); + + afterEach(() => { + mockPipelineQuery.mockReset(); + + wrapper.destroy(); + wrapper = null; + }); + + describe('while querying', () => { + it('renders loading icon', () => { + createComponent({ isQueryLoading: true, hasPipeline: false }); + + expect(findLoadingIcon().exists()).toBe(true); + expect(findPipelineLoadingMsg().text()).toBe(i18n.fetchLoading); + }); + + it('does not render loading icon if pipeline data is already set', () => { + createComponent({ isQueryLoading: true }); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('when querying data', () => { + describe('when data is set', () => { + beforeEach(async () => { + mockPipelineQuery.mockResolvedValue(mockProjectPipeline); + + createComponentWithApollo(); + await waitForPromises(); + }); + + it('query is called with correct variables', async () => { + expect(mockPipelineQuery).toHaveBeenCalledTimes(1); + expect(mockPipelineQuery).toHaveBeenCalledWith( + expect.anything(), + { + fullPath: mockProjectFullPath, + }, + expect.anything(), + expect.anything(), + ); + }); + + it('does not render error', () => { + expect(findIcon().exists()).toBe(false); + }); + + it('renders pipeline data', () => { + const { id } = mockProjectPipeline.pipeline; + + expect(findCiIcon().exists()).toBe(true); + expect(findPipelineId().text()).toBe(`#${id.match(/\d+/g)[0]}`); + expect(findPipelineCommit().text()).toBe(mockCommitSha); + }); + }); + + describe('when data cannot be fetched', () => { + beforeEach(async () => { + mockPipelineQuery.mockRejectedValue(new Error()); + + createComponentWithApollo(); + await waitForPromises(); + }); + + it('renders error', () => { + expect(findIcon().attributes('name')).toBe('warning-solid'); + expect(findPipelineErrorMsg().text()).toBe(i18n.fetchError); + }); + + it('does not render pipeline data', () => { + expect(findCiIcon().exists()).toBe(false); + expect(findPipelineId().exists()).toBe(false); + expect(findPipelineCommit().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js b/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js index cf1d89e1d7c..274c2d1b8da 100644 --- a/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js +++ b/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js @@ -7,9 +7,9 @@ import ValidationSegment, { i18n, } from '~/pipeline_editor/components/header/validation_segment.vue'; import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants'; -import { mockYmlHelpPagePath, mergeUnwrappedCiConfig } from '../../mock_data'; +import { mockYmlHelpPagePath, mergeUnwrappedCiConfig, mockCiYml } from '../../mock_data'; -describe('~/pipeline_editor/components/info/validation_segment.vue', () => { +describe('Validation segment component', () => { let wrapper; const createComponent = (props = {}) => { @@ -20,6 +20,7 @@ describe('~/pipeline_editor/components/info/validation_segment.vue', () => { }, propsData: { ciConfig: mergeUnwrappedCiConfig(), + ciFileContent: mockCiYml, loading: false, ...props, }, @@ -42,6 +43,20 @@ describe('~/pipeline_editor/components/info/validation_segment.vue', () => { expect(wrapper.text()).toBe(i18n.loading); }); + describe('when config is empty', () => { + beforeEach(() => { + createComponent({ ciFileContent: '' }); + }); + + it('has check icon', () => { + expect(findIcon().props('name')).toBe('check'); + }); + + it('shows a message for empty state', () => { + expect(findValidationMsg().text()).toBe(i18n.empty); + }); + }); + describe('when config is valid', () => { beforeEach(() => { createComponent({}); @@ -61,7 +76,7 @@ describe('~/pipeline_editor/components/info/validation_segment.vue', () => { }); }); - describe('when config is not valid', () => { + describe('when config is invalid', () => { beforeEach(() => { createComponent({ ciConfig: mergeUnwrappedCiConfig({ diff --git a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js new file mode 100644 index 00000000000..b444d9dcfea --- /dev/null +++ b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js @@ -0,0 +1,79 @@ +import { GlButton, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue'; + +describe('Pipeline editor empty state', () => { + let wrapper; + const defaultProvide = { + glFeatures: { + pipelineEditorEmptyStateAction: false, + }, + emptyStateIllustrationPath: 'my/svg/path', + }; + + const createComponent = ({ provide } = {}) => { + wrapper = shallowMount(PipelineEditorEmptyState, { + provide: { ...defaultProvide, ...provide }, + }); + }; + + const findSvgImage = () => wrapper.find('img'); + const findTitle = () => wrapper.find('h1'); + const findConfirmButton = () => wrapper.findComponent(GlButton); + const findDescription = () => wrapper.findComponent(GlSprintf); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders an svg image', () => { + expect(findSvgImage().exists()).toBe(true); + }); + + it('renders a title', () => { + expect(findTitle().exists()).toBe(true); + expect(findTitle().text()).toBe(wrapper.vm.$options.i18n.title); + }); + + it('renders a description', () => { + expect(findDescription().exists()).toBe(true); + expect(findDescription().html()).toContain(wrapper.vm.$options.i18n.body); + }); + + describe('with feature flag off', () => { + it('does not renders a CTA button', () => { + expect(findConfirmButton().exists()).toBe(false); + }); + }); + }); + + describe('with feature flag on', () => { + beforeEach(() => { + createComponent({ + provide: { + glFeatures: { + pipelineEditorEmptyStateAction: true, + }, + }, + }); + }); + + it('renders a CTA button', () => { + expect(findConfirmButton().exists()).toBe(true); + expect(findConfirmButton().text()).toBe(wrapper.vm.$options.i18n.btnText); + }); + + it('emits an event when clicking on the CTA', async () => { + const expectedEvent = 'createEmptyConfigFile'; + expect(wrapper.emitted(expectedEvent)).toBeUndefined(); + + await findConfirmButton().vm.$emit('click'); + expect(wrapper.emitted(expectedEvent)).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js index d39c0d80296..196a4133eea 100644 --- a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js +++ b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js @@ -46,6 +46,24 @@ describe('~/pipeline_editor/graphql/resolvers', () => { await expect(result.rawData).resolves.toBe(mockCiYml); }); }); + + describe('pipeline', () => { + it('resolves pipeline data with type names', async () => { + const result = await resolvers.Query.project(null); + + // eslint-disable-next-line no-underscore-dangle + expect(result.__typename).toBe('Project'); + }); + + it('resolves pipeline data with necessary data', async () => { + const result = await resolvers.Query.project(null); + const pipelineKeys = Object.keys(result.pipeline); + const statusKeys = Object.keys(result.pipeline.detailedStatus); + + expect(pipelineKeys).toContain('id', 'commitPath', 'detailedStatus', 'shortSha'); + expect(statusKeys).toContain('detailsPath', 'text'); + }); + }); }); describe('Mutation', () => { diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index 8e248c11b87..16d5ba0e714 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -138,6 +138,22 @@ export const mergeUnwrappedCiConfig = (mergedConfig) => { }; }; +export const mockProjectPipeline = { + pipeline: { + commitPath: '/-/commit/aabbccdd', + id: 'gid://gitlab/Ci::Pipeline/118', + iid: '28', + shortSha: mockCommitSha, + status: 'SUCCESS', + detailedStatus: { + detailsPath: '/root/sample-ci-project/-/pipelines/118"', + group: 'success', + icon: 'status_success', + text: 'passed', + }, + }, +}; + export const mockLintResponse = { valid: true, mergedYaml: mockCiYml, diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index 46d0452f437..887d296222f 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -7,6 +7,8 @@ import httpStatusCodes from '~/lib/utils/http_status'; import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue'; import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue'; +import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; +import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue'; import { COMMIT_SUCCESS, COMMIT_FAILURE, LOAD_FAILURE_UNKNOWN } from '~/pipeline_editor/constants'; import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.graphql'; import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue'; @@ -29,6 +31,9 @@ const MockEditorLite = { const mockProvide = { ciConfigPath: mockCiConfigPath, defaultBranch: mockDefaultBranch, + glFeatures: { + pipelineEditorEmptyStateAction: false, + }, projectFullPath: mockProjectFullPath, }; @@ -39,14 +44,17 @@ describe('Pipeline editor app component', () => { let mockBlobContentData; let mockCiConfigData; - const createComponent = ({ blobLoading = false, options = {} } = {}) => { + const createComponent = ({ blobLoading = false, options = {}, provide = {} } = {}) => { wrapper = shallowMount(PipelineEditorApp, { - provide: mockProvide, + provide: { ...mockProvide, ...provide }, stubs: { GlTabs, GlButton, CommitForm, + PipelineEditorHome, + PipelineEditorTabs, EditorLite: MockEditorLite, + PipelineEditorEmptyState, }, mocks: { $apollo: { @@ -64,7 +72,7 @@ describe('Pipeline editor app component', () => { }); }; - const createComponentWithApollo = ({ props = {} } = {}) => { + const createComponentWithApollo = ({ props = {}, provide = {} } = {}) => { const handlers = [[getCiConfigData, mockCiConfigData]]; const resolvers = { Query: { @@ -85,13 +93,16 @@ describe('Pipeline editor app component', () => { apolloProvider: mockApollo, }; - createComponent({ props, options }); + createComponent({ props, provide, options }); }; const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAlert = () => wrapper.findComponent(GlAlert); const findEditorHome = () => wrapper.findComponent(PipelineEditorHome); const findTextEditor = () => wrapper.findComponent(TextEditor); + const findEmptyState = () => wrapper.findComponent(PipelineEditorEmptyState); + const findEmptyStateButton = () => + wrapper.findComponent(PipelineEditorEmptyState).findComponent(GlButton); beforeEach(() => { mockBlobContentData = jest.fn(); @@ -103,7 +114,6 @@ describe('Pipeline editor app component', () => { mockCiConfigData.mockReset(); wrapper.destroy(); - wrapper = null; }); it('displays a loading icon if the blob query is loading', () => { @@ -146,45 +156,79 @@ describe('Pipeline editor app component', () => { }); }); - describe('when no file exists', () => { - const noFileAlertMsg = - 'There is no .gitlab-ci.yml file in this repository, please add one and visit the Pipeline Editor again.'; + describe('when no CI config file exists', () => { + describe('in a project without a repository', () => { + it('shows an empty state and does not show editor home component', async () => { + mockBlobContentData.mockRejectedValueOnce({ + response: { + status: httpStatusCodes.BAD_REQUEST, + }, + }); + createComponentWithApollo(); - it('shows a 404 error message and does not show editor home component', async () => { - mockBlobContentData.mockRejectedValueOnce({ - response: { - status: httpStatusCodes.NOT_FOUND, - }, + await waitForPromises(); + + expect(findEmptyState().exists()).toBe(true); + expect(findAlert().exists()).toBe(false); + expect(findEditorHome().exists()).toBe(false); }); - createComponentWithApollo(); + }); - await waitForPromises(); + describe('in a project with a repository', () => { + it('shows an empty state and does not show editor home component', async () => { + mockBlobContentData.mockRejectedValueOnce({ + response: { + status: httpStatusCodes.NOT_FOUND, + }, + }); + createComponentWithApollo(); - expect(findAlert().text()).toBe(noFileAlertMsg); - expect(findEditorHome().exists()).toBe(false); + await waitForPromises(); + + expect(findEmptyState().exists()).toBe(true); + expect(findAlert().exists()).toBe(false); + expect(findEditorHome().exists()).toBe(false); + }); + }); + + describe('because of a fetching error', () => { + it('shows a unkown error message', async () => { + mockBlobContentData.mockRejectedValueOnce(new Error('My error!')); + createComponentWithApollo(); + await waitForPromises(); + + expect(findEmptyState().exists()).toBe(false); + expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[LOAD_FAILURE_UNKNOWN]); + expect(findEditorHome().exists()).toBe(true); + }); }); + }); - it('shows a 400 error message and does not show editor home component', async () => { + describe('when landing on the empty state with feature flag on', () => { + it('user can click on CTA button and see an empty editor', async () => { mockBlobContentData.mockRejectedValueOnce({ response: { - status: httpStatusCodes.BAD_REQUEST, + status: httpStatusCodes.NOT_FOUND, + }, + }); + + createComponentWithApollo({ + provide: { + glFeatures: { + pipelineEditorEmptyStateAction: true, + }, }, }); - createComponentWithApollo(); await waitForPromises(); - expect(findAlert().text()).toBe(noFileAlertMsg); - expect(findEditorHome().exists()).toBe(false); - }); + expect(findEmptyState().exists()).toBe(true); + expect(findTextEditor().exists()).toBe(false); - it('shows a unkown error message', async () => { - mockBlobContentData.mockRejectedValueOnce(new Error('My error!')); - createComponentWithApollo(); - await waitForPromises(); + await findEmptyStateButton().vm.$emit('click'); - expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[LOAD_FAILURE_UNKNOWN]); - expect(findEditorHome().exists()).toBe(true); + expect(findEmptyState().exists()).toBe(false); + expect(findTextEditor().exists()).toBe(true); }); }); @@ -193,6 +237,7 @@ describe('Pipeline editor app component', () => { describe('and the commit mutation succeeds', () => { beforeEach(() => { + window.scrollTo = jest.fn(); createComponent(); findEditorHome().vm.$emit('commit', { type: COMMIT_SUCCESS }); @@ -201,11 +246,16 @@ describe('Pipeline editor app component', () => { it('shows a confirmation message', () => { expect(findAlert().text()).toBe(wrapper.vm.$options.successTexts[COMMIT_SUCCESS]); }); + + it('scrolls to the top of the page to bring attention to the confirmation message', () => { + expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); + }); }); describe('and the commit mutation fails', () => { const commitFailedReasons = ['Commit failed']; beforeEach(() => { + window.scrollTo = jest.fn(); createComponent(); findEditorHome().vm.$emit('showError', { @@ -219,11 +269,17 @@ describe('Pipeline editor app component', () => { `${updateFailureMessage} ${commitFailedReasons[0]}`, ); }); + + it('scrolls to the top of the page to bring attention to the error message', () => { + expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); + }); }); + describe('when an unknown error occurs', () => { const unknownReasons = ['Commit failed']; beforeEach(() => { + window.scrollTo = jest.fn(); createComponent(); findEditorHome().vm.$emit('showError', { @@ -237,6 +293,10 @@ describe('Pipeline editor app component', () => { `${updateFailureMessage} ${unknownReasons[0]}`, ); }); + + it('scrolls to the top of the page to bring attention to the error message', () => { + expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); + }); }); }); }); 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 51bb0ecee9c..7ec5818010a 100644 --- a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js +++ b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem, GlForm, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; +import { GlForm, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; @@ -6,34 +6,26 @@ import axios from '~/lib/utils/axios_utils'; import httpStatusCodes from '~/lib/utils/http_status'; import { redirectTo } from '~/lib/utils/url_utility'; import PipelineNewForm from '~/pipeline_new/components/pipeline_new_form.vue'; -import { - mockBranches, - mockTags, - mockParams, - mockPostParams, - mockProjectId, - mockError, -} from '../mock_data'; +import RefsDropdown from '~/pipeline_new/components/refs_dropdown.vue'; +import { mockQueryParams, mockPostParams, mockProjectId, mockError, mockRefs } from '../mock_data'; jest.mock('~/lib/utils/url_utility', () => ({ redirectTo: jest.fn(), })); +const projectRefsEndpoint = '/root/project/refs'; const pipelinesPath = '/root/project/-/pipelines'; const configVariablesPath = '/root/project/-/pipelines/config_variables'; -const postResponse = { id: 1 }; +const newPipelinePostResponse = { id: 1 }; +const defaultBranch = 'master'; describe('Pipeline New Form', () => { let wrapper; let mock; - - const dummySubmitEvent = { - preventDefault() {}, - }; + let dummySubmitEvent; const findForm = () => wrapper.find(GlForm); - const findDropdown = () => wrapper.find(GlDropdown); - const findDropdownItems = () => wrapper.findAll(GlDropdownItem); + const findRefsDropdown = () => wrapper.findComponent(RefsDropdown); const findSubmitButton = () => wrapper.find('[data-testid="run_pipeline_button"]'); const findVariableRows = () => wrapper.findAll('[data-testid="ci-variable-row"]'); const findRemoveIcons = () => wrapper.findAll('[data-testid="remove-ci-variable-row"]'); @@ -44,33 +36,42 @@ describe('Pipeline New Form', () => { const findWarningAlertSummary = () => findWarningAlert().find(GlSprintf); 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 getFormPostParams = () => JSON.parse(mock.history.post[0].data); + + const selectBranch = (branch) => { + // Select a branch in the dropdown + findRefsDropdown().vm.$emit('input', { + shortName: branch, + fullName: `refs/heads/${branch}`, + }); + }; - const createComponent = (term = '', props = {}, method = shallowMount) => { + const createComponent = (props = {}, method = shallowMount) => { wrapper = method(PipelineNewForm, { + provide: { + projectRefsEndpoint, + }, propsData: { projectId: mockProjectId, pipelinesPath, configVariablesPath, - branches: mockBranches, - tags: mockTags, - defaultBranch: 'master', + defaultBranch, + refParam: defaultBranch, settingsLink: '', maxWarnings: 25, ...props, }, - data() { - return { - searchTerm: term, - }; - }, }); }; beforeEach(() => { mock = new MockAdapter(axios); mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {}); + mock.onGet(projectRefsEndpoint).reply(httpStatusCodes.OK, mockRefs); + + dummySubmitEvent = { + preventDefault: jest.fn(), + }; }); afterEach(() => { @@ -80,38 +81,17 @@ describe('Pipeline New Form', () => { mock.restore(); }); - describe('Dropdown with branches and tags', () => { - beforeEach(() => { - mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, postResponse); - }); - - it('displays dropdown with all branches and tags', () => { - const refLength = mockBranches.length + mockTags.length; - - createComponent(); - - expect(findDropdownItems()).toHaveLength(refLength); - }); - - it('when user enters search term the list is filtered', () => { - createComponent('master'); - - expect(findDropdownItems()).toHaveLength(1); - expect(findDropdownItems().at(0).text()).toBe('master'); - }); - }); - describe('Form', () => { beforeEach(async () => { - createComponent('', mockParams, mount); + createComponent(mockQueryParams, mount); - mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, postResponse); + mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, newPipelinePostResponse); await waitForPromises(); }); it('displays the correct values for the provided query params', async () => { - expect(findDropdown().props('text')).toBe('tag-1'); + expect(findRefsDropdown().props('value')).toEqual({ shortName: 'tag-1' }); expect(findVariableRows()).toHaveLength(3); }); @@ -152,11 +132,19 @@ describe('Pipeline New Form', () => { describe('Pipeline creation', () => { beforeEach(async () => { - mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, postResponse); + mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, newPipelinePostResponse); await waitForPromises(); }); + it('does not submit the native HTML form', async () => { + createComponent(); + + findForm().vm.$emit('submit', dummySubmitEvent); + + expect(dummySubmitEvent.preventDefault).toHaveBeenCalled(); + }); + it('disables the submit button immediately after submitting', async () => { createComponent(); @@ -171,19 +159,15 @@ describe('Pipeline New Form', () => { it('creates pipeline with full ref and variables', async () => { createComponent(); - changeRef(0); - findForm().vm.$emit('submit', dummySubmitEvent); - await waitForPromises(); - expect(getExpectedPostParams().ref).toEqual(wrapper.vm.$data.refValue.fullName); - expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${postResponse.id}`); + expect(getFormPostParams().ref).toEqual(`refs/heads/${defaultBranch}`); + expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`); }); - it('creates a pipeline with short ref and variables', async () => { - // query params are used - createComponent('', mockParams); + it('creates a pipeline with short ref and variables from the query params', async () => { + createComponent(mockQueryParams); await waitForPromises(); @@ -191,19 +175,19 @@ describe('Pipeline New Form', () => { await waitForPromises(); - expect(getExpectedPostParams()).toEqual(mockPostParams); - expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${postResponse.id}`); + expect(getFormPostParams()).toEqual(mockPostParams); + expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`); }); }); describe('When the ref has been changed', () => { beforeEach(async () => { - createComponent('', {}, mount); + createComponent({}, mount); await waitForPromises(); }); it('variables persist between ref changes', async () => { - changeRef(0); // change to master + selectBranch('master'); await waitForPromises(); @@ -213,7 +197,7 @@ describe('Pipeline New Form', () => { await wrapper.vm.$nextTick(); - changeRef(1); // change to branch-1 + selectBranch('branch-1'); await waitForPromises(); @@ -223,14 +207,14 @@ describe('Pipeline New Form', () => { await wrapper.vm.$nextTick(); - changeRef(0); // change back to master + selectBranch('master'); await waitForPromises(); expect(findKeyInputs().at(0).element.value).toBe('build_var'); expect(findVariableRows().length).toBe(2); - changeRef(1); // change back to branch-1 + selectBranch('branch-1'); await waitForPromises(); @@ -248,7 +232,7 @@ describe('Pipeline New Form', () => { const mockYmlDesc = 'A var from yml.'; it('loading icon is shown when content is requested and hidden when received', async () => { - createComponent('', mockParams, mount); + createComponent(mockQueryParams, mount); mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { [mockYmlKey]: { @@ -265,7 +249,7 @@ describe('Pipeline New Form', () => { }); it('multi-line strings are added to the value field without removing line breaks', async () => { - createComponent('', mockParams, mount); + createComponent(mockQueryParams, mount); mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { [mockYmlKey]: { @@ -281,7 +265,7 @@ describe('Pipeline New Form', () => { describe('with description', () => { beforeEach(async () => { - createComponent('', mockParams, mount); + createComponent(mockQueryParams, mount); mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { [mockYmlKey]: { @@ -323,7 +307,7 @@ describe('Pipeline New Form', () => { describe('without description', () => { beforeEach(async () => { - createComponent('', mockParams, mount); + createComponent(mockQueryParams, mount); mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { [mockYmlKey]: { @@ -346,6 +330,21 @@ describe('Pipeline New Form', () => { createComponent(); }); + describe('when the refs cannot be loaded', () => { + beforeEach(() => { + mock + .onGet(projectRefsEndpoint, { params: { search: '' } }) + .reply(httpStatusCodes.INTERNAL_SERVER_ERROR); + + findRefsDropdown().vm.$emit('loadingError'); + }); + + it('shows both an error alert', () => { + expect(findErrorAlert().exists()).toBe(true); + expect(findWarningAlert().exists()).toBe(false); + }); + }); + describe('when the error response can be handled', () => { beforeEach(async () => { mock.onPost(pipelinesPath).reply(httpStatusCodes.BAD_REQUEST, mockError); diff --git a/spec/frontend/pipeline_new/components/refs_dropdown_spec.js b/spec/frontend/pipeline_new/components/refs_dropdown_spec.js new file mode 100644 index 00000000000..8dafbf230f9 --- /dev/null +++ b/spec/frontend/pipeline_new/components/refs_dropdown_spec.js @@ -0,0 +1,182 @@ +import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; + +import RefsDropdown from '~/pipeline_new/components/refs_dropdown.vue'; + +import { mockRefs, mockFilteredRefs } from '../mock_data'; + +const projectRefsEndpoint = '/root/project/refs'; +const refShortName = 'master'; +const refFullName = 'refs/heads/master'; + +jest.mock('~/flash'); + +describe('Pipeline New Form', () => { + let wrapper; + let mock; + + const findDropdown = () => wrapper.find(GlDropdown); + const findRefsDropdownItems = () => wrapper.findAll(GlDropdownItem); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + + const createComponent = (props = {}, mountFn = shallowMount) => { + wrapper = mountFn(RefsDropdown, { + provide: { + projectRefsEndpoint, + }, + propsData: { + value: { + shortName: refShortName, + fullName: refFullName, + }, + ...props, + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet(projectRefsEndpoint, { params: { search: '' } }).reply(httpStatusCodes.OK, mockRefs); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + + mock.restore(); + }); + + beforeEach(() => { + createComponent(); + }); + + it('displays empty dropdown initially', async () => { + await findDropdown().vm.$emit('show'); + + expect(findRefsDropdownItems()).toHaveLength(0); + }); + + it('does not make requests immediately', async () => { + expect(mock.history.get).toHaveLength(0); + }); + + describe('when user opens dropdown', () => { + beforeEach(async () => { + await findDropdown().vm.$emit('show'); + await waitForPromises(); + }); + + it('requests unfiltered tags and branches', async () => { + expect(mock.history.get).toHaveLength(1); + expect(mock.history.get[0].url).toBe(projectRefsEndpoint); + expect(mock.history.get[0].params).toEqual({ search: '' }); + }); + + it('displays dropdown with branches and tags', async () => { + const refLength = mockRefs.Tags.length + mockRefs.Branches.length; + + expect(findRefsDropdownItems()).toHaveLength(refLength); + }); + + it('displays the names of refs', () => { + // Branches + expect(findRefsDropdownItems().at(0).text()).toBe(mockRefs.Branches[0]); + + // Tags (appear after branches) + const firstTag = mockRefs.Branches.length; + expect(findRefsDropdownItems().at(firstTag).text()).toBe(mockRefs.Tags[0]); + }); + + it('when user shows dropdown a second time, only one request is done', () => { + expect(mock.history.get).toHaveLength(1); + }); + + describe('when user selects a value', () => { + const selectedIndex = 1; + + beforeEach(async () => { + await findRefsDropdownItems().at(selectedIndex).vm.$emit('click'); + }); + + it('component emits @input', () => { + const inputs = wrapper.emitted('input'); + + expect(inputs).toHaveLength(1); + expect(inputs[0]).toEqual([{ shortName: 'branch-1', fullName: 'refs/heads/branch-1' }]); + }); + }); + + describe('when user types searches for a tag', () => { + const mockSearchTerm = 'my-search'; + + beforeEach(async () => { + mock + .onGet(projectRefsEndpoint, { params: { search: mockSearchTerm } }) + .reply(httpStatusCodes.OK, mockFilteredRefs); + + await findSearchBox().vm.$emit('input', mockSearchTerm); + await waitForPromises(); + }); + + it('requests filtered tags and branches', async () => { + expect(mock.history.get).toHaveLength(2); + expect(mock.history.get[1].params).toEqual({ + search: mockSearchTerm, + }); + }); + + it('displays dropdown with branches and tags', async () => { + const filteredRefLength = mockFilteredRefs.Tags.length + mockFilteredRefs.Branches.length; + + expect(findRefsDropdownItems()).toHaveLength(filteredRefLength); + }); + }); + }); + + describe('when user has selected a value', () => { + const selectedIndex = 1; + const mockShortName = mockRefs.Branches[selectedIndex]; + const mockFullName = `refs/heads/${mockShortName}`; + + beforeEach(async () => { + mock + .onGet(projectRefsEndpoint, { + params: { ref: mockFullName }, + }) + .reply(httpStatusCodes.OK, mockRefs); + + createComponent({ + value: { + shortName: mockShortName, + fullName: mockFullName, + }, + }); + await findDropdown().vm.$emit('show'); + await waitForPromises(); + }); + + it('branch is checked', () => { + expect(findRefsDropdownItems().at(selectedIndex).props('isChecked')).toBe(true); + }); + }); + + describe('when server returns an error', () => { + beforeEach(async () => { + mock + .onGet(projectRefsEndpoint, { params: { search: '' } }) + .reply(httpStatusCodes.INTERNAL_SERVER_ERROR); + + await findDropdown().vm.$emit('show'); + await waitForPromises(); + }); + + it('loading error event is emitted', () => { + expect(wrapper.emitted('loadingError')).toHaveLength(1); + expect(wrapper.emitted('loadingError')[0]).toEqual([expect.any(Error)]); + }); + }); +}); diff --git a/spec/frontend/pipeline_new/mock_data.js b/spec/frontend/pipeline_new/mock_data.js index feb24ec602d..4fb58cb8e62 100644 --- a/spec/frontend/pipeline_new/mock_data.js +++ b/spec/frontend/pipeline_new/mock_data.js @@ -1,16 +1,14 @@ -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 mockRefs = { + Branches: ['master', 'branch-1', 'branch-2'], + Tags: ['1.0.0', '1.1.0', '1.2.0'], +}; -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 mockFilteredRefs = { + Branches: ['branch-1'], + Tags: ['1.0.0', '1.1.0'], +}; -export const mockParams = { +export const mockQueryParams = { refParam: 'tag-1', variableParams: { test_var: 'test_var_val', diff --git a/spec/frontend/pipelines/components/pipelines_list/pipeline_mini_graph_spec.js b/spec/frontend/pipelines/components/pipelines_list/pipeline_mini_graph_spec.js new file mode 100644 index 00000000000..154828aff4b --- /dev/null +++ b/spec/frontend/pipelines/components/pipelines_list/pipeline_mini_graph_spec.js @@ -0,0 +1,83 @@ +import { shallowMount } from '@vue/test-utils'; +import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; +import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue'; + +const { pipelines } = getJSONFixture('pipelines/pipelines.json'); +const mockStages = pipelines[0].details.stages; + +describe('Pipeline Mini Graph', () => { + let wrapper; + + const findPipelineStages = () => wrapper.findAll(PipelineStage); + const findPipelineStagesAt = (i) => findPipelineStages().at(i); + + const createComponent = (props = {}) => { + wrapper = shallowMount(PipelineMiniGraph, { + propsData: { + stages: mockStages, + ...props, + }, + }); + }; + + it('renders stages', () => { + createComponent(); + + expect(findPipelineStages()).toHaveLength(mockStages.length); + }); + + it('renders stages with a custom class', () => { + createComponent({ stagesClass: 'my-class' }); + + expect(wrapper.findAll('.my-class')).toHaveLength(mockStages.length); + }); + + it('does not fail when stages are empty', () => { + createComponent({ stages: [] }); + + expect(wrapper.exists()).toBe(true); + expect(findPipelineStages()).toHaveLength(0); + }); + + it('triggers events in "action request complete" in stages', () => { + createComponent(); + + findPipelineStagesAt(0).vm.$emit('pipelineActionRequestComplete'); + findPipelineStagesAt(1).vm.$emit('pipelineActionRequestComplete'); + + expect(wrapper.emitted('pipelineActionRequestComplete')).toHaveLength(2); + }); + + it('update dropdown is false by default', () => { + createComponent(); + + expect(findPipelineStagesAt(0).props('updateDropdown')).toBe(false); + expect(findPipelineStagesAt(1).props('updateDropdown')).toBe(false); + }); + + it('update dropdown is set to true', () => { + createComponent({ updateDropdown: true }); + + expect(findPipelineStagesAt(0).props('updateDropdown')).toBe(true); + expect(findPipelineStagesAt(1).props('updateDropdown')).toBe(true); + }); + + it('is merge train is false by default', () => { + createComponent(); + + expect(findPipelineStagesAt(0).props('isMergeTrain')).toBe(false); + expect(findPipelineStagesAt(1).props('isMergeTrain')).toBe(false); + }); + + it('is merge train is set to true', () => { + createComponent({ isMergeTrain: true }); + + expect(findPipelineStagesAt(0).props('isMergeTrain')).toBe(true); + expect(findPipelineStagesAt(1).props('isMergeTrain')).toBe(true); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); +}); diff --git a/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js b/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js new file mode 100644 index 00000000000..60026f69b84 --- /dev/null +++ b/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js @@ -0,0 +1,210 @@ +import { GlDropdown } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue'; +import eventHub from '~/pipelines/event_hub'; +import { stageReply } from '../../mock_data'; + +const dropdownPath = 'path.json'; + +describe('Pipelines stage component', () => { + let wrapper; + let mock; + + const createComponent = (props = {}) => { + wrapper = mount(PipelineStage, { + attachTo: document.body, + propsData: { + stage: { + status: { + group: 'success', + icon: 'status_success', + title: 'success', + }, + dropdown_path: dropdownPath, + }, + updateDropdown: false, + ...props, + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + jest.spyOn(eventHub, '$emit'); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + + eventHub.$emit.mockRestore(); + mock.restore(); + }); + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownToggle = () => wrapper.find('button.dropdown-toggle'); + const findDropdownMenu = () => + wrapper.find('[data-testid="mini-pipeline-graph-dropdown-menu-list"]'); + const findCiActionBtn = () => wrapper.find('.js-ci-action'); + const findMergeTrainWarning = () => wrapper.find('[data-testid="warning-message-merge-trains"]'); + + const openStageDropdown = () => { + findDropdownToggle().trigger('click'); + return new Promise((resolve) => { + wrapper.vm.$root.$on('bv::dropdown::show', resolve); + }); + }; + + describe('default appearance', () => { + beforeEach(() => { + createComponent(); + }); + + it('should render a dropdown with the status icon', () => { + expect(findDropdown().exists()).toBe(true); + expect(findDropdownToggle().exists()).toBe(true); + expect(wrapper.find('[data-testid="status_success_borderless-icon"]').exists()).toBe(true); + }); + }); + + describe('when update dropdown is changed', () => { + beforeEach(() => { + createComponent(); + }); + }); + + describe('when user opens dropdown and stage request is successful', () => { + beforeEach(async () => { + mock.onGet(dropdownPath).reply(200, stageReply); + createComponent(); + + await openStageDropdown(); + await axios.waitForAll(); + }); + + it('should render the received data and emit `clickedDropdown` event', async () => { + expect(findDropdownMenu().text()).toContain(stageReply.latest_statuses[0].name); + expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown'); + }); + + it('should refresh when updateDropdown is set to true', async () => { + expect(mock.history.get).toHaveLength(1); + + wrapper.setProps({ updateDropdown: true }); + await axios.waitForAll(); + + expect(mock.history.get).toHaveLength(2); + }); + }); + + describe('when user opens dropdown and stage request fails', () => { + beforeEach(async () => { + mock.onGet(dropdownPath).reply(500); + createComponent(); + + await openStageDropdown(); + await axios.waitForAll(); + }); + + it('should close the dropdown', () => { + expect(findDropdown().classes('show')).toBe(false); + }); + }); + + describe('update endpoint correctly', () => { + beforeEach(async () => { + const copyStage = { ...stageReply }; + copyStage.latest_statuses[0].name = 'this is the updated content'; + mock.onGet('bar.json').reply(200, copyStage); + createComponent({ + stage: { + status: { + group: 'running', + icon: 'status_running', + title: 'running', + }, + dropdown_path: 'bar.json', + }, + }); + await axios.waitForAll(); + }); + + it('should update the stage to request the new endpoint provided', async () => { + await openStageDropdown(); + await axios.waitForAll(); + + expect(findDropdownMenu().text()).toContain('this is the updated content'); + }); + }); + + describe('pipelineActionRequestComplete', () => { + beforeEach(() => { + mock.onGet(dropdownPath).reply(200, stageReply); + mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(200); + + createComponent(); + }); + + const clickCiAction = async () => { + await openStageDropdown(); + await axios.waitForAll(); + + findCiActionBtn().trigger('click'); + await axios.waitForAll(); + }; + + it('closes dropdown when job item action is clicked', async () => { + const hidden = jest.fn(); + + wrapper.vm.$root.$on('bv::dropdown::hide', hidden); + + expect(hidden).toHaveBeenCalledTimes(0); + + await clickCiAction(); + + expect(hidden).toHaveBeenCalledTimes(1); + }); + + it('emits `pipelineActionRequestComplete` when job item action is clicked', async () => { + await clickCiAction(); + + expect(wrapper.emitted('pipelineActionRequestComplete')).toHaveLength(1); + }); + }); + + describe('With merge trains enabled', () => { + beforeEach(async () => { + mock.onGet(dropdownPath).reply(200, stageReply); + createComponent({ + isMergeTrain: true, + }); + + await openStageDropdown(); + await axios.waitForAll(); + }); + + it('shows a warning on the dropdown', () => { + const warning = findMergeTrainWarning(); + + expect(warning.text()).toBe('Merge train pipeline jobs can not be retried'); + }); + }); + + describe('With merge trains disabled', () => { + beforeEach(async () => { + mock.onGet(dropdownPath).reply(200, stageReply); + createComponent(); + + await openStageDropdown(); + await axios.waitForAll(); + }); + + it('does not show a warning on the dropdown', () => { + const warning = findMergeTrainWarning(); + + expect(warning.exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/pipelines/empty_state_spec.js b/spec/frontend/pipelines/empty_state_spec.js index 3ebedc9ac87..fdbb98dfd55 100644 --- a/spec/frontend/pipelines/empty_state_spec.js +++ b/spec/frontend/pipelines/empty_state_spec.js @@ -9,7 +9,6 @@ describe('Pipelines Empty State', () => { const createWrapper = () => { wrapper = shallowMount(EmptyState, { propsData: { - helpPagePath: 'foo', emptyStateSvgPath: 'foo', canSetCi: true, }, @@ -35,7 +34,7 @@ describe('Pipelines Empty State', () => { }); it('should render a link with provided help path', () => { - expect(findGetStartedButton().attributes('href')).toBe('foo'); + expect(findGetStartedButton().attributes('href')).toBe('/help/ci/quick_start/index.md'); }); it('should render empty state information', () => { diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js index 3e8d4ba314c..6c3f848333c 100644 --- a/spec/frontend/pipelines/graph/graph_component_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_spec.js @@ -20,6 +20,10 @@ describe('graph component', () => { const defaultProps = { pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'), + configPaths: { + metricsPath: '', + graphqlResourceEtag: 'this/is/a/path', + }, }; const defaultData = { diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js index 202365ecd35..44d8e467f51 100644 --- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -9,6 +9,8 @@ import PipelineGraphWrapper from '~/pipelines/components/graph/graph_component_w import { mockPipelineResponse } from './mock_data'; const defaultProvide = { + graphqlResourceEtag: 'frog/amphibirama/etag/', + metricsPath: '', pipelineProjectPath: 'frog/amphibirama', pipelineIid: '22', }; @@ -87,6 +89,13 @@ describe('Pipeline graph wrapper', () => { it('displays the graph', () => { expect(getGraph().exists()).toBe(true); }); + + it('passes the etag resource and metrics path to the graph', () => { + expect(getGraph().props('configPaths')).toMatchObject({ + graphqlResourceEtag: defaultProvide.graphqlResourceEtag, + metricsPath: defaultProvide.metricsPath, + }); + }); }); describe('when there is an error', () => { @@ -121,4 +130,48 @@ describe('Pipeline graph wrapper', () => { expect(wrapper.vm.$apollo.queries.pipeline.refetch).toHaveBeenCalled(); }); }); + + describe('when query times out', () => { + const advanceApolloTimers = async () => { + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + }; + + beforeEach(async () => { + const errorData = { + data: { + project: { + pipelines: null, + }, + }, + errors: [{ message: 'timeout' }], + }; + + const failSucceedFail = jest + .fn() + .mockResolvedValueOnce(errorData) + .mockResolvedValueOnce(mockPipelineResponse) + .mockResolvedValueOnce(errorData); + + createComponentWithApollo(failSucceedFail); + await wrapper.vm.$nextTick(); + }); + + it('shows correct errors and does not overwrite populated data when data is empty', async () => { + /* fails at first, shows error, no data yet */ + expect(getAlert().exists()).toBe(true); + expect(getGraph().exists()).toBe(false); + + /* succeeds, clears error, shows graph */ + await advanceApolloTimers(); + expect(getAlert().exists()).toBe(false); + expect(getGraph().exists()).toBe(true); + + /* fails again, alert returns but data persists */ + await advanceApolloTimers(); + expect(getAlert().exists()).toBe(true); + expect(getGraph().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 8f01accccc1..edeab883e91 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js @@ -20,6 +20,10 @@ describe('Linked Pipelines Column', () => { columnTitle: 'Downstream', linkedPipelines: processedPipeline.downstream, type: DOWNSTREAM, + configPaths: { + metricsPath: '', + graphqlResourceEtag: 'this/is/a/path', + }, }; let wrapper; diff --git a/spec/frontend/pipelines/graph_shared/links_inner_spec.js b/spec/frontend/pipelines/graph_shared/links_inner_spec.js index 6cabe2bc8a7..15a5fbfeffe 100644 --- a/spec/frontend/pipelines/graph_shared/links_inner_spec.js +++ b/spec/frontend/pipelines/graph_shared/links_inner_spec.js @@ -1,5 +1,15 @@ import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; import { setHTMLFixture } from 'helpers/fixtures'; +import axios from '~/lib/utils/axios_utils'; +import { + PIPELINES_DETAIL_LINK_DURATION, + PIPELINES_DETAIL_LINKS_TOTAL, + PIPELINES_DETAIL_LINKS_JOB_RATIO, +} from '~/performance/constants'; +import * as perfUtils from '~/performance/utils'; +import * as sentryUtils from '~/pipelines/components/graph/utils'; +import * as Api from '~/pipelines/components/graph_shared/api'; import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; import { createJobsHash } from '~/pipelines/utils'; import { @@ -18,7 +28,9 @@ describe('Links Inner component', () => { containerMeasurements: { width: 1019, height: 445 }, pipelineId: 1, pipelineData: [], + totalGroups: 10, }; + let wrapper; const createComponent = (props) => { @@ -194,4 +206,141 @@ describe('Links Inner component', () => { expect(firstLink.classes(hoverColorClass)).toBe(true); }); }); + + describe('performance metrics', () => { + let markAndMeasure; + let reportToSentry; + let reportPerformance; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb()); + markAndMeasure = jest.spyOn(perfUtils, 'performanceMarkAndMeasure'); + reportToSentry = jest.spyOn(sentryUtils, 'reportToSentry'); + reportPerformance = jest.spyOn(Api, 'reportPerformance'); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('with no metrics config object', () => { + beforeEach(() => { + setFixtures(pipelineData); + createComponent({ + pipelineData: pipelineData.stages, + }); + }); + + it('is not called', () => { + expect(markAndMeasure).not.toHaveBeenCalled(); + expect(reportToSentry).not.toHaveBeenCalled(); + expect(reportPerformance).not.toHaveBeenCalled(); + }); + }); + + describe('with metrics config set to false', () => { + beforeEach(() => { + setFixtures(pipelineData); + createComponent({ + pipelineData: pipelineData.stages, + metricsConfig: { + collectMetrics: false, + metricsPath: '/path/to/metrics', + }, + }); + }); + + it('is not called', () => { + expect(markAndMeasure).not.toHaveBeenCalled(); + expect(reportToSentry).not.toHaveBeenCalled(); + expect(reportPerformance).not.toHaveBeenCalled(); + }); + }); + + describe('with no metrics path', () => { + beforeEach(() => { + setFixtures(pipelineData); + createComponent({ + pipelineData: pipelineData.stages, + metricsConfig: { + collectMetrics: true, + metricsPath: '', + }, + }); + }); + + it('is not called', () => { + expect(markAndMeasure).not.toHaveBeenCalled(); + expect(reportToSentry).not.toHaveBeenCalled(); + expect(reportPerformance).not.toHaveBeenCalled(); + }); + }); + + describe('with metrics path and collect set to true', () => { + const metricsPath = '/root/project/-/ci/prometheus_metrics/histograms.json'; + const duration = 0.0478; + const numLinks = 1; + const metricsData = { + histograms: [ + { name: PIPELINES_DETAIL_LINK_DURATION, value: duration }, + { name: PIPELINES_DETAIL_LINKS_TOTAL, value: numLinks }, + { + name: PIPELINES_DETAIL_LINKS_JOB_RATIO, + value: numLinks / defaultProps.totalGroups, + }, + ], + }; + + describe('when no duration is obtained', () => { + beforeEach(() => { + jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => { + return []; + }); + + setFixtures(pipelineData); + + createComponent({ + pipelineData: pipelineData.stages, + metricsConfig: { + collectMetrics: true, + path: metricsPath, + }, + }); + }); + + it('attempts to collect metrics', () => { + expect(markAndMeasure).toHaveBeenCalled(); + expect(reportPerformance).not.toHaveBeenCalled(); + expect(reportToSentry).not.toHaveBeenCalled(); + }); + }); + + describe('with duration and no error', () => { + beforeEach(() => { + jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => { + return [{ duration }]; + }); + + setFixtures(pipelineData); + + createComponent({ + pipelineData: pipelineData.stages, + metricsConfig: { + collectMetrics: true, + path: metricsPath, + }, + }); + }); + + it('it calls reportPerformance with expected arguments', () => { + expect(markAndMeasure).toHaveBeenCalled(); + expect(reportPerformance).toHaveBeenCalled(); + expect(reportPerformance).toHaveBeenCalledWith(metricsPath, metricsData); + expect(reportToSentry).not.toHaveBeenCalled(); + }); + }); + }); + }); }); diff --git a/spec/frontend/pipelines/graph_shared/links_layer_spec.js b/spec/frontend/pipelines/graph_shared/links_layer_spec.js index 0ff8583fbff..43d8fe28893 100644 --- a/spec/frontend/pipelines/graph_shared/links_layer_spec.js +++ b/spec/frontend/pipelines/graph_shared/links_layer_spec.js @@ -79,6 +79,24 @@ describe('links layer component', () => { }); }); + describe('with width or height measurement at 0', () => { + beforeEach(() => { + createComponent({ props: { containerMeasurements: { width: 0, height: 100 } } }); + }); + + it('renders the default slot', () => { + expect(wrapper.html()).toContain(slotContent); + }); + + it('does not render the alert component', () => { + expect(findAlert().exists()).toBe(false); + }); + + it('does not render the inner links component', () => { + expect(findLinksInner().exists()).toBe(false); + }); + }); + describe('interactions', () => { beforeEach(() => { createComponent({ mountFn: mount, props: { pipelineData: tooManyStages } }); diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js index 2afdbb05107..337838c41b3 100644 --- a/spec/frontend/pipelines/mock_data.js +++ b/spec/frontend/pipelines/mock_data.js @@ -2,328 +2,6 @@ const PIPELINE_RUNNING = 'RUNNING'; const PIPELINE_CANCELED = 'CANCELED'; const PIPELINE_FAILED = 'FAILED'; -export const pipelineWithStages = { - id: 20333396, - user: { - id: 128633, - name: 'Rémy Coutable', - username: 'rymai', - state: 'active', - avatar_url: - 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon', - web_url: 'https://gitlab.com/rymai', - path: '/rymai', - }, - active: true, - coverage: '58.24', - source: 'push', - created_at: '2018-04-11T14:04:53.881Z', - updated_at: '2018-04-11T14:05:00.792Z', - path: '/gitlab-org/gitlab/pipelines/20333396', - flags: { - latest: true, - stuck: false, - auto_devops: false, - yaml_errors: false, - retryable: false, - cancelable: true, - failure_reason: false, - }, - details: { - status: { - icon: 'status_running', - text: 'running', - label: 'running', - group: 'running', - has_details: true, - details_path: '/gitlab-org/gitlab/pipelines/20333396', - favicon: - 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_running-2eb56be2871937954b2ba6d6f4ee9fdf7e5e1c146ac45f7be98119ccaca1aca9.ico', - }, - duration: null, - finished_at: null, - stages: [ - { - name: 'build', - title: 'build: skipped', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - has_details: true, - details_path: '/gitlab-org/gitlab/pipelines/20333396#build', - favicon: - 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_skipped-a2eee568a5bffdb494050c7b62dde241de9189280836288ac8923d369f16222d.ico', - }, - path: '/gitlab-org/gitlab/pipelines/20333396#build', - dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=build', - }, - { - name: 'prepare', - title: 'prepare: passed', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/gitlab-org/gitlab/pipelines/20333396#prepare', - favicon: - 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_success-26f59841becbef8c6fe414e9e74471d8bfd6a91b5855c19fe7f5923a40a7da47.ico', - }, - path: '/gitlab-org/gitlab/pipelines/20333396#prepare', - dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=prepare', - }, - { - name: 'test', - title: 'test: running', - status: { - icon: 'status_running', - text: 'running', - label: 'running', - group: 'running', - has_details: true, - details_path: '/gitlab-org/gitlab/pipelines/20333396#test', - favicon: - 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_running-2eb56be2871937954b2ba6d6f4ee9fdf7e5e1c146ac45f7be98119ccaca1aca9.ico', - }, - path: '/gitlab-org/gitlab/pipelines/20333396#test', - dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=test', - }, - { - name: 'post-test', - title: 'post-test: created', - status: { - icon: 'status_created', - text: 'created', - label: 'created', - group: 'created', - has_details: true, - details_path: '/gitlab-org/gitlab/pipelines/20333396#post-test', - favicon: - 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico', - }, - path: '/gitlab-org/gitlab/pipelines/20333396#post-test', - dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=post-test', - }, - { - name: 'pages', - title: 'pages: created', - status: { - icon: 'status_created', - text: 'created', - label: 'created', - group: 'created', - has_details: true, - details_path: '/gitlab-org/gitlab/pipelines/20333396#pages', - favicon: - 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico', - }, - path: '/gitlab-org/gitlab/pipelines/20333396#pages', - dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=pages', - }, - { - name: 'post-cleanup', - title: 'post-cleanup: created', - status: { - icon: 'status_created', - text: 'created', - label: 'created', - group: 'created', - has_details: true, - details_path: '/gitlab-org/gitlab/pipelines/20333396#post-cleanup', - favicon: - 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico', - }, - path: '/gitlab-org/gitlab/pipelines/20333396#post-cleanup', - dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=post-cleanup', - }, - ], - artifacts: [ - { - name: 'gitlab:assets:compile', - expired: false, - expire_at: '2018-05-12T14:22:54.730Z', - path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/browse', - }, - { - name: 'rspec-mysql 12 28', - expired: false, - expire_at: '2018-05-12T14:22:45.136Z', - path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/browse', - }, - { - name: 'rspec-mysql 6 28', - expired: false, - expire_at: '2018-05-12T14:22:41.523Z', - path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/browse', - }, - { - name: 'rspec-pg geo 0 1', - expired: false, - expire_at: '2018-05-12T14:22:13.287Z', - path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/browse', - }, - { - name: 'rspec-mysql 0 28', - expired: false, - expire_at: '2018-05-12T14:22:06.834Z', - path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/browse', - }, - { - name: 'spinach-mysql 0 2', - expired: false, - expire_at: '2018-05-12T14:21:51.409Z', - path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/browse', - }, - { - name: 'karma', - expired: false, - expire_at: '2018-05-12T14:21:20.934Z', - path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/browse', - }, - { - name: 'spinach-pg 0 2', - expired: false, - expire_at: '2018-05-12T14:20:01.028Z', - path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/browse', - }, - { - name: 'spinach-pg 1 2', - expired: false, - expire_at: '2018-05-12T14:19:04.336Z', - path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/browse', - }, - { - name: 'sast', - expired: null, - expire_at: null, - path: '/gitlab-org/gitlab/-/jobs/62411442/artifacts/download', - browse_path: '/gitlab-org/gitlab/-/jobs/62411442/artifacts/browse', - }, - { - name: 'code_quality', - expired: false, - expire_at: '2018-04-18T14:16:24.484Z', - path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/browse', - }, - { - name: 'cache gems', - expired: null, - expire_at: null, - path: '/gitlab-org/gitlab/-/jobs/62411447/artifacts/download', - browse_path: '/gitlab-org/gitlab/-/jobs/62411447/artifacts/browse', - }, - { - name: 'dependency_scanning', - expired: null, - expire_at: null, - path: '/gitlab-org/gitlab/-/jobs/62411443/artifacts/download', - browse_path: '/gitlab-org/gitlab/-/jobs/62411443/artifacts/browse', - }, - { - name: 'compile-assets', - expired: false, - expire_at: '2018-04-18T14:12:07.638Z', - path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/browse', - }, - { - name: 'setup-test-env', - expired: false, - expire_at: '2018-04-18T14:10:27.024Z', - path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/browse', - }, - { - name: 'retrieve-tests-metadata', - expired: false, - expire_at: '2018-05-12T14:06:35.926Z', - path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/browse', - }, - ], - manual_actions: [ - { - name: 'package-and-qa', - path: '/gitlab-org/gitlab/-/jobs/62411330/play', - playable: true, - }, - { - name: 'review-docs-deploy', - path: '/gitlab-org/gitlab/-/jobs/62411332/play', - playable: true, - }, - ], - }, - ref: { - name: 'master', - path: '/gitlab-org/gitlab/commits/master', - tag: false, - branch: true, - }, - commit: { - id: 'e6a2885c503825792cb8a84a8731295e361bd059', - short_id: 'e6a2885c', - title: "Merge branch 'ce-to-ee-2018-04-11' into 'master'", - created_at: '2018-04-11T14:04:39.000Z', - parent_ids: [ - '5d9b5118f6055f72cff1a82b88133609912f2c1d', - '6fdc6ee76a8062fe41b1a33f7c503334a6ebdc02', - ], - message: - "Merge branch 'ce-to-ee-2018-04-11' into 'master'\n\nCE upstream - 2018-04-11 12:26 UTC\n\nSee merge request gitlab-org/gitlab-ee!5326", - author_name: 'Rémy Coutable', - author_email: 'remy@rymai.me', - authored_date: '2018-04-11T14:04:39.000Z', - committer_name: 'Rémy Coutable', - committer_email: 'remy@rymai.me', - committed_date: '2018-04-11T14:04:39.000Z', - author: { - id: 128633, - name: 'Rémy Coutable', - username: 'rymai', - state: 'active', - avatar_url: - 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon', - web_url: 'https://gitlab.com/rymai', - path: '/rymai', - }, - author_gravatar_url: - 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon', - commit_url: - 'https://gitlab.com/gitlab-org/gitlab/commit/e6a2885c503825792cb8a84a8731295e361bd059', - commit_path: '/gitlab-org/gitlab/commit/e6a2885c503825792cb8a84a8731295e361bd059', - }, - cancel_path: '/gitlab-org/gitlab/pipelines/20333396/cancel', - triggered_by: null, - triggered: [], -}; - const threeWeeksAgo = new Date(); threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); diff --git a/spec/frontend/pipelines/pipeline_triggerer_spec.js b/spec/frontend/pipelines/pipeline_triggerer_spec.js index 467a97d95c7..ffb2721f159 100644 --- a/spec/frontend/pipelines/pipeline_triggerer_spec.js +++ b/spec/frontend/pipelines/pipeline_triggerer_spec.js @@ -35,8 +35,8 @@ describe('Pipelines Triggerer', () => { wrapper.destroy(); }); - it('should render a table cell', () => { - expect(wrapper.find('.table-section').exists()).toBe(true); + it('should render pipeline triggerer table cell', () => { + expect(wrapper.find('[data-testid="pipeline-triggerer"]').exists()).toBe(true); }); it('should pass triggerer information when triggerer is provided', () => { diff --git a/spec/frontend/pipelines/pipeline_url_spec.js b/spec/frontend/pipelines/pipeline_url_spec.js index 44c9def99cc..367c7f2b2f6 100644 --- a/spec/frontend/pipelines/pipeline_url_spec.js +++ b/spec/frontend/pipelines/pipeline_url_spec.js @@ -1,19 +1,20 @@ import { shallowMount } from '@vue/test-utils'; -import $ from 'jquery'; import { trimText } from 'helpers/text_helper'; import PipelineUrlComponent from '~/pipelines/components/pipelines_list/pipeline_url.vue'; -$.fn.popover = () => {}; +const projectPath = 'test/test'; describe('Pipeline Url Component', () => { let wrapper; + const findTableCell = () => wrapper.find('[data-testid="pipeline-url-table-cell"]'); const findPipelineUrlLink = () => wrapper.find('[data-testid="pipeline-url-link"]'); const findScheduledTag = () => wrapper.find('[data-testid="pipeline-url-scheduled"]'); const findLatestTag = () => wrapper.find('[data-testid="pipeline-url-latest"]'); const findYamlTag = () => wrapper.find('[data-testid="pipeline-url-yaml"]'); const findFailureTag = () => wrapper.find('[data-testid="pipeline-url-failure"]'); const findAutoDevopsTag = () => wrapper.find('[data-testid="pipeline-url-autodevops"]'); + const findAutoDevopsTagLink = () => wrapper.find('[data-testid="pipeline-url-autodevops-link"]'); 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"]'); @@ -23,9 +24,9 @@ describe('Pipeline Url Component', () => { pipeline: { id: 1, path: 'foo', + project: { full_path: `/${projectPath}` }, flags: {}, }, - autoDevopsHelpPath: 'foo', pipelineScheduleUrl: 'foo', }; @@ -33,7 +34,7 @@ describe('Pipeline Url Component', () => { wrapper = shallowMount(PipelineUrlComponent, { propsData: { ...defaultProps, ...props }, provide: { - targetProjectFullPath: 'test/test', + targetProjectFullPath: projectPath, }, }); }; @@ -43,10 +44,10 @@ describe('Pipeline Url Component', () => { wrapper = null; }); - it('should render a table cell', () => { + it('should render pipeline url table cell', () => { createComponent(); - expect(wrapper.attributes('class')).toContain('table-section'); + expect(findTableCell().exists()).toBe(true); }); it('should render a link the provided path and id', () => { @@ -57,6 +58,19 @@ describe('Pipeline Url Component', () => { expect(findPipelineUrlLink().text()).toBe('#1'); }); + it('should not render tags when flags are not set', () => { + createComponent(); + + expect(findStuckTag().exists()).toBe(false); + expect(findLatestTag().exists()).toBe(false); + expect(findYamlTag().exists()).toBe(false); + expect(findAutoDevopsTag().exists()).toBe(false); + expect(findFailureTag().exists()).toBe(false); + expect(findScheduledTag().exists()).toBe(false); + expect(findForkTag().exists()).toBe(false); + expect(findTrainTag().exists()).toBe(false); + }); + it('should render the stuck tag when flag is provided', () => { createComponent({ pipeline: { @@ -96,6 +110,7 @@ describe('Pipeline Url Component', () => { it('should render an autodevops badge when flag is provided', () => { createComponent({ pipeline: { + ...defaultProps.pipeline, flags: { auto_devops: true, }, @@ -103,6 +118,11 @@ describe('Pipeline Url Component', () => { }); expect(trimText(findAutoDevopsTag().text())).toBe('Auto DevOps'); + + expect(findAutoDevopsTagLink().attributes()).toMatchObject({ + href: '/help/topics/autodevops/index.md', + target: '_blank', + }); }); it('should render a detached badge when flag is provided', () => { @@ -147,7 +167,7 @@ describe('Pipeline Url Component', () => { createComponent({ pipeline: { flags: {}, - project: { fullPath: 'test/forked' }, + project: { fullPath: '/test/forked' }, }, }); diff --git a/spec/frontend/pipelines/pipelines_actions_spec.js b/spec/frontend/pipelines/pipelines_actions_spec.js index 1e6c9e50a7e..c4bfec8ae14 100644 --- a/spec/frontend/pipelines/pipelines_actions_spec.js +++ b/spec/frontend/pipelines/pipelines_actions_spec.js @@ -1,11 +1,11 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'spec/test_constants'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import PipelinesActions from '~/pipelines/components/pipelines_list/pipelines_actions.vue'; +import PipelinesManualActions from '~/pipelines/components/pipelines_list/pipelines_manual_actions.vue'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; jest.mock('~/flash'); @@ -15,7 +15,7 @@ describe('Pipelines Actions dropdown', () => { let mock; const createComponent = (props, mountFn = shallowMount) => { - wrapper = mountFn(PipelinesActions, { + wrapper = mountFn(PipelinesManualActions, { propsData: { ...props, }, @@ -63,10 +63,6 @@ describe('Pipelines Actions dropdown', () => { }); describe('on click', () => { - beforeEach(() => { - createComponent({ actions: mockActions }, mount); - }); - it('makes a request and toggles the loading state', async () => { mock.onPost(mockActions.path).reply(200); diff --git a/spec/frontend/pipelines/pipelines_artifacts_spec.js b/spec/frontend/pipelines/pipelines_artifacts_spec.js index f077833ae16..d4a2db08d97 100644 --- a/spec/frontend/pipelines/pipelines_artifacts_spec.js +++ b/spec/frontend/pipelines/pipelines_artifacts_spec.js @@ -1,24 +1,27 @@ -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; describe('Pipelines Artifacts dropdown', () => { let wrapper; const createComponent = () => { - wrapper = mount(PipelineArtifacts, { + wrapper = shallowMount(PipelineArtifacts, { propsData: { artifacts: [ { - name: 'artifact', + name: 'job my-artifact', path: '/download/path', }, { - name: 'artifact two', + name: 'job-2 my-artifact-2', path: '/download/path-two', }, ], }, + stubs: { + GlSprintf, + }, }); }; @@ -39,8 +42,8 @@ describe('Pipelines Artifacts dropdown', () => { }); it('should render a link with the provided path', () => { - expect(findFirstGlDropdownItem().find('a').attributes('href')).toEqual('/download/path'); + expect(findFirstGlDropdownItem().attributes('href')).toBe('/download/path'); - expect(findFirstGlDropdownItem().text()).toContain('artifact'); + expect(findFirstGlDropdownItem().text()).toBe('Download job my-artifact artifact'); }); }); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index 811303a5624..2116d022603 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -1,4 +1,4 @@ -import { GlFilteredSearch, GlButton, GlLoadingIcon, GlPagination } from '@gitlab/ui'; +import { GlButton, GlFilteredSearch, GlLoadingIcon, GlPagination } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { chunk } from 'lodash'; @@ -18,7 +18,7 @@ import Store from '~/pipelines/stores/pipelines_store'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; -import { pipelineWithStages, stageReply, users, mockSearch, branches } from './mock_data'; +import { stageReply, users, mockSearch, branches } from './mock_data'; jest.mock('~/flash'); @@ -27,6 +27,9 @@ const mockProjectId = '21'; const mockPipelinesEndpoint = `/${mockProjectPath}/pipelines.json`; const mockPipelinesResponse = getJSONFixture('pipelines/pipelines.json'); const mockPipelinesIds = mockPipelinesResponse.pipelines.map(({ id }) => id); +const mockPipelineWithStages = mockPipelinesResponse.pipelines.find( + (p) => p.details.stages && p.details.stages.length, +); describe('Pipelines', () => { let wrapper; @@ -34,8 +37,6 @@ describe('Pipelines', () => { let origWindowLocation; const paths = { - autoDevopsHelpPath: '/help/topics/autodevops/index.md', - helpPagePath: '/help/ci/quick_start/README', emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg', errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg', @@ -45,8 +46,6 @@ describe('Pipelines', () => { }; const noPermissions = { - autoDevopsHelpPath: '/help/topics/autodevops/index.md', - helpPagePath: '/help/ci/quick_start/README', emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg', errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg', @@ -70,7 +69,8 @@ describe('Pipelines', () => { const findRunPipelineButton = () => wrapper.findByTestId('run-pipeline-button'); const findCiLintButton = () => wrapper.findByTestId('ci-lint-button'); const findCleanCacheButton = () => wrapper.findByTestId('clear-cache-button'); - const findStagesDropdown = () => wrapper.findByTestId('mini-pipeline-graph-dropdown-toggle'); + const findStagesDropdownToggle = () => + wrapper.find('[data-testid="mini-pipeline-graph-dropdown"] .dropdown-toggle'); const findPipelineUrlLinks = () => wrapper.findAll('[data-testid="pipeline-url-link"]'); const createComponent = (props = defaultProps) => { @@ -546,7 +546,9 @@ describe('Pipelines', () => { 'GitLab CI/CD can automatically build, test, and deploy your code.', ); expect(findEmptyState().find(GlButton).text()).toBe('Get started with CI/CD'); - expect(findEmptyState().find(GlButton).attributes('href')).toBe(paths.helpPagePath); + expect(findEmptyState().find(GlButton).attributes('href')).toBe( + '/help/ci/quick_start/index.md', + ); }); it('does not render tabs nor buttons', () => { @@ -613,14 +615,15 @@ describe('Pipelines', () => { mock.onGet(mockPipelinesEndpoint, { scope: 'all', page: '1' }).reply( 200, { - pipelines: [pipelineWithStages], + pipelines: [mockPipelineWithStages], count: { all: '1' }, }, { 'POLL-INTERVAL': 100, }, ); - mock.onGet(pipelineWithStages.details.stages[0].dropdown_path).reply(200, stageReply); + + mock.onGet(mockPipelineWithStages.details.stages[0].dropdown_path).reply(200, stageReply); createComponent(); @@ -640,7 +643,7 @@ describe('Pipelines', () => { // Mock init a polling cycle wrapper.vm.poll.options.notificationCallback(true); - findStagesDropdown().trigger('click'); + findStagesDropdownToggle().trigger('click'); await waitForPromises(); @@ -650,7 +653,9 @@ describe('Pipelines', () => { }); it('stops polling & restarts polling', async () => { - findStagesDropdown().trigger('click'); + findStagesDropdownToggle().trigger('click'); + + await waitForPromises(); expect(cancelMock).not.toHaveBeenCalled(); expect(stopMock).toHaveBeenCalled(); diff --git a/spec/frontend/pipelines/pipelines_table_row_spec.js b/spec/frontend/pipelines/pipelines_table_row_spec.js index 660651547fc..68d46575081 100644 --- a/spec/frontend/pipelines/pipelines_table_row_spec.js +++ b/spec/frontend/pipelines/pipelines_table_row_spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; import PipelinesTableRowComponent from '~/pipelines/components/pipelines_list/pipelines_table_row.vue'; import eventHub from '~/pipelines/event_hub'; @@ -9,7 +10,6 @@ describe('Pipelines Table Row', () => { mount(PipelinesTableRowComponent, { propsData: { pipeline, - autoDevopsHelpPath: 'foo', viewType: 'root', }, }); @@ -19,8 +19,6 @@ describe('Pipelines Table Row', () => { let pipelineWithoutAuthor; let pipelineWithoutCommit; - preloadFixtures(jsonFixtureName); - beforeEach(() => { const { pipelines } = getJSONFixture(jsonFixtureName); @@ -149,16 +147,22 @@ describe('Pipelines Table Row', () => { }); describe('stages column', () => { - beforeEach(() => { + const findAllMiniPipelineStages = () => + wrapper.findAll('.table-section:nth-child(5) [data-testid="mini-pipeline-graph-dropdown"]'); + + it('should render an icon for each stage', () => { wrapper = createWrapper(pipeline); + + expect(findAllMiniPipelineStages()).toHaveLength(pipeline.details.stages.length); }); - it('should render an icon for each stage', () => { - expect( - wrapper.findAll( - '.table-section:nth-child(4) [data-testid="mini-pipeline-graph-dropdown-toggle"]', - ).length, - ).toEqual(pipeline.details.stages.length); + it('should not render stages when stages are empty', () => { + const withoutStages = { ...pipeline }; + withoutStages.details = { ...withoutStages.details, stages: null }; + + wrapper = createWrapper(withoutStages); + + expect(findAllMiniPipelineStages()).toHaveLength(0); }); }); @@ -183,9 +187,16 @@ describe('Pipelines Table Row', () => { expect(wrapper.find('.js-pipelines-retry-button').attributes('title')).toMatch('Retry'); expect(wrapper.find('.js-pipelines-cancel-button').exists()).toBe(true); expect(wrapper.find('.js-pipelines-cancel-button').attributes('title')).toMatch('Cancel'); - const dropdownMenu = wrapper.find('.dropdown-menu'); + }); + + it('should render the manual actions', async () => { + const manualActions = wrapper.find('[data-testid="pipelines-manual-actions-dropdown"]'); + + // Click on the dropdown and wait for `lazy` dropdown items + manualActions.find('.dropdown-toggle').trigger('click'); + await waitForPromises(); - expect(dropdownMenu.text()).toContain(scheduledJobAction.name); + expect(manualActions.text()).toContain(scheduledJobAction.name); }); it('emits `retryPipeline` event when retry button is clicked and toggles loading', () => { diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js index fd73d507919..952bea81052 100644 --- a/spec/frontend/pipelines/pipelines_table_spec.js +++ b/spec/frontend/pipelines/pipelines_table_spec.js @@ -1,5 +1,18 @@ +import { GlTable } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; +import PipelineOperations from '~/pipelines/components/pipelines_list/pipeline_operations.vue'; +import PipelineTriggerer from '~/pipelines/components/pipelines_list/pipeline_triggerer.vue'; +import PipelineUrl from '~/pipelines/components/pipelines_list/pipeline_url.vue'; +import PipelinesStatusBadge from '~/pipelines/components/pipelines_list/pipelines_status_badge.vue'; import PipelinesTable from '~/pipelines/components/pipelines_list/pipelines_table.vue'; +import PipelinesTimeago from '~/pipelines/components/pipelines_list/time_ago.vue'; + +import eventHub from '~/pipelines/event_hub'; +import CommitComponent from '~/vue_shared/components/commit.vue'; + +jest.mock('~/pipelines/event_hub'); describe('Pipelines Table', () => { let pipeline; @@ -9,24 +22,52 @@ describe('Pipelines Table', () => { const defaultProps = { pipelines: [], - autoDevopsHelpPath: 'foo', viewType: 'root', }; - const createComponent = (props = defaultProps) => { - wrapper = mount(PipelinesTable, { - propsData: props, - }); + const createMockPipeline = () => { + const { pipelines } = getJSONFixture(jsonFixtureName); + return pipelines.find((p) => p.user !== null && p.commit !== null); }; - const findRows = () => wrapper.findAll('.commit.gl-responsive-table-row'); - preloadFixtures(jsonFixtureName); + const createComponent = (props = {}, flagState = false) => { + wrapper = extendedWrapper( + mount(PipelinesTable, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + glFeatures: { + newPipelinesTable: flagState, + }, + }, + }), + ); + }; - beforeEach(() => { - const { pipelines } = getJSONFixture(jsonFixtureName); - pipeline = pipelines.find((p) => p.user !== null && p.commit !== null); + const findRows = () => wrapper.findAll('.commit.gl-responsive-table-row'); + const findGlTable = () => wrapper.findComponent(GlTable); + const findStatusBadge = () => wrapper.findComponent(PipelinesStatusBadge); + const findPipelineInfo = () => wrapper.findComponent(PipelineUrl); + const findTriggerer = () => wrapper.findComponent(PipelineTriggerer); + const findCommit = () => wrapper.findComponent(CommitComponent); + const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph); + const findTimeAgo = () => wrapper.findComponent(PipelinesTimeago); + const findActions = () => wrapper.findComponent(PipelineOperations); + + const findLegacyTable = () => wrapper.findByTestId('legacy-ci-table'); + const findTableRows = () => wrapper.findAll('[data-testid="pipeline-table-row"]'); + const findStatusTh = () => wrapper.findByTestId('status-th'); + const findPipelineTh = () => wrapper.findByTestId('pipeline-th'); + const findTriggererTh = () => wrapper.findByTestId('triggerer-th'); + const findCommitTh = () => wrapper.findByTestId('commit-th'); + const findStagesTh = () => wrapper.findByTestId('stages-th'); + const findTimeAgoTh = () => wrapper.findByTestId('timeago-th'); + const findActionsTh = () => wrapper.findByTestId('actions-th'); - createComponent(); + beforeEach(() => { + pipeline = createMockPipeline(); }); afterEach(() => { @@ -34,33 +75,161 @@ describe('Pipelines Table', () => { wrapper = null; }); - describe('table', () => { - it('should render a table', () => { - expect(wrapper.classes()).toContain('ci-table'); + describe('table with feature flag off', () => { + describe('renders the table correctly', () => { + beforeEach(() => { + createComponent(); + }); + + it('should render a table', () => { + expect(wrapper.classes()).toContain('ci-table'); + }); + + it('should render table head with correct columns', () => { + expect(wrapper.find('.table-section.js-pipeline-status').text()).toEqual('Status'); + + expect(wrapper.find('.table-section.js-pipeline-info').text()).toEqual('Pipeline'); + + expect(wrapper.find('.table-section.js-pipeline-commit').text()).toEqual('Commit'); + + expect(wrapper.find('.table-section.js-pipeline-stages').text()).toEqual('Stages'); + }); }); - it('should render table head with correct columns', () => { - expect(wrapper.find('.table-section.js-pipeline-status').text()).toEqual('Status'); + describe('without data', () => { + it('should render an empty table', () => { + createComponent(); - expect(wrapper.find('.table-section.js-pipeline-info').text()).toEqual('Pipeline'); + expect(findRows()).toHaveLength(0); + }); + }); - expect(wrapper.find('.table-section.js-pipeline-commit').text()).toEqual('Commit'); + describe('with data', () => { + it('should render rows', () => { + createComponent({ pipelines: [pipeline], viewType: 'root' }); - expect(wrapper.find('.table-section.js-pipeline-stages').text()).toEqual('Stages'); + expect(findRows()).toHaveLength(1); + }); }); }); - describe('without data', () => { - it('should render an empty table', () => { - expect(findRows()).toHaveLength(0); + describe('table with feature flag on', () => { + beforeEach(() => { + createComponent({ pipelines: [pipeline], viewType: 'root' }, true); + }); + + it('displays new table', () => { + expect(findGlTable().exists()).toBe(true); + expect(findLegacyTable().exists()).toBe(false); + }); + + it('should render table head with correct columns', () => { + expect(findStatusTh().text()).toBe('Status'); + expect(findPipelineTh().text()).toBe('Pipeline'); + expect(findTriggererTh().text()).toBe('Triggerer'); + expect(findCommitTh().text()).toBe('Commit'); + expect(findStagesTh().text()).toBe('Stages'); + expect(findTimeAgoTh().text()).toBe('Duration'); + expect(findActionsTh().text()).toBe('Actions'); + }); + + it('should display a table row', () => { + expect(findTableRows()).toHaveLength(1); }); - }); - describe('with data', () => { - it('should render rows', () => { - createComponent({ pipelines: [pipeline], autoDevopsHelpPath: 'foo', viewType: 'root' }); + describe('status cell', () => { + it('should render a status badge', () => { + expect(findStatusBadge().exists()).toBe(true); + }); + + it('should render status badge with correct path', () => { + expect(findStatusBadge().attributes('href')).toBe(pipeline.path); + }); + }); + + describe('pipeline cell', () => { + it('should render pipeline information', () => { + expect(findPipelineInfo().exists()).toBe(true); + }); + + it('should display the pipeline id', () => { + expect(findPipelineInfo().text()).toContain(`#${pipeline.id}`); + }); + }); + + describe('triggerer cell', () => { + it('should render the pipeline triggerer', () => { + expect(findTriggerer().exists()).toBe(true); + }); + }); + + describe('commit cell', () => { + it('should render commit information', () => { + expect(findCommit().exists()).toBe(true); + }); + + it('should display and link to commit', () => { + expect(findCommit().text()).toContain(pipeline.commit.short_id); + expect(findCommit().props('commitUrl')).toBe(pipeline.commit.commit_path); + }); + + it('should display the commit author', () => { + expect(findCommit().props('author')).toEqual(pipeline.commit.author); + }); + }); + + describe('stages cell', () => { + it('should render a pipeline mini graph', () => { + expect(findPipelineMiniGraph().exists()).toBe(true); + }); + + it('should render the right number of stages', () => { + const stagesLength = pipeline.details.stages.length; + expect( + findPipelineMiniGraph().findAll('[data-testid="mini-pipeline-graph-dropdown"]'), + ).toHaveLength(stagesLength); + }); + + describe('when pipeline does not have stages', () => { + beforeEach(() => { + pipeline = createMockPipeline(); + pipeline.details.stages = null; + + createComponent({ pipelines: [pipeline] }, true); + }); + + it('stages are not rendered', () => { + expect(findPipelineMiniGraph().exists()).toBe(false); + }); + }); + + it('should not update dropdown', () => { + expect(findPipelineMiniGraph().props('updateDropdown')).toBe(false); + }); + + it('when update graph dropdown is set, should update graph dropdown', () => { + createComponent({ pipelines: [pipeline], updateGraphDropdown: true }, true); + + expect(findPipelineMiniGraph().props('updateDropdown')).toBe(true); + }); + + it('when action request is complete, should refresh table', () => { + findPipelineMiniGraph().vm.$emit('pipelineActionRequestComplete'); + + expect(eventHub.$emit).toHaveBeenCalledWith('refreshPipelinesTable'); + }); + }); + + describe('duration cell', () => { + it('should render duration information', () => { + expect(findTimeAgo().exists()).toBe(true); + }); + }); - expect(findRows()).toHaveLength(1); + describe('operations cell', () => { + it('should render pipeline operations', () => { + expect(findActions().exists()).toBe(true); + }); }); }); }); diff --git a/spec/frontend/pipelines/stage_spec.js b/spec/frontend/pipelines/stage_spec.js deleted file mode 100644 index 87b43558252..00000000000 --- a/spec/frontend/pipelines/stage_spec.js +++ /dev/null @@ -1,297 +0,0 @@ -import 'bootstrap/js/dist/dropdown'; -import { GlDropdown } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import $ from 'jquery'; -import axios from '~/lib/utils/axios_utils'; -import StageComponent from '~/pipelines/components/pipelines_list/stage.vue'; -import eventHub from '~/pipelines/event_hub'; -import { stageReply } from './mock_data'; - -describe('Pipelines stage component', () => { - let wrapper; - let mock; - let glFeatures; - - const defaultProps = { - stage: { - status: { - group: 'success', - icon: 'status_success', - title: 'success', - }, - dropdown_path: 'path.json', - }, - updateDropdown: false, - }; - - const createComponent = (props = {}) => { - wrapper = mount(StageComponent, { - attachTo: document.body, - propsData: { - ...defaultProps, - ...props, - }, - provide: { - glFeatures, - }, - }); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - jest.spyOn(eventHub, '$emit'); - glFeatures = {}; - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - - eventHub.$emit.mockRestore(); - mock.restore(); - }); - - describe('when ci_mini_pipeline_gl_dropdown feature flag is disabled', () => { - const isDropdownOpen = () => wrapper.classes('show'); - - describe('default', () => { - beforeEach(() => { - createComponent(); - }); - - it('should render a dropdown with the status icon', () => { - expect(wrapper.attributes('class')).toEqual('dropdown'); - expect(wrapper.find('svg').exists()).toBe(true); - expect(wrapper.find('button').attributes('data-toggle')).toEqual('dropdown'); - }); - }); - - describe('with successful request', () => { - beforeEach(() => { - mock.onGet('path.json').reply(200, stageReply); - createComponent(); - }); - - it('should render the received data and emit `clickedDropdown` event', async () => { - wrapper.find('button').trigger('click'); - - await axios.waitForAll(); - expect(wrapper.find('.js-builds-dropdown-container ul').text()).toContain( - stageReply.latest_statuses[0].name, - ); - - expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown'); - }); - }); - - it('when request fails should close the dropdown', async () => { - mock.onGet('path.json').reply(500); - createComponent(); - wrapper.find({ ref: 'dropdown' }).trigger('click'); - - expect(isDropdownOpen()).toBe(true); - - wrapper.find('button').trigger('click'); - await axios.waitForAll(); - - expect(isDropdownOpen()).toBe(false); - }); - - describe('update endpoint correctly', () => { - beforeEach(() => { - const copyStage = { ...stageReply }; - copyStage.latest_statuses[0].name = 'this is the updated content'; - mock.onGet('bar.json').reply(200, copyStage); - createComponent({ - stage: { - status: { - group: 'running', - icon: 'status_running', - title: 'running', - }, - dropdown_path: 'bar.json', - }, - }); - return axios.waitForAll(); - }); - - it('should update the stage to request the new endpoint provided', async () => { - wrapper.find('button').trigger('click'); - await axios.waitForAll(); - - expect(wrapper.find('.js-builds-dropdown-container ul').text()).toContain( - 'this is the updated content', - ); - }); - }); - - describe('pipelineActionRequestComplete', () => { - beforeEach(() => { - mock.onGet('path.json').reply(200, stageReply); - mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(200); - }); - - const clickCiAction = async () => { - wrapper.find('button').trigger('click'); - await axios.waitForAll(); - - wrapper.find('.js-ci-action').trigger('click'); - await axios.waitForAll(); - }; - - describe('within pipeline table', () => { - it('emits `refreshPipelinesTable` event when `pipelineActionRequestComplete` is triggered', async () => { - createComponent({ type: 'PIPELINES_TABLE' }); - - await clickCiAction(); - - expect(eventHub.$emit).toHaveBeenCalledWith('refreshPipelinesTable'); - }); - }); - - describe('in MR widget', () => { - beforeEach(() => { - jest.spyOn($.fn, 'dropdown'); - }); - - it('closes the dropdown when `pipelineActionRequestComplete` is triggered', async () => { - createComponent(); - - await clickCiAction(); - - expect($.fn.dropdown).toHaveBeenCalledWith('toggle'); - }); - }); - }); - }); - - describe('when ci_mini_pipeline_gl_dropdown feature flag is enabled', () => { - const findDropdown = () => wrapper.find(GlDropdown); - const findDropdownToggle = () => wrapper.find('button.gl-dropdown-toggle'); - const findDropdownMenu = () => - wrapper.find('[data-testid="mini-pipeline-graph-dropdown-menu-list"]'); - const findCiActionBtn = () => wrapper.find('.js-ci-action'); - - const openGlDropdown = () => { - findDropdownToggle().trigger('click'); - return new Promise((resolve) => { - wrapper.vm.$root.$on('bv::dropdown::show', resolve); - }); - }; - - beforeEach(() => { - glFeatures = { ciMiniPipelineGlDropdown: true }; - }); - - describe('default', () => { - beforeEach(() => { - createComponent(); - }); - - it('should render a dropdown with the status icon', () => { - expect(findDropdown().exists()).toBe(true); - expect(findDropdownToggle().classes('gl-dropdown-toggle')).toEqual(true); - expect(wrapper.find('[data-testid="status_success_borderless-icon"]').exists()).toBe(true); - }); - }); - - describe('with successful request', () => { - beforeEach(() => { - mock.onGet('path.json').reply(200, stageReply); - createComponent(); - }); - - it('should render the received data and emit `clickedDropdown` event', async () => { - await openGlDropdown(); - await axios.waitForAll(); - - expect(findDropdownMenu().text()).toContain(stageReply.latest_statuses[0].name); - expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown'); - }); - }); - - it('when request fails should close the dropdown', async () => { - mock.onGet('path.json').reply(500); - - createComponent(); - - await openGlDropdown(); - await axios.waitForAll(); - - expect(findDropdown().classes('show')).toBe(false); - }); - - describe('update endpoint correctly', () => { - beforeEach(async () => { - const copyStage = { ...stageReply }; - copyStage.latest_statuses[0].name = 'this is the updated content'; - mock.onGet('bar.json').reply(200, copyStage); - createComponent({ - stage: { - status: { - group: 'running', - icon: 'status_running', - title: 'running', - }, - dropdown_path: 'bar.json', - }, - }); - await axios.waitForAll(); - }); - - it('should update the stage to request the new endpoint provided', async () => { - await openGlDropdown(); - await axios.waitForAll(); - - expect(findDropdownMenu().text()).toContain('this is the updated content'); - }); - }); - - describe('pipelineActionRequestComplete', () => { - beforeEach(() => { - mock.onGet('path.json').reply(200, stageReply); - mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(200); - }); - - const clickCiAction = async () => { - await openGlDropdown(); - await axios.waitForAll(); - - findCiActionBtn().trigger('click'); - await axios.waitForAll(); - }; - - describe('within pipeline table', () => { - beforeEach(() => { - createComponent({ type: 'PIPELINES_TABLE' }); - }); - - it('emits `refreshPipelinesTable` event when `pipelineActionRequestComplete` is triggered', async () => { - await clickCiAction(); - - expect(eventHub.$emit).toHaveBeenCalledWith('refreshPipelinesTable'); - }); - }); - - describe('in MR widget', () => { - beforeEach(() => { - jest.spyOn($.fn, 'dropdown'); - createComponent(); - }); - - it('closes the dropdown when `pipelineActionRequestComplete` is triggered', async () => { - const hidden = jest.fn(); - - wrapper.vm.$root.$on('bv::dropdown::hide', hidden); - - expect(hidden).toHaveBeenCalledTimes(0); - - await clickCiAction(); - - expect(hidden).toHaveBeenCalledTimes(1); - }); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/time_ago_spec.js b/spec/frontend/pipelines/time_ago_spec.js index 55a19ef5165..4919efbb4c6 100644 --- a/spec/frontend/pipelines/time_ago_spec.js +++ b/spec/frontend/pipelines/time_ago_spec.js @@ -8,7 +8,11 @@ describe('Timeago component', () => { const createComponent = (props = {}) => { wrapper = shallowMount(TimeAgo, { propsData: { - ...props, + pipeline: { + details: { + ...props, + }, + }, }, data() { return { @@ -28,7 +32,7 @@ describe('Timeago component', () => { describe('with duration', () => { beforeEach(() => { - createComponent({ duration: 10, finishedTime: '' }); + createComponent({ duration: 10, finished_at: '' }); }); it('should render duration and timer svg', () => { @@ -41,7 +45,7 @@ describe('Timeago component', () => { describe('without duration', () => { beforeEach(() => { - createComponent({ duration: 0, finishedTime: '' }); + createComponent({ duration: 0, finished_at: '' }); }); it('should not render duration and timer svg', () => { @@ -51,7 +55,7 @@ describe('Timeago component', () => { describe('with finishedTime', () => { beforeEach(() => { - createComponent({ duration: 0, finishedTime: '2017-04-26T12:40:23.277Z' }); + createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' }); }); it('should render time and calendar icon', () => { @@ -66,7 +70,7 @@ describe('Timeago component', () => { describe('without finishedTime', () => { beforeEach(() => { - createComponent({ duration: 0, finishedTime: '' }); + createComponent({ duration: 0, finished_at: '' }); }); it('should not render time and calendar icon', () => { diff --git a/spec/frontend/pipelines_spec.js b/spec/frontend/pipelines_spec.js index 6d4d634c575..add91fbcc23 100644 --- a/spec/frontend/pipelines_spec.js +++ b/spec/frontend/pipelines_spec.js @@ -1,8 +1,6 @@ import Pipelines from '~/pipelines'; describe('Pipelines', () => { - preloadFixtures('static/pipeline_graph.html'); - beforeEach(() => { loadFixtures('static/pipeline_graph.html'); }); diff --git a/spec/frontend/profile/account/components/update_username_spec.js b/spec/frontend/profile/account/components/update_username_spec.js index 8295d1d43cf..a3d7b63373c 100644 --- a/spec/frontend/profile/account/components/update_username_spec.js +++ b/spec/frontend/profile/account/components/update_username_spec.js @@ -2,10 +2,13 @@ import { GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import UpdateUsername from '~/profile/account/components/update_username.vue'; +jest.mock('~/flash'); + describe('UpdateUsername component', () => { const rootUrl = TEST_HOST; const actionUrl = `${TEST_HOST}/update/username`; @@ -105,7 +108,8 @@ describe('UpdateUsername component', () => { axiosMock.onPut(actionUrl).replyOnce(() => { expect(input.attributes('disabled')).toBe('disabled'); - expect(openModalBtn.props('disabled')).toBe(true); + expect(openModalBtn.props('disabled')).toBe(false); + expect(openModalBtn.props('loading')).toBe(true); return [200, { message: 'Username changed' }]; }); @@ -115,6 +119,7 @@ describe('UpdateUsername component', () => { expect(input.attributes('disabled')).toBe(undefined); expect(openModalBtn.props('disabled')).toBe(true); + expect(openModalBtn.props('loading')).toBe(false); }); it('does not set the username after a erroneous update', async () => { @@ -122,7 +127,8 @@ describe('UpdateUsername component', () => { axiosMock.onPut(actionUrl).replyOnce(() => { expect(input.attributes('disabled')).toBe('disabled'); - expect(openModalBtn.props('disabled')).toBe(true); + expect(openModalBtn.props('disabled')).toBe(false); + expect(openModalBtn.props('loading')).toBe(true); return [400, { message: 'Invalid username' }]; }); @@ -130,6 +136,29 @@ describe('UpdateUsername component', () => { await expect(wrapper.vm.onConfirm()).rejects.toThrow(); expect(input.attributes('disabled')).toBe(undefined); expect(openModalBtn.props('disabled')).toBe(false); + expect(openModalBtn.props('loading')).toBe(false); + }); + + it('shows an error message if the error response has a `message` property', async () => { + axiosMock.onPut(actionUrl).replyOnce(() => { + return [400, { message: 'Invalid username' }]; + }); + + await expect(wrapper.vm.onConfirm()).rejects.toThrow(); + + expect(createFlash).toBeCalledWith('Invalid username'); + }); + + it("shows a fallback error message if the error response doesn't have a `message` property", async () => { + axiosMock.onPut(actionUrl).replyOnce(() => { + return [400]; + }); + + await expect(wrapper.vm.onConfirm()).rejects.toThrow(); + + expect(createFlash).toBeCalledWith( + 'An error occurred while updating your username, please try again.', + ); }); }); }); diff --git a/spec/frontend/profile/preferences/components/profile_preferences_spec.js b/spec/frontend/profile/preferences/components/profile_preferences_spec.js index 82c41178410..9e6f5594d26 100644 --- a/spec/frontend/profile/preferences/components/profile_preferences_spec.js +++ b/spec/frontend/profile/preferences/components/profile_preferences_spec.js @@ -1,10 +1,19 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import IntegrationView from '~/profile/preferences/components/integration_view.vue'; import ProfilePreferences from '~/profile/preferences/components/profile_preferences.vue'; import { i18n } from '~/profile/preferences/constants'; -import { integrationViews, userFields, bodyClasses } from '../mock_data'; +import { + integrationViews, + userFields, + bodyClasses, + themes, + lightModeThemeId1, + darkModeThemeId, + lightModeThemeId2, +} from '../mock_data'; const expectedUrl = '/foo'; @@ -14,7 +23,7 @@ describe('ProfilePreferences component', () => { integrationViews: [], userFields, bodyClasses, - themes: [{ id: 1, css_class: 'foo' }], + themes, profilePreferencesPath: '/update-profile', formEl: document.createElement('form'), }; @@ -49,6 +58,30 @@ describe('ProfilePreferences component', () => { return document.querySelector('.flash-container .flash-text'); } + function createThemeInput(themeId = lightModeThemeId1) { + const input = document.createElement('input'); + input.setAttribute('name', 'user[theme_id]'); + input.setAttribute('type', 'radio'); + input.setAttribute('value', themeId.toString()); + input.setAttribute('checked', 'checked'); + return input; + } + + function createForm(themeInput = createThemeInput()) { + const form = document.createElement('form'); + form.setAttribute('url', expectedUrl); + form.setAttribute('method', 'put'); + form.appendChild(themeInput); + return form; + } + + function setupBody() { + const div = document.createElement('div'); + div.classList.add('container-fluid'); + document.body.appendChild(div); + document.body.classList.add('content-wrapper'); + } + beforeEach(() => { setFixtures('<div class="flash-container"></div>'); }); @@ -84,30 +117,15 @@ describe('ProfilePreferences component', () => { let form; beforeEach(() => { - const div = document.createElement('div'); - div.classList.add('container-fluid'); - document.body.appendChild(div); - document.body.classList.add('content-wrapper'); - - form = document.createElement('form'); - form.setAttribute('url', expectedUrl); - form.setAttribute('method', 'put'); - - const input = document.createElement('input'); - input.setAttribute('name', 'user[theme_id]'); - input.setAttribute('type', 'radio'); - input.setAttribute('value', '1'); - input.setAttribute('checked', 'checked'); - form.appendChild(input); - + setupBody(); + form = createForm(); wrapper = createComponent({ provide: { formEl: form }, attachTo: document.body }); - const beforeSendEvent = new CustomEvent('ajax:beforeSend'); form.dispatchEvent(beforeSendEvent); }); it('disables the submit button', async () => { - await wrapper.vm.$nextTick(); + await nextTick(); const button = findSubmitButton(); expect(button.props('disabled')).toBe(true); }); @@ -116,7 +134,7 @@ describe('ProfilePreferences component', () => { const successEvent = new CustomEvent('ajax:success'); form.dispatchEvent(successEvent); - await wrapper.vm.$nextTick(); + await nextTick(); const button = findSubmitButton(); expect(button.props('disabled')).toBe(false); }); @@ -125,7 +143,7 @@ describe('ProfilePreferences component', () => { const errorEvent = new CustomEvent('ajax:error'); form.dispatchEvent(errorEvent); - await wrapper.vm.$nextTick(); + await nextTick(); const button = findSubmitButton(); expect(button.props('disabled')).toBe(false); }); @@ -160,4 +178,89 @@ describe('ProfilePreferences component', () => { expect(findFlashError().innerText.trim()).toEqual(message); }); }); + + describe('theme changes', () => { + const { location } = window; + + let themeInput; + let form; + + function setupWrapper() { + wrapper = createComponent({ provide: { formEl: form }, attachTo: document.body }); + } + + function selectThemeId(themeId) { + themeInput.setAttribute('value', themeId.toString()); + } + + function dispatchBeforeSendEvent() { + const beforeSendEvent = new CustomEvent('ajax:beforeSend'); + form.dispatchEvent(beforeSendEvent); + } + + function dispatchSuccessEvent() { + const successEvent = new CustomEvent('ajax:success'); + form.dispatchEvent(successEvent); + } + + beforeAll(() => { + delete window.location; + window.location = { + ...location, + reload: jest.fn(), + }; + }); + + afterAll(() => { + window.location = location; + }); + + beforeEach(() => { + setupBody(); + themeInput = createThemeInput(); + form = createForm(themeInput); + }); + + it('reloads the page when switching from light to dark mode', async () => { + selectThemeId(lightModeThemeId1); + setupWrapper(); + + selectThemeId(darkModeThemeId); + dispatchBeforeSendEvent(); + await nextTick(); + + dispatchSuccessEvent(); + await nextTick(); + + expect(window.location.reload).toHaveBeenCalledTimes(1); + }); + + it('reloads the page when switching from dark to light mode', async () => { + selectThemeId(darkModeThemeId); + setupWrapper(); + + selectThemeId(lightModeThemeId1); + dispatchBeforeSendEvent(); + await nextTick(); + + dispatchSuccessEvent(); + await nextTick(); + + expect(window.location.reload).toHaveBeenCalledTimes(1); + }); + + it('does not reload the page when switching between light mode themes', async () => { + selectThemeId(lightModeThemeId1); + setupWrapper(); + + selectThemeId(lightModeThemeId2); + dispatchBeforeSendEvent(); + await nextTick(); + + dispatchSuccessEvent(); + await nextTick(); + + expect(window.location.reload).not.toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/profile/preferences/mock_data.js b/spec/frontend/profile/preferences/mock_data.js index ce33fc79a39..91cfdfadc78 100644 --- a/spec/frontend/profile/preferences/mock_data.js +++ b/spec/frontend/profile/preferences/mock_data.js @@ -18,3 +18,15 @@ export const userFields = { }; export const bodyClasses = 'ui-light-indigo ui-light gl-dark'; + +export const themes = [ + { id: 1, css_class: 'foo' }, + { id: 2, css_class: 'bar' }, + { id: 3, css_class: 'gl-dark' }, +]; + +export const lightModeThemeId1 = 1; + +export const lightModeThemeId2 = 2; + +export const darkModeThemeId = 3; diff --git a/spec/frontend/project_select_combo_button_spec.js b/spec/frontend/project_select_combo_button_spec.js index c47db71b4ac..5cdc3d174a1 100644 --- a/spec/frontend/project_select_combo_button_spec.js +++ b/spec/frontend/project_select_combo_button_spec.js @@ -10,8 +10,6 @@ describe('Project Select Combo Button', () => { testContext = {}; }); - preloadFixtures(fixturePath); - beforeEach(() => { testContext.defaults = { label: 'Select project to create issue', diff --git a/spec/frontend/projects/compare/components/app_legacy_spec.js b/spec/frontend/projects/compare/components/app_legacy_spec.js new file mode 100644 index 00000000000..4c7f0d5cccc --- /dev/null +++ b/spec/frontend/projects/compare/components/app_legacy_spec.js @@ -0,0 +1,116 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import CompareApp from '~/projects/compare/components/app_legacy.vue'; +import RevisionDropdown from '~/projects/compare/components/revision_dropdown_legacy.vue'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +const projectCompareIndexPath = 'some/path'; +const refsProjectPath = 'some/refs/path'; +const paramsFrom = 'master'; +const paramsTo = 'master'; + +describe('CompareApp component', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(CompareApp, { + propsData: { + projectCompareIndexPath, + refsProjectPath, + paramsFrom, + paramsTo, + projectMergeRequestPath: '', + createMrPath: '', + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + beforeEach(() => { + createComponent(); + }); + + it('renders component with prop', () => { + expect(wrapper.props()).toEqual( + expect.objectContaining({ + projectCompareIndexPath, + refsProjectPath, + paramsFrom, + paramsTo, + }), + ); + }); + + it('contains the correct form attributes', () => { + expect(wrapper.attributes('action')).toBe(projectCompareIndexPath); + expect(wrapper.attributes('method')).toBe('POST'); + }); + + it('has input with csrf token', () => { + expect(wrapper.find('input[name="authenticity_token"]').attributes('value')).toBe( + 'mock-csrf-token', + ); + }); + + it('has ellipsis', () => { + expect(wrapper.find('[data-testid="ellipsis"]').exists()).toBe(true); + }); + + it('render Source and Target BranchDropdown components', () => { + const branchDropdowns = wrapper.findAll(RevisionDropdown); + + expect(branchDropdowns.length).toBe(2); + expect(branchDropdowns.at(0).props('revisionText')).toBe('Source'); + expect(branchDropdowns.at(1).props('revisionText')).toBe('Target'); + }); + + describe('compare button', () => { + const findCompareButton = () => wrapper.find(GlButton); + + it('renders button', () => { + expect(findCompareButton().exists()).toBe(true); + }); + + it('submits form', () => { + findCompareButton().vm.$emit('click'); + expect(wrapper.find('form').element.submit).toHaveBeenCalled(); + }); + + it('has compare text', () => { + expect(findCompareButton().text()).toBe('Compare'); + }); + }); + + describe('merge request buttons', () => { + const findProjectMrButton = () => wrapper.find('[data-testid="projectMrButton"]'); + const findCreateMrButton = () => wrapper.find('[data-testid="createMrButton"]'); + + it('does not have merge request buttons', () => { + createComponent(); + expect(findProjectMrButton().exists()).toBe(false); + expect(findCreateMrButton().exists()).toBe(false); + }); + + it('has "View open merge request" button', () => { + createComponent({ + projectMergeRequestPath: 'some/project/merge/request/path', + }); + expect(findProjectMrButton().exists()).toBe(true); + expect(findCreateMrButton().exists()).toBe(false); + }); + + it('has "Create merge request" button', () => { + createComponent({ + createMrPath: 'some/create/create/mr/path', + }); + expect(findProjectMrButton().exists()).toBe(false); + expect(findCreateMrButton().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/projects/compare/components/app_spec.js b/spec/frontend/projects/compare/components/app_spec.js index d28a30e93b1..6de06e4373c 100644 --- a/spec/frontend/projects/compare/components/app_spec.js +++ b/spec/frontend/projects/compare/components/app_spec.js @@ -1,7 +1,7 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import CompareApp from '~/projects/compare/components/app.vue'; -import RevisionDropdown from '~/projects/compare/components/revision_dropdown.vue'; +import RevisionCard from '~/projects/compare/components/revision_card.vue'; jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); @@ -63,11 +63,11 @@ describe('CompareApp component', () => { }); it('render Source and Target BranchDropdown components', () => { - const branchDropdowns = wrapper.findAll(RevisionDropdown); + const revisionCards = wrapper.findAll(RevisionCard); - expect(branchDropdowns.length).toBe(2); - expect(branchDropdowns.at(0).props('revisionText')).toBe('Source'); - expect(branchDropdowns.at(1).props('revisionText')).toBe('Target'); + expect(revisionCards.length).toBe(2); + expect(revisionCards.at(0).props('revisionText')).toBe('Source'); + expect(revisionCards.at(1).props('revisionText')).toBe('Target'); }); describe('compare button', () => { diff --git a/spec/frontend/projects/compare/components/repo_dropdown_spec.js b/spec/frontend/projects/compare/components/repo_dropdown_spec.js new file mode 100644 index 00000000000..af76632515c --- /dev/null +++ b/spec/frontend/projects/compare/components/repo_dropdown_spec.js @@ -0,0 +1,98 @@ +import { GlDropdown } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RepoDropdown from '~/projects/compare/components/repo_dropdown.vue'; + +const defaultProps = { + paramsName: 'to', +}; + +const projectToId = '1'; +const projectToName = 'some-to-name'; +const projectFromId = '2'; +const projectFromName = 'some-from-name'; + +const defaultProvide = { + projectTo: { id: projectToId, name: projectToName }, + projectsFrom: [ + { id: projectFromId, name: projectFromName }, + { id: 3, name: 'some-from-another-name' }, + ], +}; + +describe('RepoDropdown component', () => { + let wrapper; + + const createComponent = (props = {}, provide = {}) => { + wrapper = shallowMount(RepoDropdown, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + ...defaultProvide, + ...provide, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findGlDropdown = () => wrapper.find(GlDropdown); + const findHiddenInput = () => wrapper.find('input[type="hidden"]'); + + describe('Source Revision', () => { + beforeEach(() => { + createComponent(); + }); + + it('set hidden input', () => { + expect(findHiddenInput().attributes('value')).toBe(projectToId); + }); + + it('displays the project name in the disabled dropdown', () => { + expect(findGlDropdown().props('text')).toBe(projectToName); + expect(findGlDropdown().props('disabled')).toBe(true); + }); + + it('does not emit `changeTargetProject` event', async () => { + wrapper.vm.emitTargetProject('foo'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('changeTargetProject')).toBeUndefined(); + }); + }); + + describe('Target Revision', () => { + beforeEach(() => { + createComponent({ paramsName: 'from' }); + }); + + it('set hidden input of the first project', () => { + expect(findHiddenInput().attributes('value')).toBe(projectFromId); + }); + + it('displays the first project name initially in the dropdown', () => { + expect(findGlDropdown().props('text')).toBe(projectFromName); + }); + + it('updates the hiddin input value when onClick method is triggered', async () => { + const repoId = '100'; + wrapper.vm.onClick({ id: repoId }); + await wrapper.vm.$nextTick(); + expect(findHiddenInput().attributes('value')).toBe(repoId); + }); + + it('emits initial `changeTargetProject` event with target project', () => { + expect(wrapper.emitted('changeTargetProject')).toEqual([[projectFromName]]); + }); + + it('emits `changeTargetProject` event when another target project is selected', async () => { + const newTargetProject = 'new-from-name'; + wrapper.vm.$emit('changeTargetProject', newTargetProject); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('changeTargetProject')[1]).toEqual([newTargetProject]); + }); + }); +}); diff --git a/spec/frontend/projects/compare/components/revision_card_spec.js b/spec/frontend/projects/compare/components/revision_card_spec.js new file mode 100644 index 00000000000..83f858f4454 --- /dev/null +++ b/spec/frontend/projects/compare/components/revision_card_spec.js @@ -0,0 +1,49 @@ +import { GlCard } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RepoDropdown from '~/projects/compare/components/repo_dropdown.vue'; +import RevisionCard from '~/projects/compare/components/revision_card.vue'; +import RevisionDropdown from '~/projects/compare/components/revision_dropdown.vue'; + +const defaultProps = { + refsProjectPath: 'some/refs/path', + revisionText: 'Source', + paramsName: 'to', + paramsBranch: 'master', +}; + +describe('RepoDropdown component', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(RevisionCard, { + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + GlCard, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + beforeEach(() => { + createComponent(); + }); + + it('displays revision text', () => { + expect(wrapper.find(GlCard).text()).toContain(defaultProps.revisionText); + }); + + it('renders RepoDropdown component', () => { + expect(wrapper.findAll(RepoDropdown).exists()).toBe(true); + }); + + it('renders RevisionDropdown component', () => { + expect(wrapper.findAll(RevisionDropdown).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js new file mode 100644 index 00000000000..270c89e674c --- /dev/null +++ b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js @@ -0,0 +1,106 @@ +import { GlDropdown } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import RevisionDropdown from '~/projects/compare/components/revision_dropdown_legacy.vue'; + +const defaultProps = { + refsProjectPath: 'some/refs/path', + revisionText: 'Target', + paramsName: 'from', + paramsBranch: 'master', +}; + +jest.mock('~/flash'); + +describe('RevisionDropdown component', () => { + let wrapper; + let axiosMock; + + const createComponent = (props = {}) => { + wrapper = shallowMount(RevisionDropdown, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + axiosMock.restore(); + }); + + const findGlDropdown = () => wrapper.find(GlDropdown); + + it('sets hidden input', () => { + createComponent(); + expect(wrapper.find('input[type="hidden"]').attributes('value')).toBe( + defaultProps.paramsBranch, + ); + }); + + it('update the branches on success', async () => { + const Branches = ['branch-1', 'branch-2']; + const Tags = ['tag-1', 'tag-2', 'tag-3']; + + axiosMock.onGet(defaultProps.refsProjectPath).replyOnce(200, { + Branches, + Tags, + }); + + createComponent(); + + await axios.waitForAll(); + + expect(wrapper.vm.branches).toEqual(Branches); + expect(wrapper.vm.tags).toEqual(Tags); + }); + + it('sets branches and tags to be an empty array when no tags or branches are given', async () => { + axiosMock.onGet(defaultProps.refsProjectPath).replyOnce(200, { + Branches: undefined, + Tags: undefined, + }); + + createComponent(); + + await axios.waitForAll(); + + expect(wrapper.vm.branches).toEqual([]); + expect(wrapper.vm.tags).toEqual([]); + }); + + it('shows flash message on error', async () => { + axiosMock.onGet('some/invalid/path').replyOnce(404); + + createComponent(); + + await wrapper.vm.fetchBranchesAndTags(); + expect(createFlash).toHaveBeenCalled(); + }); + + describe('GlDropdown component', () => { + it('renders props', () => { + createComponent(); + expect(wrapper.props()).toEqual(expect.objectContaining(defaultProps)); + }); + + it('display default text', () => { + createComponent({ + paramsBranch: null, + }); + expect(findGlDropdown().props('text')).toBe('Select branch/tag'); + }); + + it('display params branch text', () => { + createComponent(); + expect(findGlDropdown().props('text')).toBe(defaultProps.paramsBranch); + }); + }); +}); diff --git a/spec/frontend/projects/compare/components/revision_dropdown_spec.js b/spec/frontend/projects/compare/components/revision_dropdown_spec.js index f3ff5e26d2b..69d3167c99c 100644 --- a/spec/frontend/projects/compare/components/revision_dropdown_spec.js +++ b/spec/frontend/projects/compare/components/revision_dropdown_spec.js @@ -7,7 +7,6 @@ import RevisionDropdown from '~/projects/compare/components/revision_dropdown.vu const defaultProps = { refsProjectPath: 'some/refs/path', - revisionText: 'Target', paramsName: 'from', paramsBranch: 'master', }; @@ -57,7 +56,6 @@ describe('RevisionDropdown component', () => { createComponent(); await axios.waitForAll(); - expect(wrapper.vm.branches).toEqual(Branches); expect(wrapper.vm.tags).toEqual(Tags); }); @@ -71,6 +69,22 @@ describe('RevisionDropdown component', () => { expect(createFlash).toHaveBeenCalled(); }); + it('makes a new request when refsProjectPath is changed', async () => { + jest.spyOn(axios, 'get'); + + const newRefsProjectPath = 'new-selected-project-path'; + + createComponent(); + + wrapper.setProps({ + ...defaultProps, + refsProjectPath: newRefsProjectPath, + }); + + await axios.waitForAll(); + expect(axios.get).toHaveBeenLastCalledWith(newRefsProjectPath); + }); + describe('GlDropdown component', () => { it('renders props', () => { createComponent(); diff --git a/spec/frontend/projects/details/upload_button_spec.js b/spec/frontend/projects/details/upload_button_spec.js new file mode 100644 index 00000000000..d7308963088 --- /dev/null +++ b/spec/frontend/projects/details/upload_button_spec.js @@ -0,0 +1,54 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import UploadButton from '~/projects/details/upload_button.vue'; +import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; + +const MODAL_ID = 'details-modal-upload-blob'; + +describe('UploadButton', () => { + let wrapper; + let glModalDirective; + + const createComponent = () => { + glModalDirective = jest.fn(); + + return shallowMount(UploadButton, { + directives: { + glModal: { + bind(_, { value }) { + glModalDirective(value); + }, + }, + }, + }); + }; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays an upload button', () => { + expect(wrapper.find(GlButton).exists()).toBe(true); + }); + + it('contains a modal', () => { + const modal = wrapper.find(UploadBlobModal); + + expect(modal.exists()).toBe(true); + expect(modal.props('modalId')).toBe(MODAL_ID); + }); + + describe('when clickinig the upload file button', () => { + beforeEach(() => { + wrapper.find(GlButton).vm.$emit('click'); + }); + + it('opens the modal', () => { + expect(glModalDirective).toHaveBeenCalledWith(MODAL_ID); + }); + }); +}); diff --git a/spec/frontend/projects/experiment_new_project_creation/components/new_project_push_tip_popover_spec.js b/spec/frontend/projects/experiment_new_project_creation/components/new_project_push_tip_popover_spec.js new file mode 100644 index 00000000000..1ce16640d4a --- /dev/null +++ b/spec/frontend/projects/experiment_new_project_creation/components/new_project_push_tip_popover_spec.js @@ -0,0 +1,75 @@ +import { GlPopover, GlFormInputGroup } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import NewProjectPushTipPopover from '~/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +describe('New project push tip popover', () => { + let wrapper; + const targetId = 'target'; + const pushToCreateProjectCommand = 'command'; + const workingWithProjectsHelpPath = 'path'; + + const findPopover = () => wrapper.findComponent(GlPopover); + const findClipboardButton = () => wrapper.findComponent(ClipboardButton); + const findFormInput = () => wrapper.findComponent(GlFormInputGroup); + const findHelpLink = () => wrapper.find('a'); + const findTarget = () => document.getElementById(targetId); + + const buildWrapper = () => { + wrapper = shallowMount(NewProjectPushTipPopover, { + propsData: { + target: findTarget(), + }, + stubs: { + GlFormInputGroup, + }, + provide: { + pushToCreateProjectCommand, + workingWithProjectsHelpPath, + }, + }); + }; + + beforeEach(() => { + setFixtures(`<a id="${targetId}"></a>`); + buildWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders popover that targets the specified target', () => { + expect(findPopover().props()).toMatchObject({ + target: findTarget(), + triggers: 'click blur', + placement: 'top', + title: 'Push to create a project', + }); + }); + + it('renders a readonly form input with the push to create command', () => { + expect(findFormInput().props()).toMatchObject({ + value: pushToCreateProjectCommand, + selectOnClick: true, + }); + expect(findFormInput().attributes()).toMatchObject({ + 'aria-label': 'Push project from command line', + readonly: 'readonly', + }); + }); + + it('allows copying the push command using the clipboard button', () => { + expect(findClipboardButton().props()).toMatchObject({ + text: pushToCreateProjectCommand, + tooltipPlacement: 'right', + title: 'Copy command', + }); + }); + + it('displays a link to open the push command help page reference', () => { + expect(findHelpLink().attributes().href).toBe( + `${workingWithProjectsHelpPath}#push-to-create-a-new-project`, + ); + }); +}); diff --git a/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js b/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js index d6764f75262..f26d1a6d2a3 100644 --- a/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js +++ b/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js @@ -1,5 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { mockTracking } from 'helpers/tracking_helper'; +import NewProjectPushTipPopover from '~/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue'; import WelcomePage from '~/projects/experiment_new_project_creation/components/welcome.vue'; describe('Welcome page', () => { @@ -28,4 +29,13 @@ describe('Welcome page', () => { expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_tab', { label: 'test' }); }); }); + + it('renders new project push tip popover', () => { + createComponent({ panels: [{ name: 'test', href: '#' }] }); + + const popover = wrapper.findComponent(NewProjectPushTipPopover); + + expect(popover.exists()).toBe(true); + expect(popover.props().target()).toBe(wrapper.find({ ref: 'clipTip' }).element); + }); }); diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js index f9fbb1b3016..8acf2376860 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js @@ -154,7 +154,7 @@ describe('ServiceDeskRoot', () => { }); it('shows an error message', () => { - expect(getAlertText()).toContain('An error occured while saving changes:'); + expect(getAlertText()).toContain('An error occurred while saving changes:'); }); }); }); diff --git a/spec/frontend/projects/upload_file_experiment_spec.js b/spec/frontend/projects/upload_file_experiment_spec.js new file mode 100644 index 00000000000..aa1b1c44a31 --- /dev/null +++ b/spec/frontend/projects/upload_file_experiment_spec.js @@ -0,0 +1,52 @@ +import ExperimentTracking from '~/experimentation/experiment_tracking'; +import * as UploadFileExperiment from '~/projects/upload_file_experiment'; + +jest.mock('~/experimentation/experiment_tracking'); + +const fixture = `<a class='js-upload-file-experiment-trigger' data-toggle='modal' data-target='#modal-upload-blob'></a><div id='modal-upload-blob'></div><div class='project-home-panel empty-project'></div>`; +const findModal = () => document.querySelector('[aria-modal="true"]'); +const findTrigger = () => document.querySelector('.js-upload-file-experiment-trigger'); + +beforeEach(() => { + document.body.innerHTML = fixture; +}); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('trackUploadFileFormSubmitted', () => { + it('initializes ExperimentTracking with the correct arguments and calls the tracking event with correct arguments', () => { + UploadFileExperiment.trackUploadFileFormSubmitted(); + + expect(ExperimentTracking).toHaveBeenCalledWith('empty_repo_upload', { + label: 'blob-upload-modal', + property: 'empty', + }); + expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith( + 'click_upload_modal_form_submit', + ); + }); + + it('initializes ExperimentTracking with the correct arguments when the project is not empty', () => { + document.querySelector('.empty-project').remove(); + + UploadFileExperiment.trackUploadFileFormSubmitted(); + + expect(ExperimentTracking).toHaveBeenCalledWith('empty_repo_upload', { + label: 'blob-upload-modal', + property: 'nonempty', + }); + }); +}); + +describe('initUploadFileTrigger', () => { + it('calls modal and tracks event', () => { + UploadFileExperiment.initUploadFileTrigger(); + + expect(findModal()).not.toExist(); + findTrigger().click(); + expect(findModal()).toExist(); + expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('click_upload_modal_trigger'); + }); +}); diff --git a/spec/frontend/prometheus_metrics/custom_metrics_spec.js b/spec/frontend/prometheus_metrics/custom_metrics_spec.js index 3e3d4ee361a..20593351ee5 100644 --- a/spec/frontend/prometheus_metrics/custom_metrics_spec.js +++ b/spec/frontend/prometheus_metrics/custom_metrics_spec.js @@ -9,7 +9,6 @@ describe('PrometheusMetrics', () => { const customMetricsEndpoint = 'http://test.host/frontend-fixtures/services-project/prometheus/metrics'; let mock; - preloadFixtures(FIXTURE); beforeEach(() => { mock = new MockAdapter(axios); diff --git a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js index 722a5274ad4..a703dc0a66f 100644 --- a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js +++ b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js @@ -6,7 +6,6 @@ import { metrics2 as metrics, missingVarMetrics } from './mock_data'; describe('PrometheusMetrics', () => { const FIXTURE = 'services/prometheus/prometheus_service.html'; - preloadFixtures(FIXTURE); beforeEach(() => { loadFixtures(FIXTURE); diff --git a/spec/frontend/protected_branches/protected_branch_edit_spec.js b/spec/frontend/protected_branches/protected_branch_edit_spec.js new file mode 100644 index 00000000000..40e31e24a14 --- /dev/null +++ b/spec/frontend/protected_branches/protected_branch_edit_spec.js @@ -0,0 +1,88 @@ +import MockAdapter from 'axios-mock-adapter'; +import $ from 'jquery'; +import { TEST_HOST } from 'helpers/test_constants'; +import { deprecatedCreateFlash as flash } from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import ProtectedBranchEdit from '~/protected_branches/protected_branch_edit'; + +jest.mock('~/flash'); + +const TEST_URL = `${TEST_HOST}/url`; +const IS_CHECKED_CLASS = 'is-checked'; + +describe('ProtectedBranchEdit', () => { + let mock; + + beforeEach(() => { + setFixtures(`<div id="wrap" data-url="${TEST_URL}"> + <button class="js-force-push-toggle">Toggle</button> + </div>`); + + jest.spyOn(ProtectedBranchEdit.prototype, 'buildDropdowns').mockImplementation(); + + mock = new MockAdapter(axios); + }); + + const findForcePushesToggle = () => document.querySelector('.js-force-push-toggle'); + + const create = ({ isChecked = false }) => { + if (isChecked) { + findForcePushesToggle().classList.add(IS_CHECKED_CLASS); + } + + return new ProtectedBranchEdit({ $wrap: $('#wrap'), hasLicense: false }); + }; + + afterEach(() => { + mock.restore(); + }); + + describe('when unchecked toggle button', () => { + let toggle; + + beforeEach(() => { + create({ isChecked: false }); + + toggle = findForcePushesToggle(); + }); + + it('is not changed', () => { + expect(toggle).not.toHaveClass(IS_CHECKED_CLASS); + expect(toggle).not.toBeDisabled(); + }); + + describe('when clicked', () => { + beforeEach(() => { + mock.onPatch(TEST_URL, { protected_branch: { allow_force_push: true } }).replyOnce(200, {}); + + toggle.click(); + }); + + it('checks and disables button', () => { + expect(toggle).toHaveClass(IS_CHECKED_CLASS); + expect(toggle).toBeDisabled(); + }); + + it('sends update to BE', () => + axios.waitForAll().then(() => { + // Args are asserted in the `.onPatch` call + expect(mock.history.patch).toHaveLength(1); + + expect(toggle).not.toBeDisabled(); + expect(flash).not.toHaveBeenCalled(); + })); + }); + + describe('when clicked and BE error', () => { + beforeEach(() => { + mock.onPatch(TEST_URL).replyOnce(500); + toggle.click(); + }); + + it('flashes error', () => + axios.waitForAll().then(() => { + expect(flash).toHaveBeenCalled(); + })); + }); + }); +}); diff --git a/spec/frontend/read_more_spec.js b/spec/frontend/read_more_spec.js index d1d01272403..16f0d7fb075 100644 --- a/spec/frontend/read_more_spec.js +++ b/spec/frontend/read_more_spec.js @@ -3,8 +3,6 @@ import initReadMore from '~/read_more'; describe('Read more click-to-expand functionality', () => { const fixtureName = 'projects/overview.html'; - preloadFixtures(fixtureName); - beforeEach(() => { loadFixtures(fixtureName); }); diff --git a/spec/frontend/ref/components/__snapshots__/ref_selector_spec.js.snap b/spec/frontend/ref/components/__snapshots__/ref_selector_spec.js.snap new file mode 100644 index 00000000000..5f05b7fc68b --- /dev/null +++ b/spec/frontend/ref/components/__snapshots__/ref_selector_spec.js.snap @@ -0,0 +1,70 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Ref selector component footer slot passes the expected slot props 1`] = ` +Object { + "isLoading": false, + "matches": Object { + "branches": Object { + "error": null, + "list": Array [ + Object { + "default": false, + "name": "add_images_and_changes", + }, + Object { + "default": false, + "name": "conflict-contains-conflict-markers", + }, + Object { + "default": false, + "name": "deleted-image-test", + }, + Object { + "default": false, + "name": "diff-files-image-to-symlink", + }, + Object { + "default": false, + "name": "diff-files-symlink-to-image", + }, + Object { + "default": false, + "name": "markdown", + }, + Object { + "default": true, + "name": "master", + }, + ], + "totalCount": 123, + }, + "commits": Object { + "error": null, + "list": Array [ + Object { + "name": "b83d6e39", + "subtitle": "Merge branch 'branch-merged' into 'master'", + "value": "b83d6e391c22777fca1ed3012fce84f633d7fed0", + }, + ], + "totalCount": 1, + }, + "tags": Object { + "error": null, + "list": Array [ + Object { + "name": "v1.1.1", + }, + Object { + "name": "v1.1.0", + }, + Object { + "name": "v1.0.0", + }, + ], + "totalCount": 456, + }, + }, + "query": "abcd1234", +} +`; diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js index 27ada131ed6..a642a8cf8c2 100644 --- a/spec/frontend/ref/components/ref_selector_spec.js +++ b/spec/frontend/ref/components/ref_selector_spec.js @@ -1,13 +1,20 @@ -import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem, GlDropdown, GlIcon } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import { merge, last } from 'lodash'; import Vuex from 'vuex'; import { trimText } from 'helpers/text_helper'; import { ENTER_KEY } from '~/lib/utils/keys'; import { sprintf } from '~/locale'; import RefSelector from '~/ref/components/ref_selector.vue'; -import { X_TOTAL_HEADER, DEFAULT_I18N } from '~/ref/constants'; +import { + X_TOTAL_HEADER, + DEFAULT_I18N, + REF_TYPE_BRANCHES, + REF_TYPE_TAGS, + REF_TYPE_COMMITS, +} from '~/ref/constants'; import createStore from '~/ref/stores/'; const localVue = createLocalVue(); @@ -26,27 +33,32 @@ describe('Ref selector component', () => { let branchesApiCallSpy; let tagsApiCallSpy; let commitApiCallSpy; - - const createComponent = (props = {}, attrs = {}) => { - wrapper = mount(RefSelector, { - propsData: { - projectId, - value: '', - ...props, - }, - attrs, - listeners: { - // simulate a parent component v-model binding - input: (selectedRef) => { - wrapper.setProps({ value: selectedRef }); + let requestSpies; + + const createComponent = (mountOverrides = {}) => { + wrapper = mount( + RefSelector, + merge( + { + propsData: { + projectId, + value: '', + }, + listeners: { + // simulate a parent component v-model binding + input: (selectedRef) => { + wrapper.setProps({ value: selectedRef }); + }, + }, + stubs: { + GlSearchBoxByType: true, + }, + localVue, + store: createStore(), }, - }, - stubs: { - GlSearchBoxByType: true, - }, - localVue, - store: createStore(), - }); + mountOverrides, + ), + ); }; beforeEach(() => { @@ -58,6 +70,7 @@ describe('Ref selector component', () => { .mockReturnValue([200, fixtures.branches, { [X_TOTAL_HEADER]: '123' }]); tagsApiCallSpy = jest.fn().mockReturnValue([200, fixtures.tags, { [X_TOTAL_HEADER]: '456' }]); commitApiCallSpy = jest.fn().mockReturnValue([200, fixtures.commit]); + requestSpies = { branchesApiCallSpy, tagsApiCallSpy, commitApiCallSpy }; mock .onGet(`/api/v4/projects/${projectId}/repository/branches`) @@ -78,7 +91,7 @@ describe('Ref selector component', () => { // // Finders // - const findButtonContent = () => wrapper.find('[data-testid="button-content"]'); + const findButtonContent = () => wrapper.find('button'); const findNoResults = () => wrapper.find('[data-testid="no-results"]'); @@ -175,7 +188,7 @@ describe('Ref selector component', () => { const id = 'git-ref'; beforeEach(() => { - createComponent({}, { id }); + createComponent({ attrs: { id } }); return waitForRequests(); }); @@ -189,7 +202,7 @@ describe('Ref selector component', () => { const preselectedRef = fixtures.branches[0].name; beforeEach(() => { - createComponent({ value: preselectedRef }); + createComponent({ propsData: { value: preselectedRef } }); return waitForRequests(); }); @@ -592,4 +605,152 @@ describe('Ref selector component', () => { }); }); }); + + describe('with non-default ref types', () => { + it.each` + enabledRefTypes | reqsCalled | reqsNotCalled + ${[REF_TYPE_BRANCHES]} | ${['branchesApiCallSpy']} | ${['tagsApiCallSpy', 'commitApiCallSpy']} + ${[REF_TYPE_TAGS]} | ${['tagsApiCallSpy']} | ${['branchesApiCallSpy', 'commitApiCallSpy']} + ${[REF_TYPE_COMMITS]} | ${[]} | ${['branchesApiCallSpy', 'tagsApiCallSpy', 'commitApiCallSpy']} + ${[REF_TYPE_TAGS, REF_TYPE_COMMITS]} | ${['tagsApiCallSpy']} | ${['branchesApiCallSpy', 'commitApiCallSpy']} + `( + 'only calls $reqsCalled requests when $enabledRefTypes are enabled', + async ({ enabledRefTypes, reqsCalled, reqsNotCalled }) => { + createComponent({ propsData: { enabledRefTypes } }); + + await waitForRequests(); + + reqsCalled.forEach((req) => expect(requestSpies[req]).toHaveBeenCalledTimes(1)); + reqsNotCalled.forEach((req) => expect(requestSpies[req]).not.toHaveBeenCalled()); + }, + ); + + it('only calls commitApiCallSpy when REF_TYPE_COMMITS is enabled', async () => { + createComponent({ propsData: { enabledRefTypes: [REF_TYPE_COMMITS] } }); + updateQuery('abcd1234'); + + await waitForRequests(); + + expect(commitApiCallSpy).toHaveBeenCalledTimes(1); + expect(branchesApiCallSpy).not.toHaveBeenCalled(); + expect(tagsApiCallSpy).not.toHaveBeenCalled(); + }); + + it('triggers another search if enabled ref types change', async () => { + createComponent({ propsData: { enabledRefTypes: [REF_TYPE_BRANCHES] } }); + await waitForRequests(); + + expect(branchesApiCallSpy).toHaveBeenCalledTimes(1); + expect(tagsApiCallSpy).not.toHaveBeenCalled(); + + wrapper.setProps({ + enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_TAGS], + }); + await waitForRequests(); + + expect(branchesApiCallSpy).toHaveBeenCalledTimes(2); + expect(tagsApiCallSpy).toHaveBeenCalledTimes(1); + }); + + it('if a ref type becomes disabled, its section is hidden, even if it had some results in store', async () => { + createComponent({ propsData: { enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_COMMITS] } }); + updateQuery('abcd1234'); + await waitForRequests(); + + expect(findBranchesSection().exists()).toBe(true); + expect(findCommitsSection().exists()).toBe(true); + + wrapper.setProps({ enabledRefTypes: [REF_TYPE_COMMITS] }); + await waitForRequests(); + + expect(findBranchesSection().exists()).toBe(false); + expect(findCommitsSection().exists()).toBe(true); + }); + + it.each` + enabledRefType | findVisibleSection | findHiddenSections + ${REF_TYPE_BRANCHES} | ${findBranchesSection} | ${[findTagsSection, findCommitsSection]} + ${REF_TYPE_TAGS} | ${findTagsSection} | ${[findBranchesSection, findCommitsSection]} + ${REF_TYPE_COMMITS} | ${findCommitsSection} | ${[findBranchesSection, findTagsSection]} + `( + 'hides section headers if a single ref type is enabled', + async ({ enabledRefType, findVisibleSection, findHiddenSections }) => { + createComponent({ propsData: { enabledRefTypes: [enabledRefType] } }); + updateQuery('abcd1234'); + await waitForRequests(); + + expect(findVisibleSection().exists()).toBe(true); + expect(findVisibleSection().find('[data-testid="section-header"]').exists()).toBe(false); + findHiddenSections.forEach((findHiddenSection) => + expect(findHiddenSection().exists()).toBe(false), + ); + }, + ); + }); + + describe('validation state', () => { + const invalidClass = 'gl-inset-border-1-red-500!'; + const isInvalidClassApplied = () => wrapper.find(GlDropdown).props('toggleClass')[invalidClass]; + + describe('valid state', () => { + describe('when the state prop is not provided', () => { + it('does not render a red border', () => { + createComponent(); + + expect(isInvalidClassApplied()).toBe(false); + }); + }); + + describe('when the state prop is true', () => { + it('does not render a red border', () => { + createComponent({ propsData: { state: true } }); + + expect(isInvalidClassApplied()).toBe(false); + }); + }); + }); + + describe('invalid state', () => { + it('renders the dropdown with a red border if the state prop is false', () => { + createComponent({ propsData: { state: false } }); + + expect(isInvalidClassApplied()).toBe(true); + }); + }); + }); + + describe('footer slot', () => { + const footerContent = 'This is the footer content'; + const createFooter = jest.fn().mockImplementation(function createMockFooter() { + return this.$createElement('div', { attrs: { 'data-testid': 'footer-content' } }, [ + footerContent, + ]); + }); + + beforeEach(() => { + createComponent({ + scopedSlots: { footer: createFooter }, + }); + + updateQuery('abcd1234'); + + return waitForRequests(); + }); + + afterEach(() => { + createFooter.mockClear(); + }); + + it('allows custom content to be shown at the bottom of the dropdown using the footer slot', () => { + expect(wrapper.find(`[data-testid="footer-content"]`).text()).toBe(footerContent); + }); + + it('passes the expected slot props', () => { + // The createFooter function gets called every time one of the scoped properties + // is updated. For the sake of this test, we'll just test the last call, which + // represents the final state of the slot props. + const lastCallProps = last(createFooter.mock.calls)[0]; + expect(lastCallProps).toMatchSnapshot(); + }); + }); }); diff --git a/spec/frontend/ref/stores/actions_spec.js b/spec/frontend/ref/stores/actions_spec.js index 11acec27165..099ce062a3a 100644 --- a/spec/frontend/ref/stores/actions_spec.js +++ b/spec/frontend/ref/stores/actions_spec.js @@ -1,4 +1,5 @@ import testAction from 'helpers/vuex_action_helper'; +import { ALL_REF_TYPES, REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS } from '~/ref/constants'; import * as actions from '~/ref/stores/actions'; import * as types from '~/ref/stores/mutation_types'; import createState from '~/ref/stores/state'; @@ -25,6 +26,14 @@ describe('Ref selector Vuex store actions', () => { state = createState(); }); + describe('setEnabledRefTypes', () => { + it(`commits ${types.SET_ENABLED_REF_TYPES} with the enabled ref types`, () => { + testAction(actions.setProjectId, ALL_REF_TYPES, state, [ + { type: types.SET_PROJECT_ID, payload: ALL_REF_TYPES }, + ]); + }); + }); + describe('setProjectId', () => { it(`commits ${types.SET_PROJECT_ID} with the new project ID`, () => { const projectId = '4'; @@ -46,12 +55,23 @@ describe('Ref selector Vuex store actions', () => { describe('search', () => { it(`commits ${types.SET_QUERY} with the new search query`, () => { const query = 'hello'; + testAction(actions.search, query, state, [{ type: types.SET_QUERY, payload: query }]); + }); + + it.each` + enabledRefTypes | expectedActions + ${[REF_TYPE_BRANCHES]} | ${['searchBranches']} + ${[REF_TYPE_COMMITS]} | ${['searchCommits']} + ${[REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS]} | ${['searchBranches', 'searchTags', 'searchCommits']} + `(`dispatches fetch actions for enabled ref types`, ({ enabledRefTypes, expectedActions }) => { + const query = 'hello'; + state.enabledRefTypes = enabledRefTypes; testAction( actions.search, query, state, [{ type: types.SET_QUERY, payload: query }], - [{ type: 'searchBranches' }, { type: 'searchTags' }, { type: 'searchCommits' }], + expectedActions.map((type) => ({ type })), ); }); }); diff --git a/spec/frontend/ref/stores/mutations_spec.js b/spec/frontend/ref/stores/mutations_spec.js index cda13089766..11d4fe0e206 100644 --- a/spec/frontend/ref/stores/mutations_spec.js +++ b/spec/frontend/ref/stores/mutations_spec.js @@ -1,4 +1,4 @@ -import { X_TOTAL_HEADER } from '~/ref/constants'; +import { X_TOTAL_HEADER, ALL_REF_TYPES } from '~/ref/constants'; import * as types from '~/ref/stores/mutation_types'; import mutations from '~/ref/stores/mutations'; import createState from '~/ref/stores/state'; @@ -13,6 +13,7 @@ describe('Ref selector Vuex store mutations', () => { describe('initial state', () => { it('is created with the correct structure and initial values', () => { expect(state).toEqual({ + enabledRefTypes: [], projectId: null, query: '', @@ -39,6 +40,14 @@ describe('Ref selector Vuex store mutations', () => { }); }); + describe(`${types.SET_ENABLED_REF_TYPES}`, () => { + it('sets the enabled ref types', () => { + mutations[types.SET_ENABLED_REF_TYPES](state, ALL_REF_TYPES); + + expect(state.enabledRefTypes).toBe(ALL_REF_TYPES); + }); + }); + describe(`${types.SET_PROJECT_ID}`, () => { it('updates the project ID', () => { const newProjectId = '4'; diff --git a/spec/frontend/registry/explorer/components/delete_button_spec.js b/spec/frontend/registry/explorer/components/delete_button_spec.js index a557d9afacc..4597c42add9 100644 --- a/spec/frontend/registry/explorer/components/delete_button_spec.js +++ b/spec/frontend/registry/explorer/components/delete_button_spec.js @@ -58,6 +58,7 @@ describe('delete_button', () => { title: 'Foo title', variant: 'danger', disabled: 'true', + category: 'secondary', }); }); 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 3fa3a2ae1de..b50ed87a563 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 @@ -1,9 +1,9 @@ -import { GlSprintf, GlButton } from '@gitlab/ui'; +import { GlButton, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { useFakeDate } from 'helpers/fake_date'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import component from '~/registry/explorer/components/details_page/details_header.vue'; import { - DETAILS_PAGE_TITLE, UNSCHEDULED_STATUS, SCHEDULED_STATUS, ONGOING_STATUS, @@ -13,6 +13,8 @@ import { CLEANUP_SCHEDULED_TOOLTIP, CLEANUP_ONGOING_TOOLTIP, CLEANUP_UNFINISHED_TOOLTIP, + ROOT_IMAGE_TEXT, + ROOT_IMAGE_TOOLTIP, } from '~/registry/explorer/constants'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; @@ -41,6 +43,7 @@ describe('Details Header', () => { const findTagsCount = () => findByTestId('tags-count'); const findCleanup = () => findByTestId('cleanup'); const findDeleteButton = () => wrapper.find(GlButton); + const findInfoIcon = () => wrapper.find(GlIcon); const waitForMetadataItems = async () => { // Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available @@ -51,8 +54,10 @@ describe('Details Header', () => { const mountComponent = (propsData = { image: defaultImage }) => { wrapper = shallowMount(component, { propsData, + directives: { + GlTooltip: createMockDirective(), + }, stubs: { - GlSprintf, TitleArea, }, }); @@ -62,15 +67,41 @@ describe('Details Header', () => { wrapper.destroy(); wrapper = null; }); + describe('image name', () => { + describe('missing image name', () => { + it('root image ', () => { + mountComponent({ image: { ...defaultImage, name: '' } }); - it('has the correct title ', () => { - mountComponent({ image: { ...defaultImage, name: '' } }); - expect(findTitle().text()).toMatchInterpolatedText(DETAILS_PAGE_TITLE); - }); + expect(findTitle().text()).toBe(ROOT_IMAGE_TEXT); + }); - it('shows imageName in the title', () => { - mountComponent(); - expect(findTitle().text()).toContain('foo'); + it('has an icon', () => { + mountComponent({ image: { ...defaultImage, name: '' } }); + + expect(findInfoIcon().exists()).toBe(true); + expect(findInfoIcon().props('name')).toBe('information-o'); + }); + + it('has a tooltip', () => { + mountComponent({ image: { ...defaultImage, name: '' } }); + + const tooltip = getBinding(findInfoIcon().element, 'gl-tooltip'); + expect(tooltip.value).toBe(ROOT_IMAGE_TOOLTIP); + }); + }); + + describe('with image name present', () => { + it('shows image.name ', () => { + mountComponent(); + expect(findTitle().text()).toContain('foo'); + }); + + it('has no icon', () => { + mountComponent(); + + expect(findInfoIcon().exists()).toBe(false); + }); + }); }); describe('delete button', () => { 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 d6ee871341b..6c897b983f7 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 @@ -12,6 +12,7 @@ import { CLEANUP_TIMED_OUT_ERROR_MESSAGE, IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS, + ROOT_IMAGE_TEXT, } from '~/registry/explorer/constants'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; @@ -73,8 +74,8 @@ describe('Image List Row', () => { mountComponent(); const link = findDetailsLink(); - expect(link.html()).toContain(item.path); - expect(link.props('to')).toMatchObject({ + expect(link.text()).toBe(item.path); + expect(findDetailsLink().props('to')).toMatchObject({ name: 'details', params: { id: getIdFromGraphQLId(item.id), @@ -82,6 +83,12 @@ describe('Image List Row', () => { }); }); + it(`when the image has no name appends ${ROOT_IMAGE_TEXT} to the path`, () => { + mountComponent({ item: { ...item, name: '' } }); + + expect(findDetailsLink().text()).toBe(`${item.path}/ ${ROOT_IMAGE_TEXT}`); + }); + it('contains a clipboard button', () => { mountComponent(); const button = findClipboardButton(); diff --git a/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js index 07256d2bbf5..11a3acd9eb9 100644 --- a/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js @@ -4,7 +4,6 @@ import Component from '~/registry/explorer/components/list_page/registry_header. import { CONTAINER_REGISTRY_TITLE, LIST_INTRO_TEXT, - EXPIRATION_POLICY_DISABLED_MESSAGE, EXPIRATION_POLICY_DISABLED_TEXT, } from '~/registry/explorer/constants'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; @@ -132,41 +131,5 @@ describe('registry_header', () => { ]); }); }); - - describe('expiration policy info message', () => { - describe('when there are images', () => { - describe('when expiration policy is disabled', () => { - beforeEach(() => { - return mountComponent({ - expirationPolicy: { enabled: false }, - expirationPolicyHelpPagePath: 'foo', - imagesCount: 1, - }); - }); - - it('the prop is correctly bound', () => { - expect(findTitleArea().props('infoMessages')).toEqual([ - { text: LIST_INTRO_TEXT, link: '' }, - { text: EXPIRATION_POLICY_DISABLED_MESSAGE, link: 'foo' }, - ]); - }); - }); - - describe.each` - desc | props - ${'when there are no images'} | ${{ expirationPolicy: { enabled: false }, imagesCount: 0 }} - ${'when expiration policy is enabled'} | ${{ expirationPolicy: { enabled: true }, imagesCount: 1 }} - ${'when the expiration policy is completely disabled'} | ${{ expirationPolicy: { enabled: false }, imagesCount: 1, hideExpirationPolicyData: true }} - `('$desc', ({ props }) => { - it('message does not exist', () => { - mountComponent(props); - - expect(findTitleArea().props('infoMessages')).toEqual([ - { text: LIST_INTRO_TEXT, link: '' }, - ]); - }); - }); - }); - }); }); }); diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js index 65c58bf9874..76baf4f72c9 100644 --- a/spec/frontend/registry/explorer/pages/details_spec.js +++ b/spec/frontend/registry/explorer/pages/details_spec.js @@ -17,6 +17,8 @@ import { UNFINISHED_STATUS, DELETE_SCHEDULED, ALERT_DANGER_IMAGE, + MISSING_OR_DELETED_IMAGE_BREADCRUMB, + ROOT_IMAGE_TEXT, } from '~/registry/explorer/constants'; import deleteContainerRepositoryTagsMutation from '~/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql'; import getContainerRepositoryDetailsQuery from '~/registry/explorer/graphql/queries/get_container_repository_details.query.graphql'; @@ -515,6 +517,26 @@ describe('Details Page', () => { expect(breadCrumbState.updateName).toHaveBeenCalledWith(containerRepositoryMock.name); }); + + it(`when the image is missing set the breadcrumb to ${MISSING_OR_DELETED_IMAGE_BREADCRUMB}`, async () => { + mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) }); + + await waitForApolloRequestRender(); + + expect(breadCrumbState.updateName).toHaveBeenCalledWith(MISSING_OR_DELETED_IMAGE_BREADCRUMB); + }); + + it(`when the image has no name set the breadcrumb to ${ROOT_IMAGE_TEXT}`, async () => { + mountComponent({ + resolver: jest + .fn() + .mockResolvedValue(graphQLImageDetailsMock({ ...containerRepositoryMock, name: null })), + }); + + await waitForApolloRequestRender(); + + expect(breadCrumbState.updateName).toHaveBeenCalledWith(ROOT_IMAGE_TEXT); + }); }); describe('when the image has a status different from null', () => { diff --git a/spec/frontend/related_issues/components/related_issuable_input_spec.js b/spec/frontend/related_issues/components/related_issuable_input_spec.js new file mode 100644 index 00000000000..79b228454f4 --- /dev/null +++ b/spec/frontend/related_issues/components/related_issuable_input_spec.js @@ -0,0 +1,117 @@ +import { shallowMount } from '@vue/test-utils'; +import { TEST_HOST } from 'helpers/test_constants'; +import RelatedIssuableInput from '~/related_issues/components/related_issuable_input.vue'; +import { issuableTypesMap, PathIdSeparator } from '~/related_issues/constants'; + +jest.mock('ee_else_ce/gfm_auto_complete', () => { + return function gfmAutoComplete() { + return { + constructor() {}, + setup() {}, + }; + }; +}); + +describe('RelatedIssuableInput', () => { + let propsData; + + beforeEach(() => { + propsData = { + inputValue: '', + references: [], + pathIdSeparator: PathIdSeparator.Issue, + issuableType: issuableTypesMap.issue, + autoCompleteSources: { + issues: `${TEST_HOST}/h5bp/html5-boilerplate/-/autocomplete_sources/issues`, + }, + }; + }); + + describe('autocomplete', () => { + describe('with autoCompleteSources', () => { + it('shows placeholder text', () => { + const wrapper = shallowMount(RelatedIssuableInput, { propsData }); + + expect(wrapper.find({ ref: 'input' }).element.placeholder).toBe( + 'Paste issue link or <#issue id>', + ); + }); + + it('has GfmAutoComplete', () => { + const wrapper = shallowMount(RelatedIssuableInput, { propsData }); + + expect(wrapper.vm.gfmAutoComplete).toBeDefined(); + }); + }); + + describe('with no autoCompleteSources', () => { + it('shows placeholder text', () => { + const wrapper = shallowMount(RelatedIssuableInput, { + propsData: { + ...propsData, + references: ['!1', '!2'], + }, + }); + + expect(wrapper.find({ ref: 'input' }).element.value).toBe(''); + }); + + it('does not have GfmAutoComplete', () => { + const wrapper = shallowMount(RelatedIssuableInput, { + propsData: { + ...propsData, + autoCompleteSources: {}, + }, + }); + + expect(wrapper.vm.gfmAutoComplete).not.toBeDefined(); + }); + }); + }); + + describe('focus', () => { + it('when clicking anywhere on the input wrapper it should focus the input', async () => { + const wrapper = shallowMount(RelatedIssuableInput, { + propsData: { + ...propsData, + references: ['foo', 'bar'], + }, + // We need to attach to document, so that `document.activeElement` is properly set in jsdom + attachTo: document.body, + }); + + wrapper.find('li').trigger('click'); + + await wrapper.vm.$nextTick(); + + expect(document.activeElement).toBe(wrapper.find({ ref: 'input' }).element); + }); + }); + + describe('when filling in the input', () => { + it('emits addIssuableFormInput with data', () => { + const wrapper = shallowMount(RelatedIssuableInput, { + propsData, + }); + + wrapper.vm.$emit = jest.fn(); + + const newInputValue = 'filling in things'; + const untouchedRawReferences = newInputValue.trim().split(/\s/); + const touchedReference = untouchedRawReferences.pop(); + const input = wrapper.find({ ref: 'input' }); + + input.element.value = newInputValue; + input.element.selectionStart = newInputValue.length; + input.element.selectionEnd = newInputValue.length; + input.trigger('input'); + + expect(wrapper.vm.$emit).toHaveBeenCalledWith('addIssuableFormInput', { + newValue: newInputValue, + caretPos: newInputValue.length, + untouchedRawReferences, + touchedReference, + }); + }); + }); +}); diff --git a/spec/frontend/releases/components/tag_field_new_spec.js b/spec/frontend/releases/components/tag_field_new_spec.js index d87718138b8..387217c2a8e 100644 --- a/spec/frontend/releases/components/tag_field_new_spec.js +++ b/spec/frontend/releases/components/tag_field_new_spec.js @@ -1,6 +1,6 @@ -import { GlFormInput } from '@gitlab/ui'; +import { GlDropdownItem } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; -import RefSelector from '~/ref/components/ref_selector.vue'; +import Vue from 'vue'; import TagFieldNew from '~/releases/components/tag_field_new.vue'; import createStore from '~/releases/stores'; import createDetailModule from '~/releases/stores/modules/detail'; @@ -8,6 +8,25 @@ import createDetailModule from '~/releases/stores/modules/detail'; const TEST_TAG_NAME = 'test-tag-name'; const TEST_PROJECT_ID = '1234'; const TEST_CREATE_FROM = 'test-create-from'; +const NONEXISTENT_TAG_NAME = 'nonexistent-tag'; + +// A mock version of the RefSelector component that simulates +// a scenario where the users has searched for "nonexistent-tag" +// and the component has found no tags that match. +const RefSelectorStub = Vue.component('RefSelectorStub', { + data() { + return { + footerSlotProps: { + isLoading: false, + matches: { + tags: { totalCount: 0 }, + }, + query: NONEXISTENT_TAG_NAME, + }, + }; + }, + template: '<div><slot name="footer" v-bind="footerSlotProps"></slot></div>', +}); describe('releases/components/tag_field_new', () => { let store; @@ -17,7 +36,7 @@ describe('releases/components/tag_field_new', () => { wrapper = mountFn(TagFieldNew, { store, stubs: { - RefSelector: true, + RefSelector: RefSelectorStub, }, }); }; @@ -47,11 +66,12 @@ describe('releases/components/tag_field_new', () => { }); const findTagNameFormGroup = () => wrapper.find('[data-testid="tag-name-field"]'); - const findTagNameGlInput = () => findTagNameFormGroup().find(GlFormInput); - const findTagNameInput = () => findTagNameFormGroup().find('input'); + const findTagNameDropdown = () => findTagNameFormGroup().find(RefSelectorStub); const findCreateFromFormGroup = () => wrapper.find('[data-testid="create-from-field"]'); - const findCreateFromDropdown = () => findCreateFromFormGroup().find(RefSelector); + const findCreateFromDropdown = () => findCreateFromFormGroup().find(RefSelectorStub); + + const findCreateNewTagOption = () => wrapper.find(GlDropdownItem); describe('"Tag name" field', () => { describe('rendering and behavior', () => { @@ -61,14 +81,37 @@ describe('releases/components/tag_field_new', () => { expect(findTagNameFormGroup().attributes().label).toBe('Tag name'); }); - describe('when the user updates the field', () => { + describe('when the user selects a new tag name', () => { + beforeEach(async () => { + findCreateNewTagOption().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + }); + + it("updates the store's release.tagName property", () => { + expect(store.state.detail.release.tagName).toBe(NONEXISTENT_TAG_NAME); + }); + + it('hides the "Create from" field', () => { + expect(findCreateFromFormGroup().exists()).toBe(true); + }); + }); + + describe('when the user selects an existing tag name', () => { + const updatedTagName = 'updated-tag-name'; + + beforeEach(async () => { + findTagNameDropdown().vm.$emit('input', updatedTagName); + + await wrapper.vm.$nextTick(); + }); + it("updates the store's release.tagName property", () => { - const updatedTagName = 'updated-tag-name'; - findTagNameGlInput().vm.$emit('input', updatedTagName); + expect(store.state.detail.release.tagName).toBe(updatedTagName); + }); - return wrapper.vm.$nextTick().then(() => { - expect(store.state.detail.release.tagName).toBe(updatedTagName); - }); + it('shows the "Create from" field', () => { + expect(findCreateFromFormGroup().exists()).toBe(false); }); }); }); @@ -83,41 +126,39 @@ describe('releases/components/tag_field_new', () => { * @param {'shown' | 'hidden'} state The expected state of the validation message. * Should be passed either 'shown' or 'hidden' */ - const expectValidationMessageToBe = (state) => { - return wrapper.vm.$nextTick().then(() => { - expect(findTagNameFormGroup().element).toHaveClass( - state === 'shown' ? 'is-invalid' : 'is-valid', - ); - expect(findTagNameFormGroup().element).not.toHaveClass( - state === 'shown' ? 'is-valid' : 'is-invalid', - ); - }); + const expectValidationMessageToBe = async (state) => { + await wrapper.vm.$nextTick(); + + expect(findTagNameFormGroup().element).toHaveClass( + state === 'shown' ? 'is-invalid' : 'is-valid', + ); + expect(findTagNameFormGroup().element).not.toHaveClass( + state === 'shown' ? 'is-valid' : 'is-invalid', + ); }; describe('when the user has not yet interacted with the component', () => { - it('does not display a validation error', () => { - findTagNameInput().setValue(''); + it('does not display a validation error', async () => { + findTagNameDropdown().vm.$emit('input', ''); - return expectValidationMessageToBe('hidden'); + await expectValidationMessageToBe('hidden'); }); }); describe('when the user has interacted with the component and the value is not empty', () => { - it('does not display validation error', () => { - findTagNameInput().trigger('blur'); + it('does not display validation error', async () => { + findTagNameDropdown().vm.$emit('hide'); - return expectValidationMessageToBe('hidden'); + await expectValidationMessageToBe('hidden'); }); }); describe('when the user has interacted with the component and the value is empty', () => { - it('displays a validation error', () => { - const tagNameInput = findTagNameInput(); - - tagNameInput.setValue(''); - tagNameInput.trigger('blur'); + it('displays a validation error', async () => { + findTagNameDropdown().vm.$emit('input', ''); + findTagNameDropdown().vm.$emit('hide'); - return expectValidationMessageToBe('shown'); + await expectValidationMessageToBe('shown'); }); }); }); @@ -131,13 +172,13 @@ describe('releases/components/tag_field_new', () => { }); describe('when the user selects a git ref', () => { - it("updates the store's createFrom property", () => { + it("updates the store's createFrom property", async () => { const updatedCreateFrom = 'update-create-from'; findCreateFromDropdown().vm.$emit('input', updatedCreateFrom); - return wrapper.vm.$nextTick().then(() => { - expect(store.state.detail.createFrom).toBe(updatedCreateFrom); - }); + await wrapper.vm.$nextTick(); + + expect(store.state.detail.createFrom).toBe(updatedCreateFrom); }); }); }); 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 ed261ed12c0..cffdcc04416 100644 --- a/spec/frontend/reports/components/grouped_test_reports_app_spec.js +++ b/spec/frontend/reports/components/grouped_test_reports_app_spec.js @@ -42,8 +42,12 @@ describe('Grouped test reports app', () => { const findHeader = () => wrapper.find('[data-testid="report-section-code-text"]'); const findExpandButton = () => wrapper.find('[data-testid="report-section-expand-button"]'); const findFullTestReportLink = () => wrapper.find('[data-testid="group-test-reports-full-link"]'); - const findSummaryDescription = () => wrapper.find('[data-testid="test-summary-row-description"]'); + const findSummaryDescription = () => wrapper.find('[data-testid="summary-row-description"]'); + const findIssueListUnresolvedHeading = () => wrapper.find('[data-testid="unresolvedHeading"]'); + const findIssueListResolvedHeading = () => wrapper.find('[data-testid="resolvedHeading"]'); const findIssueDescription = () => wrapper.find('[data-testid="test-issue-body-description"]'); + const findIssueRecentFailures = () => + wrapper.find('[data-testid="test-issue-body-recent-failures"]'); const findAllIssueDescriptions = () => wrapper.findAll('[data-testid="test-issue-body-description"]'); @@ -133,6 +137,10 @@ describe('Grouped test reports app', () => { mountComponent(); }); + it('renders New heading', () => { + expect(findIssueListUnresolvedHeading().text()).toBe('New'); + }); + it('renders failed summary text', () => { expect(findHeader().text()).toBe('Test summary contained 2 failed out of 11 total tests'); }); @@ -144,7 +152,6 @@ describe('Grouped test reports app', () => { }); it('renders failed issue in list', () => { - expect(findIssueDescription().text()).toContain('New'); expect(findIssueDescription().text()).toContain( 'Test#sum when a is 1 and b is 2 returns summary', ); @@ -157,6 +164,10 @@ describe('Grouped test reports app', () => { mountComponent(); }); + it('renders New heading', () => { + expect(findIssueListUnresolvedHeading().text()).toBe('New'); + }); + it('renders error summary text', () => { expect(findHeader().text()).toBe('Test summary contained 2 errors out of 11 total tests'); }); @@ -168,7 +179,6 @@ describe('Grouped test reports app', () => { }); it('renders error issue in list', () => { - expect(findIssueDescription().text()).toContain('New'); expect(findIssueDescription().text()).toContain( 'Test#sum when a is 1 and b is 2 returns summary', ); @@ -181,6 +191,11 @@ describe('Grouped test reports app', () => { mountComponent(); }); + it('renders New and Fixed headings', () => { + expect(findIssueListUnresolvedHeading().text()).toBe('New'); + expect(findIssueListResolvedHeading().text()).toBe('Fixed'); + }); + it('renders summary text', () => { expect(findHeader().text()).toBe( 'Test summary contained 2 failed and 2 fixed test results out of 11 total tests', @@ -194,7 +209,6 @@ describe('Grouped test reports app', () => { }); it('renders failed issue in list', () => { - expect(findIssueDescription().text()).toContain('New'); expect(findIssueDescription().text()).toContain( 'Test#subtract when a is 2 and b is 1 returns correct result', ); @@ -207,6 +221,10 @@ describe('Grouped test reports app', () => { mountComponent(); }); + it('renders Fixed heading', () => { + expect(findIssueListResolvedHeading().text()).toBe('Fixed'); + }); + it('renders summary text', () => { expect(findHeader().text()).toBe( 'Test summary contained 4 fixed test results out of 11 total tests', @@ -252,7 +270,7 @@ describe('Grouped test reports app', () => { }); it('renders the recent failures count on the test case', () => { - expect(findIssueDescription().text()).toContain( + expect(findIssueRecentFailures().text()).toBe( 'Failed 8 times in master in the last 14 days', ); }); @@ -295,6 +313,27 @@ describe('Grouped test reports app', () => { }); }); + describe('with a report parsing errors', () => { + beforeEach(() => { + const reports = failedReport; + reports.suites[0].suite_errors = { + head: 'JUnit XML parsing failed: 2:24: FATAL: attributes construct error', + base: 'JUnit data parsing failed: string not matched', + }; + setReports(reports); + mountComponent(); + }); + + it('renders the error messages', () => { + expect(findSummaryDescription().text()).toContain( + 'JUnit XML parsing failed: 2:24: FATAL: attributes construct error', + ); + expect(findSummaryDescription().text()).toContain( + 'JUnit data parsing failed: string not matched', + ); + }); + }); + describe('with error', () => { beforeEach(() => { mockStore.state.isLoading = false; diff --git a/spec/frontend/reports/components/summary_row_spec.js b/spec/frontend/reports/components/summary_row_spec.js index bdd6de1e0be..04d9d10dcd2 100644 --- a/spec/frontend/reports/components/summary_row_spec.js +++ b/spec/frontend/reports/components/summary_row_spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import SummaryRow from '~/reports/components/summary_row.vue'; describe('Summary row', () => { @@ -14,16 +15,19 @@ describe('Summary row', () => { }; const createComponent = ({ propsData = {}, slots = {} } = {}) => { - wrapper = mount(SummaryRow, { - propsData: { - ...props, - ...propsData, - }, - slots, - }); + wrapper = extendedWrapper( + mount(SummaryRow, { + propsData: { + ...props, + ...propsData, + }, + slots, + }), + ); }; - const findSummary = () => wrapper.find('.report-block-list-issue-description-text'); + const findSummary = () => wrapper.findByTestId('summary-row-description'); + const findStatusIcon = () => wrapper.findByTestId('summary-row-icon'); afterEach(() => { wrapper.destroy(); @@ -37,9 +41,7 @@ describe('Summary row', () => { it('renders provided icon', () => { createComponent(); - expect(wrapper.find('.report-block-list-icon span').classes()).toContain( - 'js-ci-status-icon-warning', - ); + expect(findStatusIcon().classes()).toContain('js-ci-status-icon-warning'); }); describe('summary slot', () => { diff --git a/spec/frontend/reports/components/test_issue_body_spec.js b/spec/frontend/reports/components/test_issue_body_spec.js index 2843620a18d..6f890d8f92c 100644 --- a/spec/frontend/reports/components/test_issue_body_spec.js +++ b/spec/frontend/reports/components/test_issue_body_spec.js @@ -1,72 +1,97 @@ -import Vue from 'vue'; -import { trimText } from 'helpers/text_helper'; -import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; -import component from '~/reports/components/test_issue_body.vue'; -import createStore from '~/reports/store'; -import { issue } from '../mock_data/mock_data'; - -describe('Test Issue body', () => { - let vm; - const Component = Vue.extend(component); - const store = createStore(); - - const commonProps = { - issue, - status: 'failed', - }; +import { GlBadge, GlButton } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import IssueStatusIcon from '~/reports/components/issue_status_icon.vue'; +import TestIssueBody from '~/reports/components/test_issue_body.vue'; +import { failedIssue, successIssue } from '../mock_data/mock_data'; - afterEach(() => { - vm.$destroy(); - }); +const localVue = createLocalVue(); +localVue.use(Vuex); - describe('on click', () => { - it('calls openModal action', () => { - vm = mountComponentWithStore(Component, { - store, - props: commonProps, - }); +describe('Test issue body', () => { + let wrapper; + let store; - jest.spyOn(vm, 'openModal').mockImplementation(() => {}); + const findDescription = () => wrapper.findByTestId('test-issue-body-description'); + const findStatusIcon = () => wrapper.findComponent(IssueStatusIcon); + const findBadge = () => wrapper.findComponent(GlBadge); - vm.$el.querySelector('button').click(); + const actionSpies = { + openModal: jest.fn(), + }; - expect(vm.openModal).toHaveBeenCalledWith({ - issue: commonProps.issue, - }); + const createComponent = ({ issue = failedIssue } = {}) => { + store = new Vuex.Store({ + actions: actionSpies, }); + + wrapper = extendedWrapper( + shallowMount(TestIssueBody, { + store, + localVue, + propsData: { + issue, + }, + stubs: { + GlBadge, + GlButton, + IssueStatusIcon, + }, + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); }); - describe('is new', () => { + describe('when issue has failed status', () => { beforeEach(() => { - vm = mountComponentWithStore(Component, { - store, - props: { ...commonProps, isNew: true }, - }); + createComponent(); }); it('renders issue name', () => { - expect(vm.$el.textContent).toContain(commonProps.issue.name); + expect(findDescription().text()).toBe(failedIssue.name); + }); + + it('renders failed status icon', () => { + expect(findStatusIcon().props('status')).toBe('failed'); }); - it('renders new badge', () => { - expect(trimText(vm.$el.querySelector('.badge').textContent)).toEqual('New'); + describe('when issue has recent failures', () => { + it('renders recent failures badge', () => { + expect(findBadge().exists()).toBe(true); + }); }); }); - describe('not new', () => { + describe('when issue has success status', () => { beforeEach(() => { - vm = mountComponentWithStore(Component, { - store, - props: commonProps, - }); + createComponent({ issue: successIssue }); + }); + + it('does not render recent failures', () => { + expect(findBadge().exists()).toBe(false); }); it('renders issue name', () => { - expect(vm.$el.textContent).toContain(commonProps.issue.name); + expect(findDescription().text()).toBe(successIssue.name); + }); + + it('renders success status icon', () => { + expect(findStatusIcon().props('status')).toBe('success'); }); + }); - it('does not renders new badge', () => { - expect(vm.$el.querySelector('.badge')).toEqual(null); + describe('when clicking on an issue', () => { + it('calls openModal action', () => { + createComponent(); + wrapper.findComponent(GlButton).trigger('click'); + + expect(actionSpies.openModal).toHaveBeenCalledWith(expect.any(Object), { + issue: failedIssue, + }); }); }); }); diff --git a/spec/frontend/reports/mock_data/mock_data.js b/spec/frontend/reports/mock_data/mock_data.js index 3caaab2fd79..68c7439df47 100644 --- a/spec/frontend/reports/mock_data/mock_data.js +++ b/spec/frontend/reports/mock_data/mock_data.js @@ -1,9 +1,23 @@ -export const issue = { +export const failedIssue = { result: 'failure', name: 'Test#sum when a is 1 and b is 2 returns summary', execution_time: 0.009411, + status: 'failed', 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 \u003ctop (required)\u003e'", + recent_failures: { + count: 3, + base_branch: 'master', + }, +}; + +export const successIssue = { + result: 'success', + name: 'Test#sum when a is 1 and b is 2 returns summary', + execution_time: 0.009411, + status: 'success', + system_output: null, + recent_failures: null, }; export const failedReport = { diff --git a/spec/frontend/reports/store/mutations_spec.js b/spec/frontend/reports/store/mutations_spec.js index 652b3b0ec45..07dd4a0f75c 100644 --- a/spec/frontend/reports/store/mutations_spec.js +++ b/spec/frontend/reports/store/mutations_spec.js @@ -1,7 +1,7 @@ import * as types from '~/reports/store/mutation_types'; import mutations from '~/reports/store/mutations'; import state from '~/reports/store/state'; -import { issue } from '../mock_data/mock_data'; +import { failedIssue } from '../mock_data/mock_data'; describe('Reports Store Mutations', () => { let stateCopy; @@ -115,17 +115,17 @@ describe('Reports Store Mutations', () => { describe('SET_ISSUE_MODAL_DATA', () => { beforeEach(() => { mutations[types.SET_ISSUE_MODAL_DATA](stateCopy, { - issue, + issue: failedIssue, }); }); it('should set modal title', () => { - expect(stateCopy.modal.title).toEqual(issue.name); + expect(stateCopy.modal.title).toEqual(failedIssue.name); }); it('should set modal data', () => { - expect(stateCopy.modal.data.execution_time.value).toEqual(issue.execution_time); - expect(stateCopy.modal.data.system_output.value).toEqual(issue.system_output); + expect(stateCopy.modal.data.execution_time.value).toEqual(failedIssue.execution_time); + expect(stateCopy.modal.data.system_output.value).toEqual(failedIssue.system_output); }); it('should open modal', () => { @@ -136,7 +136,7 @@ describe('Reports Store Mutations', () => { describe('RESET_ISSUE_MODAL_DATA', () => { beforeEach(() => { mutations[types.SET_ISSUE_MODAL_DATA](stateCopy, { - issue, + issue: failedIssue, }); mutations[types.RESET_ISSUE_MODAL_DATA](stateCopy); diff --git a/spec/frontend/repository/components/upload_blob_modal_spec.js b/spec/frontend/repository/components/upload_blob_modal_spec.js new file mode 100644 index 00000000000..6e3cbad558d --- /dev/null +++ b/spec/frontend/repository/components/upload_blob_modal_spec.js @@ -0,0 +1,193 @@ +import { GlModal, GlFormInput, GlFormTextarea, GlToggle, GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import httpStatusCodes from '~/lib/utils/http_status'; +import { visitUrl } from '~/lib/utils/url_utility'; +import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; +import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; + +jest.mock('~/flash'); +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn(), + joinPaths: () => '/new_upload', +})); + +const initialProps = { + modalId: 'upload-blob', + commitMessage: 'Upload New File', + targetBranch: 'master', + origionalBranch: 'master', + canPushCode: true, + path: 'new_upload', +}; + +describe('UploadBlobModal', () => { + let wrapper; + let mock; + + const mockEvent = { preventDefault: jest.fn() }; + + const createComponent = (props) => { + wrapper = shallowMount(UploadBlobModal, { + propsData: { + ...initialProps, + ...props, + }, + mocks: { + $route: { + params: { + path: '', + }, + }, + }, + }); + }; + + const findModal = () => wrapper.find(GlModal); + const findAlert = () => wrapper.find(GlAlert); + const findCommitMessage = () => wrapper.find(GlFormTextarea); + const findBranchName = () => wrapper.find(GlFormInput); + const findMrToggle = () => wrapper.find(GlToggle); + const findUploadDropzone = () => wrapper.find(UploadDropzone); + const actionButtonDisabledState = () => findModal().props('actionPrimary').attributes[0].disabled; + const cancelButtonDisabledState = () => findModal().props('actionCancel').attributes[0].disabled; + const actionButtonLoadingState = () => findModal().props('actionPrimary').attributes[0].loading; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe.each` + canPushCode | displayBranchName | displayForkedBranchMessage + ${true} | ${true} | ${false} + ${false} | ${false} | ${true} + `( + 'canPushCode = $canPushCode', + ({ canPushCode, displayBranchName, displayForkedBranchMessage }) => { + beforeEach(() => { + createComponent({ canPushCode }); + }); + + it('displays the modal', () => { + expect(findModal().exists()).toBe(true); + }); + + it('includes the upload dropzone', () => { + expect(findUploadDropzone().exists()).toBe(true); + }); + + it('includes the commit message', () => { + expect(findCommitMessage().exists()).toBe(true); + }); + + it('displays the disabled upload button', () => { + expect(actionButtonDisabledState()).toBe(true); + }); + + it('displays the enabled cancel button', () => { + expect(cancelButtonDisabledState()).toBe(false); + }); + + it('does not display the MR toggle', () => { + expect(findMrToggle().exists()).toBe(false); + }); + + it(`${ + displayForkedBranchMessage ? 'displays' : 'does not display' + } the forked branch message`, () => { + expect(findAlert().exists()).toBe(displayForkedBranchMessage); + }); + + it(`${displayBranchName ? 'displays' : 'does not display'} the branch name`, () => { + expect(findBranchName().exists()).toBe(displayBranchName); + }); + + if (canPushCode) { + describe('when changing the branch name', () => { + it('displays the MR toggle', async () => { + wrapper.setData({ target: 'Not master' }); + + await wrapper.vm.$nextTick(); + + expect(findMrToggle().exists()).toBe(true); + }); + }); + } + + describe('completed form', () => { + beforeEach(() => { + wrapper.setData({ + file: { type: 'jpg' }, + filePreviewURL: 'http://file.com?format=jpg', + }); + }); + + it('enables the upload button when the form is completed', () => { + expect(actionButtonDisabledState()).toBe(false); + }); + + describe('form submission', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + + findModal().vm.$emit('primary', mockEvent); + }); + + afterEach(() => { + mock.restore(); + }); + + it('disables the upload button', () => { + expect(actionButtonDisabledState()).toBe(true); + }); + + it('sets the upload button to loading', () => { + expect(actionButtonLoadingState()).toBe(true); + }); + }); + + describe('successful response', () => { + beforeEach(async () => { + mock = new MockAdapter(axios); + mock.onPost(initialProps.path).reply(httpStatusCodes.OK, { filePath: 'blah' }); + + findModal().vm.$emit('primary', mockEvent); + + await waitForPromises(); + }); + + it('redirects to the uploaded file', () => { + expect(visitUrl).toHaveBeenCalled(); + }); + + afterEach(() => { + mock.restore(); + }); + }); + + describe('error response', () => { + beforeEach(async () => { + mock = new MockAdapter(axios); + mock.onPost(initialProps.path).timeout(); + + findModal().vm.$emit('primary', mockEvent); + + await waitForPromises(); + }); + + it('creates a flash error', () => { + expect(createFlash).toHaveBeenCalledWith('Error uploading file. Please try again.'); + }); + + afterEach(() => { + mock.restore(); + }); + }); + }); + }, + ); +}); diff --git a/spec/frontend/right_sidebar_spec.js b/spec/frontend/right_sidebar_spec.js index f3719b28baa..8699e1cf420 100644 --- a/spec/frontend/right_sidebar_spec.js +++ b/spec/frontend/right_sidebar_spec.js @@ -27,7 +27,6 @@ const assertSidebarState = (state) => { describe('RightSidebar', () => { describe('fixture tests', () => { const fixtureName = 'issues/open-issue.html'; - preloadFixtures(fixtureName); let mock; beforeEach(() => { diff --git a/spec/frontend/search/highlight_blob_search_result_spec.js b/spec/frontend/search/highlight_blob_search_result_spec.js index c1b0c7d794b..6908bcbd283 100644 --- a/spec/frontend/search/highlight_blob_search_result_spec.js +++ b/spec/frontend/search/highlight_blob_search_result_spec.js @@ -4,8 +4,6 @@ const fixture = 'search/blob_search_result.html'; const searchKeyword = 'Send'; // spec/frontend/fixtures/search.rb#79 describe('search/highlight_blob_search_result', () => { - preloadFixtures(fixture); - beforeEach(() => loadFixtures(fixture)); it('highlights lines with search term occurrence', () => { diff --git a/spec/frontend/search_autocomplete_spec.js b/spec/frontend/search_autocomplete_spec.js index a9fbe0fe552..5aca07d59e4 100644 --- a/spec/frontend/search_autocomplete_spec.js +++ b/spec/frontend/search_autocomplete_spec.js @@ -105,7 +105,6 @@ describe('Search autocomplete dropdown', () => { expect(list.find(mrsIHaveCreatedLink).text()).toBe("Merge requests I've created"); }; - preloadFixtures('static/search_autocomplete.html'); beforeEach(() => { loadFixtures('static/search_autocomplete.html'); diff --git a/spec/frontend/security_configuration/configuration_table_spec.js b/spec/frontend/security_configuration/configuration_table_spec.js index 49f9a7a3ea8..b8a574dc4e0 100644 --- a/spec/frontend/security_configuration/configuration_table_spec.js +++ b/spec/frontend/security_configuration/configuration_table_spec.js @@ -1,15 +1,11 @@ import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import ConfigurationTable from '~/security_configuration/components/configuration_table.vue'; -import { features, UPGRADE_CTA } from '~/security_configuration/components/features_constants'; +import { scanners, UPGRADE_CTA } from '~/security_configuration/components/scanners_constants'; import { REPORT_TYPE_SAST, - REPORT_TYPE_DAST, - REPORT_TYPE_DEPENDENCY_SCANNING, - REPORT_TYPE_CONTAINER_SCANNING, - REPORT_TYPE_COVERAGE_FUZZING, - REPORT_TYPE_LICENSE_COMPLIANCE, + REPORT_TYPE_SECRET_DETECTION, } from '~/vue_shared/security_reports/constants'; describe('Configuration Table Component', () => { @@ -19,6 +15,8 @@ describe('Configuration Table Component', () => { wrapper = extendedWrapper(mount(ConfigurationTable, {})); }; + const findHelpLinks = () => wrapper.findAll('[data-testid="help-link"]'); + afterEach(() => { wrapper.destroy(); }); @@ -27,22 +25,20 @@ describe('Configuration Table Component', () => { createComponent(); }); - it.each(features)('should match strings', (feature) => { - expect(wrapper.text()).toContain(feature.name); - expect(wrapper.text()).toContain(feature.description); - - if (feature.type === REPORT_TYPE_SAST) { - expect(wrapper.findByTestId(feature.type).text()).toBe('Configure via Merge Request'); - } else if ( - [ - REPORT_TYPE_DAST, - REPORT_TYPE_DEPENDENCY_SCANNING, - REPORT_TYPE_CONTAINER_SCANNING, - REPORT_TYPE_COVERAGE_FUZZING, - REPORT_TYPE_LICENSE_COMPLIANCE, - ].includes(feature.type) - ) { - expect(wrapper.findByTestId(feature.type).text()).toMatchInterpolatedText(UPGRADE_CTA); - } + describe.each(scanners.map((scanner, i) => [scanner, i]))('given scanner %s', (scanner, i) => { + it('should match strings', () => { + expect(wrapper.text()).toContain(scanner.name); + expect(wrapper.text()).toContain(scanner.description); + if (scanner.type === REPORT_TYPE_SAST) { + expect(wrapper.findByTestId(scanner.type).text()).toBe('Configure via Merge Request'); + } else if (scanner.type !== REPORT_TYPE_SECRET_DETECTION) { + expect(wrapper.findByTestId(scanner.type).text()).toMatchInterpolatedText(UPGRADE_CTA); + } + }); + + it('should show expected help link', () => { + const helpLink = findHelpLinks().at(i); + expect(helpLink.attributes('href')).toBe(scanner.helpPath); + }); }); }); diff --git a/spec/frontend/security_configuration/upgrade_spec.js b/spec/frontend/security_configuration/upgrade_spec.js index 0ab1108b265..1f0cc795fc5 100644 --- a/spec/frontend/security_configuration/upgrade_spec.js +++ b/spec/frontend/security_configuration/upgrade_spec.js @@ -1,28 +1,29 @@ import { mount } from '@vue/test-utils'; -import { UPGRADE_CTA } from '~/security_configuration/components/features_constants'; +import { UPGRADE_CTA } from '~/security_configuration/components/scanners_constants'; import Upgrade from '~/security_configuration/components/upgrade.vue'; +const TEST_URL = 'http://www.example.test'; let wrapper; -const createComponent = () => { - wrapper = mount(Upgrade, {}); +const createComponent = (componentData = {}) => { + wrapper = mount(Upgrade, componentData); }; -beforeEach(() => { - createComponent(); -}); - afterEach(() => { wrapper.destroy(); }); describe('Upgrade component', () => { + beforeEach(() => { + createComponent({ provide: { upgradePath: TEST_URL } }); + }); + it('renders correct text in link', () => { expect(wrapper.text()).toMatchInterpolatedText(UPGRADE_CTA); }); - it('renders link with correct attributes', () => { + it('renders link with correct default attributes', () => { expect(wrapper.find('a').attributes()).toMatchObject({ - href: 'https://about.gitlab.com/pricing/', + href: TEST_URL, target: '_blank', }); }); diff --git a/spec/frontend/sentry/sentry_config_spec.js b/spec/frontend/sentry/sentry_config_spec.js index f7102f9b2f9..1f5097ef2a8 100644 --- a/spec/frontend/sentry/sentry_config_spec.js +++ b/spec/frontend/sentry/sentry_config_spec.js @@ -1,5 +1,5 @@ +import * as Sentry from '@sentry/browser'; import SentryConfig from '~/sentry/sentry_config'; -import * as Sentry from '~/sentry/wrapper'; describe('SentryConfig', () => { describe('IGNORE_ERRORS', () => { diff --git a/spec/frontend/settings_panels_spec.js b/spec/frontend/settings_panels_spec.js index 8666106d3c6..6b739617b97 100644 --- a/spec/frontend/settings_panels_spec.js +++ b/spec/frontend/settings_panels_spec.js @@ -2,8 +2,6 @@ import $ from 'jquery'; import initSettingsPanels, { isExpanded } from '~/settings_panels'; describe('Settings Panels', () => { - preloadFixtures('groups/edit.html'); - beforeEach(() => { loadFixtures('groups/edit.html'); }); diff --git a/spec/frontend/shared/popover_spec.js b/spec/frontend/shared/popover_spec.js deleted file mode 100644 index 59b0b3b006c..00000000000 --- a/spec/frontend/shared/popover_spec.js +++ /dev/null @@ -1,166 +0,0 @@ -import $ from 'jquery'; -import { togglePopover, mouseleave, mouseenter } from '~/shared/popover'; - -describe('popover', () => { - describe('togglePopover', () => { - describe('togglePopover(true)', () => { - it('returns true when popover is shown', () => { - const context = { - hasClass: () => false, - popover: () => {}, - toggleClass: () => {}, - }; - - expect(togglePopover.call(context, true)).toEqual(true); - }); - - it('returns false when popover is already shown', () => { - const context = { - hasClass: () => true, - }; - - expect(togglePopover.call(context, true)).toEqual(false); - }); - - it('shows popover', (done) => { - const context = { - hasClass: () => false, - popover: () => {}, - toggleClass: () => {}, - }; - - jest.spyOn(context, 'popover').mockImplementation((method) => { - expect(method).toEqual('show'); - done(); - }); - - togglePopover.call(context, true); - }); - - it('adds disable-animation and js-popover-show class', (done) => { - const context = { - hasClass: () => false, - popover: () => {}, - toggleClass: () => {}, - }; - - jest.spyOn(context, 'toggleClass').mockImplementation((classNames, show) => { - expect(classNames).toEqual('disable-animation js-popover-show'); - expect(show).toEqual(true); - done(); - }); - - togglePopover.call(context, true); - }); - }); - - describe('togglePopover(false)', () => { - it('returns true when popover is hidden', () => { - const context = { - hasClass: () => true, - popover: () => {}, - toggleClass: () => {}, - }; - - expect(togglePopover.call(context, false)).toEqual(true); - }); - - it('returns false when popover is already hidden', () => { - const context = { - hasClass: () => false, - }; - - expect(togglePopover.call(context, false)).toEqual(false); - }); - - it('hides popover', (done) => { - const context = { - hasClass: () => true, - popover: () => {}, - toggleClass: () => {}, - }; - - jest.spyOn(context, 'popover').mockImplementation((method) => { - expect(method).toEqual('hide'); - done(); - }); - - togglePopover.call(context, false); - }); - - it('removes disable-animation and js-popover-show class', (done) => { - const context = { - hasClass: () => true, - popover: () => {}, - toggleClass: () => {}, - }; - - jest.spyOn(context, 'toggleClass').mockImplementation((classNames, show) => { - expect(classNames).toEqual('disable-animation js-popover-show'); - expect(show).toEqual(false); - done(); - }); - - togglePopover.call(context, false); - }); - }); - }); - - describe('mouseleave', () => { - it('calls hide popover if .popover:hover is false', () => { - const fakeJquery = { - length: 0, - }; - - jest - .spyOn($.fn, 'init') - .mockImplementation((selector) => (selector === '.popover:hover' ? fakeJquery : $.fn)); - jest.spyOn(togglePopover, 'call').mockImplementation(() => {}); - mouseleave(); - - expect(togglePopover.call).toHaveBeenCalledWith(expect.any(Object), false); - }); - - it('does not call hide popover if .popover:hover is true', () => { - const fakeJquery = { - length: 1, - }; - - jest - .spyOn($.fn, 'init') - .mockImplementation((selector) => (selector === '.popover:hover' ? fakeJquery : $.fn)); - jest.spyOn(togglePopover, 'call').mockImplementation(() => {}); - mouseleave(); - - expect(togglePopover.call).not.toHaveBeenCalledWith(false); - }); - }); - - describe('mouseenter', () => { - const context = {}; - - it('shows popover', () => { - jest.spyOn(togglePopover, 'call').mockReturnValue(false); - mouseenter.call(context); - - expect(togglePopover.call).toHaveBeenCalledWith(expect.any(Object), true); - }); - - it('registers mouseleave event if popover is showed', (done) => { - jest.spyOn(togglePopover, 'call').mockReturnValue(true); - jest.spyOn($.fn, 'on').mockImplementation((eventName) => { - expect(eventName).toEqual('mouseleave'); - done(); - }); - mouseenter.call(context); - }); - - it('does not register mouseleave event if popover is not showed', () => { - jest.spyOn(togglePopover, 'call').mockReturnValue(false); - const spy = jest.spyOn($.fn, 'on').mockImplementation(() => {}); - mouseenter.call(context); - - expect(spy).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js index 1650dd2c1ca..fc5eeee9687 100644 --- a/spec/frontend/shortcuts_spec.js +++ b/spec/frontend/shortcuts_spec.js @@ -20,8 +20,6 @@ describe('Shortcuts', () => { target, }); - preloadFixtures(fixtureName); - beforeEach(() => { loadFixtures(fixtureName); diff --git a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap b/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap deleted file mode 100644 index 2367667544d..00000000000 --- a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap +++ /dev/null @@ -1,199 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Confidential Issue Sidebar Block renders for confidential = false and isEditable = false 1`] = ` -<div - class="block issuable-sidebar-item confidentiality" - iid="" -> - <div - class="sidebar-collapsed-icon" - title="Not confidential" - > - <gl-icon-stub - name="eye" - size="16" - /> - </div> - - <div - class="title hide-collapsed" - > - - Confidentiality - - <!----> - </div> - - <div - class="value sidebar-item-value hide-collapsed" - > - <!----> - - <div - class="no-value sidebar-item-value" - data-testid="not-confidential" - > - <gl-icon-stub - class="sidebar-item-icon inline" - name="eye" - size="16" - /> - - Not confidential - - </div> - </div> -</div> -`; - -exports[`Confidential Issue Sidebar Block renders for confidential = false and isEditable = true 1`] = ` -<div - class="block issuable-sidebar-item confidentiality" - iid="" -> - <div - class="sidebar-collapsed-icon" - title="Not confidential" - > - <gl-icon-stub - name="eye" - size="16" - /> - </div> - - <div - class="title hide-collapsed" - > - - Confidentiality - - <a - class="float-right confidential-edit" - data-track-event="click_edit_button" - data-track-label="right_sidebar" - data-track-property="confidentiality" - href="#" - > - Edit - </a> - </div> - - <div - class="value sidebar-item-value hide-collapsed" - > - <!----> - - <div - class="no-value sidebar-item-value" - data-testid="not-confidential" - > - <gl-icon-stub - class="sidebar-item-icon inline" - name="eye" - size="16" - /> - - Not confidential - - </div> - </div> -</div> -`; - -exports[`Confidential Issue Sidebar Block renders for confidential = true and isEditable = false 1`] = ` -<div - class="block issuable-sidebar-item confidentiality" - iid="" -> - <div - class="sidebar-collapsed-icon" - title="Confidential" - > - <gl-icon-stub - name="eye-slash" - size="16" - /> - </div> - - <div - class="title hide-collapsed" - > - - Confidentiality - - <!----> - </div> - - <div - class="value sidebar-item-value hide-collapsed" - > - <!----> - - <div - class="value sidebar-item-value hide-collapsed" - > - <gl-icon-stub - class="sidebar-item-icon inline is-active" - name="eye-slash" - size="16" - /> - - This issue is confidential - - </div> - </div> -</div> -`; - -exports[`Confidential Issue Sidebar Block renders for confidential = true and isEditable = true 1`] = ` -<div - class="block issuable-sidebar-item confidentiality" - iid="" -> - <div - class="sidebar-collapsed-icon" - title="Confidential" - > - <gl-icon-stub - name="eye-slash" - size="16" - /> - </div> - - <div - class="title hide-collapsed" - > - - Confidentiality - - <a - class="float-right confidential-edit" - data-track-event="click_edit_button" - data-track-label="right_sidebar" - data-track-property="confidentiality" - href="#" - > - Edit - </a> - </div> - - <div - class="value sidebar-item-value hide-collapsed" - > - <!----> - - <div - class="value sidebar-item-value hide-collapsed" - > - <gl-icon-stub - class="sidebar-item-icon inline is-active" - name="eye-slash" - size="16" - /> - - This issue is confidential - - </div> - </div> -</div> -`; diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js new file mode 100644 index 00000000000..8844e1626cd --- /dev/null +++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js @@ -0,0 +1,71 @@ +import { GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import SidebarConfidentialityContent from '~/sidebar/components/confidential/sidebar_confidentiality_content.vue'; + +describe('Sidebar Confidentiality Content', () => { + let wrapper; + + const findIcon = () => wrapper.findComponent(GlIcon); + const findText = () => wrapper.find('[data-testid="confidential-text"]'); + const findCollapsedIcon = () => wrapper.find('[data-testid="sidebar-collapsed-icon"]'); + + const createComponent = ({ confidential = false, issuableType = 'issue' } = {}) => { + wrapper = shallowMount(SidebarConfidentialityContent, { + propsData: { + confidential, + issuableType, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('emits `expandSidebar` event on collapsed icon click', () => { + createComponent(); + findCollapsedIcon().trigger('click'); + + expect(wrapper.emitted('expandSidebar')).toHaveLength(1); + }); + + describe('when issue is non-confidential', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders a non-confidential icon', () => { + expect(findIcon().props('name')).toBe('eye'); + }); + + it('does not add `is-active` class to the icon', () => { + expect(findIcon().classes()).not.toContain('is-active'); + }); + + it('displays a non-confidential text', () => { + expect(findText().text()).toBe('Not confidential'); + }); + }); + + describe('when issue is confidential', () => { + it('renders a confidential icon', () => { + createComponent({ confidential: true }); + expect(findIcon().props('name')).toBe('eye-slash'); + }); + + it('adds `is-active` class to the icon', () => { + createComponent({ confidential: true }); + expect(findIcon().classes()).toContain('is-active'); + }); + + it('displays a correct confidential text for issue', () => { + createComponent({ confidential: true }); + expect(findText().text()).toBe('This issue is confidential'); + }); + + it('displays a correct confidential text for epic', () => { + createComponent({ confidential: true, issuableType: 'epic' }); + expect(findText().text()).toBe('This epic is confidential'); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js new file mode 100644 index 00000000000..d5e6310ed38 --- /dev/null +++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js @@ -0,0 +1,173 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import SidebarConfidentialityForm from '~/sidebar/components/confidential/sidebar_confidentiality_form.vue'; +import { confidentialityQueries } from '~/sidebar/constants'; + +jest.mock('~/flash'); + +describe('Sidebar Confidentiality Form', () => { + let wrapper; + + const findWarningMessage = () => wrapper.find(`[data-testid="warning-message"]`); + const findConfidentialToggle = () => wrapper.find(`[data-testid="confidential-toggle"]`); + const findCancelButton = () => wrapper.find(`[data-testid="confidential-cancel"]`); + + const createComponent = ({ + props = {}, + mutate = jest.fn().mockResolvedValue('Success'), + } = {}) => { + wrapper = shallowMount(SidebarConfidentialityForm, { + provide: { + fullPath: 'group/project', + iid: '1', + }, + propsData: { + confidential: false, + issuableType: 'issue', + ...props, + }, + mocks: { + $apollo: { + mutate, + }, + }, + stubs: { + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('emits a `closeForm` event when Cancel button is clicked', () => { + createComponent(); + findCancelButton().vm.$emit('click'); + + expect(wrapper.emitted().closeForm).toHaveLength(1); + }); + + it('renders a loading state after clicking on turn on/off button', async () => { + createComponent(); + findConfidentialToggle().vm.$emit('click', new MouseEvent('click')); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled(); + await nextTick(); + expect(findConfidentialToggle().props('loading')).toBe(true); + }); + + it('creates a flash if mutation is rejected', async () => { + createComponent({ mutate: jest.fn().mockRejectedValue('Error!') }); + findConfidentialToggle().vm.$emit('click', new MouseEvent('click')); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'Something went wrong while setting issue confidentiality.', + }); + }); + + it('creates a flash if mutation contains errors', async () => { + createComponent({ + mutate: jest.fn().mockResolvedValue({ + data: { issuableSetConfidential: { errors: ['Houston, we have a problem!'] } }, + }), + }); + findConfidentialToggle().vm.$emit('click', new MouseEvent('click')); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'Houston, we have a problem!', + }); + }); + + describe('when issue is not confidential', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders a message about making an issue confidential', () => { + expect(findWarningMessage().text()).toBe( + 'You are going to turn on confidentiality. Only team members with at least Reporter access will be able to see and leave comments on the issue.', + ); + }); + + it('has a `Turn on` button text', () => { + expect(findConfidentialToggle().text()).toBe('Turn on'); + }); + + it('calls a mutation to set confidential to true on button click', () => { + findConfidentialToggle().vm.$emit('click', new MouseEvent('click')); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: confidentialityQueries[wrapper.vm.issuableType].mutation, + variables: { + input: { + confidential: true, + iid: '1', + projectPath: 'group/project', + }, + }, + }); + }); + }); + + describe('when issue is confidential', () => { + beforeEach(() => { + createComponent({ props: { confidential: true } }); + }); + + it('renders a message about making an issue non-confidential', () => { + expect(findWarningMessage().text()).toBe( + 'You are going to turn off the confidentiality. This means everyone will be able to see and leave a comment on this issue.', + ); + }); + + it('has a `Turn off` button text', () => { + expect(findConfidentialToggle().text()).toBe('Turn off'); + }); + + it('calls a mutation to set confidential to false on button click', () => { + findConfidentialToggle().vm.$emit('click', new MouseEvent('click')); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: confidentialityQueries[wrapper.vm.issuableType].mutation, + variables: { + input: { + confidential: false, + iid: '1', + projectPath: 'group/project', + }, + }, + }); + }); + }); + + describe('when issuable type is `epic`', () => { + beforeEach(() => { + createComponent({ props: { confidential: true, issuableType: 'epic' } }); + }); + + it('renders a message about making an epic non-confidential', () => { + expect(findWarningMessage().text()).toBe( + 'You are going to turn off the confidentiality. This means everyone will be able to see and leave a comment on this epic.', + ); + }); + + it('calls a mutation to set epic confidentiality with correct parameters', () => { + findConfidentialToggle().vm.$emit('click', new MouseEvent('click')); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: confidentialityQueries[wrapper.vm.issuableType].mutation, + variables: { + input: { + confidential: false, + iid: '1', + groupPath: 'group/project', + }, + }, + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js new file mode 100644 index 00000000000..20a5be9b518 --- /dev/null +++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js @@ -0,0 +1,159 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import SidebarConfidentialityContent from '~/sidebar/components/confidential/sidebar_confidentiality_content.vue'; +import SidebarConfidentialityForm from '~/sidebar/components/confidential/sidebar_confidentiality_form.vue'; +import SidebarConfidentialityWidget, { + confidentialWidget, +} from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql'; +import { issueConfidentialityResponse } from '../../mock_data'; + +jest.mock('~/flash'); + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('Sidebar Confidentiality Widget', () => { + let wrapper; + let fakeApollo; + + const findEditableItem = () => wrapper.findComponent(SidebarEditableItem); + const findConfidentialityForm = () => wrapper.findComponent(SidebarConfidentialityForm); + const findConfidentialityContent = () => wrapper.findComponent(SidebarConfidentialityContent); + + const createComponent = ({ + confidentialQueryHandler = jest.fn().mockResolvedValue(issueConfidentialityResponse()), + } = {}) => { + fakeApollo = createMockApollo([[issueConfidentialQuery, confidentialQueryHandler]]); + + wrapper = shallowMount(SidebarConfidentialityWidget, { + localVue, + apolloProvider: fakeApollo, + provide: { + fullPath: 'group/project', + iid: '1', + canUpdate: true, + }, + propsData: { + issuableType: 'issue', + }, + stubs: { + SidebarEditableItem, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + it('passes a `loading` prop as true to editable item when query is loading', () => { + createComponent(); + + expect(findEditableItem().props('loading')).toBe(true); + }); + + it('exposes a method via external observable', () => { + createComponent(); + + expect(confidentialWidget.setConfidentiality).toEqual(wrapper.vm.setConfidentiality); + }); + + describe('when issue is not confidential', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('passes a `loading` prop as false to editable item', () => { + expect(findEditableItem().props('loading')).toBe(false); + }); + + it('passes false to `confidential` prop of child components', () => { + expect(findConfidentialityForm().props('confidential')).toBe(false); + expect(findConfidentialityContent().props('confidential')).toBe(false); + }); + + it('changes confidentiality to true after setConfidentiality is called', async () => { + confidentialWidget.setConfidentiality(); + await nextTick(); + expect(findConfidentialityForm().props('confidential')).toBe(true); + expect(findConfidentialityContent().props('confidential')).toBe(true); + }); + + it('emits `confidentialityUpdated` event with a `false` payload', () => { + expect(wrapper.emitted('confidentialityUpdated')).toEqual([[false]]); + }); + }); + + describe('when issue is confidential', () => { + beforeEach(async () => { + createComponent({ + confidentialQueryHandler: jest.fn().mockResolvedValue(issueConfidentialityResponse(true)), + }); + await waitForPromises(); + }); + + it('passes a `loading` prop as false to editable item', () => { + expect(findEditableItem().props('loading')).toBe(false); + }); + + it('passes false to `confidential` prop of child components', () => { + expect(findConfidentialityForm().props('confidential')).toBe(true); + expect(findConfidentialityContent().props('confidential')).toBe(true); + }); + + it('changes confidentiality to false after setConfidentiality is called', async () => { + confidentialWidget.setConfidentiality(); + await nextTick(); + expect(findConfidentialityForm().props('confidential')).toBe(false); + expect(findConfidentialityContent().props('confidential')).toBe(false); + }); + + it('emits `confidentialityUpdated` event with a `true` payload', () => { + expect(wrapper.emitted('confidentialityUpdated')).toEqual([[true]]); + }); + }); + + it('displays a flash message when query is rejected', async () => { + createComponent({ + confidentialQueryHandler: jest.fn().mockRejectedValue('Houston, we have a problem'), + }); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalled(); + }); + + it('closes the form and dispatches an event when `closeForm` is emitted', async () => { + createComponent(); + const el = wrapper.vm.$el; + jest.spyOn(el, 'dispatchEvent'); + + await waitForPromises(); + wrapper.vm.$refs.editable.expand(); + await nextTick(); + + expect(findConfidentialityForm().isVisible()).toBe(true); + + findConfidentialityForm().vm.$emit('closeForm'); + await nextTick(); + expect(findConfidentialityForm().isVisible()).toBe(false); + + expect(el.dispatchEvent).toHaveBeenCalled(); + expect(wrapper.emitted('closeForm')).toHaveLength(1); + }); + + it('emits `expandSidebar` event when it is emitted from child component', async () => { + createComponent(); + await waitForPromises(); + findConfidentialityContent().vm.$emit('expandSidebar'); + + expect(wrapper.emitted('expandSidebar')).toHaveLength(1); + }); +}); diff --git a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js index 7c67149b517..9f6878db785 100644 --- a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js +++ b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js @@ -7,6 +7,8 @@ import userDataMock from '../../user_data_mock'; describe('UncollapsedReviewerList component', () => { let wrapper; + const reviewerApprovalIcons = () => wrapper.findAll('[data-testid="re-approved"]'); + function createComponent(props = {}) { const propsData = { users: [], @@ -58,19 +60,29 @@ describe('UncollapsedReviewerList component', () => { const user = userDataMock(); createComponent({ - users: [user, { ...user, id: 2, username: 'hello-world' }], + users: [user, { ...user, id: 2, username: 'hello-world', approved: true }], }); }); - it('only has one user', () => { + it('has both users', () => { expect(wrapper.findAll(ReviewerAvatarLink).length).toBe(2); }); - it('shows one user with avatar, username and author name', () => { + it('shows both users with avatar, username and author name', () => { expect(wrapper.text()).toContain(`@root`); expect(wrapper.text()).toContain(`@hello-world`); }); + it('renders approval icon', () => { + expect(reviewerApprovalIcons().length).toBe(1); + }); + + it('shows that hello-world approved', () => { + const icon = reviewerApprovalIcons().at(0); + + expect(icon.attributes('title')).toEqual('Approved by @hello-world'); + }); + it('renders re-request loading icon', async () => { await wrapper.setData({ loadingStates: { 2: 'loading' } }); diff --git a/spec/frontend/sidebar/confidential/__snapshots__/edit_form_spec.js.snap b/spec/frontend/sidebar/confidential/__snapshots__/edit_form_spec.js.snap deleted file mode 100644 index d33f6c7f389..00000000000 --- a/spec/frontend/sidebar/confidential/__snapshots__/edit_form_spec.js.snap +++ /dev/null @@ -1,50 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Edit Form Dropdown when confidential renders on or off text based on confidentiality 1`] = ` -<div - class="dropdown show" - toggleform="function () {}" - updateconfidentialattribute="function () {}" -> - <div - class="dropdown-menu sidebar-item-warning-message" - > - <div> - <p> - <gl-sprintf-stub - message="You are going to turn off the confidentiality. This means %{strongStart}everyone%{strongEnd} will be able to see and leave a comment on this %{issuableType}." - /> - </p> - - <edit-form-buttons-stub - confidential="true" - fullpath="" - /> - </div> - </div> -</div> -`; - -exports[`Edit Form Dropdown when not confidential renders "You are going to turn on the confidentiality." in the 1`] = ` -<div - class="dropdown show" - toggleform="function () {}" - updateconfidentialattribute="function () {}" -> - <div - class="dropdown-menu sidebar-item-warning-message" - > - <div> - <p> - <gl-sprintf-stub - message="You are going to turn on the confidentiality. This means that only team members with %{strongStart}at least Reporter access%{strongEnd} are able to see and leave comments on the %{issuableType}." - /> - </p> - - <edit-form-buttons-stub - fullpath="" - /> - </div> - </div> -</div> -`; diff --git a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js deleted file mode 100644 index 427e3a89c29..00000000000 --- a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js +++ /dev/null @@ -1,146 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import waitForPromises from 'helpers/wait_for_promises'; -import { deprecatedCreateFlash as flash } from '~/flash'; -import createStore from '~/notes/stores'; -import EditFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue'; -import eventHub from '~/sidebar/event_hub'; - -jest.mock('~/sidebar/event_hub', () => ({ $emit: jest.fn() })); -jest.mock('~/flash'); - -describe('Edit Form Buttons', () => { - let wrapper; - let store; - const findConfidentialToggle = () => wrapper.find('[data-testid="confidential-toggle"]'); - - const createComponent = ({ props = {}, data = {}, resolved = true }) => { - store = createStore(); - if (resolved) { - jest.spyOn(store, 'dispatch').mockResolvedValue(); - } else { - jest.spyOn(store, 'dispatch').mockRejectedValue(); - } - - wrapper = shallowMount(EditFormButtons, { - propsData: { - fullPath: '', - ...props, - }, - data() { - return { - isLoading: true, - ...data, - }; - }, - store, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('when isLoading', () => { - beforeEach(() => { - createComponent({ - props: { - confidential: false, - }, - }); - }); - - it('renders "Applying" in the toggle button', () => { - expect(findConfidentialToggle().text()).toBe('Applying'); - }); - - it('disables the toggle button', () => { - expect(findConfidentialToggle().props('disabled')).toBe(true); - }); - - it('sets loading on the toggle button', () => { - expect(findConfidentialToggle().props('loading')).toBe(true); - }); - }); - - describe('when not confidential', () => { - it('renders Turn On in the toggle button', () => { - createComponent({ - data: { - isLoading: false, - }, - props: { - confidential: false, - }, - }); - - expect(findConfidentialToggle().text()).toBe('Turn On'); - }); - }); - - describe('when confidential', () => { - beforeEach(() => { - createComponent({ - data: { - isLoading: false, - }, - props: { - confidential: true, - }, - }); - }); - - it('renders on or off text based on confidentiality', () => { - expect(findConfidentialToggle().text()).toBe('Turn Off'); - }); - }); - - describe('when succeeds', () => { - beforeEach(() => { - createComponent({ data: { isLoading: false }, props: { confidential: true } }); - findConfidentialToggle().vm.$emit('click', new Event('click')); - }); - - it('dispatches the correct action', () => { - expect(store.dispatch).toHaveBeenCalledWith('updateConfidentialityOnIssuable', { - confidential: false, - fullPath: '', - }); - }); - - it('resets loading on the toggle button', () => { - return waitForPromises().then(() => { - expect(findConfidentialToggle().props('loading')).toBe(false); - }); - }); - - it('emits close form', () => { - return waitForPromises().then(() => { - expect(eventHub.$emit).toHaveBeenCalledWith('closeConfidentialityForm'); - }); - }); - - it('emits updateOnConfidentiality event', () => { - return waitForPromises().then(() => { - expect(eventHub.$emit).toHaveBeenCalledWith('updateIssuableConfidentiality', false); - }); - }); - }); - - describe('when fails', () => { - beforeEach(() => { - createComponent({ - data: { isLoading: false }, - props: { confidential: true }, - resolved: false, - }); - findConfidentialToggle().vm.$emit('click', new Event('click')); - }); - - it('calls flash with the correct message', () => { - expect(flash).toHaveBeenCalledWith( - 'Something went wrong trying to change the confidentiality of this issue', - ); - }); - }); -}); diff --git a/spec/frontend/sidebar/confidential/edit_form_spec.js b/spec/frontend/sidebar/confidential/edit_form_spec.js deleted file mode 100644 index 6b571df10ae..00000000000 --- a/spec/frontend/sidebar/confidential/edit_form_spec.js +++ /dev/null @@ -1,48 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import EditForm from '~/sidebar/components/confidential/edit_form.vue'; - -describe('Edit Form Dropdown', () => { - let wrapper; - const toggleForm = () => {}; - const updateConfidentialAttribute = () => {}; - - const createComponent = (props) => { - wrapper = shallowMount(EditForm, { - propsData: { - ...props, - isLoading: false, - fullPath: '', - issuableType: 'issue', - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('when not confidential', () => { - it('renders "You are going to turn on the confidentiality." in the ', () => { - createComponent({ - confidential: false, - toggleForm, - updateConfidentialAttribute, - }); - - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - describe('when confidential', () => { - it('renders on or off text based on confidentiality', () => { - createComponent({ - confidential: true, - toggleForm, - updateConfidentialAttribute, - }); - - expect(wrapper.element).toMatchSnapshot(); - }); - }); -}); diff --git a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js b/spec/frontend/sidebar/confidential_issue_sidebar_spec.js deleted file mode 100644 index 93a6401b1fc..00000000000 --- a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js +++ /dev/null @@ -1,159 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; -import { mockTracking, triggerEvent } from 'helpers/tracking_helper'; -import createStore from '~/notes/stores'; -import * as types from '~/notes/stores/mutation_types'; -import ConfidentialIssueSidebar from '~/sidebar/components/confidential/confidential_issue_sidebar.vue'; -import EditForm from '~/sidebar/components/confidential/edit_form.vue'; - -jest.mock('~/flash'); -jest.mock('~/sidebar/services/sidebar_service'); - -describe('Confidential Issue Sidebar Block', () => { - useMockLocationHelper(); - - let wrapper; - const mutate = jest - .fn() - .mockResolvedValue({ data: { issueSetConfidential: { issue: { confidential: true } } } }); - - const createComponent = ({ propsData, data = {} }) => { - const store = createStore(); - wrapper = shallowMount(ConfidentialIssueSidebar, { - store, - data() { - return data; - }, - propsData: { - iid: '', - fullPath: '', - ...propsData, - }, - mocks: { - $apollo: { - mutate, - }, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it.each` - confidential | isEditable - ${false} | ${false} - ${false} | ${true} - ${true} | ${false} - ${true} | ${true} - `( - 'renders for confidential = $confidential and isEditable = $isEditable', - ({ confidential, isEditable }) => { - createComponent({ - propsData: { - isEditable, - }, - }); - wrapper.vm.$store.state.noteableData.confidential = confidential; - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); - }); - }, - ); - - describe('if editable', () => { - beforeEach(() => { - createComponent({ - propsData: { - isEditable: true, - }, - }); - wrapper.vm.$store.state.noteableData.confidential = true; - }); - - it('displays the edit form when editable', () => { - wrapper.setData({ edit: false }); - - return wrapper.vm - .$nextTick() - .then(() => { - wrapper.find({ ref: 'editLink' }).trigger('click'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(wrapper.find(EditForm).exists()).toBe(true); - }); - }); - - it('displays the edit form when opened from collapsed state', () => { - wrapper.setData({ edit: false }); - - return wrapper.vm - .$nextTick() - .then(() => { - wrapper.find({ ref: 'collapseIcon' }).trigger('click'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(wrapper.find(EditForm).exists()).toBe(true); - }); - }); - - it('tracks the event when "Edit" is clicked', () => { - const spy = mockTracking('_category_', wrapper.element, jest.spyOn); - - const editLink = wrapper.find({ ref: 'editLink' }); - triggerEvent(editLink.element); - - expect(spy).toHaveBeenCalledWith('_category_', 'click_edit_button', { - label: 'right_sidebar', - property: 'confidentiality', - }); - }); - }); - describe('computed confidential', () => { - beforeEach(() => { - createComponent({ - propsData: { - isEditable: true, - }, - }); - }); - - it('returns false when noteableData is not present', () => { - wrapper.vm.$store.commit(types.SET_NOTEABLE_DATA, null); - - expect(wrapper.vm.confidential).toBe(false); - }); - - it('returns true when noteableData has confidential attr as true', () => { - wrapper.vm.$store.commit(types.SET_NOTEABLE_DATA, {}); - wrapper.vm.$store.commit(types.SET_ISSUE_CONFIDENTIAL, true); - - expect(wrapper.vm.confidential).toBe(true); - }); - - it('returns false when noteableData has confidential attr as false', () => { - wrapper.vm.$store.commit(types.SET_NOTEABLE_DATA, {}); - wrapper.vm.$store.commit(types.SET_ISSUE_CONFIDENTIAL, false); - - expect(wrapper.vm.confidential).toBe(false); - }); - - it('returns true when confidential attr is true', () => { - wrapper.vm.$store.commit(types.SET_NOTEABLE_DATA, {}); - wrapper.vm.$store.commit(types.SET_ISSUE_CONFIDENTIAL, true); - - expect(wrapper.vm.confidential).toBe(true); - }); - - it('returns false when confidential attr is false', () => { - wrapper.vm.$store.commit(types.SET_NOTEABLE_DATA, {}); - wrapper.vm.$store.commit(types.SET_ISSUE_CONFIDENTIAL, false); - - expect(wrapper.vm.confidential).toBe(false); - }); - }); -}); diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index 3dde40260eb..6268499b4e8 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -220,4 +220,17 @@ const mockData = { }, }; +export const issueConfidentialityResponse = (confidential = false) => ({ + data: { + workspace: { + __typename: 'Project', + issuable: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/4', + confidential, + }, + }, + }, +}); + export default mockData; diff --git a/spec/frontend/sidebar/subscriptions_spec.js b/spec/frontend/sidebar/subscriptions_spec.js index e7ae59e26cf..6ab8e1e0ebc 100644 --- a/spec/frontend/sidebar/subscriptions_spec.js +++ b/spec/frontend/sidebar/subscriptions_spec.js @@ -84,6 +84,15 @@ describe('Subscriptions', () => { spy.mockRestore(); }); + it('has visually hidden label', () => { + wrapper = mountComponent(); + + expect(findToggleButton().props()).toMatchObject({ + label: 'Notifications', + labelPosition: 'hidden', + }); + }); + describe('given project emails are disabled', () => { const subscribeDisabledDescription = 'Notifications have been disabled'; diff --git a/spec/frontend/sidebar/user_data_mock.js b/spec/frontend/sidebar/user_data_mock.js index 41d0331f34a..7c11551b0be 100644 --- a/spec/frontend/sidebar/user_data_mock.js +++ b/spec/frontend/sidebar/user_data_mock.js @@ -10,4 +10,5 @@ export default () => ({ can_merge: true, can_update_merge_request: true, reviewed: true, + approved: false, }); diff --git a/spec/frontend/single_file_diff_spec.js b/spec/frontend/single_file_diff_spec.js new file mode 100644 index 00000000000..8718152655f --- /dev/null +++ b/spec/frontend/single_file_diff_spec.js @@ -0,0 +1,96 @@ +import MockAdapter from 'axios-mock-adapter'; +import $ from 'jquery'; +import { setHTMLFixture } from 'helpers/fixtures'; +import axios from '~/lib/utils/axios_utils'; +import SingleFileDiff from '~/single_file_diff'; + +describe('SingleFileDiff', () => { + let mock = new MockAdapter(axios); + const blobDiffPath = '/mock-path'; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet(blobDiffPath).replyOnce(200, { html: `<div class="diff-content">MOCKED</div>` }); + }); + + afterEach(() => { + mock.restore(); + }); + + it('loads diff via axios exactly once for collapsed diffs', async () => { + setHTMLFixture(` + <div class="diff-file"> + <div class="js-file-title"> + MOCK TITLE + </div> + + <div class="diff-content"> + <div class="diff-viewer" data-type="simple"> + <div + class="nothing-here-block diff-collapsed" + data-diff-for-path="${blobDiffPath}" + > + MOCK CONTENT + </div> + </div> + </div> + </div> +`); + + // Collapsed is the default state + const diff = new SingleFileDiff(document.querySelector('.diff-file')); + expect(diff.isOpen).toBe(false); + expect(diff.content).toBeNull(); + expect(diff.diffForPath).toEqual(blobDiffPath); + + // Opening for the first time + await diff.toggleDiff($(document.querySelector('.js-file-title'))); + expect(diff.isOpen).toBe(true); + expect(diff.content).not.toBeNull(); + + // Collapsing again + await diff.toggleDiff($(document.querySelector('.js-file-title'))); + expect(diff.isOpen).toBe(false); + expect(diff.content).not.toBeNull(); + + mock.onGet(blobDiffPath).replyOnce(400, ''); + + // Opening again + await diff.toggleDiff($(document.querySelector('.js-file-title'))); + expect(diff.isOpen).toBe(true); + expect(diff.content).not.toBeNull(); + + expect(mock.history.get.length).toBe(1); + }); + + it('does not load diffs via axios for already expanded diffs', async () => { + setHTMLFixture(` + <div class="diff-file"> + <div class="js-file-title"> + MOCK TITLE + </div> + + <div class="diff-content"> + EXPANDED MOCK CONTENT + </div> + </div> +`); + + // Opened is the default state + const diff = new SingleFileDiff(document.querySelector('.diff-file')); + expect(diff.isOpen).toBe(true); + expect(diff.content).not.toBeNull(); + expect(diff.diffForPath).toEqual(undefined); + + // Collapsing for the first time + await diff.toggleDiff($(document.querySelector('.js-file-title'))); + expect(diff.isOpen).toBe(false); + expect(diff.content).not.toBeNull(); + + // Opening again + await diff.toggleDiff($(document.querySelector('.js-file-title'))); + expect(diff.isOpen).toBe(true); + + expect(mock.history.get.length).toBe(0); + }); +}); diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap index 8446f0f50c4..95da67c2bbf 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap @@ -46,6 +46,8 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] = <span class="font-weight-bold ml-1 js-visibility-option" + data-qa-selector="visibility_content" + data-qa-visibility="Private" > Private </span> @@ -65,6 +67,8 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] = <span class="font-weight-bold ml-1 js-visibility-option" + data-qa-selector="visibility_content" + data-qa-visibility="Internal" > Internal </span> @@ -84,6 +88,8 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] = <span class="font-weight-bold ml-1 js-visibility-option" + data-qa-selector="visibility_content" + data-qa-visibility="Public" > Public </span> diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js index b6b29faef79..9b95ed6b816 100644 --- a/spec/frontend/test_setup.js +++ b/spec/frontend/test_setup.js @@ -44,11 +44,6 @@ Object.assign(global, { getJSONFixture, loadFixtures: loadHTMLFixture, setFixtures: setHTMLFixture, - - // The following functions fill the fixtures cache in Karma. - // This is not necessary in Jest because we make no Ajax request. - loadJSONFixtures() {}, - preloadFixtures() {}, }); // custom-jquery-matchers was written for an old Jest version, we need to make it compatible diff --git a/spec/frontend/tooltips/components/tooltips_spec.js b/spec/frontend/tooltips/components/tooltips_spec.js index e21626456e2..c44918ceaf3 100644 --- a/spec/frontend/tooltips/components/tooltips_spec.js +++ b/spec/frontend/tooltips/components/tooltips_spec.js @@ -217,4 +217,14 @@ describe('tooltips/components/tooltips.vue', () => { wrapper.destroy(); expect(observersCount()).toBe(0); }); + + it('exposes hidden event', async () => { + buildWrapper(); + wrapper.vm.addTooltips([createTooltipTarget()]); + + await wrapper.vm.$nextTick(); + + wrapper.findComponent(GlTooltip).vm.$emit('hidden'); + expect(wrapper.emitted('hidden')).toHaveLength(1); + }); }); diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js index a516a4a8269..6a22de3be5c 100644 --- a/spec/frontend/tracking_spec.js +++ b/spec/frontend/tracking_spec.js @@ -1,5 +1,9 @@ import { setHTMLFixture } from 'helpers/fixtures'; -import Tracking, { initUserTracking, initDefaultTrackers } from '~/tracking'; +import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; +import { getExperimentData } from '~/experimentation/utils'; +import Tracking, { initUserTracking, initDefaultTrackers, STANDARD_CONTEXT } from '~/tracking'; + +jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn() })); describe('Tracking', () => { let snowplowSpy; @@ -7,6 +11,8 @@ describe('Tracking', () => { let trackLoadEventsSpy; beforeEach(() => { + getExperimentData.mockReturnValue(undefined); + window.snowplow = window.snowplow || (() => {}); window.snowplowOptions = { namespace: '_namespace_', @@ -45,7 +51,7 @@ describe('Tracking', () => { it('should activate features based on what has been enabled', () => { initDefaultTrackers(); expect(snowplowSpy).toHaveBeenCalledWith('enableActivityTracking', 30, 30); - expect(snowplowSpy).toHaveBeenCalledWith('trackPageView'); + expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [STANDARD_CONTEXT]); expect(snowplowSpy).not.toHaveBeenCalledWith('enableFormTracking'); expect(snowplowSpy).not.toHaveBeenCalledWith('enableLinkClickTracking'); @@ -78,6 +84,34 @@ describe('Tracking', () => { navigator.msDoNotTrack = undefined; }); + describe('builds the standard context', () => { + let standardContext; + + beforeAll(async () => { + window.gl = window.gl || {}; + window.gl.snowplowStandardContext = { + schema: 'iglu:com.gitlab/gitlab_standard', + data: { + environment: 'testing', + source: 'unknown', + }, + }; + + jest.resetModules(); + + ({ STANDARD_CONTEXT: standardContext } = await import('~/tracking')); + }); + + it('uses server data', () => { + expect(standardContext.schema).toBe('iglu:com.gitlab/gitlab_standard'); + expect(standardContext.data.environment).toBe('testing'); + }); + + it('overrides schema source', () => { + expect(standardContext.data.source).toBe('gitlab-javascript'); + }); + }); + it('tracks to snowplow (our current tracking system)', () => { Tracking.event('_category_', '_eventName_', { label: '_label_' }); @@ -88,7 +122,7 @@ describe('Tracking', () => { '_label_', undefined, undefined, - undefined, + [STANDARD_CONTEXT], ); }); @@ -121,6 +155,27 @@ describe('Tracking', () => { }); }); + describe('.flushPendingEvents', () => { + it('flushes any pending events', () => { + Tracking.initialized = false; + Tracking.event('_category_', '_eventName_', { label: '_label_' }); + + expect(snowplowSpy).not.toHaveBeenCalled(); + + Tracking.flushPendingEvents(); + + expect(snowplowSpy).toHaveBeenCalledWith( + 'trackStructEvent', + '_category_', + '_eventName_', + '_label_', + undefined, + undefined, + [STANDARD_CONTEXT], + ); + }); + }); + describe('tracking interface events', () => { let eventSpy; @@ -134,6 +189,7 @@ describe('Tracking', () => { <input class="dropdown" data-track-event="toggle_dropdown"/> <div data-track-event="nested_event"><span class="nested"></span></div> <input data-track-eventbogus="click_bogusinput" data-track-label="_label_" value="_value_"/> + <input data-track-event="click_input3" data-track-experiment="example" value="_value_"/> `); }); @@ -193,6 +249,22 @@ describe('Tracking', () => { expect(eventSpy).toHaveBeenCalledWith('_category_', 'nested_event', {}); }); + + it('brings in experiment data if linked to an experiment', () => { + const mockExperimentData = { + variant: 'candidate', + experiment: 'repo_integrations_link', + key: '2bff73f6bb8cc11156c50a8ba66b9b8b', + }; + getExperimentData.mockReturnValue(mockExperimentData); + + document.querySelector('[data-track-event="click_input3"]').click(); + + expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input3', { + value: '_value_', + context: { schema: TRACKING_CONTEXT_SCHEMA, data: mockExperimentData }, + }); + }); }); describe('tracking page loaded events', () => { @@ -235,21 +307,21 @@ describe('Tracking', () => { describe('tracking mixin', () => { describe('trackingOptions', () => { - it('return the options defined on initialisation', () => { + it('returns the options defined on initialisation', () => { const mixin = Tracking.mixin({ foo: 'bar' }); expect(mixin.computed.trackingOptions()).toEqual({ foo: 'bar' }); }); - it('local tracking value override and extend options', () => { + it('lets local tracking value override and extend options', () => { const mixin = Tracking.mixin({ foo: 'bar' }); - // the value of this in the vue lifecyle is different, but this serve the tests purposes + // The value of this in the Vue lifecyle is different, but this serves the test's purposes mixin.computed.tracking = { foo: 'baz', baz: 'bar' }; expect(mixin.computed.trackingOptions()).toEqual({ foo: 'baz', baz: 'bar' }); }); }); describe('trackingCategory', () => { - it('return the category set in the component properties first', () => { + it('returns the category set in the component properties first', () => { const mixin = Tracking.mixin({ category: 'foo' }); mixin.computed.tracking = { category: 'bar', @@ -257,12 +329,12 @@ describe('Tracking', () => { expect(mixin.computed.trackingCategory()).toBe('bar'); }); - it('return the category set in the options', () => { + it('returns the category set in the options', () => { const mixin = Tracking.mixin({ category: 'foo' }); expect(mixin.computed.trackingCategory()).toBe('foo'); }); - it('if no category is selected returns undefined', () => { + it('returns undefined if no category is selected', () => { const mixin = Tracking.mixin(); expect(mixin.computed.trackingCategory()).toBe(undefined); }); @@ -297,7 +369,7 @@ describe('Tracking', () => { expect(eventSpy).toHaveBeenCalledWith(undefined, 'foo', {}); }); - it('give precedence to data for category and options', () => { + it('gives precedence to data for category and options', () => { mixin.trackingCategory = mixin.trackingCategory(); mixin.trackingOptions = mixin.trackingOptions(); const data = { category: 'foo', label: 'baz' }; diff --git a/spec/frontend/user_popovers_spec.js b/spec/frontend/user_popovers_spec.js index 7c9c3d69efa..745b66fd700 100644 --- a/spec/frontend/user_popovers_spec.js +++ b/spec/frontend/user_popovers_spec.js @@ -3,9 +3,21 @@ import initUserPopovers from '~/user_popovers'; describe('User Popovers', () => { const fixtureTemplate = 'merge_requests/merge_request_with_mentions.html'; - preloadFixtures(fixtureTemplate); const selector = '.js-user-link, .gfm-project_member'; + const findFixtureLinks = () => { + return Array.from(document.querySelectorAll(selector)).filter( + ({ dataset }) => dataset.user || dataset.userId, + ); + }; + const createUserLink = () => { + const link = document.createElement('a'); + + link.classList.add('js-user-link'); + link.setAttribute('data-user', '1'); + + return link; + }; const dummyUser = { name: 'root' }; const dummyUserStatus = { message: 'active' }; @@ -37,13 +49,20 @@ describe('User Popovers', () => { }); it('initializes a popover for each user link with a user id', () => { - const linksWithUsers = Array.from(document.querySelectorAll(selector)).filter( - ({ dataset }) => dataset.user || dataset.userId, - ); + const linksWithUsers = findFixtureLinks(); expect(linksWithUsers.length).toBe(popovers.length); }); + it('adds popovers to user links added to the DOM tree after the initial call', async () => { + document.body.appendChild(createUserLink()); + document.body.appendChild(createUserLink()); + + const linksWithUsers = findFixtureLinks(); + + expect(linksWithUsers.length).toBe(popovers.length + 2); + }); + it('does not initialize the user popovers twice for the same element', () => { const newPopovers = initUserPopovers(document.querySelectorAll(selector)); const samePopovers = popovers.every((popover, index) => newPopovers[index] === popover); diff --git a/spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js b/spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js index b2cc7d9be6b..e2386bc7f2b 100644 --- a/spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js +++ b/spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js @@ -48,7 +48,7 @@ describe('Merge Requests Artifacts list app', () => { }; const findButtons = () => wrapper.findAll('button'); - const findTitle = () => wrapper.find('.js-title'); + const findTitle = () => wrapper.find('[data-testid="mr-collapsible-title"]'); const findErrorMessage = () => wrapper.find('.js-error-state'); const findTableRows = () => wrapper.findAll('tbody tr'); diff --git a/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js b/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js index 94d4cccab5f..1aeb080aa04 100644 --- a/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js @@ -1,4 +1,4 @@ -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MrCollapsibleSection from '~/vue_merge_request_widget/components/mr_collapsible_extension.vue'; @@ -15,12 +15,14 @@ describe('Merge Request Collapsible Extension', () => { }, slots: { default: '<div class="js-slot">Foo</div>', + header: '<span data-testid="collapsed-header">hello there</span>', }, }); }; - const findTitle = () => wrapper.find('.js-title'); + const findTitle = () => wrapper.find('[data-testid="mr-collapsible-title"]'); const findErrorMessage = () => wrapper.find('.js-error-state'); + const findIcon = () => wrapper.find(GlIcon); afterEach(() => { wrapper.destroy(); @@ -35,8 +37,12 @@ describe('Merge Request Collapsible Extension', () => { expect(findTitle().text()).toBe(data.title); }); + it('renders the header slot', () => { + expect(wrapper.find('[data-testid="collapsed-header"]').text()).toBe('hello there'); + }); + it('renders angle-right icon', () => { - expect(wrapper.vm.arrowIconName).toBe('angle-right'); + expect(findIcon().props('name')).toBe('angle-right'); }); describe('onClick', () => { @@ -54,7 +60,7 @@ describe('Merge Request Collapsible Extension', () => { }); it('renders angle-down icon', () => { - expect(wrapper.vm.arrowIconName).toBe('angle-down'); + expect(findIcon().props('name')).toBe('angle-down'); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js index 3baade5161e..5ec719b17d6 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js @@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import ArtifactsApp from '~/vue_merge_request_widget/components/artifacts_list_app.vue'; +import DeploymentList from '~/vue_merge_request_widget/components/deployment/deployment_list.vue'; import MrWidgetPipeline from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; import MrWidgetPipelineContainer from '~/vue_merge_request_widget/components/mr_widget_pipeline_container.vue'; import { mockStore } from '../mock_data'; @@ -28,6 +29,8 @@ describe('MrWidgetPipelineContainer', () => { wrapper.destroy(); }); + const findDeploymentList = () => wrapper.findComponent(DeploymentList); + describe('when pre merge', () => { beforeEach(() => { factory(); @@ -55,6 +58,9 @@ describe('MrWidgetPipelineContainer', () => { const deployments = wrapper.findAll('.mr-widget-extension .js-pre-deployment'); + expect(findDeploymentList().exists()).toBe(true); + expect(findDeploymentList().props('deployments')).toBe(mockStore.deployments); + expect(deployments.wrappers.map((x) => x.props())).toEqual(expectedProps); }); }); @@ -100,6 +106,8 @@ describe('MrWidgetPipelineContainer', () => { const deployments = wrapper.findAll('.mr-widget-extension .js-post-deployment'); + expect(findDeploymentList().exists()).toBe(true); + expect(findDeploymentList().props('deployments')).toBe(mockStore.postMergeDeployments); expect(deployments.wrappers.map((x) => x.props())).toEqual(expectedProps); }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js index b93236d4628..28492018600 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js @@ -1,7 +1,8 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; import { trimText } from 'helpers/text_helper'; -import PipelineStage from '~/pipelines/components/pipelines_list/stage.vue'; +import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; +import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue'; import PipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; import { SUCCESS } from '~/vue_merge_request_widget/constants'; import mockData from '../mock_data'; @@ -25,7 +26,7 @@ describe('MRWidgetPipeline', () => { const findPipelineID = () => wrapper.find('[data-testid="pipeline-id"]'); const findPipelineInfoContainer = () => wrapper.find('[data-testid="pipeline-info-container"]'); const findCommitLink = () => wrapper.find('[data-testid="commit-link"]'); - const findPipelineGraph = () => wrapper.find('[data-testid="widget-mini-pipeline-graph"]'); + const findPipelineMiniGraph = () => wrapper.find(PipelineMiniGraph); const findAllPipelineStages = () => wrapper.findAll(PipelineStage); const findPipelineCoverage = () => wrapper.find('[data-testid="pipeline-coverage"]'); const findPipelineCoverageDelta = () => wrapper.find('[data-testid="pipeline-coverage-delta"]'); @@ -35,7 +36,7 @@ describe('MRWidgetPipeline', () => { wrapper.find('[data-testid="monitoring-pipeline-message"]'); const findLoadingIcon = () => wrapper.find(GlLoadingIcon); - const createWrapper = (props, mountFn = shallowMount) => { + const createWrapper = (props = {}, mountFn = shallowMount) => { wrapper = mountFn(PipelineComponent, { propsData: { ...defaultProps, @@ -65,10 +66,13 @@ describe('MRWidgetPipeline', () => { describe('with a pipeline', () => { beforeEach(() => { - createWrapper({ - pipelineCoverageDelta: mockData.pipelineCoverageDelta, - buildsWithCoverage: mockData.buildsWithCoverage, - }); + createWrapper( + { + pipelineCoverageDelta: mockData.pipelineCoverageDelta, + buildsWithCoverage: mockData.buildsWithCoverage, + }, + mount, + ); }); it('should render pipeline ID', () => { @@ -84,8 +88,8 @@ describe('MRWidgetPipeline', () => { }); it('should render pipeline graph', () => { - expect(findPipelineGraph().exists()).toBe(true); - expect(findAllPipelineStages().length).toBe(mockData.pipeline.details.stages.length); + expect(findPipelineMiniGraph().exists()).toBe(true); + expect(findAllPipelineStages()).toHaveLength(mockData.pipeline.details.stages.length); }); describe('should render pipeline coverage information', () => { @@ -136,7 +140,7 @@ describe('MRWidgetPipeline', () => { const mockCopy = JSON.parse(JSON.stringify(mockData)); delete mockCopy.pipeline.commit; - createWrapper({}); + createWrapper({}, mount); }); it('should render pipeline ID', () => { @@ -147,9 +151,15 @@ describe('MRWidgetPipeline', () => { expect(findPipelineInfoContainer().text()).toMatch(mockData.pipeline.details.status.label); }); - it('should render pipeline graph', () => { - expect(findPipelineGraph().exists()).toBe(true); - expect(findAllPipelineStages().length).toBe(mockData.pipeline.details.stages.length); + it('should render pipeline graph with correct styles', () => { + const stagesCount = mockData.pipeline.details.stages.length; + + expect(findPipelineMiniGraph().exists()).toBe(true); + expect(findPipelineMiniGraph().findAll('.mr-widget-pipeline-stages')).toHaveLength( + stagesCount, + ); + + expect(findAllPipelineStages()).toHaveLength(stagesCount); }); it('should render coverage information', () => { @@ -181,7 +191,7 @@ describe('MRWidgetPipeline', () => { }); it('should not render a pipeline graph', () => { - expect(findPipelineGraph().exists()).toBe(false); + expect(findPipelineMiniGraph().exists()).toBe(false); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap b/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap index c425a3a86a9..e5862df5dda 100644 --- a/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap +++ b/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap @@ -16,6 +16,7 @@ exports[`MRWidgetAutoMergeEnabled when graphql is disabled template should have > <span class="gl-mr-3" + data-qa-selector="merge_request_status_content" > <span class="js-status-text-before-author" @@ -107,6 +108,7 @@ exports[`MRWidgetAutoMergeEnabled when graphql is enabled template should have c > <span class="gl-mr-3" + data-qa-selector="merge_request_status_content" > <span class="js-status-text-before-author" 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 d3fc1e0e05b..dc2f227b29c 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 @@ -1,36 +1,41 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import $ from 'jquery'; +import { GlPopover } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; import { removeBreakLine } from 'helpers/text_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import ConflictsComponent from '~/vue_merge_request_widget/components/states/mr_widget_conflicts.vue'; describe('MRWidgetConflicts', () => { - let vm; + let wrapper; let mergeRequestWidgetGraphql = null; const path = '/conflicts'; - function createComponent(propsData = {}) { - const localVue = createLocalVue(); + const findPopover = () => wrapper.find(GlPopover); + const findResolveButton = () => wrapper.findByTestId('resolve-conflicts-button'); + const findMergeLocalButton = () => wrapper.findByTestId('merge-locally-button'); - vm = shallowMount(localVue.extend(ConflictsComponent), { - propsData, - provide: { - glFeatures: { - mergeRequestWidgetGraphql, + function createComponent(propsData = {}) { + wrapper = extendedWrapper( + shallowMount(ConflictsComponent, { + propsData, + provide: { + glFeatures: { + mergeRequestWidgetGraphql, + }, }, - }, - mocks: { - $apollo: { - queries: { - userPermissions: { loading: false }, - stateData: { loading: false }, + mocks: { + $apollo: { + queries: { + userPermissions: { loading: false }, + stateData: { loading: false }, + }, }, }, - }, - }); + }), + ); if (mergeRequestWidgetGraphql) { - vm.setData({ + wrapper.setData({ userPermissions: { canMerge: propsData.mr.canMerge, pushToSourceBranch: propsData.mr.canPushToSourceBranch, @@ -42,16 +47,12 @@ describe('MRWidgetConflicts', () => { }); } - return vm.vm.$nextTick(); + return wrapper.vm.$nextTick(); } - beforeEach(() => { - jest.spyOn($.fn, 'popover'); - }); - afterEach(() => { mergeRequestWidgetGraphql = null; - vm.destroy(); + wrapper.destroy(); }); [false, true].forEach((featureEnabled) => { @@ -82,18 +83,16 @@ describe('MRWidgetConflicts', () => { }); 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'); + expect(wrapper.text()).toContain('There are merge conflicts'); + expect(wrapper.text()).not.toContain('ask someone with write access'); }); it('should not allow you to resolve the conflicts', () => { - expect(vm.text()).not.toContain('Resolve conflicts'); + expect(wrapper.text()).not.toContain('Resolve conflicts'); }); it('should have merge buttons', () => { - const mergeLocallyButton = vm.find('.js-merge-locally-button'); - - expect(mergeLocallyButton.text()).toContain('Merge locally'); + expect(findMergeLocalButton().text()).toContain('Merge locally'); }); }); @@ -110,19 +109,17 @@ describe('MRWidgetConflicts', () => { }); it('should tell you about conflicts', () => { - expect(vm.text()).toContain('There are merge conflicts'); - expect(vm.text()).toContain('ask someone with write access'); + expect(wrapper.text()).toContain('There are merge conflicts'); + expect(wrapper.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); + expect(findResolveButton().text()).toContain('Resolve conflicts'); + expect(findResolveButton().attributes('href')).toEqual(path); }); it('should not have merge buttons', () => { - expect(vm.text()).not.toContain('Merge locally'); + expect(wrapper.text()).not.toContain('Merge locally'); }); }); @@ -139,21 +136,17 @@ describe('MRWidgetConflicts', () => { }); 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'); + expect(wrapper.text()).toContain('There are merge conflicts'); + expect(wrapper.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); + expect(findResolveButton().text()).toContain('Resolve conflicts'); + expect(findResolveButton().attributes('href')).toEqual(path); }); it('should have merge buttons', () => { - const mergeLocallyButton = vm.find('.js-merge-locally-button'); - - expect(mergeLocallyButton.text()).toContain('Merge locally'); + expect(findMergeLocalButton().text()).toContain('Merge locally'); }); }); @@ -167,7 +160,7 @@ describe('MRWidgetConflicts', () => { }, }); - expect(vm.text().trim().replace(/\s\s+/g, ' ')).toContain( + expect(wrapper.text().trim().replace(/\s\s+/g, ' ')).toContain( 'ask someone with write access', ); }); @@ -181,8 +174,8 @@ describe('MRWidgetConflicts', () => { }, }); - expect(vm.find('.js-resolve-conflicts-button').exists()).toBe(false); - expect(vm.find('.js-merge-locally-button').exists()).toBe(false); + expect(findResolveButton().exists()).toBe(false); + expect(findMergeLocalButton().exists()).toBe(false); }); it('should not have resolve button when no conflict resolution path', async () => { @@ -194,7 +187,7 @@ describe('MRWidgetConflicts', () => { }, }); - expect(vm.find('.js-resolve-conflicts-button').exists()).toBe(false); + expect(findResolveButton().exists()).toBe(false); }); }); @@ -207,7 +200,7 @@ describe('MRWidgetConflicts', () => { }, }); - expect(removeBreakLine(vm.text()).trim()).toContain( + expect(removeBreakLine(wrapper.text()).trim()).toContain( 'Fast-forward merge is not possible. To merge this request, first rebase locally.', ); }); @@ -227,11 +220,11 @@ describe('MRWidgetConflicts', () => { }); it('sets resolve button as disabled', () => { - expect(vm.find('.js-resolve-conflicts-button').attributes('disabled')).toBe('true'); + expect(findResolveButton().attributes('disabled')).toBe('true'); }); - it('renders popover', () => { - expect($.fn.popover).toHaveBeenCalled(); + it('shows the popover', () => { + expect(findPopover().exists()).toBe(true); }); }); @@ -249,11 +242,11 @@ describe('MRWidgetConflicts', () => { }); it('sets resolve button as disabled', () => { - expect(vm.find('.js-resolve-conflicts-button').attributes('disabled')).toBe(undefined); + expect(findResolveButton().attributes('disabled')).toBe(undefined); }); - it('renders popover', () => { - expect($.fn.popover).not.toHaveBeenCalled(); + it('does not show the popover', () => { + expect(findPopover().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js index bd0bd36ebc2..2c04905d3a9 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js @@ -14,20 +14,14 @@ describe('NothingToMerge', () => { it('should have correct elements', () => { expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); - expect(vm.$el.querySelector('a').href).toContain(newBlobPath); - expect(vm.$el.innerText).toContain( - "Currently there are no changes in this merge request's source branch", - ); - - expect(vm.$el.innerText.replace(/\s\s+/g, ' ')).toContain( - 'Please push new commits or use a different branch.', - ); + expect(vm.$el.querySelector('[data-testid="createFileButton"]').href).toContain(newBlobPath); + expect(vm.$el.innerText).toContain('Use merge requests to propose changes to your project'); }); it('should not show new blob link if there is no link available', () => { vm.mr.newBlobPath = null; Vue.nextTick(() => { - expect(vm.$el.querySelector('a')).toEqual(null); + expect(vm.$el.querySelector('[data-testid="createFileButton"]')).toEqual(null); }); }); }); diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_list_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_list_spec.js new file mode 100644 index 00000000000..dd0c483b28a --- /dev/null +++ b/spec/frontend/vue_mr_widget/deployment/deployment_list_spec.js @@ -0,0 +1,101 @@ +import { mount } from '@vue/test-utils'; +import { zip } from 'lodash'; +import { trimText } from 'helpers/text_helper'; +import Deployment from '~/vue_merge_request_widget/components/deployment/deployment.vue'; +import DeploymentList from '~/vue_merge_request_widget/components/deployment/deployment_list.vue'; +import MrCollapsibleExtension from '~/vue_merge_request_widget/components/mr_collapsible_extension.vue'; +import { mockStore } from '../mock_data'; + +const DEFAULT_PROPS = { + showVisualReviewAppLink: false, + hasDeploymentMetrics: false, + deploymentClass: 'js-pre-deployment', +}; + +describe('~/vue_merge_request_widget/components/deployment/deployment_list.vue', () => { + let wrapper; + let propsData; + + const factory = (props = {}) => { + propsData = { + ...DEFAULT_PROPS, + deployments: mockStore.deployments, + ...props, + }; + wrapper = mount(DeploymentList, { + propsData, + }); + }; + + afterEach(() => { + wrapper?.destroy?.(); + wrapper = null; + }); + + describe('with few deployments', () => { + beforeEach(() => { + factory(); + }); + + it('shows all deployments', () => { + const deploymentWrappers = wrapper.findAllComponents(Deployment); + expect(wrapper.findComponent(MrCollapsibleExtension).exists()).toBe(false); + expect(deploymentWrappers).toHaveLength(propsData.deployments.length); + + zip(deploymentWrappers.wrappers, propsData.deployments).forEach( + ([deploymentWrapper, deployment]) => { + expect(deploymentWrapper.props('deployment')).toEqual(deployment); + expect(deploymentWrapper.props()).toMatchObject({ + showVisualReviewApp: DEFAULT_PROPS.showVisualReviewAppLink, + showMetrics: DEFAULT_PROPS.hasDeploymentMetrics, + }); + expect(deploymentWrapper.classes(DEFAULT_PROPS.deploymentClass)).toBe(true); + expect(deploymentWrapper.text()).toEqual(expect.any(String)); + expect(deploymentWrapper.text()).not.toBe(''); + }, + ); + }); + }); + describe('with many deployments', () => { + let deployments; + let collapsibleExtension; + + beforeEach(() => { + deployments = [ + ...mockStore.deployments, + ...mockStore.deployments.map((deployment) => ({ + ...deployment, + id: deployment.id + mockStore.deployments.length, + })), + ]; + factory({ deployments }); + + collapsibleExtension = wrapper.findComponent(MrCollapsibleExtension); + }); + + it('shows collapsed deployments', () => { + expect(collapsibleExtension.exists()).toBe(true); + expect(trimText(collapsibleExtension.text())).toBe( + `${deployments.length} environments impacted. View all environments.`, + ); + }); + it('shows all deployments on click', async () => { + await collapsibleExtension.find('button').trigger('click'); + const deploymentWrappers = wrapper.findAllComponents(Deployment); + expect(deploymentWrappers).toHaveLength(deployments.length); + + zip(deploymentWrappers.wrappers, propsData.deployments).forEach( + ([deploymentWrapper, deployment]) => { + expect(deploymentWrapper.props('deployment')).toEqual(deployment); + expect(deploymentWrapper.props()).toMatchObject({ + showVisualReviewApp: DEFAULT_PROPS.showVisualReviewAppLink, + showMetrics: DEFAULT_PROPS.hasDeploymentMetrics, + }); + expect(deploymentWrapper.classes(DEFAULT_PROPS.deploymentClass)).toBe(true); + expect(deploymentWrapper.text()).toEqual(expect.any(String)); + expect(deploymentWrapper.text()).not.toBe(''); + }, + ); + }); + }); +}); diff --git a/spec/frontend/vue_shared/alert_details/alert_details_spec.js b/spec/frontend/vue_shared/alert_details/alert_details_spec.js index ce410a8b3e7..68bcf1dc491 100644 --- a/spec/frontend/vue_shared/alert_details/alert_details_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_details_spec.js @@ -89,7 +89,7 @@ describe('AlertDetails', () => { const findIncidentCreationAlert = () => wrapper.findByTestId('incidentCreationError'); const findEnvironmentName = () => wrapper.findByTestId('environmentName'); const findEnvironmentPath = () => wrapper.findByTestId('environmentPath'); - const findDetailsTable = () => wrapper.find(AlertDetailsTable); + const findDetailsTable = () => wrapper.findComponent(AlertDetailsTable); const findMetricsTab = () => wrapper.findByTestId('metrics'); describe('Alert details', () => { @@ -188,27 +188,39 @@ describe('AlertDetails', () => { }); expect(findMetricsTab().exists()).toBe(false); }); + + it('should display "View incident" button that links the issues page when incident exists', () => { + const iid = '3'; + mountComponent({ + data: { alert: { ...mockAlert, issue: { iid } }, sidebarStatus: false }, + provide: { isThreatMonitoringPage: true }, + }); + + expect(findViewIncidentBtn().exists()).toBe(true); + expect(findViewIncidentBtn().attributes('href')).toBe(joinPaths(projectIssuesPath, iid)); + expect(findCreateIncidentBtn().exists()).toBe(false); + }); }); describe('Create incident from alert', () => { it('should display "View incident" button that links the incident page when incident exists', () => { - const issueIid = '3'; + const iid = '3'; mountComponent({ - data: { alert: { ...mockAlert, issueIid }, sidebarStatus: false }, + data: { alert: { ...mockAlert, issue: { iid } }, sidebarStatus: false }, }); expect(findViewIncidentBtn().exists()).toBe(true); expect(findViewIncidentBtn().attributes('href')).toBe( - joinPaths(projectIssuesPath, issueIid), + joinPaths(projectIssuesPath, 'incident', iid), ); expect(findCreateIncidentBtn().exists()).toBe(false); }); it('should display "Create incident" button when incident doesn\'t exist yet', () => { - const issueIid = null; + const issue = null; mountComponent({ mountMethod: mount, - data: { alert: { ...mockAlert, issueIid } }, + data: { alert: { ...mockAlert, issue } }, }); return wrapper.vm.$nextTick().then(() => { diff --git a/spec/frontend/vue_shared/alert_details/mocks/alerts.json b/spec/frontend/vue_shared/alert_details/mocks/alerts.json index 5267a4fe50d..007557e234a 100644 --- a/spec/frontend/vue_shared/alert_details/mocks/alerts.json +++ b/spec/frontend/vue_shared/alert_details/mocks/alerts.json @@ -21,7 +21,7 @@ "endedAt": "2020-04-17T23:18:14.996Z", "status": "ACKNOWLEDGED", "assignees": { "nodes": [{ "username": "root", "avatarUrl": "/url", "name": "root" }] }, - "issueIid": "1", + "issue": { "state" : "closed", "iid": "1", "title": "My test issue" }, "notes": { "nodes": [ { diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js index bf65adc866d..5364e2d5f52 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js @@ -1,5 +1,6 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import ApplySuggestion from '~/vue_shared/components/markdown/apply_suggestion.vue'; import SuggestionDiffHeader from '~/vue_shared/components/markdown/suggestion_diff_header.vue'; const DEFAULT_PROPS = { @@ -38,7 +39,7 @@ describe('Suggestion Diff component', () => { wrapper.destroy(); }); - const findApplyButton = () => wrapper.find('.js-apply-btn'); + const findApplyButton = () => wrapper.find(ApplySuggestion); const findApplyBatchButton = () => wrapper.find('.js-apply-batch-btn'); const findAddToBatchButton = () => wrapper.find('.js-add-to-batch-btn'); const findRemoveFromBatchButton = () => wrapper.find('.js-remove-from-batch-btn'); @@ -88,7 +89,7 @@ describe('Suggestion Diff component', () => { beforeEach(() => { createComponent(); - findApplyButton().vm.$emit('click'); + findApplyButton().vm.$emit('apply'); }); it('emits apply', () => { diff --git a/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js b/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js index 99671f1ffb7..566ca1817f2 100644 --- a/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js @@ -1,3 +1,4 @@ +import { GlDropdown } from '@gitlab/ui'; import { getByText } from '@testing-library/dom'; import { shallowMount } from '@vue/test-utils'; import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; @@ -25,6 +26,9 @@ describe('MultiSelectDropdown Component', () => { slots: { search: '<p>Search</p>', }, + stubs: { + GlDropdown, + }, }); expect(getByText(wrapper.element, 'Search')).toBeDefined(); }); 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 da49778f216..30b7f0c2d28 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 @@ -2,20 +2,26 @@ exports[`Package code instruction multiline to match the snapshot 1`] = ` <div> - <pre - class="gl-font-monospace" - data-testid="multiline-instruction" + <label + for="instruction-input_3" > - this is some + foo_label + </label> + + <div> + <pre + class="gl-font-monospace" + data-testid="multiline-instruction" + > + this is some multiline text - </pre> + </pre> + </div> </div> `; exports[`Package code instruction single line to match the default snapshot 1`] = ` -<div - class="gl-mb-3" -> +<div> <label for="instruction-input_2" > @@ -23,42 +29,46 @@ exports[`Package code instruction single line to match the default snapshot 1`] </label> <div - class="input-group gl-mb-3" + class="gl-mb-3" > - <input - class="form-control gl-font-monospace" - data-testid="instruction-input" - id="instruction-input_2" - readonly="readonly" - type="text" - /> - - <span - class="input-group-append" - data-testid="instruction-button" + <div + class="input-group gl-mb-3" > - <button - aria-label="Copy this value" - class="btn input-group-text btn-default btn-md gl-button btn-default-secondary btn-icon" - data-clipboard-text="npm i @my-package" - title="Copy npm install command" - type="button" + <input + class="form-control gl-font-monospace" + data-testid="instruction-input" + id="instruction-input_2" + readonly="readonly" + type="text" + /> + + <span + class="input-group-append" + data-testid="instruction-button" > - <!----> - - <svg - aria-hidden="true" - class="gl-button-icon gl-icon s16" - data-testid="copy-to-clipboard-icon" + <button + aria-label="Copy this value" + class="btn input-group-text btn-default btn-md gl-button btn-default-secondary btn-icon" + data-clipboard-text="npm i @my-package" + title="Copy npm install command" + type="button" > - <use - href="#copy-to-clipboard" - /> - </svg> - - <!----> - </button> - </span> + <!----> + + <svg + aria-hidden="true" + class="gl-button-icon gl-icon s16" + data-testid="copy-to-clipboard-icon" + > + <use + href="#copy-to-clipboard" + /> + </svg> + + <!----> + </button> + </span> + </div> </div> </div> `; diff --git a/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js b/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js new file mode 100644 index 00000000000..c65ded000d3 --- /dev/null +++ b/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js @@ -0,0 +1,122 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import component from '~/vue_shared/components/registry/persisted_dropdown_selection.vue'; + +describe('Persisted dropdown selection', () => { + let wrapper; + + const defaultProps = { + storageKey: 'foo_bar', + options: [ + { value: 'maven', label: 'Maven' }, + { value: 'gradle', label: 'Gradle' }, + ], + }; + + function createComponent({ props = {}, data = {} } = {}) { + wrapper = shallowMount(component, { + propsData: { + ...defaultProps, + ...props, + }, + data() { + return data; + }, + }); + } + + const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('local storage sync', () => { + it('uses the local storage sync component', () => { + createComponent(); + + expect(findLocalStorageSync().exists()).toBe(true); + }); + + it('passes the right props', () => { + createComponent({ data: { selected: 'foo' } }); + + expect(findLocalStorageSync().props()).toMatchObject({ + storageKey: defaultProps.storageKey, + value: 'foo', + }); + }); + + it('on input event updates the model and emits event', async () => { + const inputPayload = 'bar'; + createComponent(); + findLocalStorageSync().vm.$emit('input', inputPayload); + + await nextTick(); + + expect(wrapper.emitted('change')).toStrictEqual([[inputPayload]]); + expect(findLocalStorageSync().props('value')).toBe(inputPayload); + }); + }); + + describe('dropdown', () => { + it('has a dropdown component', () => { + createComponent(); + + expect(findDropdown().exists()).toBe(true); + }); + + describe('dropdown text', () => { + it('when no selection shows the first', () => { + createComponent(); + + expect(findDropdown().props('text')).toBe('Maven'); + }); + + it('when an option is selected, shows that option label', () => { + createComponent({ data: { selected: defaultProps.options[1].value } }); + + expect(findDropdown().props('text')).toBe('Gradle'); + }); + }); + + describe('dropdown items', () => { + it('has one item for each option', () => { + createComponent(); + + expect(findDropdownItems()).toHaveLength(defaultProps.options.length); + }); + + it('binds the correct props', () => { + createComponent({ data: { selected: defaultProps.options[0].value } }); + + expect(findDropdownItems().at(0).props()).toMatchObject({ + isChecked: true, + isCheckItem: true, + }); + + expect(findDropdownItems().at(1).props()).toMatchObject({ + isChecked: false, + isCheckItem: true, + }); + }); + + it('on click updates the data and emits event', async () => { + createComponent({ data: { selected: defaultProps.options[0].value } }); + expect(findDropdownItems().at(0).props('isChecked')).toBe(true); + + findDropdownItems().at(1).vm.$emit('click'); + + await nextTick(); + + expect(wrapper.emitted('change')).toStrictEqual([['gradle']]); + expect(findDropdownItems().at(0).props('isChecked')).toBe(false); + expect(findDropdownItems().at(1).props('isChecked')).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap b/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap index 51b8aa162bc..ed085fb66dc 100644 --- a/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap +++ b/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap @@ -2,7 +2,7 @@ exports[`Settings Block renders the correct markup 1`] = ` <section - class="settings no-animate" + class="settings" > <div class="settings-header" diff --git a/spec/frontend/vue_shared/components/settings/settings_block_spec.js b/spec/frontend/vue_shared/components/settings/settings_block_spec.js index 2db0b001b5b..be5a15631eb 100644 --- a/spec/frontend/vue_shared/components/settings/settings_block_spec.js +++ b/spec/frontend/vue_shared/components/settings/settings_block_spec.js @@ -50,6 +50,27 @@ describe('Settings Block', () => { expect(findDescriptionSlot().exists()).toBe(true); }); + describe('slide animation behaviour', () => { + it('is animated by default', () => { + mountComponent(); + + expect(wrapper.classes('no-animate')).toBe(false); + }); + + it.each` + slideAnimated | noAnimatedClass + ${true} | ${false} + ${false} | ${true} + `( + 'sets the correct state when slideAnimated is $slideAnimated', + ({ slideAnimated, noAnimatedClass }) => { + mountComponent({ slideAnimated }); + + expect(wrapper.classes('no-animate')).toBe(noAnimatedClass); + }, + ); + }); + describe('expanded behaviour', () => { it('is collapsed by default', () => { mountComponent(); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js index 0d1d6ebcfe5..c90e63313b2 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js @@ -11,32 +11,31 @@ import { mockConfig, mockRegularLabel, mockScopedLabel } from './mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); -const createComponent = (initialState = mockConfig, slots = {}) => { - const store = new Vuex.Store(labelsSelectModule()); - - store.dispatch('setInitialState', initialState); - - return shallowMount(DropdownValue, { - localVue, - store, - slots, - }); -}; - describe('DropdownValue', () => { let wrapper; - beforeEach(() => { - wrapper = createComponent(); - }); + const createComponent = (initialState = {}, slots = {}) => { + const store = new Vuex.Store(labelsSelectModule()); + + store.dispatch('setInitialState', { ...mockConfig, ...initialState }); + + wrapper = shallowMount(DropdownValue, { + localVue, + store, + slots, + }); + }; afterEach(() => { wrapper.destroy(); + wrapper = null; }); describe('methods', () => { describe('labelFilterUrl', () => { it('returns a label filter URL based on provided label param', () => { + createComponent(); + expect(wrapper.vm.labelFilterUrl(mockRegularLabel)).toBe( '/gitlab-org/my-project/issues?label_name[]=Foo%20Label', ); @@ -44,6 +43,10 @@ describe('DropdownValue', () => { }); describe('scopedLabel', () => { + beforeEach(() => { + createComponent(); + }); + it('returns `true` when provided label param is a scoped label', () => { expect(wrapper.vm.scopedLabel(mockScopedLabel)).toBe(true); }); @@ -56,28 +59,29 @@ describe('DropdownValue', () => { describe('template', () => { it('renders class `has-labels` on component container element when `selectedLabels` is not empty', () => { + createComponent(); + expect(wrapper.attributes('class')).toContain('has-labels'); }); it('renders element containing `None` when `selectedLabels` is empty', () => { - const wrapperNoLabels = createComponent( + createComponent( { - ...mockConfig, selectedLabels: [], }, { default: 'None', }, ); - const noneEl = wrapperNoLabels.find('span.text-secondary'); + const noneEl = wrapper.find('span.text-secondary'); expect(noneEl.exists()).toBe(true); expect(noneEl.text()).toBe('None'); - - wrapperNoLabels.destroy(); }); it('renders labels when `selectedLabels` is not empty', () => { + createComponent(); + expect(wrapper.findAll(GlLabel).length).toBe(2); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js index 85a14226585..f293b8422e7 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js @@ -47,6 +47,7 @@ export const mockConfig = { labelsFetchPath: '/gitlab-org/my-project/-/labels.json', labelsManagePath: '/gitlab-org/my-project/-/labels', labelsFilterBasePath: '/gitlab-org/my-project/issues', + labelsFilterParam: 'label_name', }; export const mockSuggestedColors = { diff --git a/spec/frontend/vue_shared/components/tabs/tab_spec.js b/spec/frontend/vue_shared/components/tabs/tab_spec.js deleted file mode 100644 index ee0c983c764..00000000000 --- a/spec/frontend/vue_shared/components/tabs/tab_spec.js +++ /dev/null @@ -1,32 +0,0 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import Tab from '~/vue_shared/components/tabs/tab.vue'; - -describe('Tab component', () => { - const Component = Vue.extend(Tab); - let vm; - - beforeEach(() => { - vm = mountComponent(Component); - }); - - it('sets localActive to equal active', (done) => { - vm.active = true; - - vm.$nextTick(() => { - expect(vm.localActive).toBe(true); - - done(); - }); - }); - - it('sets active class', (done) => { - vm.active = true; - - vm.$nextTick(() => { - expect(vm.$el.classList).toContain('active'); - - done(); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/tabs/tabs_spec.js b/spec/frontend/vue_shared/components/tabs/tabs_spec.js deleted file mode 100644 index fe7be5be899..00000000000 --- a/spec/frontend/vue_shared/components/tabs/tabs_spec.js +++ /dev/null @@ -1,61 +0,0 @@ -import Vue from 'vue'; -import Tab from '~/vue_shared/components/tabs/tab.vue'; -import Tabs from '~/vue_shared/components/tabs/tabs'; - -describe('Tabs component', () => { - let vm; - - beforeEach(() => { - vm = new Vue({ - components: { - Tabs, - Tab, - }, - render(h) { - return h('div', [ - h('tabs', [ - h('tab', { attrs: { title: 'Testing', active: true } }, 'First tab'), - h('tab', [h('template', { slot: 'title' }, 'Test slot'), 'Second tab']), - ]), - ]); - }, - }).$mount(); - - return vm.$nextTick(); - }); - - describe('tab links', () => { - it('renders links for tabs', () => { - expect(vm.$el.querySelectorAll('a').length).toBe(2); - }); - - it('renders link titles from props', () => { - expect(vm.$el.querySelector('a').textContent).toContain('Testing'); - }); - - it('renders link titles from slot', () => { - expect(vm.$el.querySelectorAll('a')[1].textContent).toContain('Test slot'); - }); - - it('renders active class', () => { - expect(vm.$el.querySelector('a').classList).toContain('active'); - }); - - it('updates active class on click', () => { - vm.$el.querySelectorAll('a')[1].click(); - - return vm.$nextTick(() => { - expect(vm.$el.querySelector('a').classList).not.toContain('active'); - expect(vm.$el.querySelectorAll('a')[1].classList).toContain('active'); - }); - }); - }); - - describe('content', () => { - it('renders content panes', () => { - expect(vm.$el.querySelectorAll('.tab-pane').length).toBe(2); - expect(vm.$el.querySelectorAll('.tab-pane')[0].textContent).toContain('First tab'); - expect(vm.$el.querySelectorAll('.tab-pane')[1].textContent).toContain('Second tab'); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js index 27c9b099306..380b7231acd 100644 --- a/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js +++ b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js @@ -11,6 +11,15 @@ jest.mock('~/lib/utils/dom_utils', () => ({ throw new Error('this needs to be mocked'); }), })); +jest.mock('@gitlab/ui', () => ({ + GlTooltipDirective: { + bind(el, binding) { + el.classList.add('gl-tooltip'); + el.setAttribute('data-original-title', el.title); + el.dataset.placement = binding.value.placement; + }, + }, +})); describe('TooltipOnTruncate component', () => { let wrapper; @@ -52,7 +61,7 @@ describe('TooltipOnTruncate component', () => { wrapper = parent.find(TooltipOnTruncate); }; - const hasTooltip = () => wrapper.classes('js-show-tooltip'); + const hasTooltip = () => wrapper.classes('gl-tooltip'); afterEach(() => { wrapper.destroy(); diff --git a/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap b/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap index d2fe3cd76cb..af4fa462cbf 100644 --- a/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap +++ b/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap @@ -19,6 +19,7 @@ exports[`Upload dropzone component correctly overrides description and drop mess <p class="gl-mb-0" + data-testid="upload-text" > <span> Test %{linkStart}description%{linkEnd} message. @@ -98,10 +99,15 @@ exports[`Upload dropzone component when dragging renders correct template when d <p class="gl-mb-0" + data-testid="upload-text" > - <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} files to attach" - /> + Drop or + <gl-link-stub> + + upload + + </gl-link-stub> + files to attach </p> </div> </button> @@ -178,10 +184,15 @@ exports[`Upload dropzone component when dragging renders correct template when d <p class="gl-mb-0" + data-testid="upload-text" > - <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} files to attach" - /> + Drop or + <gl-link-stub> + + upload + + </gl-link-stub> + files to attach </p> </div> </button> @@ -258,10 +269,15 @@ exports[`Upload dropzone component when dragging renders correct template when d <p class="gl-mb-0" + data-testid="upload-text" > - <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} files to attach" - /> + Drop or + <gl-link-stub> + + upload + + </gl-link-stub> + files to attach </p> </div> </button> @@ -337,10 +353,15 @@ exports[`Upload dropzone component when dragging renders correct template when d <p class="gl-mb-0" + data-testid="upload-text" > - <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} files to attach" - /> + Drop or + <gl-link-stub> + + upload + + </gl-link-stub> + files to attach </p> </div> </button> @@ -416,10 +437,15 @@ exports[`Upload dropzone component when dragging renders correct template when d <p class="gl-mb-0" + data-testid="upload-text" > - <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} files to attach" - /> + Drop or + <gl-link-stub> + + upload + + </gl-link-stub> + files to attach </p> </div> </button> @@ -495,10 +521,15 @@ exports[`Upload dropzone component when no slot provided renders default dropzon <p class="gl-mb-0" + data-testid="upload-text" > - <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} files to attach" - /> + Drop or + <gl-link-stub> + + upload + + </gl-link-stub> + files to attach </p> </div> </button> diff --git a/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js b/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js index ace486b1f32..b3cdbccb271 100644 --- a/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js +++ b/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js @@ -1,4 +1,4 @@ -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; @@ -14,6 +14,7 @@ describe('Upload dropzone component', () => { const findDropzoneCard = () => wrapper.find('.upload-dropzone-card'); const findDropzoneArea = () => wrapper.find('[data-testid="dropzone-area"]'); const findIcon = () => wrapper.find(GlIcon); + const findUploadText = () => wrapper.find('[data-testid="upload-text"]').text(); function createComponent({ slots = {}, data = {}, props = {} } = {}) { wrapper = shallowMount(UploadDropzone, { @@ -22,6 +23,9 @@ describe('Upload dropzone component', () => { displayAsCard: true, ...props, }, + stubs: { + GlSprintf, + }, data() { return data; }, @@ -30,6 +34,7 @@ describe('Upload dropzone component', () => { afterEach(() => { wrapper.destroy(); + wrapper = null; }); describe('when slot provided', () => { @@ -60,6 +65,18 @@ describe('Upload dropzone component', () => { }); }); + describe('upload text', () => { + it.each` + collection | description | props | expected + ${'multiple'} | ${'by default'} | ${null} | ${'files to attach'} + ${'singular'} | ${'when singleFileSelection'} | ${{ singleFileSelection: true }} | ${'file to attach'} + `('displays $collection version $description', ({ props, expected }) => { + createComponent({ props }); + + expect(findUploadText()).toContain(expected); + }); + }); + describe('when dragging', () => { it.each` description | eventPayload @@ -141,6 +158,21 @@ describe('Upload dropzone component', () => { wrapper.vm.ondrop(mockEvent); expect(wrapper.emitted()).not.toHaveProperty('error'); }); + + describe('singleFileSelection = true', () => { + it('emits a single file on drop', () => { + createComponent({ + data: mockData, + props: { singleFileSelection: true }, + }); + + const mockFile = { type: 'image/jpg' }; + const mockEvent = mockDragEvent({ files: [mockFile] }); + + wrapper.vm.ondrop(mockEvent); + expect(wrapper.emitted().change[0]).toEqual([mockFile]); + }); + }); }); }); diff --git a/spec/frontend/vue_shared/components/user_access_role_badge_spec.js b/spec/frontend/vue_shared/components/user_access_role_badge_spec.js new file mode 100644 index 00000000000..7f25f7c08e7 --- /dev/null +++ b/spec/frontend/vue_shared/components/user_access_role_badge_spec.js @@ -0,0 +1,26 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; + +describe('UserAccessRoleBadge', () => { + let wrapper; + + const createComponent = ({ slots } = {}) => { + wrapper = shallowMount(UserAccessRoleBadge, { + slots, + }); + }; + + it('renders slot content inside GlBadge', () => { + createComponent({ + slots: { + default: 'test slot content', + }, + }); + + const badge = wrapper.find(GlBadge); + + expect(badge.exists()).toBe(true); + expect(badge.html()).toContain('test slot content'); + }); +}); diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js index a6c5e23ae14..650d204201e 100644 --- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js @@ -18,7 +18,6 @@ const DEFAULT_PROPS = { describe('User Popover Component', () => { const fixtureTemplate = 'merge_requests/diff_comment.html'; - preloadFixtures(fixtureTemplate); let wrapper; diff --git a/spec/frontend/vue_shared/directives/tooltip_spec.js b/spec/frontend/vue_shared/directives/tooltip_spec.js deleted file mode 100644 index 99e8b5b552b..00000000000 --- a/spec/frontend/vue_shared/directives/tooltip_spec.js +++ /dev/null @@ -1,157 +0,0 @@ -import { mount } from '@vue/test-utils'; -import $ from 'jquery'; -import { escape } from 'lodash'; -import tooltip from '~/vue_shared/directives/tooltip'; - -const DEFAULT_TOOLTIP_TEMPLATE = '<div v-tooltip :title="tooltip"></div>'; -const HTML_TOOLTIP_TEMPLATE = '<div v-tooltip data-html="true" :title="tooltip"></div>'; - -describe('Tooltip directive', () => { - let wrapper; - - function createTooltipContainer({ - template = DEFAULT_TOOLTIP_TEMPLATE, - text = 'some text', - } = {}) { - wrapper = mount( - { - directives: { tooltip }, - data: () => ({ tooltip: text }), - template, - }, - { attachTo: document.body }, - ); - } - - async function showTooltip() { - $(wrapper.vm.$el).tooltip('show'); - jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); - } - - function findTooltipInnerHtml() { - return document.querySelector('.tooltip-inner').innerHTML; - } - - function findTooltipHtml() { - return document.querySelector('.tooltip').innerHTML; - } - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('with a single tooltip', () => { - it('should have tooltip plugin applied', () => { - createTooltipContainer(); - - expect($(wrapper.vm.$el).data('bs.tooltip')).toBeDefined(); - }); - - it('displays the title as tooltip', () => { - createTooltipContainer(); - - $(wrapper.vm.$el).tooltip('show'); - - jest.runOnlyPendingTimers(); - - const tooltipElement = document.querySelector('.tooltip-inner'); - - expect(tooltipElement.textContent).toContain('some text'); - }); - - it.each` - condition | template | sanitize - ${'does not contain any html'} | ${DEFAULT_TOOLTIP_TEMPLATE} | ${false} - ${'contains html'} | ${HTML_TOOLTIP_TEMPLATE} | ${true} - `('passes sanitize=$sanitize if the tooltip $condition', ({ template, sanitize }) => { - createTooltipContainer({ template }); - - expect($(wrapper.vm.$el).data('bs.tooltip').config.sanitize).toEqual(sanitize); - }); - - it('updates a visible tooltip', async () => { - createTooltipContainer(); - - $(wrapper.vm.$el).tooltip('show'); - jest.runOnlyPendingTimers(); - - const tooltipElement = document.querySelector('.tooltip-inner'); - - wrapper.vm.tooltip = 'other text'; - - jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); - - expect(tooltipElement.textContent).toContain('other text'); - }); - - describe('tooltip sanitization', () => { - it('reads tooltip content as text if data-html is not passed', async () => { - createTooltipContainer({ text: 'sample text<script>alert("XSS!!")</script>' }); - - await showTooltip(); - - const result = findTooltipInnerHtml(); - expect(result).toEqual('sample text<script>alert("XSS!!")</script>'); - }); - - it('sanitizes tooltip if data-html is passed', async () => { - createTooltipContainer({ - template: HTML_TOOLTIP_TEMPLATE, - text: 'sample text<script>alert("XSS!!")</script>', - }); - - await showTooltip(); - - const result = findTooltipInnerHtml(); - expect(result).toEqual('sample text'); - expect(result).not.toContain('XSS!!'); - }); - - it('sanitizes tooltip if data-template is passed', async () => { - const tooltipTemplate = escape( - '<div class="tooltip" role="tooltip"><div onclick="alert(\'XSS!\')" class="arrow"></div><div class="tooltip-inner"></div></div>', - ); - - createTooltipContainer({ - template: `<div v-tooltip :title="tooltip" data-html="false" data-template="${tooltipTemplate}"></div>`, - }); - - await showTooltip(); - - const result = findTooltipHtml(); - expect(result).toEqual( - // objectionable element is removed - '<div class="arrow"></div><div class="tooltip-inner">some text</div>', - ); - expect(result).not.toContain('XSS!!'); - }); - }); - }); - - describe('with multiple tooltips', () => { - beforeEach(() => { - createTooltipContainer({ - template: ` - <div> - <div - v-tooltip - class="js-look-for-tooltip" - title="foo"> - </div> - <div - v-tooltip - title="bar"> - </div> - </div> - `, - }); - }); - - it('should have tooltip plugin applied to all instances', () => { - expect($(wrapper.vm.$el).find('.js-look-for-tooltip').data('bs.tooltip')).toBeDefined(); - }); - }); -}); diff --git a/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js b/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js index 6ecc330b5af..3fb60c254c9 100644 --- a/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js +++ b/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js @@ -11,6 +11,10 @@ describe('GitLab Feature Flags Plugin', () => { aFeature: true, bFeature: false, }, + licensed_features: { + cFeature: true, + dFeature: false, + }, }; localVue.use(GlFeatureFlags); @@ -25,6 +29,8 @@ describe('GitLab Feature Flags Plugin', () => { expect(wrapper.vm.glFeatures).toEqual({ aFeature: true, bFeature: false, + cFeature: true, + dFeature: false, }); }); @@ -37,6 +43,8 @@ describe('GitLab Feature Flags Plugin', () => { expect(wrapper.vm.glFeatures).toEqual({ aFeature: true, bFeature: false, + cFeature: true, + dFeature: false, }); }); }); diff --git a/spec/frontend/zen_mode_spec.js b/spec/frontend/zen_mode_spec.js index 5cc1d2200d3..bf4b57d8afb 100644 --- a/spec/frontend/zen_mode_spec.js +++ b/spec/frontend/zen_mode_spec.js @@ -13,8 +13,6 @@ describe('ZenMode', () => { let dropzoneForElementSpy; const fixtureName = 'snippets/show.html'; - preloadFixtures(fixtureName); - function enterZen() { $('.notes-form .js-zen-enter').click(); } |