diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-05-19 15:44:42 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-05-19 15:44:42 +0000 |
commit | 4555e1b21c365ed8303ffb7a3325d773c9b8bf31 (patch) | |
tree | 5423a1c7516cffe36384133ade12572cf709398d /spec/frontend | |
parent | e570267f2f6b326480d284e0164a6464ba4081bc (diff) | |
download | gitlab-ce-4555e1b21c365ed8303ffb7a3325d773c9b8bf31.tar.gz |
Add latest changes from gitlab-org/gitlab@13-12-stable-eev13.12.0-rc42
Diffstat (limited to 'spec/frontend')
381 files changed, 15270 insertions, 5432 deletions
diff --git a/spec/frontend/__helpers__/mock_apollo_helper.js b/spec/frontend/__helpers__/mock_apollo_helper.js index bd97a06071a..520d6c72541 100644 --- a/spec/frontend/__helpers__/mock_apollo_helper.js +++ b/spec/frontend/__helpers__/mock_apollo_helper.js @@ -1,5 +1,5 @@ import { InMemoryCache } from 'apollo-cache-inmemory'; -import { createMockClient } from 'mock-apollo-client'; +import { createMockClient as createMockApolloClient } from 'mock-apollo-client'; import VueApollo from 'vue-apollo'; const defaultCacheOptions = { @@ -7,13 +7,13 @@ const defaultCacheOptions = { addTypename: false, }; -export default (handlers = [], resolvers = {}, cacheOptions = {}) => { +export function createMockClient(handlers = [], resolvers = {}, cacheOptions = {}) { const cache = new InMemoryCache({ ...defaultCacheOptions, ...cacheOptions, }); - const mockClient = createMockClient({ cache, resolvers }); + const mockClient = createMockApolloClient({ cache, resolvers }); if (Array.isArray(handlers)) { handlers.forEach(([query, value]) => mockClient.setRequestHandler(query, value)); @@ -21,7 +21,12 @@ export default (handlers = [], resolvers = {}, cacheOptions = {}) => { throw new Error('You should pass an array of handlers to mock Apollo client'); } + return mockClient; +} + +export default function createMockApollo(handlers, resolvers, cacheOptions) { + const mockClient = createMockClient(handlers, resolvers, cacheOptions); const apolloProvider = new VueApollo({ defaultClient: mockClient }); return apolloProvider; -}; +} diff --git a/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap b/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap index 33c29cea6d8..0b86c10ea46 100644 --- a/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap +++ b/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap @@ -7,6 +7,7 @@ exports[`~/access_tokens/components/expires_at_field should render datepicker wi container="" displayfield="true" firstday="0" + inputlabel="Enter date" mindate="Mon Jul 06 2020 00:00:00 GMT+0000 (Greenwich Mean Time)" placeholder="YYYY-MM-DD" theme="" diff --git a/spec/frontend/actioncable_link_spec.js b/spec/frontend/actioncable_link_spec.js new file mode 100644 index 00000000000..c785151f8fd --- /dev/null +++ b/spec/frontend/actioncable_link_spec.js @@ -0,0 +1,110 @@ +import { print } from 'graphql'; +import gql from 'graphql-tag'; +import cable from '~/actioncable_consumer'; +import ActionCableLink from '~/actioncable_link'; + +// Mock uuids module for determinism +jest.mock('~/lib/utils/uuids', () => ({ + uuids: () => ['testuuid'], +})); + +const TEST_OPERATION = { + query: gql` + query foo { + project { + id + } + } + `, + operationName: 'foo', + variables: [], +}; + +/** + * Create an observer that passes calls to the given spy. + * + * This helps us assert which calls were made in what order. + */ +const createSpyObserver = (spy) => ({ + next: (...args) => spy('next', ...args), + error: (...args) => spy('error', ...args), + complete: (...args) => spy('complete', ...args), +}); + +const notify = (...notifications) => { + notifications.forEach((data) => cable.subscriptions.notifyAll('received', data)); +}; + +const getSubscriptionCount = () => cable.subscriptions.subscriptions.length; + +describe('~/actioncable_link', () => { + let cableLink; + + beforeEach(() => { + jest.spyOn(cable.subscriptions, 'create'); + + cableLink = new ActionCableLink(); + }); + + describe('request', () => { + let subscription; + let spy; + + beforeEach(() => { + spy = jest.fn(); + subscription = cableLink.request(TEST_OPERATION).subscribe(createSpyObserver(spy)); + }); + + afterEach(() => { + subscription.unsubscribe(); + }); + + it('creates a subscription', () => { + expect(getSubscriptionCount()).toBe(1); + expect(cable.subscriptions.create).toHaveBeenCalledWith( + { + channel: 'GraphqlChannel', + nonce: 'testuuid', + ...TEST_OPERATION, + query: print(TEST_OPERATION.query), + }, + { received: expect.any(Function) }, + ); + }); + + it('when "unsubscribe", unsubscribes underlying cable subscription', () => { + subscription.unsubscribe(); + + expect(getSubscriptionCount()).toBe(0); + }); + + it('when receives data, triggers observer until no ".more"', () => { + notify( + { result: 'test result', more: true }, + { result: 'test result 2', more: true }, + { result: 'test result 3' }, + { result: 'test result 4' }, + ); + + expect(spy.mock.calls).toEqual([ + ['next', 'test result'], + ['next', 'test result 2'], + ['next', 'test result 3'], + ['complete'], + ]); + }); + + it('when receives errors, triggers observer', () => { + notify( + { result: 'test result', more: true }, + { result: 'test result 2', errors: ['boom!'], more: true }, + { result: 'test result 3' }, + ); + + expect(spy.mock.calls).toEqual([ + ['next', 'test result'], + ['error', ['boom!']], + ]); + }); + }); +}); diff --git a/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js b/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js index d32e582e498..2832de98769 100644 --- a/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js +++ b/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js @@ -40,7 +40,7 @@ describe('AddContextCommitsModal', () => { store, propsData: { contextCommitsPath: '', - targetBranch: 'master', + targetBranch: 'main', mergeRequestIid: 1, projectId: 1, ...props, diff --git a/spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js b/spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js new file mode 100644 index 00000000000..7c20bbe21c8 --- /dev/null +++ b/spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js @@ -0,0 +1,134 @@ +import { GlTable, GlBadge, GlEmptyState, GlLink } from '@gitlab/ui'; +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import DevopsScore from '~/analytics/devops_report/components/devops_score.vue'; +import { + devopsScoreMetricsData, + devopsReportDocsPath, + noDataImagePath, + devopsScoreTableHeaders, +} from '../mock_data'; + +describe('DevopsScore', () => { + let wrapper; + + const createComponent = ({ devopsScoreMetrics = devopsScoreMetricsData } = {}) => { + wrapper = extendedWrapper( + mount(DevopsScore, { + provide: { + devopsScoreMetrics, + devopsReportDocsPath, + noDataImagePath, + }, + }), + ); + }; + + const findTable = () => wrapper.findComponent(GlTable); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findCol = (testId) => findTable().find(`[data-testid="${testId}"]`); + const findUsageCol = () => findCol('usageCol'); + const findDevopsScoreApp = () => wrapper.findByTestId('devops-score-app'); + + describe('with no data', () => { + beforeEach(() => { + createComponent({ devopsScoreMetrics: {} }); + }); + + describe('empty state', () => { + it('displays the empty state', () => { + expect(findEmptyState().exists()).toBe(true); + }); + + it('displays the correct message', () => { + expect(findEmptyState().text()).toBe( + 'Data is still calculating... It may be several days before you see feature usage data. See example DevOps Score page in our documentation.', + ); + }); + + it('contains a link to the feature documentation', () => { + expect(wrapper.findComponent(GlLink).exists()).toBe(true); + }); + }); + + it('does not display the devops score app', () => { + expect(findDevopsScoreApp().exists()).toBe(false); + }); + }); + + describe('with data', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not display the empty state', () => { + expect(findEmptyState().exists()).toBe(false); + }); + + it('displays the devops score app', () => { + expect(findDevopsScoreApp().exists()).toBe(true); + }); + + describe('devops score app', () => { + it('displays the title note', () => { + expect(wrapper.findByTestId('devops-score-note-text').text()).toBe( + 'DevOps score metrics are based on usage over the last 30 days. Last updated: 2020-06-29 08:16.', + ); + }); + + it('displays the single stat section', () => { + const component = wrapper.findComponent(GlSingleStat); + + expect(component.exists()).toBe(true); + expect(component.props('value')).toBe(devopsScoreMetricsData.averageScore.value); + }); + + describe('devops score table', () => { + it('displays the table', () => { + expect(findTable().exists()).toBe(true); + }); + + describe('table headings', () => { + let headers; + + beforeEach(() => { + headers = findTable().findAll("[data-testid='header']"); + }); + + it('displays the correct number of headings', () => { + expect(headers).toHaveLength(devopsScoreTableHeaders.length); + }); + + describe.each(devopsScoreTableHeaders)('header fields', ({ label, index }) => { + let headerWrapper; + + beforeEach(() => { + headerWrapper = headers.at(index); + }); + + it(`displays the correct table heading text for "${label}"`, () => { + expect(headerWrapper.text()).toContain(label); + }); + }); + }); + + describe('table columns', () => { + describe('Your usage', () => { + it('displays the corrrect value', () => { + expect(findUsageCol().text()).toContain('3.2'); + }); + + it('displays the corrrect badge', () => { + const badge = findUsageCol().find(GlBadge); + + expect(badge.exists()).toBe(true); + expect(badge.props('variant')).toBe('muted'); + expect(badge.text()).toBe('Low'); + }); + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/admin/analytics/devops_score/mock_data.js b/spec/frontend/admin/analytics/devops_score/mock_data.js new file mode 100644 index 00000000000..ae0c01a2661 --- /dev/null +++ b/spec/frontend/admin/analytics/devops_score/mock_data.js @@ -0,0 +1,46 @@ +export const devopsScoreTableHeaders = [ + { + index: 0, + label: '', + }, + { + index: 1, + label: 'Your usage', + }, + { + index: 2, + label: 'Leader usage', + }, + { + index: 3, + label: 'Score', + }, +]; + +export const devopsScoreMetricsData = { + createdAt: '2020-06-29 08:16', + cards: [ + { + title: 'Issues created per active user', + usage: '3.2', + leadInstance: '10.2', + score: '0', + scoreLevel: { + label: 'Low', + variant: 'muted', + }, + }, + ], + averageScore: { + value: '10', + scoreLevel: { + label: 'High', + icon: 'check-circle', + variant: 'success', + }, + }, +}; + +export const devopsReportDocsPath = 'docs-path'; + +export const noDataImagePath = 'image-path'; diff --git a/spec/frontend/admin/users/components/actions/actions_spec.js b/spec/frontend/admin/users/components/actions/actions_spec.js index 5e232f34311..5db5b8a90a9 100644 --- a/spec/frontend/admin/users/components/actions/actions_spec.js +++ b/spec/frontend/admin/users/components/actions/actions_spec.js @@ -71,6 +71,7 @@ describe('Action components', () => { }); describe('DELETE_ACTION_COMPONENTS', () => { + const oncallSchedules = [{ name: 'schedule1' }, { name: 'schedule2' }]; it.each(DELETE_ACTIONS)('renders a dropdown item for "%s"', async (action) => { initComponent({ component: Actions[capitalizeFirstCharacter(action)], @@ -80,6 +81,7 @@ describe('Action components', () => { delete: '/delete', block: '/block', }, + oncallSchedules, }, stubs: { SharedDeleteAction }, }); @@ -92,6 +94,9 @@ describe('Action components', () => { expect(sharedAction.attributes('data-delete-user-url')).toBe('/delete'); expect(sharedAction.attributes('data-gl-modal-action')).toBe(kebabCase(action)); expect(sharedAction.attributes('data-username')).toBe('John Doe'); + expect(sharedAction.attributes('data-oncall-schedules')).toBe( + JSON.stringify(oncallSchedules), + ); expect(findDropdownItem().exists()).toBe(true); }); }); diff --git a/spec/frontend/admin/users/components/user_actions_spec.js b/spec/frontend/admin/users/components/user_actions_spec.js index 0745d961f25..debe964e7aa 100644 --- a/spec/frontend/admin/users/components/user_actions_spec.js +++ b/spec/frontend/admin/users/components/user_actions_spec.js @@ -1,5 +1,5 @@ import { GlDropdownDivider } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import Actions from '~/admin/users/components/actions'; import AdminUserActions from '~/admin/users/components/user_actions.vue'; import { I18N_USER_ACTIONS } from '~/admin/users/constants'; @@ -14,12 +14,14 @@ describe('AdminUserActions component', () => { const user = users[0]; const userPaths = generateUserPaths(paths, user.username); - const findEditButton = () => wrapper.find('[data-testid="edit"]'); - const findActionsDropdown = () => wrapper.find('[data-testid="actions"'); - const findDropdownDivider = () => wrapper.find(GlDropdownDivider); + const findUserActions = (id) => wrapper.findByTestId(`user-actions-${id}`); + const findEditButton = (id = user.id) => findUserActions(id).find('[data-testid="edit"]'); + const findActionsDropdown = (id = user.id) => + findUserActions(id).find('[data-testid="dropdown-toggle"]'); + const findDropdownDivider = () => wrapper.findComponent(GlDropdownDivider); const initComponent = ({ actions = [] } = {}) => { - wrapper = shallowMount(AdminUserActions, { + wrapper = shallowMountExtended(AdminUserActions, { propsData: { user: { ...user, diff --git a/spec/frontend/admin/users/components/users_table_spec.js b/spec/frontend/admin/users/components/users_table_spec.js index 424b0deebd3..708c9e1979e 100644 --- a/spec/frontend/admin/users/components/users_table_spec.js +++ b/spec/frontend/admin/users/components/users_table_spec.js @@ -1,16 +1,36 @@ -import { GlTable } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { GlTable, GlSkeletonLoader } from '@gitlab/ui'; +import { createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; + +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import AdminUserActions from '~/admin/users/components/user_actions.vue'; import AdminUserAvatar from '~/admin/users/components/user_avatar.vue'; import AdminUsersTable from '~/admin/users/components/users_table.vue'; +import getUsersGroupCountsQuery from '~/admin/users/graphql/queries/get_users_group_counts.query.graphql'; +import createFlash from '~/flash'; import AdminUserDate from '~/vue_shared/components/user_date.vue'; -import { users, paths } from '../mock_data'; +import { users, paths, createGroupCountResponse } from '../mock_data'; + +jest.mock('~/flash'); + +const localVue = createLocalVue(); +localVue.use(VueApollo); describe('AdminUsersTable component', () => { let wrapper; + const user = users[0]; + const createFetchGroupCount = (data) => + jest.fn().mockResolvedValue(createGroupCountResponse(data)); + const fetchGroupCountsLoading = jest.fn().mockResolvedValue(new Promise(() => {})); + const fetchGroupCountsError = jest.fn().mockRejectedValue(new Error('Network error')); + const fetchGroupCountsResponse = createFetchGroupCount([{ id: user.id, groupCount: 5 }]); + + const findUserGroupCount = (id) => wrapper.findByTestId(`user-group-count-${id}`); + const findUserGroupCountLoader = (id) => findUserGroupCount(id).find(GlSkeletonLoader); const getCellByLabel = (trIdx, label) => { return wrapper .find(GlTable) @@ -20,8 +40,16 @@ describe('AdminUsersTable component', () => { .find(`[data-label="${label}"][role="cell"]`); }; - const initComponent = (props = {}) => { - wrapper = mount(AdminUsersTable, { + function createMockApolloProvider(resolverMock) { + const requestHandlers = [[getUsersGroupCountsQuery, resolverMock]]; + + return createMockApollo(requestHandlers); + } + + const initComponent = (props = {}, resolverMock = fetchGroupCountsResponse) => { + wrapper = mountExtended(AdminUsersTable, { + localVue, + apolloProvider: createMockApolloProvider(resolverMock), propsData: { users, paths, @@ -36,8 +64,6 @@ describe('AdminUsersTable component', () => { }); describe('when there are users', () => { - const user = users[0]; - beforeEach(() => { initComponent(); }); @@ -69,4 +95,51 @@ describe('AdminUsersTable component', () => { expect(wrapper.text()).toContain('No users found'); }); }); + + describe('group counts', () => { + describe('when fetching the data', () => { + beforeEach(() => { + initComponent({}, fetchGroupCountsLoading); + }); + + it('renders a loader for each user', () => { + expect(findUserGroupCountLoader(user.id).exists()).toBe(true); + }); + }); + + describe('when the data has been fetched', () => { + beforeEach(() => { + initComponent(); + }); + + it("renders the user's group count", () => { + expect(findUserGroupCount(user.id).text()).toBe('5'); + }); + + describe("and a user's group count is null", () => { + beforeEach(() => { + initComponent({}, createFetchGroupCount([{ id: user.id, groupCount: null }])); + }); + + it("renders the user's group count as 0", () => { + expect(findUserGroupCount(user.id).text()).toBe('0'); + }); + }); + }); + + describe('when there is an error while fetching the data', () => { + beforeEach(() => { + initComponent({}, fetchGroupCountsError); + }); + + it('creates a flash message and captures the error', () => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Could not load user group counts. Please refresh the page to try again.', + captureError: true, + error: expect.any(Error), + }); + }); + }); + }); }); diff --git a/spec/frontend/admin/users/mock_data.js b/spec/frontend/admin/users/mock_data.js index c3918ef5173..4689ab36773 100644 --- a/spec/frontend/admin/users/mock_data.js +++ b/spec/frontend/admin/users/mock_data.js @@ -10,7 +10,7 @@ export const users = [ 'https://secure.gravatar.com/avatar/054f062d8b1a42b123f17e13a173cda8?s=80\\u0026d=identicon', badges: [ { text: 'Admin', variant: 'success' }, - { text: "It's you!", variant: null }, + { text: "It's you!", variant: 'muted' }, ], projectsCount: 0, actions: [], @@ -31,3 +31,16 @@ export const paths = { deleteWithContributions: '/admin/users/id', adminUser: '/admin/users/id', }; + +export const createGroupCountResponse = (groupCounts) => ({ + data: { + users: { + nodes: groupCounts.map(({ id, groupCount }) => ({ + id: `gid://gitlab/User/${id}`, + groupCount, + __typename: 'UserCore', + })), + __typename: 'UserCoreConnection', + }, + }, +}); diff --git a/spec/frontend/admin/users/tabs_spec.js b/spec/frontend/admin/users/tabs_spec.js deleted file mode 100644 index 39ba8618486..00000000000 --- a/spec/frontend/admin/users/tabs_spec.js +++ /dev/null @@ -1,37 +0,0 @@ -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 dece3dfbe5f..826fb820d9b 100644 --- a/spec/frontend/alert_management/components/alert_management_table_spec.js +++ b/spec/frontend/alert_management/components/alert_management_table_spec.js @@ -7,6 +7,7 @@ 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'; +import AlertDeprecationWarning from '~/vue_shared/components/alerts_deprecation_warning.vue'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import defaultProvideValues from '../mocks/alerts_provide_config.json'; @@ -14,6 +15,7 @@ import defaultProvideValues from '../mocks/alerts_provide_config.json'; jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn().mockName('visitUrlMock'), joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths, + setUrlFragment: jest.requireActual('~/lib/utils/url_utility').setUrlFragment, })); describe('AlertManagementTable', () => { @@ -39,6 +41,8 @@ describe('AlertManagementTable', () => { resolved: 11, all: 26, }; + const findDeprecationNotice = () => + wrapper.findComponent(AlertDeprecationWarning).findComponent(GlAlert); function mountComponent({ provide = {}, data = {}, loading = false, stubs = {} } = {}) { wrapper = extendedWrapper( @@ -47,6 +51,7 @@ describe('AlertManagementTable', () => { ...defaultProvideValues, alertManagementEnabled: true, userCanEnableAlertManagement: true, + hasManagedPrometheus: false, ...provide, }, data() { @@ -234,6 +239,20 @@ describe('AlertManagementTable', () => { expect(visitUrl).toHaveBeenCalledWith('/1527542/details', true); }); + describe('deprecation notice', () => { + it('shows the deprecation notice when available', () => { + mountComponent({ provide: { hasManagedPrometheus: true } }); + + expect(findDeprecationNotice().exists()).toBe(true); + }); + + it('hides the deprecation notice when not available', () => { + mountComponent(); + + expect(findDeprecationNotice().exists()).toBe(false); + }); + }); + describe('alert issue links', () => { beforeEach(() => { mountComponent({ 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 9912ac433a5..298596085ef 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js @@ -8,7 +8,6 @@ import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_for import { typeSet } from '~/alerts_settings/constants'; import alertFields from '../mocks/alert_fields.json'; import parsedMapping from '../mocks/parsed_mapping.json'; -import { defaultAlertSettingsConfig } from './util'; const scrollIntoViewMock = jest.fn(); HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; @@ -29,7 +28,6 @@ describe('AlertsSettingsForm', () => { ...props, }, provide: { - ...defaultAlertSettingsConfig, multiIntegrations, }, mocks: { @@ -50,7 +48,6 @@ describe('AlertsSettingsForm', () => { const findFormToggle = () => wrapper.findComponent(GlToggle); const findSamplePayloadSection = () => wrapper.findByTestId('sample-payload-section'); const findMappingBuilder = () => wrapper.findComponent(MappingBuilder); - const findSubmitButton = () => wrapper.findByTestId('integration-form-submit'); const findMultiSupportText = () => wrapper.findByTestId('multi-integrations-not-supported'); const findJsonTestSubmit = () => wrapper.findByTestId('send-test-alert'); 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 dd8ce838dfd..595c3f1a289 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js @@ -20,6 +20,7 @@ import resetPrometheusTokenMutation from '~/alerts_settings/graphql/mutations/re 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 getHttpIntegrationQuery from '~/alerts_settings/graphql/queries/get_http_integration.query.graphql'; import getIntegrationsQuery from '~/alerts_settings/graphql/queries/get_integrations.query.graphql'; import alertsUpdateService from '~/alerts_settings/services'; import { @@ -47,7 +48,6 @@ import { destroyIntegrationResponseWithErrors, } from './mocks/apollo_mock'; import mockIntegrations from './mocks/integrations.json'; -import { defaultAlertSettingsConfig } from './util'; jest.mock('~/flash'); @@ -58,27 +58,12 @@ 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.findComponent(IntegrationsList).findComponent(GlLoadingIcon); const findIntegrationsList = () => wrapper.findComponent(IntegrationsList); @@ -109,13 +94,14 @@ describe('AlertsSettingsWrapper', () => { return { ...data }; }, provide: { - ...defaultAlertSettingsConfig, ...provide, }, mocks: { $apollo: { mutate: jest.fn(), - query: jest.fn(), + addSmartQuery: jest.fn((_, options) => { + options.result.call(wrapper.vm); + }), queries: { integrations: { loading, @@ -143,9 +129,6 @@ describe('AlertsSettingsWrapper', () => { wrapper = mount(AlertsSettingsWrapper, { localVue, apolloProvider: fakeApollo, - provide: { - ...defaultAlertSettingsConfig, - }, }); } @@ -158,17 +141,29 @@ describe('AlertsSettingsWrapper', () => { beforeEach(() => { createComponent({ data: { - integrations: { list: mockIntegrations }, - httpIntegrations: { list: [] }, + integrations: mockIntegrations, currentIntegration: mockIntegrations[0], }, loading: false, }); }); - it('renders alerts integrations list and add new integration button by default', () => { + it('renders alerts integrations list', () => { expect(findLoader().exists()).toBe(false); expect(findIntegrations()).toHaveLength(mockIntegrations.length); + }); + + it('renders `Add new integration` button when multiple integrations are supported ', () => { + createComponent({ + data: { + integrations: mockIntegrations, + currentIntegration: mockIntegrations[0], + }, + provide: { + multiIntegrations: true, + }, + loading: false, + }); expect(findAddIntegrationBtn().exists()).toBe(true); }); @@ -177,6 +172,16 @@ describe('AlertsSettingsWrapper', () => { }); it('hides `add new integration` button and displays setting form on btn click', async () => { + createComponent({ + data: { + integrations: mockIntegrations, + currentIntegration: mockIntegrations[0], + }, + provide: { + multiIntegrations: true, + }, + loading: false, + }); const addNewIntegrationBtn = findAddIntegrationBtn(); expect(addNewIntegrationBtn.exists()).toBe(true); await addNewIntegrationBtn.trigger('click'); @@ -186,7 +191,7 @@ describe('AlertsSettingsWrapper', () => { it('shows loading indicator inside the IntegrationsList table', () => { createComponent({ - data: { integrations: {} }, + data: { integrations: [] }, loading: true, }); expect(wrapper.find(IntegrationsList).exists()).toBe(true); @@ -198,7 +203,7 @@ describe('AlertsSettingsWrapper', () => { beforeEach(() => { createComponent({ data: { - integrations: { list: mockIntegrations }, + integrations: mockIntegrations, currentIntegration: mockIntegrations[0], formVisible: true, }, @@ -283,7 +288,7 @@ describe('AlertsSettingsWrapper', () => { it('calls `$apollo.mutate` with `updatePrometheusIntegrationMutation`', () => { createComponent({ data: { - integrations: { list: mockIntegrations }, + integrations: mockIntegrations, currentIntegration: mockIntegrations[3], formVisible: true, }, @@ -374,39 +379,61 @@ describe('AlertsSettingsWrapper', () => { }); }); - it('calls `$apollo.mutate` with `updateCurrentHttpIntegrationMutation` on HTTP integration edit', () => { - createComponent({ - data: { - integrations: { list: mockIntegrations }, - currentIntegration: mockIntegrations[0], - httpIntegrations, - }, - loading: false, - }); + describe('Edit integration', () => { + describe('HTTP', () => { + beforeEach(() => { + createComponent({ + data: { + integrations: mockIntegrations, + currentIntegration: mockIntegrations[0], + currentHttpIntegration: { id: mockIntegrations[0].id, ...httpMappingData }, + }, + provide: { + multiIntegrations: true, + }, + loading: false, + }); + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValueOnce({}); + findIntegrationsList().vm.$emit('edit-integration', updateHttpVariables); + }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValueOnce({}); - findIntegrationsList().vm.$emit('edit-integration', updateHttpVariables); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: updateCurrentHttpIntegrationMutation, - variables: { ...mockIntegrations[0], ...httpMappingData }, - }); - }); + it('requests `currentHttpIntegration`', () => { + expect(wrapper.vm.$apollo.addSmartQuery).toHaveBeenCalledWith( + 'currentHttpIntegration', + expect.objectContaining({ + query: getHttpIntegrationQuery, + result: expect.any(Function), + update: expect.any(Function), + variables: expect.any(Function), + }), + ); + }); - it('calls `$apollo.mutate` with `updateCurrentPrometheusIntegrationMutation` on PROMETHEUS integration edit', () => { - createComponent({ - data: { - integrations: { list: mockIntegrations }, - currentIntegration: mockIntegrations[3], - httpIntegrations, - }, - loading: false, + it('calls `$apollo.mutate` with `updateCurrentHttpIntegrationMutation`', () => { + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: updateCurrentHttpIntegrationMutation, + variables: { ...mockIntegrations[0], ...httpMappingData }, + }); + }); }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); - findIntegrationsList().vm.$emit('edit-integration', updatePrometheusVariables); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: updateCurrentPrometheusIntegrationMutation, - variables: mockIntegrations[3], + describe('Prometheus', () => { + it('calls `$apollo.mutate` with `updateCurrentPrometheusIntegrationMutation`', () => { + createComponent({ + data: { + integrations: mockIntegrations, + currentIntegration: mockIntegrations[3], + }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); + findIntegrationsList().vm.$emit('edit-integration', updatePrometheusVariables); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: updateCurrentPrometheusIntegrationMutation, + variables: mockIntegrations[3], + }); + }); }); }); diff --git a/spec/frontend/alerts_settings/components/util.js b/spec/frontend/alerts_settings/components/util.js deleted file mode 100644 index 5c07f22f1c9..00000000000 --- a/spec/frontend/alerts_settings/components/util.js +++ /dev/null @@ -1,24 +0,0 @@ -const PROMETHEUS_URL = '/prometheus/alerts/notify.json'; -const GENERIC_URL = '/alerts/notify.json'; -const KEY = 'abcedfg123'; -const INVALID_URL = 'http://invalid'; -const ACTIVE = false; - -export const defaultAlertSettingsConfig = { - generic: { - authorizationKey: KEY, - formPath: INVALID_URL, - url: GENERIC_URL, - alertsSetupUrl: INVALID_URL, - alertsUsageUrl: INVALID_URL, - active: ACTIVE, - }, - prometheus: { - authorizationKey: KEY, - prometheusFormPath: INVALID_URL, - url: PROMETHEUS_URL, - active: ACTIVE, - }, - projectPath: '', - multiIntegrations: true, -}; diff --git a/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js b/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js index 62b95c6078b..8a0800457c6 100644 --- a/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js +++ b/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js @@ -1,4 +1,8 @@ -import { getMappingData, transformForSave } from '~/alerts_settings/utils/mapping_transformations'; +import { + getMappingData, + setFieldsLabels, + transformForSave, +} from '~/alerts_settings/utils/mapping_transformations'; import alertFields from '../mocks/alert_fields.json'; import parsedMapping from '../mocks/parsed_mapping.json'; @@ -64,4 +68,33 @@ describe('Mapping Transformation Utilities', () => { expect(result).toEqual([]); }); }); + + describe('setFieldsLabels', () => { + const nonNestedFields = [{ label: 'title' }]; + const nonNestedFieldsResult = { displayLabel: 'Title', tooltip: undefined }; + + const nestedFields = [ + { + label: 'field/subfield', + }, + ]; + const nestedFieldsResult = { displayLabel: '...Subfield', tooltip: 'field.subfield' }; + + const nestedArrayFields = [ + { + label: 'fields[1]/subfield', + }, + ]; + + const nestedArrayFieldsResult = { displayLabel: '...Subfield', tooltip: 'fields[1].subfield' }; + + it.each` + type | fields | result + ${'not nested field'} | ${nonNestedFields} | ${nonNestedFieldsResult} + ${'nested field'} | ${nestedFields} | ${nestedFieldsResult} + ${'nested inside array'} | ${nestedArrayFields} | ${nestedArrayFieldsResult} + `('adds correct displayLabel and tooltip for $type', ({ fields, result }) => { + expect(setFieldsLabels(fields)[0]).toMatchObject(result); + }); + }); }); diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index cb29dab86bf..139128e6d4a 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -930,7 +930,7 @@ describe('Api', () => { describe('createBranch', () => { it('creates new branch', (done) => { - const ref = 'master'; + const ref = 'main'; const branch = 'new-branch-name'; const dummyProjectPath = 'gitlab-org/gitlab-ce'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${encodeURIComponent( @@ -1262,7 +1262,7 @@ describe('Api', () => { )}/merge_requests`; const options = { source_branch: 'feature', - target_branch: 'master', + target_branch: 'main', title: 'Add feature', }; diff --git a/spec/frontend/batch_comments/components/preview_dropdown_spec.js b/spec/frontend/batch_comments/components/preview_dropdown_spec.js new file mode 100644 index 00000000000..41be04d0b7e --- /dev/null +++ b/spec/frontend/batch_comments/components/preview_dropdown_spec.js @@ -0,0 +1,71 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PreviewDropdown from '~/batch_comments/components/preview_dropdown.vue'; + +Vue.use(Vuex); + +let wrapper; + +const toggleActiveFileByHash = jest.fn(); +const scrollToDraft = jest.fn(); + +function factory({ viewDiffsFileByFile = false, draftsCount = 1, sortedDrafts = [] } = {}) { + const store = new Vuex.Store({ + modules: { + diffs: { + namespaced: true, + actions: { + toggleActiveFileByHash, + }, + state: { + viewDiffsFileByFile, + }, + }, + batchComments: { + namespaced: true, + actions: { scrollToDraft }, + getters: { draftsCount: () => draftsCount, sortedDrafts: () => sortedDrafts }, + }, + }, + }); + + wrapper = shallowMountExtended(PreviewDropdown, { + store, + }); +} + +describe('Batch comments preview dropdown', () => { + afterEach(() => { + wrapper.destroy(); + }); + + describe('clicking draft', () => { + it('it toggles active file when viewDiffsFileByFile is true', async () => { + factory({ + viewDiffsFileByFile: true, + sortedDrafts: [{ id: 1, file_hash: 'hash' }], + }); + + wrapper.findByTestId('preview-item').vm.$emit('click'); + + await Vue.nextTick(); + + expect(toggleActiveFileByHash).toHaveBeenCalledWith(expect.anything(), 'hash'); + expect(scrollToDraft).toHaveBeenCalledWith(expect.anything(), { id: 1, file_hash: 'hash' }); + }); + + it('calls scrollToDraft', async () => { + factory({ + viewDiffsFileByFile: false, + sortedDrafts: [{ id: 1 }], + }); + + wrapper.findByTestId('preview-item').vm.$emit('click'); + + await Vue.nextTick(); + + expect(scrollToDraft).toHaveBeenCalledWith(expect.anything(), { id: 1 }); + }); + }); +}); diff --git a/spec/frontend/behaviors/date_picker_spec.js b/spec/frontend/behaviors/date_picker_spec.js new file mode 100644 index 00000000000..9f7701a0366 --- /dev/null +++ b/spec/frontend/behaviors/date_picker_spec.js @@ -0,0 +1,30 @@ +import * as Pikaday from 'pikaday'; +import initDatePickers from '~/behaviors/date_picker'; +import * as utils from '~/lib/utils/datetime_utility'; + +jest.mock('pikaday'); +jest.mock('~/lib/utils/datetime_utility'); + +describe('date_picker behavior', () => { + let pikadayMock; + let parseMock; + + beforeEach(() => { + pikadayMock = jest.spyOn(Pikaday, 'default'); + parseMock = jest.spyOn(utils, 'parsePikadayDate'); + setFixtures(` + <div> + <input class="datepicker" value="2020-10-01" /> + </div> + <div> + <input class="datepicker" value="" /> + </div>`); + }); + + it('Instantiates Pickaday for every instance of a .datepicker class', () => { + initDatePickers(); + + expect(pikadayMock.mock.calls.length).toEqual(2); + expect(parseMock.mock.calls).toEqual([['2020-10-01'], ['']]); + }); +}); diff --git a/spec/frontend/behaviors/shortcuts/shortcut_spec.js b/spec/frontend/behaviors/shortcuts/shortcut_spec.js new file mode 100644 index 00000000000..44bb74ce179 --- /dev/null +++ b/spec/frontend/behaviors/shortcuts/shortcut_spec.js @@ -0,0 +1,96 @@ +import { shallowMount } from '@vue/test-utils'; +import Shortcut from '~/behaviors/shortcuts/shortcut.vue'; + +describe('Shortcut Vue Component', () => { + const render = (shortcuts) => shallowMount(Shortcut, { propsData: { shortcuts } }).html(); + + afterEach(() => { + delete window.gl.client; + }); + + describe.each([true, false])('With browser env isMac: %p', (isMac) => { + beforeEach(() => { + window.gl = { client: { isMac } }; + }); + + it.each([ + ['up', '<kbd>↑</kbd>'], + ['down', '<kbd>↓</kbd>'], + ['left', '<kbd>←</kbd>'], + ['right', '<kbd>→</kbd>'], + ['ctrl', '<kbd>Ctrl</kbd>'], + ['shift', '<kbd>Shift</kbd>'], + ['enter', '<kbd>Enter</kbd>'], + ['esc', '<kbd>Esc</kbd>'], + // Some normal ascii letter + ['a', '<kbd>a</kbd>'], + // An umlaut letter + ['ø', '<kbd>ø</kbd>'], + // A number + ['5', '<kbd>5</kbd>'], + ])('renders platform agnostic key %p as: %p', (key, rendered) => { + expect(render([key])).toEqual(`<div>${rendered}</div>`); + }); + + it('renders keys combined with plus ("+") correctly', () => { + expect(render(['shift+a+b+c'])).toEqual( + `<div><kbd>Shift</kbd> + <kbd>a</kbd> + <kbd>b</kbd> + <kbd>c</kbd></div>`, + ); + }); + + it('renders keys combined with space (" ") correctly', () => { + expect(render(['shift a b c'])).toEqual( + `<div><kbd>Shift</kbd> then <kbd>a</kbd> then <kbd>b</kbd> then <kbd>c</kbd></div>`, + ); + }); + + it('renders multiple shortcuts correctly', () => { + expect(render(['shift+[', 'shift+k'])).toEqual( + `<div><kbd>Shift</kbd> + <kbd>[</kbd> or <br><kbd>Shift</kbd> + <kbd>k</kbd></div>`, + ); + expect(render(['[', 'k'])).toEqual(`<div><kbd>[</kbd> or <kbd>k</kbd></div>`); + }); + }); + + describe('With browser env isMac: true', () => { + beforeEach(() => { + window.gl = { client: { isMac: true } }; + }); + + it.each([ + ['mod', '<kbd>⌘</kbd>'], + ['command', '<kbd>⌘</kbd>'], + ['meta', '<kbd>⌘</kbd>'], + ['option', '<kbd>⌥</kbd>'], + ['alt', '<kbd>⌥</kbd>'], + ])('renders platform specific key %p as: %p', (key, rendered) => { + expect(render([key])).toEqual(`<div>${rendered}</div>`); + }); + + it('does render Mac specific shortcuts', () => { + expect(render(['command+[', 'ctrl+k'])).toEqual( + `<div><kbd>⌘</kbd> + <kbd>[</kbd> or <br><kbd>Ctrl</kbd> + <kbd>k</kbd></div>`, + ); + }); + }); + + describe('With browser env isMac: false', () => { + beforeEach(() => { + window.gl = { client: { isMac: false } }; + }); + + it.each([ + ['mod', '<kbd>Ctrl</kbd>'], + ['command', ''], + ['meta', ''], + ['option', '<kbd>Alt</kbd>'], + ['alt', '<kbd>Alt</kbd>'], + ])('renders platform specific key %p as: %p', (key, rendered) => { + expect(render([key])).toEqual(`<div>${rendered}</div>`); + }); + + it('does not render Mac specific shortcuts', () => { + expect(render(['command+[', 'ctrl+k'])).toEqual(`<div><kbd>Ctrl</kbd> + <kbd>k</kbd></div>`); + }); + }); +}); diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js index 022f8c05e1e..ceafa6ead94 100644 --- a/spec/frontend/boards/components/board_card_spec.js +++ b/spec/frontend/boards/components/board_card_spec.js @@ -1,4 +1,5 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlLabel } from '@gitlab/ui'; +import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; import Vuex from 'vuex'; import BoardCard from '~/boards/components/board_card.vue'; @@ -14,10 +15,11 @@ describe('Board card', () => { const localVue = createLocalVue(); localVue.use(Vuex); - const createStore = ({ initialState = {}, isSwimlanesOn = false } = {}) => { + const createStore = ({ initialState = {} } = {}) => { mockActions = { toggleBoardItem: jest.fn(), toggleBoardItemMultiSelection: jest.fn(), + performSearch: jest.fn(), }; store = new Vuex.Store({ @@ -28,19 +30,21 @@ describe('Board card', () => { }, 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 = {}, provide = {} } = {}) => { - wrapper = shallowMount(BoardCard, { + const mountComponent = ({ + propsData = {}, + provide = {}, + mountFn = shallowMount, + stubs = { BoardCardInner }, + } = {}) => { + wrapper = mountFn(BoardCard, { localVue, - stubs: { - BoardCardInner, - }, + stubs, store, propsData: { list: mockLabelList, @@ -74,72 +78,76 @@ describe('Board card', () => { store = null; }); - describe.each` - isSwimlanesOn - ${true} | ${false} - `('when isSwimlanesOn is $isSwimlanesOn', ({ isSwimlanesOn }) => { - it('should not highlight the card by default', async () => { - createStore({ isSwimlanesOn }); - mountComponent(); + describe('when GlLabel is clicked in BoardCardInner', () => { + it('doesnt call toggleBoardItem', () => { + createStore({ initialState: { isShowingLabels: true } }); + mountComponent({ mountFn: mount, stubs: {} }); + + wrapper.find(GlLabel).trigger('mouseup'); - expect(wrapper.classes()).not.toContain('is-active'); - expect(wrapper.classes()).not.toContain('multi-select'); + expect(mockActions.toggleBoardItem).toHaveBeenCalledTimes(0); }); + }); - it('should highlight the card with a correct style when selected', async () => { - createStore({ - initialState: { - activeId: mockIssue.id, - }, - isSwimlanesOn, - }); - mountComponent(); + it('should not highlight the card by default', async () => { + createStore(); + mountComponent(); - expect(wrapper.classes()).toContain('is-active'); - expect(wrapper.classes()).not.toContain('multi-select'); + expect(wrapper.classes()).not.toContain('is-active'); + expect(wrapper.classes()).not.toContain('multi-select'); + }); + + it('should highlight the card with a correct style when selected', async () => { + createStore({ + initialState: { + activeId: mockIssue.id, + }, }); + mountComponent(); - it('should highlight the card with a correct style when multi-selected', async () => { - createStore({ - initialState: { - activeId: inactiveId, - selectedBoardItems: [mockIssue], - }, - isSwimlanesOn, - }); - mountComponent(); + expect(wrapper.classes()).toContain('is-active'); + expect(wrapper.classes()).not.toContain('multi-select'); + }); - expect(wrapper.classes()).toContain('multi-select'); - expect(wrapper.classes()).not.toContain('is-active'); + it('should highlight the card with a correct style when multi-selected', async () => { + createStore({ + initialState: { + activeId: inactiveId, + selectedBoardItems: [mockIssue], + }, }); + mountComponent(); - describe('when mouseup event is called on the card', () => { - beforeEach(() => { - createStore({ isSwimlanesOn }); - mountComponent(); - }); + expect(wrapper.classes()).toContain('multi-select'); + expect(wrapper.classes()).not.toContain('is-active'); + }); - describe('when not using multi-select', () => { - it('should call vuex action "toggleBoardItem" with correct parameters', async () => { - await selectCard(); + describe('when mouseup event is called on the card', () => { + beforeEach(() => { + createStore(); + mountComponent(); + }); + + describe('when not using multi-select', () => { + it('should call vuex action "toggleBoardItem" with correct parameters', async () => { + await selectCard(); - expect(mockActions.toggleBoardItem).toHaveBeenCalledTimes(1); - expect(mockActions.toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), { - boardItem: mockIssue, - }); + expect(mockActions.toggleBoardItem).toHaveBeenCalledTimes(1); + expect(mockActions.toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), { + boardItem: mockIssue, }); }); + }); - describe('when using multi-select', () => { - it('should call vuex action "multiSelectBoardItem" with correct parameters', async () => { - await multiSelectCard(); + describe('when using multi-select', () => { + it('should call vuex action "multiSelectBoardItem" with correct parameters', async () => { + await multiSelectCard(); - expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledTimes(1); - expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledWith( - expect.any(Object), - mockIssue, - ); - }); + expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledTimes(1); + expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledWith( + expect.any(Object), + mockIssue, + ); }); }); }); diff --git a/spec/frontend/boards/components/board_content_sidebar_spec.js b/spec/frontend/boards/components/board_content_sidebar_spec.js index 7f949739891..01c99a02db2 100644 --- a/spec/frontend/boards/components/board_content_sidebar_spec.js +++ b/spec/frontend/boards/components/board_content_sidebar_spec.js @@ -6,9 +6,9 @@ import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue'; import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue'; import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue'; -import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { ISSUABLE } from '~/boards/constants'; +import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import { mockIssue, mockIssueGroupPath, mockIssueProjectPath } from '../mock_data'; describe('BoardContentSidebar', () => { @@ -111,7 +111,7 @@ describe('BoardContentSidebar', () => { }); it('renders BoardSidebarSubscription', () => { - expect(wrapper.find(BoardSidebarSubscription).exists()).toBe(true); + expect(wrapper.find(SidebarSubscriptionsWidget).exists()).toBe(true); }); it('renders BoardSidebarMilestoneSelect', () => { diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js new file mode 100644 index 00000000000..e27badca9de --- /dev/null +++ b/spec/frontend/boards/components/board_filtered_search_spec.js @@ -0,0 +1,146 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue'; +import { createStore } from '~/boards/stores'; +import * as urlUtility from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; + +Vue.use(Vuex); + +describe('BoardFilteredSearch', () => { + let wrapper; + let store; + const tokens = [ + { + icon: 'labels', + title: __('Label'), + type: 'label_name', + operators: [ + { value: '=', description: 'is' }, + { value: '!=', description: 'is not' }, + ], + token: LabelToken, + unique: false, + symbol: '~', + fetchLabels: () => new Promise(() => {}), + }, + { + icon: 'pencil', + title: __('Author'), + type: 'author_username', + operators: [ + { value: '=', description: 'is' }, + { value: '!=', description: 'is not' }, + ], + symbol: '@', + token: AuthorToken, + unique: true, + fetchAuthors: () => new Promise(() => {}), + }, + ]; + + const createComponent = ({ initialFilterParams = {} } = {}) => { + wrapper = shallowMount(BoardFilteredSearch, { + provide: { initialFilterParams, fullPath: '' }, + store, + propsData: { + tokens, + }, + }); + }; + + const findFilteredSearch = () => wrapper.findComponent(FilteredSearchBarRoot); + + 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('renders FilteredSearch', () => { + expect(findFilteredSearch().exists()).toBe(true); + }); + + it('passes the correct tokens to FilteredSearch', () => { + expect(findFilteredSearch().props('tokens')).toEqual(tokens); + }); + + describe('when onFilter is emitted', () => { + it('calls performSearch', () => { + findFilteredSearch().vm.$emit('onFilter', [{ value: { data: '' } }]); + + expect(store.dispatch).toHaveBeenCalledWith('performSearch'); + }); + + it('calls historyPushState', () => { + jest.spyOn(urlUtility, 'updateHistory'); + findFilteredSearch().vm.$emit('onFilter', [{ value: { data: 'searchQuery' } }]); + + expect(urlUtility.updateHistory).toHaveBeenCalledWith({ + replace: true, + title: '', + url: 'http://test.host/', + }); + }); + }); + }); + + describe('when searching', () => { + beforeEach(() => { + store = createStore(); + + jest.spyOn(store, 'dispatch'); + + createComponent(); + }); + + it('sets the url params to the correct results', async () => { + const mockFilters = [ + { type: 'author_username', value: { data: 'root', operator: '=' } }, + { type: 'label_name', value: { data: 'label', operator: '=' } }, + { type: 'label_name', value: { data: 'label2', operator: '=' } }, + ]; + jest.spyOn(urlUtility, 'updateHistory'); + findFilteredSearch().vm.$emit('onFilter', mockFilters); + + expect(urlUtility.updateHistory).toHaveBeenCalledWith({ + title: '', + replace: true, + url: 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label2', + }); + }); + }); + + describe('when url params are already set', () => { + beforeEach(() => { + store = createStore(); + + jest.spyOn(store, 'dispatch'); + + createComponent({ initialFilterParams: { authorUsername: 'root', labelName: ['label'] } }); + }); + + it('passes the correct props to FilterSearchBar', () => { + expect(findFilteredSearch().props('initialFilterValue')).toEqual([ + { type: 'author_username', value: { data: 'root', operator: '=' } }, + { type: 'label_name', value: { data: 'label', operator: '=' } }, + ]); + }); + }); +}); 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 153d0640b23..ad682774ee6 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 @@ -1,7 +1,11 @@ import { GlLabel } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; -import { labels as TEST_LABELS, mockIssue as TEST_ISSUE } from 'jest/boards/mock_data'; +import { + labels as TEST_LABELS, + mockIssue as TEST_ISSUE, + mockIssueFullPath as TEST_ISSUE_FULLPATH, +} from 'jest/boards/mock_data'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; import { createStore } from '~/boards/stores'; @@ -23,7 +27,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { wrapper = null; }); - const createWrapper = ({ labels = [] } = {}) => { + const createWrapper = ({ labels = [], providedValues = {} } = {}) => { store = createStore(); store.state.boardItems = { [TEST_ISSUE.id]: { ...TEST_ISSUE, labels } }; store.state.activeId = TEST_ISSUE.id; @@ -32,9 +36,9 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { store, provide: { canUpdate: true, - labelsFetchPath: TEST_HOST, labelsManagePath: TEST_HOST, labelsFilterBasePath: TEST_HOST, + ...providedValues, }, stubs: { BoardEditableItem, @@ -48,6 +52,22 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { wrapper.findAll(GlLabel).wrappers.map((item) => item.props('title')); const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]'); + describe('when labelsFetchPath is provided', () => { + it('uses injected labels fetch path', () => { + createWrapper({ providedValues: { labelsFetchPath: 'foobar' } }); + + expect(findLabelsSelect().props('labelsFetchPath')).toEqual('foobar'); + }); + }); + + it('uses the default project label endpoint', () => { + createWrapper(); + + expect(findLabelsSelect().props('labelsFetchPath')).toEqual( + `/${TEST_ISSUE_FULLPATH}/-/labels?include_ancestor_groups=true`, + ); + }); + it('renders "None" when no labels are selected', () => { createWrapper(); @@ -78,7 +98,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { it('commits change to the server', () => { expect(wrapper.vm.setActiveBoardItemLabels).toHaveBeenCalledWith({ addLabelIds: TEST_LABELS.map((label) => label.id), - projectPath: 'gitlab-org/test-subgroup/gitlab-test', + projectPath: TEST_ISSUE_FULLPATH, removeLabelIds: [], }); }); @@ -103,7 +123,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { expect(wrapper.vm.setActiveBoardItemLabels).toHaveBeenCalledWith({ addLabelIds: [5, 7], removeLabelIds: [6], - projectPath: 'gitlab-org/test-subgroup/gitlab-test', + projectPath: TEST_ISSUE_FULLPATH, }); }); }); @@ -122,7 +142,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { expect(wrapper.vm.setActiveBoardItemLabels).toHaveBeenCalledWith({ removeLabelIds: [getIdFromGraphQLId(testLabel.id)], - projectPath: 'gitlab-org/test-subgroup/gitlab-test', + projectPath: TEST_ISSUE_FULLPATH, }); }); }); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 1c5b7cf8248..bcaca9522e4 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -151,6 +151,8 @@ export const rawIssue = { }, }; +export const mockIssueFullPath = 'gitlab-org/test-subgroup/gitlab-test'; + export const mockIssue = { id: 'gid://gitlab/Issue/436', iid: '27', @@ -159,8 +161,8 @@ export const mockIssue = { timeEstimate: 0, weight: null, confidential: false, - referencePath: 'gitlab-org/test-subgroup/gitlab-test#27', - path: '/gitlab-org/test-subgroup/gitlab-test/-/issues/27', + referencePath: `${mockIssueFullPath}#27`, + path: `/${mockIssueFullPath}/-/issues/27`, assignees, labels: [ { diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 460e77a3f03..09343b5704f 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -1,15 +1,21 @@ import * as Sentry from '@sentry/browser'; +import { + inactiveId, + ISSUABLE, + ListType, + issuableTypes, + BoardType, + listsQuery, +} from 'ee_else_ce/boards/constants'; import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; import testAction from 'helpers/vuex_action_helper'; import { - fullBoardId, formatListIssues, formatBoardLists, formatIssueInput, formatIssue, getMoveData, } from '~/boards/boards_util'; -import { inactiveId, ISSUABLE, ListType } from '~/boards/constants'; import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql'; import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql'; import actions, { gqlClient } from '~/boards/stores/actions'; @@ -34,12 +40,6 @@ import { jest.mock('~/flash'); -const expectNotImplemented = (action) => { - it('is not implemented', () => { - expect(action).toThrow(new Error('Not implemented!')); - }); -}; - // We need this helper to make sure projectPath is including // subgroups when the movIssue action is called. const getProjectPath = (path) => path.split('#')[0]; @@ -66,20 +66,32 @@ describe('setInitialBoardData', () => { }); describe('setFilters', () => { - it('should commit mutation SET_FILTERS', (done) => { + it.each([ + [ + 'with correct filters as payload', + { + filters: { labelName: 'label' }, + updatedFilters: { labelName: 'label', not: {} }, + }, + ], + [ + 'and updates assigneeWildcardId', + { + filters: { assigneeId: 'None' }, + updatedFilters: { assigneeWildcardId: 'NONE', not: {} }, + }, + ], + ])('should commit mutation SET_FILTERS %s', (_, { filters, updatedFilters }) => { const state = { filters: {}, }; - const filters = { labelName: 'label' }; - testAction( actions.setFilters, filters, state, - [{ type: types.SET_FILTERS, payload: { ...filters, not: {} } }], + [{ type: types.SET_FILTERS, payload: updatedFilters }], [], - done, ); }); }); @@ -120,20 +132,12 @@ describe('setActiveId', () => { }); describe('fetchLists', () => { - it('should dispatch fetchIssueLists action', () => { - testAction({ - action: actions.fetchLists, - expectedActions: [{ type: 'fetchIssueLists' }], - }); - }); -}); - -describe('fetchIssueLists', () => { - const state = { + let state = { fullPath: 'gitlab-org', - boardId: '1', + fullBoardId: 'gid://gitlab/Board/1', filterParams: {}, boardType: 'group', + issuableType: 'issue', }; let queryResponse = { @@ -155,7 +159,7 @@ describe('fetchIssueLists', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); testAction( - actions.fetchIssueLists, + actions.fetchLists, {}, state, [ @@ -173,7 +177,7 @@ describe('fetchIssueLists', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject()); testAction( - actions.fetchIssueLists, + actions.fetchLists, {}, state, [ @@ -202,7 +206,7 @@ describe('fetchIssueLists', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); testAction( - actions.fetchIssueLists, + actions.fetchLists, {}, state, [ @@ -215,6 +219,43 @@ describe('fetchIssueLists', () => { done, ); }); + + it.each` + issuableType | boardType | fullBoardId | isGroup | isProject + ${issuableTypes.issue} | ${BoardType.group} | ${'gid://gitlab/Board/1'} | ${true} | ${false} + ${issuableTypes.issue} | ${BoardType.project} | ${'gid://gitlab/Board/1'} | ${false} | ${true} + `( + 'calls $issuableType query with correct variables', + async ({ issuableType, boardType, fullBoardId, isGroup, isProject }) => { + const commit = jest.fn(); + const dispatch = jest.fn(); + + state = { + fullPath: 'gitlab-org', + fullBoardId, + filterParams: {}, + boardType, + issuableType, + }; + + const variables = { + query: listsQuery[issuableType].query, + variables: { + fullPath: 'gitlab-org', + boardId: fullBoardId, + filters: {}, + isGroup, + isProject, + }, + }; + + jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); + + await actions.fetchLists({ commit, state, dispatch }); + + expect(gqlClient.query).toHaveBeenCalledWith(variables); + }, + ); }); describe('createList', () => { @@ -236,7 +277,7 @@ describe('createIssueList', () => { beforeEach(() => { state = { fullPath: 'gitlab-org', - boardId: '1', + fullBoardId: 'gid://gitlab/Board/1', boardType: 'group', disabled: false, boardLists: [{ type: 'closed' }], @@ -366,7 +407,7 @@ describe('moveList', () => { const state = { fullPath: 'gitlab-org', - boardId: '1', + fullBoardId: 'gid://gitlab/Board/1', boardType: 'group', disabled: false, boardLists: initialBoardListsState, @@ -409,7 +450,7 @@ describe('moveList', () => { const state = { fullPath: 'gitlab-org', - boardId: '1', + fullBoardId: 'gid://gitlab/Board/1', boardType: 'group', disabled: false, boardLists: initialBoardListsState, @@ -443,10 +484,11 @@ describe('updateList', () => { const state = { fullPath: 'gitlab-org', - boardId: '1', + fullBoardId: 'gid://gitlab/Board/1', boardType: 'group', disabled: false, boardLists: [{ type: 'closed' }], + issuableType: issuableTypes.issue, }; testAction( @@ -490,6 +532,7 @@ describe('removeList', () => { beforeEach(() => { state = { boardLists: mockListsById, + issuableType: issuableTypes.issue, }; }); @@ -559,7 +602,7 @@ describe('fetchItemsForList', () => { const state = { fullPath: 'gitlab-org', - boardId: '1', + fullBoardId: 'gid://gitlab/Board/1', filterParams: {}, boardType: 'group', }; @@ -946,7 +989,7 @@ describe('updateIssueOrder', () => { const state = { boardItems: issues, - boardId: 'gid://gitlab/Board/1', + fullBoardId: 'gid://gitlab/Board/1', }; const moveData = { @@ -960,7 +1003,7 @@ describe('updateIssueOrder', () => { mutation: issueMoveListMutation, variables: { projectPath: getProjectPath(mockIssue.referencePath), - boardId: fullBoardId(state.boardId), + boardId: state.fullBoardId, iid: mockIssue.iid, fromListId: 1, toListId: 2, @@ -1362,7 +1405,7 @@ describe('setActiveItemSubscribed', () => { [mockActiveIssue.id]: mockActiveIssue, }, fullPath: 'gitlab-org', - issuableType: 'issue', + issuableType: issuableTypes.issue, }; const getters = { activeBoardItem: mockActiveIssue, isEpicBoard: false }; const subscribedState = true; @@ -1470,7 +1513,7 @@ describe('setActiveIssueMilestone', () => { describe('setActiveItemTitle', () => { const state = { boardItems: { [mockIssue.id]: mockIssue }, - issuableType: 'issue', + issuableType: issuableTypes.issue, fullPath: 'path/f', }; const getters = { activeBoardItem: mockIssue, isEpicBoard: false }; @@ -1522,6 +1565,33 @@ describe('setActiveItemTitle', () => { }); }); +describe('setActiveItemConfidential', () => { + const state = { boardItems: { [mockIssue.id]: mockIssue } }; + const getters = { activeBoardItem: mockIssue }; + + it('set confidential value on board item', (done) => { + const payload = { + itemId: getters.activeBoardItem.id, + prop: 'confidential', + value: true, + }; + + testAction( + actions.setActiveItemConfidential, + true, + { ...state, ...getters }, + [ + { + type: types.UPDATE_BOARD_ITEM_BY_ID, + payload, + }, + ], + [], + done, + ); + }); +}); + describe('fetchGroupProjects', () => { const state = { fullPath: 'gitlab-org', @@ -1749,27 +1819,3 @@ describe('unsetError', () => { }); }); }); - -describe('fetchBacklog', () => { - expectNotImplemented(actions.fetchBacklog); -}); - -describe('bulkUpdateIssues', () => { - expectNotImplemented(actions.bulkUpdateIssues); -}); - -describe('fetchIssue', () => { - expectNotImplemented(actions.fetchIssue); -}); - -describe('toggleIssueSubscription', () => { - expectNotImplemented(actions.toggleIssueSubscription); -}); - -describe('showPage', () => { - expectNotImplemented(actions.showPage); -}); - -describe('toggleEmptyState', () => { - expectNotImplemented(actions.toggleEmptyState); -}); diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js index 6114ba0af5f..e7efb21bee5 100644 --- a/spec/frontend/boards/stores/getters_spec.js +++ b/spec/frontend/boards/stores/getters_spec.js @@ -110,6 +110,15 @@ describe('Boards - Getters', () => { ); }); + it('returns group path of last subgroup for the active issue', () => { + const mockActiveIssue = { + referencePath: 'gitlab-org/subgroup/subsubgroup/gitlab-test#1', + }; + expect(getters.groupPathForActiveIssue({}, { activeBoardItem: mockActiveIssue })).toEqual( + 'gitlab-org/subgroup/subsubgroup', + ); + }); + it('returns empty string as group path when active issue is an empty object', () => { const mockActiveIssue = {}; expect(getters.groupPathForActiveIssue({}, { activeBoardItem: mockActiveIssue })).toEqual(''); diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index af6d439e294..d89abcc79ae 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -13,12 +13,6 @@ import { mockList, } from '../mock_data'; -const expectNotImplemented = (action) => { - it('is not implemented', () => { - expect(action).toThrow(new Error('Not implemented!')); - }); -}; - describe('Board Store Mutations', () => { let state; @@ -158,10 +152,6 @@ describe('Board Store Mutations', () => { }); }); - describe('REQUEST_ADD_LIST', () => { - expectNotImplemented(mutations.REQUEST_ADD_LIST); - }); - describe('RECEIVE_ADD_LIST_SUCCESS', () => { it('adds list to boardLists state', () => { mutations.RECEIVE_ADD_LIST_SUCCESS(state, mockLists[0]); @@ -172,10 +162,6 @@ describe('Board Store Mutations', () => { }); }); - describe('RECEIVE_ADD_LIST_ERROR', () => { - expectNotImplemented(mutations.RECEIVE_ADD_LIST_ERROR); - }); - describe('MOVE_LIST', () => { it('updates boardLists state with reordered lists', () => { state = { @@ -341,10 +327,6 @@ describe('Board Store Mutations', () => { }); }); - describe('REQUEST_ADD_ISSUE', () => { - expectNotImplemented(mutations.REQUEST_ADD_ISSUE); - }); - describe('UPDATE_BOARD_ITEM_BY_ID', () => { const issueId = '1'; const prop = 'id'; @@ -386,14 +368,6 @@ describe('Board Store Mutations', () => { }); }); - describe('RECEIVE_ADD_ISSUE_SUCCESS', () => { - expectNotImplemented(mutations.RECEIVE_ADD_ISSUE_SUCCESS); - }); - - describe('RECEIVE_ADD_ISSUE_ERROR', () => { - expectNotImplemented(mutations.RECEIVE_ADD_ISSUE_ERROR); - }); - describe('MUTATE_ISSUE_SUCCESS', () => { it('updates issue in issues state', () => { const issues = { @@ -434,18 +408,6 @@ describe('Board Store Mutations', () => { }); }); - describe('REQUEST_UPDATE_ISSUE', () => { - expectNotImplemented(mutations.REQUEST_UPDATE_ISSUE); - }); - - describe('RECEIVE_UPDATE_ISSUE_SUCCESS', () => { - expectNotImplemented(mutations.RECEIVE_UPDATE_ISSUE_SUCCESS); - }); - - describe('RECEIVE_UPDATE_ISSUE_ERROR', () => { - expectNotImplemented(mutations.RECEIVE_UPDATE_ISSUE_ERROR); - }); - describe('ADD_BOARD_ITEM_TO_LIST', () => { beforeEach(() => { setBoardsListsState(); @@ -540,14 +502,6 @@ describe('Board Store Mutations', () => { }); }); - describe('SET_CURRENT_PAGE', () => { - expectNotImplemented(mutations.SET_CURRENT_PAGE); - }); - - describe('TOGGLE_EMPTY_STATE', () => { - expectNotImplemented(mutations.TOGGLE_EMPTY_STATE); - }); - describe('REQUEST_GROUP_PROJECTS', () => { it('Should set isLoading in groupProjectsFlags to true in state when fetchNext is false', () => { mutations[types.REQUEST_GROUP_PROJECTS](state, false); diff --git a/spec/frontend/branches/components/__snapshots__/divergence_graph_spec.js.snap b/spec/frontend/branches/components/__snapshots__/divergence_graph_spec.js.snap index 261c406171e..2afca66b0c1 100644 --- a/spec/frontend/branches/components/__snapshots__/divergence_graph_spec.js.snap +++ b/spec/frontend/branches/components/__snapshots__/divergence_graph_spec.js.snap @@ -3,7 +3,7 @@ exports[`Branch divergence graph component renders ahead and behind count 1`] = ` <div class="divergence-graph px-2 d-none d-md-block" - title="10 commits behind master, 10 commits ahead" + title="10 commits behind main, 10 commits ahead" > <graph-bar-stub count="10" @@ -26,7 +26,7 @@ exports[`Branch divergence graph component renders ahead and behind count 1`] = exports[`Branch divergence graph component renders distance count 1`] = ` <div class="divergence-graph px-2 d-none d-md-block" - title="More than 900 commits different with master" + title="More than 900 commits different with main" > <graph-bar-stub count="900" diff --git a/spec/frontend/branches/components/divergence_graph_spec.js b/spec/frontend/branches/components/divergence_graph_spec.js index b54b2ceb233..3b565539f87 100644 --- a/spec/frontend/branches/components/divergence_graph_spec.js +++ b/spec/frontend/branches/components/divergence_graph_spec.js @@ -15,7 +15,7 @@ describe('Branch divergence graph component', () => { it('renders ahead and behind count', () => { factory({ - defaultBranch: 'master', + defaultBranch: 'main', aheadCount: 10, behindCount: 10, maxCommits: 100, @@ -27,18 +27,18 @@ describe('Branch divergence graph component', () => { it('sets title for ahead and behind count', () => { factory({ - defaultBranch: 'master', + defaultBranch: 'main', aheadCount: 10, behindCount: 10, maxCommits: 100, }); - expect(vm.attributes('title')).toBe('10 commits behind master, 10 commits ahead'); + expect(vm.attributes('title')).toBe('10 commits behind main, 10 commits ahead'); }); it('renders distance count', () => { factory({ - defaultBranch: 'master', + defaultBranch: 'main', aheadCount: 0, behindCount: 0, distance: 900, @@ -55,13 +55,13 @@ describe('Branch divergence graph component', () => { ${1100} | ${'999+'} `('sets title for $distance as $titleText', ({ distance, titleText }) => { factory({ - defaultBranch: 'master', + defaultBranch: 'main', aheadCount: 0, behindCount: 0, distance, maxCommits: 100, }); - expect(vm.attributes('title')).toBe(`More than ${titleText} commits different with master`); + expect(vm.attributes('title')).toBe(`More than ${titleText} commits different with main`); }); }); diff --git a/spec/frontend/branches/divergence_graph_spec.js b/spec/frontend/branches/divergence_graph_spec.js index be97a1724d3..7c367f83add 100644 --- a/spec/frontend/branches/divergence_graph_spec.js +++ b/spec/frontend/branches/divergence_graph_spec.js @@ -9,14 +9,14 @@ describe('Divergence graph', () => { mock = new MockAdapter(axios); mock.onGet('/-/diverging_counts').reply(200, { - master: { ahead: 1, behind: 1 }, + main: { ahead: 1, behind: 1 }, 'test/hello-world': { ahead: 1, behind: 1 }, }); jest.spyOn(axios, 'get'); document.body.innerHTML = ` - <div class="js-branch-item" data-name="master"><div class="js-branch-divergence-graph"></div></div> + <div class="js-branch-item" data-name="main"><div class="js-branch-divergence-graph"></div></div> <div class="js-branch-item" data-name="test/hello-world"><div class="js-branch-divergence-graph"></div></div> `; }); @@ -28,7 +28,7 @@ describe('Divergence graph', () => { it('calls axios get with list of branch names', () => init('/-/diverging_counts').then(() => { expect(axios.get).toHaveBeenCalledWith('/-/diverging_counts', { - params: { names: ['master', 'test/hello-world'] }, + params: { names: ['main', 'test/hello-world'] }, }); })); @@ -46,7 +46,7 @@ describe('Divergence graph', () => { it('creates Vue components', () => init('/-/diverging_counts').then(() => { - expect(document.querySelector('[data-name="master"]').innerHTML).not.toEqual(''); + expect(document.querySelector('[data-name="main"]').innerHTML).not.toEqual(''); expect(document.querySelector('[data-name="test/hello-world"]').innerHTML).not.toEqual(''); })); }); diff --git a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js index 752783a306a..eb18147fcef 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js @@ -226,7 +226,7 @@ describe('Ci variable modal', () => { }; createComponent(mount); store.state.variable = validMaskandKeyVariable; - store.state.maskableRegex = /^[a-zA-Z0-9_+=/@:-]{8,}$/; + store.state.maskableRegex = /^[a-zA-Z0-9_+=/@:.~-]{8,}$/; }); it('does not disable the submit button', () => { diff --git a/spec/frontend/code_navigation/components/app_spec.js b/spec/frontend/code_navigation/components/app_spec.js index ea389fa35c0..798f3bc0ee2 100644 --- a/spec/frontend/code_navigation/components/app_spec.js +++ b/spec/frontend/code_navigation/components/app_spec.js @@ -16,7 +16,7 @@ function factory(initialState = {}) { state: { ...createState(), ...initialState, - definitionPathPrefix: 'https://test.com/blob/master', + definitionPathPrefix: 'https://test.com/blob/main', }, actions: { fetchData, diff --git a/spec/frontend/code_navigation/store/mutations_spec.js b/spec/frontend/code_navigation/store/mutations_spec.js index d4a75da429e..cb10729f4b6 100644 --- a/spec/frontend/code_navigation/store/mutations_spec.js +++ b/spec/frontend/code_navigation/store/mutations_spec.js @@ -12,11 +12,11 @@ describe('Code navigation mutations', () => { it('sets initial data', () => { mutations.SET_INITIAL_DATA(state, { blobs: ['test'], - definitionPathPrefix: 'https://test.com/blob/master', + definitionPathPrefix: 'https://test.com/blob/main', }); expect(state.blobs).toEqual(['test']); - expect(state.definitionPathPrefix).toBe('https://test.com/blob/master'); + expect(state.definitionPathPrefix).toBe('https://test.com/blob/main'); }); }); diff --git a/spec/frontend/code_quality_walkthrough/components/__snapshots__/step_spec.js.snap b/spec/frontend/code_quality_walkthrough/components/__snapshots__/step_spec.js.snap new file mode 100644 index 00000000000..f17d99ad257 --- /dev/null +++ b/spec/frontend/code_quality_walkthrough/components/__snapshots__/step_spec.js.snap @@ -0,0 +1,174 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`When the code_quality_walkthrough URL parameter is present Code Quality Walkthrough Step component commit_ci_file step renders a popover 1`] = ` +<div> + <gl-popover-stub + container="viewport" + cssclasses="" + offset="90" + placement="right" + show="" + target="#js-code-quality-walkthrough" + triggers="manual" + > + + <gl-sprintf-stub + message="To begin with code quality, we first need to create a new CI file using our code editor. We added a code quality template in the code editor to help you get started %{emojiStart}wink%{emojiEnd} .%{lineBreak}Take some time to review the template, when you are ready, use the %{strongStart}commit changes%{strongEnd} button at the bottom of the page." + /> + + <div + class="gl-mt-2 gl-text-right" + > + <gl-button-stub + buttontextclasses="" + category="tertiary" + href="" + icon="" + size="medium" + variant="link" + > + + Got it + + </gl-button-stub> + </div> + </gl-popover-stub> + + <!----> +</div> +`; + +exports[`When the code_quality_walkthrough URL parameter is present Code Quality Walkthrough Step component failed_pipeline step renders a popover 1`] = ` +<div> + <gl-popover-stub + container="viewport" + cssclasses="" + offset="98" + placement="bottom" + show="" + target="#js-code-quality-walkthrough" + triggers="manual" + > + + <gl-sprintf-stub + message="Your job failed. No worries - this happens. Let's view the logs, and see how we can fix it." + /> + + <div + class="gl-mt-2 gl-text-right" + > + <gl-button-stub + buttontextclasses="" + category="tertiary" + href="/group/project/-/jobs/:id?code_quality_walkthrough=true" + icon="" + size="medium" + variant="link" + > + + View the logs + + </gl-button-stub> + </div> + </gl-popover-stub> + + <!----> +</div> +`; + +exports[`When the code_quality_walkthrough URL parameter is present Code Quality Walkthrough Step component running_pipeline step renders a popover 1`] = ` +<div> + <gl-popover-stub + container="viewport" + cssclasses="" + offset="97" + placement="bottom" + show="" + target="#js-code-quality-walkthrough" + triggers="manual" + > + + <gl-sprintf-stub + message="Your pipeline can take a few minutes to run. If you enabled email notifications, you'll receive an email with your pipeline status. In the meantime, why don't you get some coffee? You earned it!" + /> + + <div + class="gl-mt-2 gl-text-right" + > + <gl-button-stub + buttontextclasses="" + category="tertiary" + href="" + icon="" + size="medium" + variant="link" + > + + Got it + + </gl-button-stub> + </div> + </gl-popover-stub> + + <!----> +</div> +`; + +exports[`When the code_quality_walkthrough URL parameter is present Code Quality Walkthrough Step component success_pipeline step renders a popover 1`] = ` +<div> + <gl-popover-stub + container="viewport" + cssclasses="" + offset="98" + placement="bottom" + show="" + target="#js-code-quality-walkthrough" + triggers="manual" + > + + <gl-sprintf-stub + message="A code quality job will now run every time you or your team members commit changes to your project. You can view the results of the code quality job in the job logs." + /> + + <div + class="gl-mt-2 gl-text-right" + > + <gl-button-stub + buttontextclasses="" + category="tertiary" + href="/group/project/-/jobs/:id?code_quality_walkthrough=true" + icon="" + size="medium" + variant="link" + > + + View the logs + + </gl-button-stub> + </div> + </gl-popover-stub> + + <!----> +</div> +`; + +exports[`When the code_quality_walkthrough URL parameter is present Code Quality Walkthrough Step component troubleshoot_job step renders an alert 1`] = ` +<div> + <!----> + + <gl-alert-stub + class="gl-my-5" + dismissible="true" + dismisslabel="Dismiss" + primarybuttontext="Read the documentation" + secondarybuttonlink="" + secondarybuttontext="" + title="Troubleshoot your code quality job" + variant="tip" + > + + Not sure how to fix your failed job? We have compiled some tips on how to troubleshoot code quality jobs in the documentation. + + </gl-alert-stub> +</div> +`; diff --git a/spec/frontend/code_quality_walkthrough/components/step_spec.js b/spec/frontend/code_quality_walkthrough/components/step_spec.js new file mode 100644 index 00000000000..c397faf1f35 --- /dev/null +++ b/spec/frontend/code_quality_walkthrough/components/step_spec.js @@ -0,0 +1,156 @@ +import { GlButton, GlPopover } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Cookies from 'js-cookie'; +import Step from '~/code_quality_walkthrough/components/step.vue'; +import { EXPERIMENT_NAME, STEPS } from '~/code_quality_walkthrough/constants'; +import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; +import { getParameterByName } from '~/lib/utils/common_utils'; +import Tracking from '~/tracking'; + +jest.mock('~/lib/utils/common_utils', () => ({ + ...jest.requireActual('~/lib/utils/common_utils'), + getParameterByName: jest.fn(), +})); + +let wrapper; + +function factory({ step, link }) { + wrapper = shallowMount(Step, { + propsData: { step, link }, + }); +} + +afterEach(() => { + wrapper.destroy(); +}); + +const dummyLink = '/group/project/-/jobs/:id?code_quality_walkthrough=true'; +const dummyContext = 'experiment_context'; + +const findButton = () => wrapper.findComponent(GlButton); +const findPopover = () => wrapper.findComponent(GlPopover); + +describe('When the code_quality_walkthrough URL parameter is missing', () => { + beforeEach(() => { + getParameterByName.mockReturnValue(false); + }); + + it('does not render the component', () => { + factory({ + step: STEPS.commitCiFile, + }); + + expect(findPopover().exists()).toBe(false); + }); +}); + +describe('When the code_quality_walkthrough URL parameter is present', () => { + beforeEach(() => { + getParameterByName.mockReturnValue(true); + Cookies.set(EXPERIMENT_NAME, { data: dummyContext }); + }); + + afterEach(() => { + Cookies.remove(EXPERIMENT_NAME); + }); + + describe('When mounting the component', () => { + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + + factory({ + step: STEPS.commitCiFile, + }); + }); + + it('tracks an event', () => { + expect(Tracking.event).toHaveBeenCalledWith( + EXPERIMENT_NAME, + `${STEPS.commitCiFile}_displayed`, + { + context: { + schema: TRACKING_CONTEXT_SCHEMA, + data: dummyContext, + }, + }, + ); + }); + }); + + describe('When updating the component', () => { + beforeEach(() => { + factory({ + step: STEPS.runningPipeline, + }); + + jest.spyOn(Tracking, 'event'); + + wrapper.setProps({ step: STEPS.successPipeline }); + }); + + it('tracks an event', () => { + expect(Tracking.event).toHaveBeenCalledWith( + EXPERIMENT_NAME, + `${STEPS.successPipeline}_displayed`, + { + context: { + schema: TRACKING_CONTEXT_SCHEMA, + data: dummyContext, + }, + }, + ); + }); + }); + + describe('When dismissing a popover', () => { + beforeEach(() => { + factory({ + step: STEPS.commitCiFile, + }); + + jest.spyOn(Cookies, 'set'); + jest.spyOn(Tracking, 'event'); + + findButton().vm.$emit('click'); + }); + + it('sets a cookie', () => { + expect(Cookies.set).toHaveBeenCalledWith( + EXPERIMENT_NAME, + { commit_ci_file: true, data: dummyContext }, + { expires: 365 }, + ); + }); + + it('removes the popover', () => { + expect(findPopover().exists()).toBe(false); + }); + + it('tracks an event', () => { + expect(Tracking.event).toHaveBeenCalledWith( + EXPERIMENT_NAME, + `${STEPS.commitCiFile}_dismissed`, + { + context: { + schema: TRACKING_CONTEXT_SCHEMA, + data: dummyContext, + }, + }, + ); + }); + }); + + describe('Code Quality Walkthrough Step component', () => { + describe.each(Object.values(STEPS))('%s step', (step) => { + it(`renders ${step === STEPS.troubleshootJob ? 'an alert' : 'a popover'}`, () => { + const options = { step }; + if ([STEPS.successPipeline, STEPS.failedPipeline].includes(step)) { + options.link = dummyLink; + } + factory(options); + + expect(wrapper.element).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/spec/frontend/commit/pipelines/pipelines_spec.js b/spec/frontend/commit/pipelines/pipelines_spec.js index bbe02daa24b..fe928a01acf 100644 --- a/spec/frontend/commit/pipelines/pipelines_spec.js +++ b/spec/frontend/commit/pipelines/pipelines_spec.js @@ -1,3 +1,4 @@ +import '~/commons'; import MockAdapter from 'axios-mock-adapter'; import Vue from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; diff --git a/spec/frontend/commits_spec.js b/spec/frontend/commits_spec.js index 954025091cf..8189ebe6e55 100644 --- a/spec/frontend/commits_spec.js +++ b/spec/frontend/commits_spec.js @@ -10,7 +10,7 @@ describe('Commits List', () => { beforeEach(() => { setFixtures(` - <form class="commits-search-form" action="/h5bp/html5-boilerplate/commits/master"> + <form class="commits-search-form" action="/h5bp/html5-boilerplate/commits/main"> <input id="commits-search"> </form> <ol id="commits-list"></ol> @@ -59,7 +59,7 @@ describe('Commits List', () => { jest.spyOn(window.history, 'replaceState').mockImplementation(() => {}); mock = new MockAdapter(axios); - mock.onGet('/h5bp/html5-boilerplate/commits/master').reply(200, { + mock.onGet('/h5bp/html5-boilerplate/commits/main').reply(200, { html: '<li>Result</li>', }); diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap new file mode 100644 index 00000000000..35c02911e27 --- /dev/null +++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`content_editor/components/toolbar_button displays tertiary, small button with a provided label and icon 1`] = ` +"<b-button-stub size=\\"sm\\" variant=\\"default\\" type=\\"button\\" tag=\\"button\\" aria-label=\\"Bold\\" title=\\"Bold\\" class=\\"gl-mx-2 gl-button btn-default-tertiary btn-icon\\"> + <!----> + <gl-icon-stub name=\\"bold\\" size=\\"16\\" class=\\"gl-button-icon\\"></gl-icon-stub> + <!----> +</b-button-stub>" +`; diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index f055a49135b..e3741032bf4 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -1,26 +1,59 @@ +import { EditorContent } from '@tiptap/vue-2'; import { shallowMount } from '@vue/test-utils'; -import { EditorContent } from 'tiptap'; import ContentEditor from '~/content_editor/components/content_editor.vue'; -import createEditor from '~/content_editor/services/create_editor'; - -jest.mock('~/content_editor/services/create_editor'); +import TopToolbar from '~/content_editor/components/top_toolbar.vue'; +import { createContentEditor } from '~/content_editor/services/create_content_editor'; describe('ContentEditor', () => { let wrapper; + let editor; - const buildWrapper = () => { - wrapper = shallowMount(ContentEditor); + const createWrapper = async (contentEditor) => { + wrapper = shallowMount(ContentEditor, { + propsData: { + contentEditor, + }, + }); }; + beforeEach(() => { + editor = createContentEditor({ renderMarkdown: () => true }); + }); + afterEach(() => { wrapper.destroy(); }); it('renders editor content component and attaches editor instance', () => { - const editor = {}; + createWrapper(editor); + + expect(wrapper.findComponent(EditorContent).props().editor).toBe(editor.tiptapEditor); + }); + + it('renders top toolbar component and attaches editor instance', () => { + createWrapper(editor); + + expect(wrapper.findComponent(TopToolbar).props().contentEditor).toBe(editor); + }); + + it.each` + isFocused | classes + ${true} | ${['md', 'md-area', 'is-focused']} + ${false} | ${['md', 'md-area']} + `( + 'has $classes class selectors when tiptapEditor.isFocused = $isFocused', + ({ isFocused, classes }) => { + editor.tiptapEditor.isFocused = isFocused; + createWrapper(editor); + + expect(wrapper.classes()).toStrictEqual(classes); + }, + ); + + it('adds isFocused class when tiptapEditor is focused', () => { + editor.tiptapEditor.isFocused = true; + createWrapper(editor); - createEditor.mockReturnValueOnce(editor); - buildWrapper(); - expect(wrapper.findComponent(EditorContent).props().editor).toBe(editor); + expect(wrapper.classes()).toContain('is-focused'); }); }); diff --git a/spec/frontend/content_editor/components/toolbar_button_spec.js b/spec/frontend/content_editor/components/toolbar_button_spec.js new file mode 100644 index 00000000000..a49efa34017 --- /dev/null +++ b/spec/frontend/content_editor/components/toolbar_button_spec.js @@ -0,0 +1,98 @@ +import { GlButton } from '@gitlab/ui'; +import { Extension } from '@tiptap/core'; +import { shallowMount } from '@vue/test-utils'; +import ToolbarButton from '~/content_editor/components/toolbar_button.vue'; +import { createContentEditor } from '~/content_editor/services/create_content_editor'; + +describe('content_editor/components/toolbar_button', () => { + let wrapper; + let tiptapEditor; + let toggleFooSpy; + const CONTENT_TYPE = 'bold'; + const ICON_NAME = 'bold'; + const LABEL = 'Bold'; + + const buildEditor = () => { + toggleFooSpy = jest.fn(); + tiptapEditor = createContentEditor({ + extensions: [ + { + tiptapExtension: Extension.create({ + addCommands() { + return { + toggleFoo: () => toggleFooSpy, + }; + }, + }), + }, + ], + renderMarkdown: () => true, + }).tiptapEditor; + + jest.spyOn(tiptapEditor, 'isActive'); + }; + + const buildWrapper = (propsData = {}) => { + wrapper = shallowMount(ToolbarButton, { + stubs: { + GlButton, + }, + propsData: { + tiptapEditor, + contentType: CONTENT_TYPE, + iconName: ICON_NAME, + label: LABEL, + ...propsData, + }, + }); + }; + const findButton = () => wrapper.findComponent(GlButton); + + beforeEach(() => { + buildEditor(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays tertiary, small button with a provided label and icon', () => { + buildWrapper(); + + expect(findButton().html()).toMatchSnapshot(); + }); + + it.each` + editorState | outcomeDescription | outcome + ${{ isActive: true, isFocused: true }} | ${'button is active'} | ${true} + ${{ isActive: false, isFocused: true }} | ${'button is not active'} | ${false} + ${{ isActive: true, isFocused: false }} | ${'button is not active '} | ${false} + `('$outcomeDescription when when editor state is $editorState', ({ editorState, outcome }) => { + tiptapEditor.isActive.mockReturnValueOnce(editorState.isActive); + tiptapEditor.isFocused = editorState.isFocused; + buildWrapper(); + + expect(findButton().classes().includes('active')).toBe(outcome); + expect(tiptapEditor.isActive).toHaveBeenCalledWith(CONTENT_TYPE); + }); + + describe('when button is clicked', () => { + it('executes the content type command when executeCommand = true', async () => { + buildWrapper({ editorCommand: 'toggleFoo' }); + + await findButton().trigger('click'); + + expect(toggleFooSpy).toHaveBeenCalled(); + expect(wrapper.emitted().execute).toHaveLength(1); + }); + + it('does not executes the content type command when executeCommand = false', async () => { + buildWrapper(); + + await findButton().trigger('click'); + + expect(toggleFooSpy).not.toHaveBeenCalled(); + expect(wrapper.emitted().execute).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js new file mode 100644 index 00000000000..8f47be3f489 --- /dev/null +++ b/spec/frontend/content_editor/components/top_toolbar_spec.js @@ -0,0 +1,76 @@ +import { shallowMount } from '@vue/test-utils'; +import { mockTracking } from 'helpers/tracking_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import TopToolbar from '~/content_editor/components/top_toolbar.vue'; +import { + TOOLBAR_CONTROL_TRACKING_ACTION, + CONTENT_EDITOR_TRACKING_LABEL, +} from '~/content_editor/constants'; +import { createContentEditor } from '~/content_editor/services/create_content_editor'; + +describe('content_editor/components/top_toolbar', () => { + let wrapper; + let contentEditor; + let trackingSpy; + const buildEditor = () => { + contentEditor = createContentEditor({ renderMarkdown: () => true }); + }; + + const buildWrapper = () => { + wrapper = extendedWrapper( + shallowMount(TopToolbar, { + propsData: { + contentEditor, + }, + }), + ); + }; + + beforeEach(() => { + trackingSpy = mockTracking(undefined, null, jest.spyOn); + }); + + beforeEach(() => { + buildEditor(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each` + testId | buttonProps + ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }} + ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }} + ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }} + ${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }} + ${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }} + ${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }} + `('given a $testId toolbar control', ({ testId, buttonProps }) => { + beforeEach(() => { + buildWrapper(); + }); + + it('renders the toolbar control with the provided properties', () => { + expect(wrapper.findByTestId(testId).props()).toEqual({ + ...buttonProps, + tiptapEditor: contentEditor.tiptapEditor, + }); + }); + + it.each` + control | eventData + ${'bold'} | ${{ contentType: 'bold' }} + ${'blockquote'} | ${{ contentType: 'blockquote', value: 1 }} + `('tracks the execution of toolbar controls', ({ control, eventData }) => { + const { contentType, value } = eventData; + wrapper.findByTestId(control).vm.$emit('execute', eventData); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, TOOLBAR_CONTROL_TRACKING_ACTION, { + label: CONTENT_EDITOR_TRACKING_LABEL, + property: contentType, + value, + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/markdown_processing_spec.js b/spec/frontend/content_editor/markdown_processing_spec.js index e435af30e9f..cb34476d680 100644 --- a/spec/frontend/content_editor/markdown_processing_spec.js +++ b/spec/frontend/content_editor/markdown_processing_spec.js @@ -1,12 +1,13 @@ -import { createEditor } from '~/content_editor'; +import { createContentEditor } from '~/content_editor'; import { loadMarkdownApiExamples, loadMarkdownApiResult } from './markdown_processing_examples'; describe('markdown processing', () => { // Ensure we generate same markdown that was provided to Markdown API. it.each(loadMarkdownApiExamples())('correctly handles %s', async (testName, markdown) => { const { html } = loadMarkdownApiResult(testName); - const editor = await createEditor({ content: markdown, renderMarkdown: () => html }); + const contentEditor = createContentEditor({ renderMarkdown: () => html }); + await contentEditor.setSerializedContent(markdown); - expect(editor.getSerializedContent()).toBe(markdown); + expect(contentEditor.getSerializedContent()).toBe(markdown); }); }); diff --git a/spec/frontend/content_editor/services/build_serializer_config_spec.js b/spec/frontend/content_editor/services/build_serializer_config_spec.js new file mode 100644 index 00000000000..532e0493830 --- /dev/null +++ b/spec/frontend/content_editor/services/build_serializer_config_spec.js @@ -0,0 +1,38 @@ +import * as Blockquote from '~/content_editor/extensions/blockquote'; +import * as Bold from '~/content_editor/extensions/bold'; +import * as Dropcursor from '~/content_editor/extensions/dropcursor'; +import * as Paragraph from '~/content_editor/extensions/paragraph'; + +import buildSerializerConfig from '~/content_editor/services/build_serializer_config'; + +describe('content_editor/services/build_serializer_config', () => { + describe('given one or more content editor extensions', () => { + it('creates a serializer config that collects all extension serializers by type', () => { + const extensions = [Bold, Blockquote, Paragraph]; + const serializerConfig = buildSerializerConfig(extensions); + + extensions.forEach(({ tiptapExtension, serializer }) => { + const { name, type } = tiptapExtension; + expect(serializerConfig[`${type}s`][name]).toBe(serializer); + }); + }); + }); + + describe('given an extension without serializer', () => { + it('does not include the extension in the serializer config', () => { + const serializerConfig = buildSerializerConfig([Dropcursor]); + + expect(serializerConfig.marks[Dropcursor.tiptapExtension.name]).toBe(undefined); + expect(serializerConfig.nodes[Dropcursor.tiptapExtension.name]).toBe(undefined); + }); + }); + + describe('given no extensions', () => { + it('creates an empty serializer config', () => { + expect(buildSerializerConfig()).toStrictEqual({ + marks: {}, + nodes: {}, + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/services/create_content_editor_spec.js b/spec/frontend/content_editor/services/create_content_editor_spec.js new file mode 100644 index 00000000000..59b2fab6d54 --- /dev/null +++ b/spec/frontend/content_editor/services/create_content_editor_spec.js @@ -0,0 +1,51 @@ +import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '~/content_editor/constants'; +import { createContentEditor } from '~/content_editor/services/create_content_editor'; +import { createTestContentEditorExtension } from '../test_utils'; + +describe('content_editor/services/create_editor', () => { + let renderMarkdown; + let editor; + + beforeEach(() => { + renderMarkdown = jest.fn(); + editor = createContentEditor({ renderMarkdown }); + }); + + it('sets gl-outline-0! class selector to the tiptapEditor instance', () => { + expect(editor.tiptapEditor.options.editorProps).toMatchObject({ + attributes: { + class: 'gl-outline-0!', + }, + }); + }); + + it('provides the renderMarkdown function to the markdown serializer', async () => { + const serializedContent = '**bold text**'; + + renderMarkdown.mockReturnValueOnce('<p><b>bold text</b></p>'); + + await editor.setSerializedContent(serializedContent); + + expect(renderMarkdown).toHaveBeenCalledWith(serializedContent); + }); + + it('allows providing external content editor extensions', async () => { + const labelReference = 'this is a ~group::editor'; + + renderMarkdown.mockReturnValueOnce( + '<p>this is a <span data-reference="label" data-label-name="group::editor">group::editor</span></p>', + ); + editor = createContentEditor({ + renderMarkdown, + extensions: [createTestContentEditorExtension()], + }); + + await editor.setSerializedContent(labelReference); + + expect(editor.getSerializedContent()).toBe(labelReference); + }); + + it('throws an error when a renderMarkdown fn is not provided', () => { + expect(() => createContentEditor()).toThrow(PROVIDE_SERIALIZER_OR_RENDERER_ERROR); + }); +}); diff --git a/spec/frontend/content_editor/services/create_editor_spec.js b/spec/frontend/content_editor/services/create_editor_spec.js deleted file mode 100644 index 4cf63e608eb..00000000000 --- a/spec/frontend/content_editor/services/create_editor_spec.js +++ /dev/null @@ -1,39 +0,0 @@ -import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '~/content_editor/constants'; -import createEditor from '~/content_editor/services/create_editor'; -import createMarkdownSerializer from '~/content_editor/services/markdown_serializer'; - -jest.mock('~/content_editor/services/markdown_serializer'); - -describe('content_editor/services/create_editor', () => { - const buildMockSerializer = () => ({ - serialize: jest.fn(), - deserialize: jest.fn(), - }); - - describe('creating an editor', () => { - it('uses markdown serializer when a renderMarkdown function is provided', async () => { - const renderMarkdown = () => true; - const mockSerializer = buildMockSerializer(); - createMarkdownSerializer.mockReturnValueOnce(mockSerializer); - - await createEditor({ renderMarkdown }); - - expect(createMarkdownSerializer).toHaveBeenCalledWith({ render: renderMarkdown }); - }); - - it('uses custom serializer when it is provided', async () => { - const mockSerializer = buildMockSerializer(); - const serializedContent = '**bold**'; - - mockSerializer.serialize.mockReturnValueOnce(serializedContent); - - const editor = await createEditor({ serializer: mockSerializer }); - - expect(editor.getSerializedContent()).toBe(serializedContent); - }); - - it('throws an error when neither a serializer or renderMarkdown fn are provided', async () => { - await expect(createEditor()).rejects.toThrow(PROVIDE_SERIALIZER_OR_RENDERER_ERROR); - }); - }); -}); diff --git a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js new file mode 100644 index 00000000000..437714ba938 --- /dev/null +++ b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js @@ -0,0 +1,108 @@ +import { BulletList } from '@tiptap/extension-bullet-list'; +import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; +import { Document } from '@tiptap/extension-document'; +import { Heading } from '@tiptap/extension-heading'; +import { ListItem } from '@tiptap/extension-list-item'; +import { Paragraph } from '@tiptap/extension-paragraph'; +import { Text } from '@tiptap/extension-text'; +import { Editor, EditorContent } from '@tiptap/vue-2'; +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { mockTracking } from 'helpers/tracking_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { + KEYBOARD_SHORTCUT_TRACKING_ACTION, + INPUT_RULE_TRACKING_ACTION, + CONTENT_EDITOR_TRACKING_LABEL, +} from '~/content_editor/constants'; +import trackInputRulesAndShortcuts from '~/content_editor/services/track_input_rules_and_shortcuts'; +import { ENTER_KEY, BACKSPACE_KEY } from '~/lib/utils/keys'; + +describe('content_editor/services/track_input_rules_and_shortcuts', () => { + let wrapper; + let trackingSpy; + let editor; + const HEADING_TEXT = 'Heading text'; + + const buildWrapper = () => { + wrapper = extendedWrapper( + mount(EditorContent, { + propsData: { + editor, + }, + }), + ); + }; + + beforeEach(() => { + trackingSpy = mockTracking(undefined, null, jest.spyOn); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('given the heading extension is instrumented', () => { + beforeEach(() => { + editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + Heading, + CodeBlockLowlight, + BulletList, + ListItem, + ].map(trackInputRulesAndShortcuts), + }); + }); + + beforeEach(async () => { + buildWrapper(); + await nextTick(); + }); + + describe('when creating a heading using an keyboard shortcut', () => { + it('sends a tracking event indicating that a heading was created using an input rule', async () => { + const shortcuts = Heading.config.addKeyboardShortcuts.call(Heading); + const [firstShortcut] = Object.keys(shortcuts); + const nodeName = Heading.name; + + editor.chain().keyboardShortcut(firstShortcut).insertContent(HEADING_TEXT).run(); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, KEYBOARD_SHORTCUT_TRACKING_ACTION, { + label: CONTENT_EDITOR_TRACKING_LABEL, + property: `${nodeName}.${firstShortcut}`, + }); + }); + }); + + it.each` + extension | shortcut + ${ListItem.name} | ${ENTER_KEY} + ${CodeBlockLowlight.name} | ${BACKSPACE_KEY} + `('does not track $shortcut shortcut for $extension extension', ({ shortcut }) => { + editor.chain().keyboardShortcut(shortcut).run(); + + expect(trackingSpy).not.toHaveBeenCalled(); + }); + + describe('when creating a heading using an input rule', () => { + it('sends a tracking event indicating that a heading was created using an input rule', async () => { + const nodeName = Heading.name; + const { view } = editor; + const { selection } = view.state; + + // Triggers the event handler that input rules listen to + view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, '## ')); + + editor.chain().insertContent(HEADING_TEXT).run(); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, INPUT_RULE_TRACKING_ACTION, { + label: CONTENT_EDITOR_TRACKING_LABEL, + property: `${nodeName}`, + }); + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js new file mode 100644 index 00000000000..a92ceb6d058 --- /dev/null +++ b/spec/frontend/content_editor/test_utils.js @@ -0,0 +1,34 @@ +import { Node } from '@tiptap/core'; + +export const createTestContentEditorExtension = () => ({ + tiptapExtension: Node.create({ + name: 'label', + priority: 101, + inline: true, + group: 'inline', + addAttributes() { + return { + labelName: { + default: null, + parseHTML: (element) => { + return { labelName: element.dataset.labelName }; + }, + }, + }; + }, + parseHTML() { + return [ + { + tag: 'span[data-reference="label"]', + }, + ]; + }, + renderHTML({ HTMLAttributes }) { + return ['span', HTMLAttributes, 0]; + }, + }), + serializer: (state, node) => { + state.write(`~${node.attrs.labelName}`); + state.closeBlock(node); + }, +}); diff --git a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap index 15b052fffbb..3f812d3cf4e 100644 --- a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap +++ b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap @@ -8,7 +8,7 @@ exports[`Contributors charts should render charts when loading completed and the <h4 class="gl-mb-2 gl-mt-5" > - Commits to master + Commits to main </h4> <span> diff --git a/spec/frontend/contributors/component/contributors_spec.js b/spec/frontend/contributors/component/contributors_spec.js index de55be4aa72..cb7e13b9fed 100644 --- a/spec/frontend/contributors/component/contributors_spec.js +++ b/spec/frontend/contributors/component/contributors_spec.js @@ -10,7 +10,7 @@ let mock; let store; const Component = Vue.extend(ContributorsCharts); const endpoint = 'contributors'; -const branch = 'master'; +const branch = 'main'; const chartData = [ { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-05-05' }, { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-03-03' }, diff --git a/spec/frontend/create_merge_request_dropdown_spec.js b/spec/frontend/create_merge_request_dropdown_spec.js index b4c13981dd5..8878891701f 100644 --- a/spec/frontend/create_merge_request_dropdown_spec.js +++ b/spec/frontend/create_merge_request_dropdown_spec.js @@ -15,7 +15,7 @@ describe('CreateMergeRequestDropdown', () => { <div id="dummy-wrapper-element"> <div class="available"></div> <div class="unavailable"> - <div class="spinner"></div> + <div class="gl-spinner"></div> <div class="text"></div> </div> <div class="js-ref"></div> @@ -56,7 +56,7 @@ describe('CreateMergeRequestDropdown', () => { describe('updateCreatePaths', () => { it('escapes branch names correctly', () => { dropdown.createBranchPath = `${TEST_HOST}/branches?branch_name=some-branch&issue=42`; - dropdown.createMrPath = `${TEST_HOST}/create_merge_request?branch_name=some-branch&ref=master`; + dropdown.createMrPath = `${TEST_HOST}/create_merge_request?branch_name=some-branch&ref=main`; dropdown.updateCreatePaths('branch', 'contains#hash'); @@ -65,7 +65,7 @@ describe('CreateMergeRequestDropdown', () => { ); expect(dropdown.createMrPath).toBe( - `${TEST_HOST}/create_merge_request?branch_name=contains%23hash&ref=master`, + `${TEST_HOST}/create_merge_request?branch_name=contains%23hash&ref=main`, ); }); }); diff --git a/spec/frontend/cycle_analytics/mock_data.js b/spec/frontend/cycle_analytics/mock_data.js new file mode 100644 index 00000000000..091b574821d --- /dev/null +++ b/spec/frontend/cycle_analytics/mock_data.js @@ -0,0 +1,186 @@ +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +export const summary = [ + { value: '20', title: 'New Issues' }, + { value: null, title: 'Commits' }, + { value: null, title: 'Deploys' }, + { value: null, title: 'Deployment Frequency', unit: 'per day' }, +]; + +const issueStage = { + title: 'Issue', + name: 'issue', + legend: '', + description: 'Time before an issue gets scheduled', + value: null, +}; + +const planStage = { + title: 'Plan', + name: 'plan', + legend: '', + description: 'Time before an issue starts implementation', + value: 'about 21 hours', +}; + +const codeStage = { + title: 'Code', + name: 'code', + legend: '', + description: 'Time until first merge request', + value: '2 days', +}; + +const testStage = { + title: 'Test', + name: 'test', + legend: '', + description: 'Total test time for all commits/merges', + value: 'about 5 hours', +}; + +const reviewStage = { + title: 'Review', + name: 'review', + legend: '', + description: 'Time between merge request creation and merge/close', + value: null, +}; + +const stagingStage = { + title: 'Staging', + name: 'staging', + legend: '', + description: 'From merge request merge until deploy to production', + value: '2 days', +}; + +export const selectedStage = { + ...issueStage, + value: null, + active: false, + isUserAllowed: true, + emptyStageText: + 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.', + component: 'stage-issue-component', + slug: 'issue', +}; + +export const stats = [issueStage, planStage, codeStage, testStage, reviewStage, stagingStage]; + +export const permissions = { + issue: true, + plan: true, + code: true, + test: true, + review: true, + staging: true, +}; + +export const rawData = { + summary, + stats, + permissions, +}; + +export const convertedData = { + stages: [ + selectedStage, + { + ...planStage, + active: false, + isUserAllowed: true, + emptyStageText: + 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.', + component: 'stage-plan-component', + slug: 'plan', + }, + { + ...codeStage, + active: false, + isUserAllowed: true, + emptyStageText: + 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.', + component: 'stage-code-component', + slug: 'code', + }, + { + ...testStage, + active: false, + isUserAllowed: true, + emptyStageText: + 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.', + component: 'stage-test-component', + slug: 'test', + }, + { + ...reviewStage, + active: false, + isUserAllowed: true, + emptyStageText: + 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.', + component: 'stage-review-component', + slug: 'review', + }, + { + ...stagingStage, + active: false, + isUserAllowed: true, + emptyStageText: + 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.', + component: 'stage-staging-component', + slug: 'staging', + }, + ], + summary: [ + { value: '20', title: 'New Issues' }, + { value: '-', title: 'Commits' }, + { value: '-', title: 'Deploys' }, + { value: '-', title: 'Deployment Frequency', unit: 'per day' }, + ], +}; + +export const rawEvents = [ + { + title: 'Brockfunc-1617160796', + author: { + id: 275, + name: 'VSM User4', + username: 'vsm-user-4-1617160796', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/6a6f5480ae582ba68982a34169420747?s=80&d=identicon', + web_url: 'http://gdk.test:3001/vsm-user-4-1617160796', + show_status: false, + path: '/vsm-user-4-1617160796', + }, + iid: '16', + total_time: { days: 1, hours: 9 }, + created_at: 'about 1 month ago', + url: 'http://gdk.test:3001/vsa-life/ror-project-vsa/-/issues/16', + short_sha: 'some_sha', + commit_url: 'some_commit_url', + }, + { + title: 'Subpod-1617160796', + author: { + id: 274, + name: 'VSM User3', + username: 'vsm-user-3-1617160796', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/fde853fc3ab7dc552e649dcb4fcf5f7f?s=80&d=identicon', + web_url: 'http://gdk.test:3001/vsm-user-3-1617160796', + show_status: false, + path: '/vsm-user-3-1617160796', + }, + iid: '20', + total_time: { days: 2, hours: 18 }, + created_at: 'about 1 month ago', + url: 'http://gdk.test:3001/vsa-life/ror-project-vsa/-/issues/20', + }, +]; + +export const convertedEvents = rawEvents.map((ev) => + convertObjectPropsToCamelCase(ev, { deep: true }), +); diff --git a/spec/frontend/cycle_analytics/store/actions_spec.js b/spec/frontend/cycle_analytics/store/actions_spec.js new file mode 100644 index 00000000000..630c5100754 --- /dev/null +++ b/spec/frontend/cycle_analytics/store/actions_spec.js @@ -0,0 +1,130 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import * as actions from '~/cycle_analytics/store/actions'; +import httpStatusCodes from '~/lib/utils/http_status'; +import { selectedStage } from '../mock_data'; + +const mockRequestPath = 'some/cool/path'; +const mockStartDate = 30; + +describe('Project Value Stream Analytics actions', () => { + let state; + let mock; + + beforeEach(() => { + state = {}; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + state = {}; + }); + + it.each` + action | type | payload | expectedActions + ${'initializeVsa'} | ${'INITIALIZE_VSA'} | ${{ requestPath: mockRequestPath }} | ${['fetchCycleAnalyticsData']} + ${'setDateRange'} | ${'SET_DATE_RANGE'} | ${{ startDate: 30 }} | ${[]} + ${'setSelectedStage'} | ${'SET_SELECTED_STAGE'} | ${{ selectedStage }} | ${[]} + `( + '$action should dispatch $expectedActions and commit $type', + ({ action, type, payload, expectedActions }) => + testAction({ + action: actions[action], + state, + payload, + expectedMutations: [ + { + type, + payload, + }, + ], + expectedActions: expectedActions.map((a) => ({ type: a })), + }), + ); + + describe('fetchCycleAnalyticsData', () => { + beforeEach(() => { + state = { requestPath: mockRequestPath }; + mock = new MockAdapter(axios); + mock.onGet(mockRequestPath).reply(httpStatusCodes.OK); + }); + + it(`dispatches the 'setSelectedStage' and 'fetchStageData' actions`, () => + testAction({ + action: actions.fetchCycleAnalyticsData, + state, + payload: {}, + expectedMutations: [ + { type: 'REQUEST_CYCLE_ANALYTICS_DATA' }, + { type: 'RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS' }, + ], + expectedActions: [{ type: 'setSelectedStage' }, { type: 'fetchStageData' }], + })); + + describe('with a failing request', () => { + beforeEach(() => { + state = { requestPath: mockRequestPath }; + mock = new MockAdapter(axios); + mock.onGet(mockRequestPath).reply(httpStatusCodes.BAD_REQUEST); + }); + + it(`commits the 'RECEIVE_CYCLE_ANALYTICS_DATA_ERROR' mutation`, () => + testAction({ + action: actions.fetchCycleAnalyticsData, + state, + payload: {}, + expectedMutations: [ + { type: 'REQUEST_CYCLE_ANALYTICS_DATA' }, + { type: 'RECEIVE_CYCLE_ANALYTICS_DATA_ERROR' }, + ], + expectedActions: [], + })); + }); + }); + + describe('fetchStageData', () => { + const mockStagePath = `${mockRequestPath}/events/${selectedStage.name}.json`; + + beforeEach(() => { + state = { + requestPath: mockRequestPath, + startDate: mockStartDate, + selectedStage, + }; + mock = new MockAdapter(axios); + mock.onGet(mockStagePath).reply(httpStatusCodes.OK); + }); + + it(`commits the 'RECEIVE_STAGE_DATA_SUCCESS' mutation`, () => + testAction({ + action: actions.fetchStageData, + state, + payload: {}, + expectedMutations: [{ type: 'REQUEST_STAGE_DATA' }, { type: 'RECEIVE_STAGE_DATA_SUCCESS' }], + expectedActions: [], + })); + + describe('with a failing request', () => { + beforeEach(() => { + state = { + requestPath: mockRequestPath, + startDate: mockStartDate, + selectedStage, + }; + mock = new MockAdapter(axios); + mock.onGet(mockStagePath).reply(httpStatusCodes.BAD_REQUEST); + }); + + it(`commits the 'RECEIVE_STAGE_DATA_ERROR' mutation`, () => + testAction({ + action: actions.fetchStageData, + state, + payload: {}, + expectedMutations: [{ type: 'REQUEST_STAGE_DATA' }, { type: 'RECEIVE_STAGE_DATA_ERROR' }], + expectedActions: [], + })); + }); + }); +}); diff --git a/spec/frontend/cycle_analytics/store/mutations_spec.js b/spec/frontend/cycle_analytics/store/mutations_spec.js new file mode 100644 index 00000000000..08c70af6ef6 --- /dev/null +++ b/spec/frontend/cycle_analytics/store/mutations_spec.js @@ -0,0 +1,83 @@ +import * as types from '~/cycle_analytics/store/mutation_types'; +import mutations from '~/cycle_analytics/store/mutations'; +import { selectedStage, rawEvents, convertedEvents, rawData, convertedData } from '../mock_data'; + +let state; +const mockRequestPath = 'fake/request/path'; +const mockStartData = '2021-04-20'; + +describe('Project Value Stream Analytics mutations', () => { + beforeEach(() => { + state = {}; + }); + + afterEach(() => { + state = null; + }); + + it.each` + mutation | stateKey | value + ${types.SET_SELECTED_STAGE} | ${'isLoadingStage'} | ${false} + ${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'isLoading'} | ${true} + ${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'stages'} | ${[]} + ${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'hasError'} | ${false} + ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${'isLoading'} | ${false} + ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${'hasError'} | ${false} + ${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR} | ${'isLoading'} | ${false} + ${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR} | ${'hasError'} | ${true} + ${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR} | ${'stages'} | ${[]} + ${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true} + ${types.REQUEST_STAGE_DATA} | ${'isEmptyStage'} | ${false} + ${types.REQUEST_STAGE_DATA} | ${'hasError'} | ${false} + ${types.REQUEST_STAGE_DATA} | ${'selectedStageEvents'} | ${[]} + ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${'isLoadingStage'} | ${false} + ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${'selectedStageEvents'} | ${[]} + ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${'hasError'} | ${false} + ${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false} + ${types.RECEIVE_STAGE_DATA_ERROR} | ${'selectedStageEvents'} | ${[]} + ${types.RECEIVE_STAGE_DATA_ERROR} | ${'hasError'} | ${true} + ${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true} + `('$mutation will set $stateKey to $value', ({ mutation, stateKey, value }) => { + mutations[mutation](state, {}); + + expect(state).toMatchObject({ [stateKey]: value }); + }); + + it.each` + mutation | payload | stateKey | value + ${types.INITIALIZE_VSA} | ${{ requestPath: mockRequestPath }} | ${'requestPath'} | ${mockRequestPath} + ${types.SET_SELECTED_STAGE} | ${selectedStage} | ${'selectedStage'} | ${selectedStage} + ${types.SET_DATE_RANGE} | ${{ startDate: mockStartData }} | ${'startDate'} | ${mockStartData} + ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'stages'} | ${convertedData.stages} + ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'summary'} | ${convertedData.summary} + `( + '$mutation with $payload will set $stateKey to $value', + ({ mutation, payload, stateKey, value }) => { + mutations[mutation](state, payload); + + expect(state).toMatchObject({ [stateKey]: value }); + }, + ); + + describe('with a stage selected', () => { + beforeEach(() => { + state = { + selectedStage, + }; + }); + + it.each` + mutation | payload | stateKey | value + ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${{ events: [] }} | ${'isEmptyStage'} | ${true} + ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${{ events: rawEvents }} | ${'selectedStageEvents'} | ${convertedEvents} + ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${{ events: rawEvents }} | ${'isEmptyStage'} | ${false} + `( + '$mutation with $payload will set $stateKey to $value', + ({ mutation, payload, stateKey, value }) => { + mutations[mutation](state, payload); + + expect(state).toMatchObject({ [stateKey]: value }); + }, + ); + }); +}); diff --git a/spec/frontend/cycle_analytics/utils_spec.js b/spec/frontend/cycle_analytics/utils_spec.js new file mode 100644 index 00000000000..73e26e1cdcc --- /dev/null +++ b/spec/frontend/cycle_analytics/utils_spec.js @@ -0,0 +1,77 @@ +import { decorateEvents, decorateData } from '~/cycle_analytics/utils'; +import { selectedStage, rawData, convertedData, rawEvents } from './mock_data'; + +describe('Value stream analytics utils', () => { + describe('decorateEvents', () => { + const [result] = decorateEvents(rawEvents, selectedStage); + const eventKeys = Object.keys(result); + const authorKeys = Object.keys(result.author); + it('will return the same number of events', () => { + expect(decorateEvents(rawEvents, selectedStage).length).toBe(rawEvents.length); + }); + + it('will set all the required event fields', () => { + ['totalTime', 'author', 'createdAt', 'shortSha', 'commitUrl'].forEach((key) => { + expect(eventKeys).toContain(key); + }); + ['webUrl', 'avatarUrl'].forEach((key) => { + expect(authorKeys).toContain(key); + }); + }); + + it('will remove unused fields', () => { + ['total_time', 'created_at', 'short_sha', 'commit_url'].forEach((key) => { + expect(eventKeys).not.toContain(key); + }); + + ['web_url', 'avatar_url'].forEach((key) => { + expect(authorKeys).not.toContain(key); + }); + }); + }); + + describe('decorateData', () => { + const result = decorateData(rawData); + it('returns the summary data', () => { + expect(result.summary).toEqual(convertedData.summary); + }); + + it('returns the stages data', () => { + expect(result.stages).toEqual(convertedData.stages); + }); + + it('returns each of the default value stream stages', () => { + const stages = result.stages.map(({ name }) => name); + ['issue', 'plan', 'code', 'test', 'review', 'staging'].forEach((stageName) => { + expect(stages).toContain(stageName); + }); + }); + + it('returns `-` for summary data that has no value', () => { + const singleSummaryResult = decorateData({ + stats: [], + permissions: { issue: true }, + summary: [{ value: null, title: 'Commits' }], + }); + expect(singleSummaryResult.summary).toEqual([{ value: '-', title: 'Commits' }]); + }); + + it('returns additional fields for each stage', () => { + const singleStageResult = decorateData({ + stats: [{ name: 'issue', value: null }], + permissions: { issue: false }, + }); + const stage = singleStageResult.stages[0]; + const txt = + 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.'; + + expect(stage).toMatchObject({ + active: false, + isUserAllowed: false, + emptyStageText: txt, + slug: 'issue', + component: 'stage-issue-component', + }); + }); + }); +}); diff --git a/spec/frontend/deploy_freeze/store/mutations_spec.js b/spec/frontend/deploy_freeze/store/mutations_spec.js index ce75e3b89c3..f8683489340 100644 --- a/spec/frontend/deploy_freeze/store/mutations_spec.js +++ b/spec/frontend/deploy_freeze/store/mutations_spec.js @@ -27,15 +27,19 @@ describe('Deploy freeze mutations', () => { describe('RECEIVE_FREEZE_PERIODS_SUCCESS', () => { it('should set freeze periods and format timezones from identifiers to names', () => { - const timezoneNames = ['Berlin', 'UTC', 'Eastern Time (US & Canada)']; + const timezoneNames = { + 'Europe/Berlin': 'Berlin', + 'Etc/UTC': 'UTC', + 'America/New_York': 'Eastern Time (US & Canada)', + }; mutations[types.RECEIVE_FREEZE_PERIODS_SUCCESS](stateCopy, freezePeriodsFixture); - const expectedFreezePeriods = freezePeriodsFixture.map((freezePeriod, index) => ({ + const expectedFreezePeriods = freezePeriodsFixture.map((freezePeriod) => ({ ...convertObjectPropsToCamelCase(freezePeriod), cronTimezone: { - formattedTimezone: timezoneNames[index], - identifier: freezePeriod.cronTimezone, + formattedTimezone: timezoneNames[freezePeriod.cron_timezone], + identifier: freezePeriod.cron_timezone, }, })); diff --git a/spec/frontend/deploy_keys/components/action_btn_spec.js b/spec/frontend/deploy_keys/components/action_btn_spec.js index 21281ff15b1..307a0b6d8b0 100644 --- a/spec/frontend/deploy_keys/components/action_btn_spec.js +++ b/spec/frontend/deploy_keys/components/action_btn_spec.js @@ -1,4 +1,4 @@ -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import actionBtn from '~/deploy_keys/components/action_btn.vue'; import eventHub from '~/deploy_keys/eventhub'; @@ -8,13 +8,16 @@ describe('Deploy keys action btn', () => { const deployKey = data.enabled_keys[0]; let wrapper; - const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findButton = () => wrapper.findComponent(GlButton); beforeEach(() => { wrapper = shallowMount(actionBtn, { propsData: { deployKey, type: 'enable', + category: 'primary', + variant: 'confirm', + icon: 'edit', }, slots: { default: 'Enable', @@ -26,10 +29,18 @@ describe('Deploy keys action btn', () => { expect(wrapper.text()).toBe('Enable'); }); + it('passes the button props on', () => { + expect(findButton().props()).toMatchObject({ + category: 'primary', + variant: 'confirm', + icon: 'edit', + }); + }); + it('sends eventHub event with btn type', () => { jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - wrapper.trigger('click'); + findButton().vm.$emit('click'); return wrapper.vm.$nextTick().then(() => { expect(eventHub.$emit).toHaveBeenCalledWith('enable.key', deployKey, expect.anything()); @@ -37,18 +48,10 @@ describe('Deploy keys action btn', () => { }); it('shows loading spinner after click', () => { - wrapper.trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(findLoadingIcon().exists()).toBe(true); - }); - }); - - it('disables button after click', () => { - wrapper.trigger('click'); + findButton().vm.$emit('click'); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.attributes('disabled')).toBe('disabled'); + expect(findButton().props('loading')).toBe(true); }); }); }); diff --git a/spec/frontend/deploy_keys/components/app_spec.js b/spec/frontend/deploy_keys/components/app_spec.js index b48e0424580..a72b2b00776 100644 --- a/spec/frontend/deploy_keys/components/app_spec.js +++ b/spec/frontend/deploy_keys/components/app_spec.js @@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'spec/test_constants'; import deployKeysApp from '~/deploy_keys/components/app.vue'; +import ConfirmModal from '~/deploy_keys/components/confirm_modal.vue'; import eventHub from '~/deploy_keys/eventhub'; import axios from '~/lib/utils/axios_utils'; @@ -36,6 +37,7 @@ describe('Deploy keys app component', () => { const findLoadingIcon = () => wrapper.find('.gl-spinner'); const findKeyPanels = () => wrapper.findAll('.deploy-keys .gl-tabs-nav li'); + const findModal = () => wrapper.findComponent(ConfirmModal); it('renders loading icon while waiting for request', () => { mock.onGet(TEST_ENDPOINT).reply(() => new Promise()); @@ -94,11 +96,16 @@ describe('Deploy keys app component', () => { const key = data.public_keys[0]; return mountComponent() .then(() => { - jest.spyOn(window, 'confirm').mockReturnValue(true); jest.spyOn(wrapper.vm.service, 'getKeys').mockImplementation(() => {}); jest.spyOn(wrapper.vm.service, 'disableKey').mockImplementation(() => Promise.resolve()); - eventHub.$emit('disable.key', key); + eventHub.$emit('disable.key', key, () => {}); + + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(findModal().props('visible')).toBe(true); + findModal().vm.$emit('remove'); return wrapper.vm.$nextTick(); }) @@ -112,11 +119,16 @@ describe('Deploy keys app component', () => { const key = data.public_keys[0]; return mountComponent() .then(() => { - jest.spyOn(window, 'confirm').mockReturnValue(true); jest.spyOn(wrapper.vm.service, 'getKeys').mockImplementation(() => {}); jest.spyOn(wrapper.vm.service, 'disableKey').mockImplementation(() => Promise.resolve()); - eventHub.$emit('remove.key', key); + eventHub.$emit('remove.key', key, () => {}); + + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(findModal().props('visible')).toBe(true); + findModal().vm.$emit('remove'); return wrapper.vm.$nextTick(); }) diff --git a/spec/frontend/deploy_keys/components/confirm_modal_spec.js b/spec/frontend/deploy_keys/components/confirm_modal_spec.js new file mode 100644 index 00000000000..42cc2b377a7 --- /dev/null +++ b/spec/frontend/deploy_keys/components/confirm_modal_spec.js @@ -0,0 +1,28 @@ +import { GlModal } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import ConfirmModal from '~/deploy_keys/components/confirm_modal.vue'; + +describe('~/deploy_keys/components/confirm_modal.vue', () => { + let wrapper; + let modal; + + beforeEach(() => { + wrapper = mount(ConfirmModal, { propsData: { modalId: 'test', visible: true } }); + modal = extendedWrapper(wrapper.findComponent(GlModal)); + }); + + it('emits a remove event if the primary button is clicked', () => { + modal.findByText('Remove deploy key').trigger('click'); + expect(wrapper.emitted('remove')).toEqual([[]]); + }); + + it('emits a cancel event if the secondary button is clicked', () => { + modal.findByText('Cancel').trigger('click'); + expect(wrapper.emitted('cancel')).toEqual([[]]); + }); + + it('displays the warning about removing the deploy key', () => { + expect(modal.text()).toContain('Are you sure you want to remove this deploy key?'); + }); +}); diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap index f8c68ca4c83..d9f5ba0bade 100644 --- a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap +++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Design reply form component renders button text as "Comment" when creating a comment 1`] = ` -"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled gl-button\\"> +"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn gl-mr-3 gl-w-auto! btn-confirm btn-md disabled gl-button\\"> <!----> <!----> <span class=\\"gl-button-text\\"> Comment @@ -9,7 +9,7 @@ exports[`Design reply form component renders button text as "Comment" when creat `; exports[`Design reply form component renders button text as "Save comment" when creating a comment 1`] = ` -"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled gl-button\\"> +"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn gl-mr-3 gl-w-auto! btn-confirm btn-md disabled gl-button\\"> <!----> <!----> <span class=\\"gl-button-text\\"> Save comment diff --git a/spec/frontend/diffs/components/compare_versions_spec.js b/spec/frontend/diffs/components/compare_versions_spec.js index a01ec1db35c..80a51ee137a 100644 --- a/spec/frontend/diffs/components/compare_versions_spec.js +++ b/spec/frontend/diffs/components/compare_versions_spec.js @@ -19,8 +19,11 @@ describe('CompareVersions', () => { const targetBranchName = 'tmp-wine-dev'; const { commit } = getDiffWithCommit(); - const createWrapper = (props = {}, commitArgs = {}) => { - store.state.diffs.commit = { ...store.state.diffs.commit, ...commitArgs }; + const createWrapper = (props = {}, commitArgs = {}, createCommit = true) => { + if (createCommit) { + store.state.diffs.commit = { ...store.state.diffs.commit, ...commitArgs }; + } + wrapper = mount(CompareVersionsComponent, { localVue, store, @@ -59,7 +62,7 @@ describe('CompareVersions', () => { describe('template', () => { beforeEach(() => { - createWrapper(); + createWrapper({}, {}, false); }); it('should render Tree List toggle button with correct attribute values', () => { diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js index 9c3c3e82ad5..1e8ad9344f2 100644 --- a/spec/frontend/diffs/components/diff_file_spec.js +++ b/spec/frontend/diffs/components/diff_file_spec.js @@ -1,5 +1,6 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import { nextTick } from 'vue'; import Vuex from 'vuex'; import DiffContentComponent from '~/diffs/components/diff_content.vue'; @@ -16,11 +17,14 @@ import createDiffsStore from '~/diffs/store/modules'; import { diffViewerModes, diffViewerErrors } from '~/ide/constants'; import axios from '~/lib/utils/axios_utils'; +import { scrollToElement } from '~/lib/utils/common_utils'; import httpStatus from '~/lib/utils/http_status'; import createNotesStore from '~/notes/stores/modules'; import diffFileMockDataReadable from '../mock_data/diff_file'; import diffFileMockDataUnreadable from '../mock_data/diff_file_unreadable'; +jest.mock('~/lib/utils/common_utils'); + function changeViewer(store, index, { automaticallyCollapsed, manuallyCollapsed, name }) { const file = store.state.diffs.diffFiles[index]; const newViewer = { @@ -355,6 +359,49 @@ describe('DiffFile', () => { }); }); + describe('scoll-to-top of file after collapse', () => { + beforeEach(() => { + jest.spyOn(wrapper.vm.$store, 'dispatch').mockImplementation(() => {}); + }); + + it("scrolls to the top when the file is open, the users initiates the collapse, and there's a content block to scroll to", async () => { + makeFileOpenByDefault(store); + await nextTick(); + + toggleFile(wrapper); + + expect(scrollToElement).toHaveBeenCalled(); + }); + + it('does not scroll when the content block is missing', async () => { + makeFileOpenByDefault(store); + await nextTick(); + findDiffContentArea(wrapper).element.remove(); + + toggleFile(wrapper); + + expect(scrollToElement).not.toHaveBeenCalled(); + }); + + it("does not scroll if the user doesn't initiate the file collapse", async () => { + makeFileOpenByDefault(store); + await nextTick(); + + wrapper.vm.handleToggle(); + + expect(scrollToElement).not.toHaveBeenCalled(); + }); + + it('does not scroll if the file is already collapsed', async () => { + makeFileManuallyCollapsed(store); + await nextTick(); + + toggleFile(wrapper); + + expect(scrollToElement).not.toHaveBeenCalled(); + }); + }); + describe('fetch collapsed diff', () => { const prepFile = async (inlineLines, parallelLines, readableText) => { forceHasDiff({ diff --git a/spec/frontend/diffs/components/diff_row_spec.js b/spec/frontend/diffs/components/diff_row_spec.js index 0bc1bd40f06..137cc7e3f86 100644 --- a/spec/frontend/diffs/components/diff_row_spec.js +++ b/spec/frontend/diffs/components/diff_row_spec.js @@ -1,5 +1,6 @@ import { getByTestId, fireEvent } from '@testing-library/dom'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import DiffRow from '~/diffs/components/diff_row.vue'; import { mapParallel } from '~/diffs/components/diff_row_utils'; @@ -28,12 +29,12 @@ describe('DiffRow', () => { }, ]; - const createWrapper = ({ props, state, isLoggedIn = true }) => { - const localVue = createLocalVue(); - localVue.use(Vuex); + const createWrapper = ({ props, state, actions, isLoggedIn = true }) => { + Vue.use(Vuex); const diffs = diffsModule(); diffs.state = { ...diffs.state, ...state }; + diffs.actions = { ...diffs.actions, ...actions }; const getters = { isLoggedIn: () => isLoggedIn }; @@ -54,7 +55,7 @@ describe('DiffRow', () => { glFeatures: { dragCommentSelection: true }, }; - return shallowMount(DiffRow, { propsData, localVue, store, provide }); + return shallowMount(DiffRow, { propsData, store, provide }); }; it('isHighlighted returns true given line.left', () => { @@ -95,6 +96,9 @@ describe('DiffRow', () => { expect(wrapper.vm.isHighlighted).toBe(false); }); + const getCommentButton = (wrapper, side) => + wrapper.find(`[data-testid="${side}-comment-button"]`); + describe.each` side ${'left'} @@ -102,18 +106,59 @@ describe('DiffRow', () => { `('$side side', ({ side }) => { it(`renders empty cells if ${side} is unavailable`, () => { const wrapper = createWrapper({ props: { line: testLines[2], inline: false } }); - expect(wrapper.find(`[data-testid="${side}LineNumber"]`).exists()).toBe(false); - expect(wrapper.find(`[data-testid="${side}EmptyCell"]`).exists()).toBe(true); + expect(wrapper.find(`[data-testid="${side}-line-number"]`).exists()).toBe(false); + expect(wrapper.find(`[data-testid="${side}-empty-cell"]`).exists()).toBe(true); }); - it('renders comment button', () => { - const wrapper = createWrapper({ props: { line: testLines[3], inline: false } }); - expect(wrapper.find(`[data-testid="${side}CommentButton"]`).exists()).toBe(true); + describe('comment button', () => { + const showCommentForm = jest.fn(); + let line; + + beforeEach(() => { + showCommentForm.mockReset(); + // https://eslint.org/docs/rules/prefer-destructuring#when-not-to-use-it + // eslint-disable-next-line prefer-destructuring + line = testLines[3]; + }); + + it('renders', () => { + const wrapper = createWrapper({ props: { line, inline: false } }); + expect(getCommentButton(wrapper, side).exists()).toBe(true); + }); + + it('responds to click and keyboard events', async () => { + const wrapper = createWrapper({ + props: { line, inline: false }, + actions: { showCommentForm }, + }); + const commentButton = getCommentButton(wrapper, side); + + await commentButton.trigger('click'); + await commentButton.trigger('keydown.enter'); + await commentButton.trigger('keydown.space'); + + expect(showCommentForm).toHaveBeenCalledTimes(3); + }); + + it('ignores click and keyboard events when comments are disabled', async () => { + line[side].commentsDisabled = true; + const wrapper = createWrapper({ + props: { line, inline: false }, + actions: { showCommentForm }, + }); + const commentButton = getCommentButton(wrapper, side); + + await commentButton.trigger('click'); + await commentButton.trigger('keydown.enter'); + await commentButton.trigger('keydown.space'); + + expect(showCommentForm).not.toHaveBeenCalled(); + }); }); it('renders avatars', () => { const wrapper = createWrapper({ props: { line: testLines[0], inline: false } }); - expect(wrapper.find(`[data-testid="${side}Discussions"]`).exists()).toBe(true); + expect(wrapper.find(`[data-testid="${side}-discussions"]`).exists()).toBe(true); }); }); diff --git a/spec/frontend/diffs/components/inline_diff_table_row_spec.js b/spec/frontend/diffs/components/inline_diff_table_row_spec.js index 66b63a7a1d0..9c3e00cd6cf 100644 --- a/spec/frontend/diffs/components/inline_diff_table_row_spec.js +++ b/spec/frontend/diffs/components/inline_diff_table_row_spec.js @@ -216,14 +216,14 @@ describe('InlineDiffTableRow', () => { const TEST_LINE_NUMBER = 1; describe.each` - lineProps | findLineNumber | expectedHref | expectedClickArg | expectedQaSelector - ${{ line_code: TEST_LINE_CODE, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${`#${TEST_LINE_CODE}`} | ${TEST_LINE_CODE} | ${undefined} - ${{ line_code: undefined, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${undefined} | ${undefined} - ${{ line_code: undefined, left: { line_code: TEST_LINE_CODE }, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${TEST_LINE_CODE} | ${undefined} - ${{ line_code: undefined, right: { line_code: TEST_LINE_CODE }, new_line: TEST_LINE_NUMBER }} | ${findLineNumberNew} | ${'#'} | ${TEST_LINE_CODE} | ${'new_diff_line_link'} + lineProps | findLineNumber | expectedHref | expectedClickArg + ${{ line_code: TEST_LINE_CODE, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${`#${TEST_LINE_CODE}`} | ${TEST_LINE_CODE} + ${{ line_code: undefined, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${undefined} + ${{ line_code: undefined, left: { line_code: TEST_LINE_CODE }, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${TEST_LINE_CODE} + ${{ line_code: undefined, right: { line_code: TEST_LINE_CODE }, new_line: TEST_LINE_NUMBER }} | ${findLineNumberNew} | ${'#'} | ${TEST_LINE_CODE} `( 'with line ($lineProps)', - ({ lineProps, findLineNumber, expectedHref, expectedClickArg, expectedQaSelector }) => { + ({ lineProps, findLineNumber, expectedHref, expectedClickArg }) => { beforeEach(() => { jest.spyOn(store, 'dispatch').mockImplementation(); createComponent({ @@ -236,7 +236,6 @@ describe('InlineDiffTableRow', () => { expect(findLineNumber().attributes()).toEqual({ href: expectedHref, 'data-linenumber': TEST_LINE_NUMBER.toString(), - 'data-qa-selector': expectedQaSelector, }); }); diff --git a/spec/frontend/diffs/mock_data/diff_metadata.js b/spec/frontend/diffs/mock_data/diff_metadata.js index cfa0038c06f..ce79843b8b1 100644 --- a/spec/frontend/diffs/mock_data/diff_metadata.js +++ b/spec/frontend/diffs/mock_data/diff_metadata.js @@ -3,7 +3,7 @@ export const diffMetadata = { size: 1, branch_name: 'update-changelog', source_branch_exists: true, - target_branch_name: 'master', + target_branch_name: 'main', commit: null, context_commits: null, merge_request_diff: { diff --git a/spec/frontend/editor/editor_lite_extension_base_spec.js b/spec/frontend/editor/editor_lite_extension_base_spec.js index 1ae8c70c741..59e1b8968eb 100644 --- a/spec/frontend/editor/editor_lite_extension_base_spec.js +++ b/spec/frontend/editor/editor_lite_extension_base_spec.js @@ -7,6 +7,21 @@ import { } from '~/editor/constants'; import { EditorLiteExtension } from '~/editor/extensions/editor_lite_extension_base'; +jest.mock('~/helpers/startup_css_helper', () => { + return { + waitForCSSLoaded: jest.fn().mockImplementation((cb) => { + // We have to artificially put the callback's execution + // to the end of the current call stack to be able to + // test that the callback is called after waitForCSSLoaded. + // setTimeout with 0 delay does exactly that. + // Otherwise we might end up with false positive results + setTimeout(() => { + cb.apply(); + }, 0); + }), + }; +}); + describe('The basis for an Editor Lite extension', () => { const defaultLine = 3; let ext; @@ -44,6 +59,19 @@ describe('The basis for an Editor Lite extension', () => { }); describe('constructor', () => { + it('resets the layout in waitForCSSLoaded callback', async () => { + const instance = { + layout: jest.fn(), + }; + ext = new EditorLiteExtension({ instance }); + expect(instance.layout).not.toHaveBeenCalled(); + + // We're waiting for the waitForCSSLoaded mock to kick in + await jest.runOnlyPendingTimers(); + + expect(instance.layout).toHaveBeenCalled(); + }); + it.each` description | instance | options ${'accepts configuration options and instance'} | ${{}} | ${defaultOptions} @@ -51,6 +79,7 @@ describe('The basis for an Editor Lite extension', () => { ${'does not fail if both instance and the options are omitted'} | ${undefined} | ${undefined} ${'throws if only options are passed'} | ${undefined} | ${defaultOptions} `('$description', ({ instance, options } = {}) => { + EditorLiteExtension.deferRerender = jest.fn(); const originalInstance = { ...instance }; if (instance) { @@ -82,12 +111,14 @@ describe('The basis for an Editor Lite extension', () => { }); it('initializes the line highlighting', () => { + EditorLiteExtension.deferRerender = jest.fn(); const spy = jest.spyOn(EditorLiteExtension, 'highlightLines'); ext = new EditorLiteExtension({ instance: {} }); expect(spy).toHaveBeenCalled(); }); it('sets up the line linking for code instance', () => { + EditorLiteExtension.deferRerender = jest.fn(); const spy = jest.spyOn(EditorLiteExtension, 'setupLineLinking'); const instance = { getEditorType: jest.fn().mockReturnValue(EDITOR_TYPE_CODE), @@ -99,6 +130,7 @@ describe('The basis for an Editor Lite extension', () => { }); it('does not set up the line linking for diff instance', () => { + EditorLiteExtension.deferRerender = jest.fn(); const spy = jest.spyOn(EditorLiteExtension, 'setupLineLinking'); const instance = { getEditorType: jest.fn().mockReturnValue(EDITOR_TYPE_DIFF), diff --git a/spec/frontend/environments/environment_table_spec.js b/spec/frontend/environments/environment_table_spec.js index 863c4526bb9..71426ee5170 100644 --- a/spec/frontend/environments/environment_table_spec.js +++ b/spec/frontend/environments/environment_table_spec.js @@ -89,6 +89,42 @@ describe('Environment table', () => { expect(wrapper.find('.deploy-board-icon').exists()).toBe(true); }); + it('should render deploy board container when data is provided for children', async () => { + const mockItem = { + name: 'review', + size: 1, + environment_path: 'url', + logs_path: 'url', + id: 1, + isFolder: true, + isOpen: true, + children: [ + { + name: 'review/test', + hasDeployBoard: true, + deployBoardData: deployBoardMockData, + isDeployBoardVisible: true, + isLoadingDeployBoard: false, + isEmptyDeployBoard: false, + }, + ], + }; + + await factory({ + propsData: { + environments: [mockItem], + canCreateDeployment: false, + canReadEnvironment: true, + userCalloutsPath: '/callouts', + lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', + helpCanaryDeploymentsPath: 'help/canary-deployments', + }, + }); + + expect(wrapper.find('.js-deploy-board-row').exists()).toBe(true); + expect(wrapper.find('.deploy-board-icon').exists()).toBe(true); + }); + it('should toggle deploy board visibility when arrow is clicked', (done) => { const mockItem = { name: 'review', @@ -125,7 +161,7 @@ describe('Environment table', () => { wrapper.find('.deploy-board-icon').trigger('click'); }); - it('should set the enviornment to change and weight when a change canary weight event is recevied', async () => { + it('should set the environment to change and weight when a change canary weight event is recevied', async () => { const mockItem = { name: 'review', size: 1, @@ -359,7 +395,7 @@ describe('Environment table', () => { }, }, { - name: 'review/master', + name: 'review/main', last_deployment: { created_at: '2019-02-17T16:26:15.125Z', }, @@ -374,7 +410,7 @@ describe('Environment table', () => { }, ]; const [production, review, staging] = mockItems; - const [addcibuildstatus, master] = mockItems[1].children; + const [addcibuildstatus, main] = mockItems[1].children; factory({ propsData: { @@ -390,7 +426,7 @@ describe('Environment table', () => { production.name, ]); - expect(wrapper.vm.sortedEnvironments[0].children).toEqual([master, addcibuildstatus]); + expect(wrapper.vm.sortedEnvironments[0].children).toEqual([main, addcibuildstatus]); }); }); }); diff --git a/spec/frontend/environments/environments_store_spec.js b/spec/frontend/environments/environments_store_spec.js index 4a07281353f..cb2394b224d 100644 --- a/spec/frontend/environments/environments_store_spec.js +++ b/spec/frontend/environments/environments_store_spec.js @@ -123,6 +123,29 @@ describe('Store', () => { expect(store.state.environments[1].children.length).toEqual(serverData.length); }); + + it('should parse deploy board data for children', () => { + store.storeEnvironments(serverData); + + store.setfolderContent(store.state.environments[1], [ + { + name: 'foo', + size: 1, + latest: { + id: 1, + rollout_status: deployBoardMockData, + }, + }, + ]); + const result = store.state.environments[1].children[0]; + expect(result).toMatchObject({ + deployBoardData: deployBoardMockData, + hasDeployBoard: true, + isDeployBoardVisible: true, + isLoadingDeployBoard: false, + isEmptyDeployBoard: false, + }); + }); }); describe('store pagination', () => { diff --git a/spec/frontend/environments/mock_data.js b/spec/frontend/environments/mock_data.js index 4ad005f55c3..9ba71b78c2f 100644 --- a/spec/frontend/environments/mock_data.js +++ b/spec/frontend/environments/mock_data.js @@ -76,8 +76,8 @@ const environment = { iid: 6, sha: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd', ref: { - name: 'master', - ref_url: 'root/ci-folders/tree/master', + name: 'main', + ref_url: 'root/ci-folders/tree/main', }, tag: true, 'last?': true, @@ -130,8 +130,8 @@ const environment = { iid: 27, sha: '1132df044b73943943c949e7ac2c2f120a89bf59', ref: { - name: 'master', - ref_path: '/root/environment-test/-/tree/master', + name: 'main', + ref_path: '/root/environment-test/-/tree/main', }, status: 'running', created_at: '2020-12-04T19:57:49.514Z', diff --git a/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js b/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js index f02a261f323..2e8a42dbfe6 100644 --- a/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js +++ b/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js @@ -33,11 +33,12 @@ describe('error tracking settings form', () => { describe('an empty form', () => { it('is rendered', () => { - expect(wrapper.findAll(GlFormInput).length).toBe(2); - expect(wrapper.find(GlFormInput).attributes('id')).toBe('error-tracking-api-host'); - expect(wrapper.findAll(GlFormInput).at(1).attributes('id')).toBe('error-tracking-token'); - - expect(wrapper.findAll(GlButton).exists()).toBe(true); + expect(wrapper.findAllComponents(GlFormInput).length).toBe(2); + expect(wrapper.findComponent(GlFormInput).attributes('id')).toBe('error-tracking-api-host'); + expect(wrapper.findAllComponents(GlFormInput).at(1).attributes('id')).toBe( + 'error-tracking-token', + ); + expect(wrapper.findAllComponents(GlButton).exists()).toBe(true); }); it('is rendered with labels and placeholders', () => { @@ -51,7 +52,7 @@ describe('error tracking settings form', () => { ); expect(pageText).not.toContain('Connection failed. Check Auth Token and try again.'); - expect(wrapper.findAll(GlFormInput).at(0).attributes('placeholder')).toContain( + expect(wrapper.findAllComponents(GlFormInput).at(0).attributes('placeholder')).toContain( 'https://mysentryserver.com', ); }); @@ -63,7 +64,7 @@ describe('error tracking settings form', () => { }); it('shows loading spinner', () => { - const buttonEl = wrapper.find(GlButton); + const buttonEl = wrapper.findComponent(GlButton); expect(buttonEl.props('loading')).toBe(true); expect(buttonEl.text()).toBe('Connecting'); diff --git a/spec/frontend/experimentation/components/experiment_spec.js b/spec/frontend/experimentation/components/gitlab_experiment_spec.js index dbc7da5c535..f52ebf0f3c4 100644 --- a/spec/frontend/experimentation/components/experiment_spec.js +++ b/spec/frontend/experimentation/components/gitlab_experiment_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import ExperimentComponent from '~/experimentation/components/experiment.vue'; +import ExperimentComponent from '~/experimentation/components/gitlab_experiment.vue'; const defaultProps = { name: 'experiment_name' }; const defaultSlots = { diff --git a/spec/frontend/experimentation/utils_spec.js b/spec/frontend/experimentation/utils_spec.js index ec09bbab349..2ba8c65a252 100644 --- a/spec/frontend/experimentation/utils_spec.js +++ b/spec/frontend/experimentation/utils_spec.js @@ -1,5 +1,9 @@ import { assignGitlabExperiment } from 'helpers/experimentation_helper'; -import { DEFAULT_VARIANT, CANDIDATE_VARIANT } from '~/experimentation/constants'; +import { + DEFAULT_VARIANT, + CANDIDATE_VARIANT, + TRACKING_CONTEXT_SCHEMA, +} from '~/experimentation/constants'; import * as experimentUtils from '~/experimentation/utils'; describe('experiment Utilities', () => { @@ -19,6 +23,20 @@ describe('experiment Utilities', () => { }); }); + describe('getExperimentContexts', () => { + describe.each` + gon | input | output + ${[TEST_KEY, '_data_']} | ${[TEST_KEY]} | ${[{ schema: TRACKING_CONTEXT_SCHEMA, data: { variant: '_data_' } }]} + ${[]} | ${[TEST_KEY]} | ${[]} + `('with input=$input and gon=$gon', ({ gon, input, output }) => { + assignGitlabExperiment(...gon); + + it(`returns ${output}`, () => { + expect(experimentUtils.getExperimentContexts(...input)).toEqual(output); + }); + }); + }); + describe('isExperimentVariant', () => { describe.each` gon | input | output 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 2fd8e524e7a..0948b08f942 100644 --- a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js +++ b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js @@ -90,7 +90,7 @@ describe('Edit feature flag form', () => { expect(wrapper.find(GlToggle).props('value')).toBe(true); }); - it('should alert users the flag is read only', () => { + it('should alert users the flag is read-only', () => { expect(findAlert().text()).toContain('GitLab is moving to a new way of managing feature flags'); }); diff --git a/spec/frontend/feature_flags/components/form_spec.js b/spec/frontend/feature_flags/components/form_spec.js index 00d557c11cf..6c3fce68618 100644 --- a/spec/frontend/feature_flags/components/form_spec.js +++ b/spec/frontend/feature_flags/components/form_spec.js @@ -281,7 +281,7 @@ describe('feature flag form', () => { }); }); - it('renders read only name', () => { + it('renders read-only name', () => { expect(wrapper.find('.js-scope-all').exists()).toEqual(true); }); }); diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml index a83d5374e2c..a1ea2806879 100644 --- a/spec/frontend/fixtures/api_markdown.yml +++ b/spec/frontend/fixtures/api_markdown.yml @@ -48,3 +48,7 @@ 3. list item 3 - name: image markdown: '![alt text](https://gitlab.com/logo.png)' +- name: hard_break + markdown: |- + This is a line after a\ + hard break diff --git a/spec/frontend/fixtures/merge_requests.rb b/spec/frontend/fixtures/merge_requests.rb index 418912638f9..f10f96f2516 100644 --- a/spec/frontend/fixtures/merge_requests.rb +++ b/spec/frontend/fixtures/merge_requests.rb @@ -62,8 +62,14 @@ RSpec.describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: remove_repository(project) end + it 'merge_requests/merge_request_with_single_assignee_feature.html' do + stub_licensed_features(multiple_merge_request_assignees: false) + + render_merge_request(merge_request) + end + it 'merge_requests/merge_request_of_current_user.html' do - merge_request.update(author: user) + merge_request.update!(author: user) render_merge_request(merge_request) end diff --git a/spec/frontend/fixtures/raw.rb b/spec/frontend/fixtures/raw.rb index cf51f2389bc..44927bd29d8 100644 --- a/spec/frontend/fixtures/raw.rb +++ b/spec/frontend/fixtures/raw.rb @@ -25,6 +25,10 @@ RSpec.describe 'Raw files', '(JavaScript fixtures)' do @blob = project.repository.blob_at('6d85bb69', 'files/ipython/basic.ipynb') end + it 'blob/notebook/markdown-table.json' do + @blob = project.repository.blob_at('f6b7a707', 'files/ipython/markdown-table.ipynb') + end + it 'blob/notebook/worksheets.json' do @blob = project.repository.blob_at('6d85bb69', 'files/ipython/worksheets.ipynb') end diff --git a/spec/frontend/fixtures/releases.rb b/spec/frontend/fixtures/releases.rb index dc282b49be5..7ec155fcb10 100644 --- a/spec/frontend/fixtures/releases.rb +++ b/spec/frontend/fixtures/releases.rb @@ -119,16 +119,18 @@ RSpec.describe 'Releases (JavaScript fixtures)' do describe GraphQL::Query, type: :request do include GraphqlHelpers - all_releases_query_path = 'releases/queries/all_releases.query.graphql' - one_release_query_path = 'releases/queries/one_release.query.graphql' - fragment_paths = ['releases/queries/release.fragment.graphql'] + all_releases_query_path = 'releases/graphql/queries/all_releases.query.graphql' + one_release_query_path = 'releases/graphql/queries/one_release.query.graphql' + one_release_for_editing_query_path = 'releases/graphql/queries/one_release_for_editing.query.graphql' + release_fragment_path = 'releases/graphql/fragments/release.fragment.graphql' + release_for_editing_fragment_path = 'releases/graphql/fragments/release_for_editing.fragment.graphql' before(:all) do clean_frontend_fixtures('graphql/releases/') end it "graphql/#{all_releases_query_path}.json" do - query = get_graphql_query_as_string(all_releases_query_path, fragment_paths) + query = get_graphql_query_as_string(all_releases_query_path, [release_fragment_path]) post_graphql(query, current_user: admin, variables: { fullPath: project.full_path }) @@ -136,7 +138,15 @@ RSpec.describe 'Releases (JavaScript fixtures)' do end it "graphql/#{one_release_query_path}.json" do - query = get_graphql_query_as_string(one_release_query_path, fragment_paths) + query = get_graphql_query_as_string(one_release_query_path, [release_fragment_path]) + + post_graphql(query, current_user: admin, variables: { fullPath: project.full_path, tagName: release.tag }) + + expect_graphql_errors_to_be_empty + end + + it "graphql/#{one_release_for_editing_query_path}.json" do + query = get_graphql_query_as_string(one_release_for_editing_query_path, [release_for_editing_fragment_path]) post_graphql(query, current_user: admin, variables: { fullPath: project.full_path, tagName: release.tag }) diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js index 6d482e5814d..6a5ac76a4d0 100644 --- a/spec/frontend/flash_spec.js +++ b/spec/frontend/flash_spec.js @@ -339,6 +339,20 @@ describe('Flash', () => { expect(actionConfig.clickHandler).toHaveBeenCalled(); }); }); + + describe('additional behavior', () => { + describe('close', () => { + it('clicks the close icon', () => { + const flash = createFlash({ ...defaultParams }); + const close = document.querySelector('.flash-alert .js-close-icon'); + + jest.spyOn(close, 'click'); + flash.close(); + + expect(close.click.mock.calls.length).toBe(1); + }); + }); + }); }); }); diff --git a/spec/frontend/frequent_items/components/app_spec.js b/spec/frontend/frequent_items/components/app_spec.js index 80059c4c87f..7a1026e8bfc 100644 --- a/spec/frontend/frequent_items/components/app_spec.js +++ b/spec/frontend/frequent_items/components/app_spec.js @@ -1,10 +1,11 @@ import MockAdapter from 'axios-mock-adapter'; import Vue from 'vue'; -import { useRealDate } from 'helpers/fake_date'; +import Vuex from 'vuex'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; -import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import appComponent from '~/frequent_items/components/app.vue'; +import App from '~/frequent_items/components/app.vue'; +import FrequentItemsList from '~/frequent_items/components/frequent_items_list.vue'; import { FREQUENT_ITEMS, HOUR_IN_MS } from '~/frequent_items/constants'; import eventHub from '~/frequent_items/event_hub'; import { createStore } from '~/frequent_items/store'; @@ -12,246 +13,230 @@ import { getTopFrequentItems } from '~/frequent_items/utils'; import axios from '~/lib/utils/axios_utils'; import { currentSession, mockFrequentProjects, mockSearchedProjects } from '../mock_data'; +Vue.use(Vuex); + useLocalStorageSpy(); -let session; -const createComponentWithStore = (namespace = 'projects') => { - session = currentSession[namespace]; - gon.api_version = session.apiVersion; - const Component = Vue.extend(appComponent); - const store = createStore(); - - return mountComponentWithStore(Component, { - store, - props: { - namespace, - currentUserName: session.username, - currentItem: session.project || session.group, - }, - }); -}; +const TEST_NAMESPACE = 'projects'; +const TEST_VUEX_MODULE = 'frequentProjects'; +const TEST_PROJECT = currentSession[TEST_NAMESPACE].project; +const TEST_STORAGE_KEY = currentSession[TEST_NAMESPACE].storageKey; describe('Frequent Items App Component', () => { - let vm; + let wrapper; let mock; + let store; + + const createComponent = ({ currentItem = null } = {}) => { + const session = currentSession[TEST_NAMESPACE]; + gon.api_version = session.apiVersion; + + wrapper = mountExtended(App, { + store, + propsData: { + namespace: TEST_NAMESPACE, + currentUserName: session.username, + currentItem: currentItem || session.project, + }, + provide: { + vuexModule: TEST_VUEX_MODULE, + }, + }); + }; + + const triggerDropdownOpen = () => eventHub.$emit(`${TEST_NAMESPACE}-dropdownOpen`); + const getStoredProjects = () => JSON.parse(localStorage.getItem(TEST_STORAGE_KEY)); + const findSearchInput = () => wrapper.findByTestId('frequent-items-search-input'); + const findLoading = () => wrapper.findByTestId('loading'); + const findSectionHeader = () => wrapper.findByTestId('header'); + const findFrequentItemsList = () => wrapper.findComponent(FrequentItemsList); + const findFrequentItems = () => findFrequentItemsList().findAll('li'); + const setSearch = (search) => { + const searchInput = wrapper.find('input'); + + searchInput.setValue(search); + }; beforeEach(() => { mock = new MockAdapter(axios); - vm = createComponentWithStore(); + store = createStore(); }); afterEach(() => { mock.restore(); - vm.$destroy(); + wrapper.destroy(); }); - describe('methods', () => { - describe('dropdownOpenHandler', () => { - it('should fetch frequent items when no search has been previously made on desktop', () => { - jest.spyOn(vm, 'fetchFrequentItems').mockImplementation(() => {}); - - vm.dropdownOpenHandler(); + describe('default', () => { + beforeEach(() => { + jest.spyOn(store, 'dispatch'); - expect(vm.fetchFrequentItems).toHaveBeenCalledWith(); - }); + createComponent(); }); - describe('logItemAccess', () => { - let storage; - - beforeEach(() => { - storage = {}; - - localStorage.setItem.mockImplementation((storageKey, value) => { - storage[storageKey] = value; - }); - - localStorage.getItem.mockImplementation((storageKey) => { - if (storage[storageKey]) { - return storage[storageKey]; - } - - return null; - }); - }); + it('should fetch frequent items', () => { + triggerDropdownOpen(); - it('should create a project store if it does not exist and adds a project', () => { - vm.logItemAccess(session.storageKey, session.project); - - const projects = JSON.parse(storage[session.storageKey]); - - expect(projects.length).toBe(1); - expect(projects[0].frequency).toBe(1); - expect(projects[0].lastAccessedOn).toBeDefined(); - }); - - it('should prevent inserting same report multiple times into store', () => { - vm.logItemAccess(session.storageKey, session.project); - vm.logItemAccess(session.storageKey, session.project); - - const projects = JSON.parse(storage[session.storageKey]); - - expect(projects.length).toBe(1); - }); - - describe('with real date', () => { - useRealDate(); - - it('should increase frequency of report if it was logged multiple times over the course of an hour', () => { - let projects; - const newTimestamp = Date.now() + HOUR_IN_MS + 1; + expect(store.dispatch).toHaveBeenCalledWith(`${TEST_VUEX_MODULE}/fetchFrequentItems`); + }); - vm.logItemAccess(session.storageKey, session.project); - projects = JSON.parse(storage[session.storageKey]); + it('should not fetch frequent items if detroyed', () => { + wrapper.destroy(); + triggerDropdownOpen(); - expect(projects[0].frequency).toBe(1); + expect(store.dispatch).not.toHaveBeenCalledWith(`${TEST_VUEX_MODULE}/fetchFrequentItems`); + }); - vm.logItemAccess(session.storageKey, { - ...session.project, - lastAccessedOn: newTimestamp, - }); - projects = JSON.parse(storage[session.storageKey]); + it('should render search input', () => { + expect(findSearchInput().exists()).toBe(true); + }); - expect(projects[0].frequency).toBe(2); - expect(projects[0].lastAccessedOn).not.toBe(session.project.lastAccessedOn); - }); - }); + it('should render loading animation', async () => { + triggerDropdownOpen(); + store.state[TEST_VUEX_MODULE].isLoadingItems = true; - it('should always update project metadata', () => { - let projects; - const oldProject = { - ...session.project, - }; + await wrapper.vm.$nextTick(); - const newProject = { - ...session.project, - name: 'New Name', - avatarUrl: 'new/avatar.png', - namespace: 'New / Namespace', - webUrl: 'http://localhost/new/web/url', - }; + const loading = findLoading(); - vm.logItemAccess(session.storageKey, oldProject); - projects = JSON.parse(storage[session.storageKey]); + expect(loading.exists()).toBe(true); + expect(loading.find('[aria-label="Loading projects"]').exists()).toBe(true); + }); - expect(projects[0].name).toBe(oldProject.name); - expect(projects[0].avatarUrl).toBe(oldProject.avatarUrl); - expect(projects[0].namespace).toBe(oldProject.namespace); - expect(projects[0].webUrl).toBe(oldProject.webUrl); + it('should render frequent projects list header', () => { + const sectionHeader = findSectionHeader(); - vm.logItemAccess(session.storageKey, newProject); - projects = JSON.parse(storage[session.storageKey]); + expect(sectionHeader.exists()).toBe(true); + expect(sectionHeader.text()).toBe('Frequently visited'); + }); - expect(projects[0].name).toBe(newProject.name); - expect(projects[0].avatarUrl).toBe(newProject.avatarUrl); - expect(projects[0].namespace).toBe(newProject.namespace); - expect(projects[0].webUrl).toBe(newProject.webUrl); - }); + it('should render frequent projects list', async () => { + const expectedResult = getTopFrequentItems(mockFrequentProjects); + localStorage.setItem(TEST_STORAGE_KEY, JSON.stringify(mockFrequentProjects)); - it('should not add more than 20 projects in store', () => { - for (let id = 0; id < FREQUENT_ITEMS.MAX_COUNT; id += 1) { - const project = { - ...session.project, - id, - }; - vm.logItemAccess(session.storageKey, project); - } + expect(findFrequentItems().length).toBe(1); - const projects = JSON.parse(storage[session.storageKey]); + triggerDropdownOpen(); + await wrapper.vm.$nextTick(); - expect(projects.length).toBe(FREQUENT_ITEMS.MAX_COUNT); + expect(findFrequentItems().length).toBe(expectedResult.length); + expect(findFrequentItemsList().props()).toEqual({ + items: expectedResult, + namespace: TEST_NAMESPACE, + hasSearchQuery: false, + isFetchFailed: false, + matcher: '', }); }); - }); - - describe('created', () => { - it('should bind event listeners on eventHub', (done) => { - jest.spyOn(eventHub, '$on').mockImplementation(() => {}); - createComponentWithStore().$mount(); - - Vue.nextTick(() => { - expect(eventHub.$on).toHaveBeenCalledWith('projects-dropdownOpen', expect.any(Function)); - done(); - }); + it('should render searched projects list', async () => { + mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects.data); + + setSearch('gitlab'); + await wrapper.vm.$nextTick(); + + expect(findLoading().exists()).toBe(true); + + await waitForPromises(); + + expect(findFrequentItems().length).toBe(mockSearchedProjects.data.length); + expect(findFrequentItemsList().props()).toEqual( + expect.objectContaining({ + items: mockSearchedProjects.data.map( + ({ avatar_url, web_url, name_with_namespace, ...item }) => ({ + ...item, + avatarUrl: avatar_url, + webUrl: web_url, + namespace: name_with_namespace, + }), + ), + namespace: TEST_NAMESPACE, + hasSearchQuery: true, + isFetchFailed: false, + matcher: 'gitlab', + }), + ); }); }); - describe('beforeDestroy', () => { - it('should unbind event listeners on eventHub', (done) => { - jest.spyOn(eventHub, '$off').mockImplementation(() => {}); + describe('logging', () => { + it('when created, it should create a project storage entry and adds a project', () => { + createComponent(); - vm.$mount(); - vm.$destroy(); + expect(getStoredProjects()).toEqual([ + expect.objectContaining({ + frequency: 1, + lastAccessedOn: Date.now(), + }), + ]); + }); - Vue.nextTick(() => { - expect(eventHub.$off).toHaveBeenCalledWith('projects-dropdownOpen', expect.any(Function)); - done(); + describe('when created multiple times', () => { + beforeEach(() => { + createComponent(); + wrapper.destroy(); + createComponent(); + wrapper.destroy(); }); - }); - }); - describe('template', () => { - it('should render search input', () => { - expect(vm.$el.querySelector('.search-input-container')).toBeDefined(); - }); + it('should only log once', () => { + expect(getStoredProjects()).toEqual([ + expect.objectContaining({ + lastAccessedOn: Date.now(), + frequency: 1, + }), + ]); + }); - it('should render loading animation', (done) => { - vm.$store.dispatch('fetchSearchedItems'); + it('should increase frequency, when created an hour later', () => { + const hourLater = Date.now() + HOUR_IN_MS + 1; - Vue.nextTick(() => { - const loadingEl = vm.$el.querySelector('.loading-animation'); + jest.spyOn(Date, 'now').mockReturnValue(hourLater); + createComponent({ currentItem: { ...TEST_PROJECT, lastAccessedOn: hourLater } }); - expect(loadingEl).toBeDefined(); - expect(loadingEl.classList.contains('prepend-top-20')).toBe(true); - expect(loadingEl.querySelector('span').getAttribute('aria-label')).toBe('Loading projects'); - done(); + expect(getStoredProjects()).toEqual([ + expect.objectContaining({ + lastAccessedOn: hourLater, + frequency: 2, + }), + ]); }); }); - it('should render frequent projects list header', (done) => { - Vue.nextTick(() => { - const sectionHeaderEl = vm.$el.querySelector('.section-header'); + it('should always update project metadata', () => { + const oldProject = { + ...TEST_PROJECT, + }; - expect(sectionHeaderEl).toBeDefined(); - expect(sectionHeaderEl.innerText.trim()).toBe('Frequently visited'); - done(); - }); - }); + const newProject = { + ...oldProject, + name: 'New Name', + avatarUrl: 'new/avatar.png', + namespace: 'New / Namespace', + webUrl: 'http://localhost/new/web/url', + }; - it('should render frequent projects list', (done) => { - const expectedResult = getTopFrequentItems(mockFrequentProjects); - localStorage.getItem.mockImplementation(() => JSON.stringify(mockFrequentProjects)); + createComponent({ currentItem: oldProject }); + wrapper.destroy(); + expect(getStoredProjects()).toEqual([expect.objectContaining(oldProject)]); - expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(1); + createComponent({ currentItem: newProject }); + wrapper.destroy(); - vm.fetchFrequentItems(); - Vue.nextTick(() => { - expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe( - expectedResult.length, - ); - done(); - }); + expect(getStoredProjects()).toEqual([expect.objectContaining(newProject)]); }); - it('should render searched projects list', (done) => { - mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects); - - expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(1); - - vm.$store.dispatch('setSearchQuery', 'gitlab'); - vm.$nextTick() - .then(() => { - expect(vm.$el.querySelector('.loading-animation')).toBeDefined(); - }) - .then(waitForPromises) - .then(() => { - expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe( - mockSearchedProjects.data.length, - ); - }) - .then(done) - .catch(done.fail); + it('should not add more than 20 projects in store', () => { + for (let id = 0; id < FREQUENT_ITEMS.MAX_COUNT + 10; id += 1) { + const project = { + ...TEST_PROJECT, + id, + }; + createComponent({ currentItem: project }); + wrapper.destroy(); + } + + expect(getStoredProjects().length).toBe(FREQUENT_ITEMS.MAX_COUNT); }); }); }); diff --git a/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js b/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js index 66fb346cb38..9a68115e4f6 100644 --- a/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js +++ b/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js @@ -1,14 +1,18 @@ -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; import { trimText } from 'helpers/text_helper'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue'; import { createStore } from '~/frequent_items/store'; import { mockProject } from '../mock_data'; +const localVue = createLocalVue(); +localVue.use(Vuex); + describe('FrequentItemsListItemComponent', () => { let wrapper; let trackingSpy; - let store = createStore(); + let store; const findTitle = () => wrapper.find({ ref: 'frequentItemsItemTitle' }); const findAvatar = () => wrapper.find({ ref: 'frequentItemsItemAvatar' }); @@ -31,11 +35,15 @@ describe('FrequentItemsListItemComponent', () => { avatarUrl: mockProject.avatarUrl, ...props, }, + provide: { + vuexModule: 'frequentProjects', + }, + localVue, }); }; beforeEach(() => { - store = createStore({ dropdownType: 'project' }); + store = createStore(); trackingSpy = mockTracking('_category_', document, jest.spyOn); trackingSpy.mockImplementation(() => {}); }); @@ -119,7 +127,7 @@ describe('FrequentItemsListItemComponent', () => { }); link.trigger('click'); expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_link', { - label: 'project_dropdown_frequent_items_list_item', + label: 'projects_dropdown_frequent_items_list_item', }); }); }); diff --git a/spec/frontend/frequent_items/components/frequent_items_list_spec.js b/spec/frontend/frequent_items/components/frequent_items_list_spec.js index bd0711005b3..c015914c991 100644 --- a/spec/frontend/frequent_items/components/frequent_items_list_spec.js +++ b/spec/frontend/frequent_items/components/frequent_items_list_spec.js @@ -1,9 +1,13 @@ -import { mount } from '@vue/test-utils'; +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; import frequentItemsListComponent from '~/frequent_items/components/frequent_items_list.vue'; import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue'; import { createStore } from '~/frequent_items/store'; import { mockFrequentProjects } from '../mock_data'; +const localVue = createLocalVue(); +localVue.use(Vuex); + describe('FrequentItemsListComponent', () => { let wrapper; @@ -18,6 +22,10 @@ describe('FrequentItemsListComponent', () => { matcher: 'lab', ...props, }, + localVue, + provide: { + vuexModule: 'frequentProjects', + }, }); }; diff --git a/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js b/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js index 0280fdb0ca2..c9b7e0f3d13 100644 --- a/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js +++ b/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js @@ -1,9 +1,13 @@ import { GlSearchBoxByType } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import searchComponent from '~/frequent_items/components/frequent_items_search_input.vue'; import { createStore } from '~/frequent_items/store'; +const localVue = createLocalVue(); +localVue.use(Vuex); + describe('FrequentItemsSearchInputComponent', () => { let wrapper; let trackingSpy; @@ -14,12 +18,16 @@ describe('FrequentItemsSearchInputComponent', () => { shallowMount(searchComponent, { store, propsData: { namespace }, + localVue, + provide: { + vuexModule: 'frequentProjects', + }, }); const findSearchBoxByType = () => wrapper.find(GlSearchBoxByType); beforeEach(() => { - store = createStore({ dropdownType: 'project' }); + store = createStore(); jest.spyOn(store, 'dispatch').mockImplementation(() => {}); trackingSpy = mockTracking('_category_', document, jest.spyOn); @@ -57,9 +65,9 @@ describe('FrequentItemsSearchInputComponent', () => { await wrapper.vm.$nextTick(); expect(trackingSpy).toHaveBeenCalledWith(undefined, 'type_search_query', { - label: 'project_dropdown_frequent_items_search_input', + label: 'projects_dropdown_frequent_items_search_input', }); - expect(store.dispatch).toHaveBeenCalledWith('setSearchQuery', value); + expect(store.dispatch).toHaveBeenCalledWith('frequentProjects/setSearchQuery', value); }); }); }); diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 5453c93eac3..211ed064762 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -691,12 +691,9 @@ describe('GfmAutoComplete', () => { { search: 'ErlindaMayert nicolle' }, { search: 'PhoebeSchaden salina' }, { search: 'KinaCummings robena' }, - // Remaining members are grouped last - { search: 'Administrator root' }, - { search: 'AntoineLedner ammie' }, ]; - it('sorts by match with start of name/username, then match with any part of name/username, and maintains sort order', () => { + it('filters out non-matches, then puts matches with start of name/username first', () => { expect(GfmAutoComplete.Members.sort(query, items)).toMatchObject(expected); }); }); diff --git a/spec/frontend/groups/components/invite_members_banner_spec.js b/spec/frontend/groups/components/invite_members_banner_spec.js index 9a2068a27a1..0da2f84f2a1 100644 --- a/spec/frontend/groups/components/invite_members_banner_spec.js +++ b/spec/frontend/groups/components/invite_members_banner_spec.js @@ -2,6 +2,7 @@ import { GlBanner, GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import InviteMembersBanner from '~/groups/components/invite_members_banner.vue'; +import eventHub from '~/invite_members/event_hub'; import { setCookie, parseBoolean } from '~/lib/utils/common_utils'; jest.mock('~/lib/utils/common_utils'); @@ -58,12 +59,23 @@ describe('InviteMembersBanner', () => { }); }); - it('sets the button attributes for the buttonClickEvent', () => { - const button = wrapper.find(`[href='${wrapper.vm.inviteMembersPath}']`); + describe('when the button is clicked', () => { + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + wrapper.find(GlBanner).vm.$emit('primary'); + }); + + it('calls openModal through the eventHub', () => { + expect(eventHub.$emit).toHaveBeenCalledWith('openModal', { + inviteeType: 'members', + source: 'invite_members_banner', + }); + }); - expect(button.attributes()).toMatchObject({ - 'data-track-event': buttonClickEvent, - 'data-track-label': trackLabel, + it('sends the buttonClickEvent with correct trackCategory and trackLabel', () => { + expect(trackingSpy).toHaveBeenCalledWith(trackCategory, buttonClickEvent, { + label: trackLabel, + }); }); }); @@ -100,10 +112,6 @@ describe('InviteMembersBanner', () => { it('uses the button_text text from options for buttontext', () => { expect(findBanner().attributes('buttontext')).toBe(buttonText); }); - - it('uses the href from inviteMembersPath for buttonlink', () => { - expect(findBanner().attributes('buttonlink')).toBe(inviteMembersPath); - }); }); describe('dismissing', () => { diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index a3b327343e5..646e51160d8 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -510,6 +510,7 @@ describe('RepoEditor', () => { }, }); await vm.$nextTick(); + await vm.$nextTick(); expect(vm.initEditor).toHaveBeenCalled(); }); diff --git a/spec/frontend/ide/lib/alerts/environment_spec.js b/spec/frontend/ide/lib/alerts/environment_spec.js new file mode 100644 index 00000000000..d645209345c --- /dev/null +++ b/spec/frontend/ide/lib/alerts/environment_spec.js @@ -0,0 +1,21 @@ +import { GlLink } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import Environments from '~/ide/lib/alerts/environments.vue'; + +describe('~/ide/lib/alerts/environment.vue', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(Environments); + }); + + it('shows a message regarding environments', () => { + expect(wrapper.text()).toBe( + "No deployments detected. Use environments to control your software's continuous deployment. Learn more about deployment jobs.", + ); + }); + + it('links to the help page on environments', () => { + expect(wrapper.findComponent(GlLink).attributes('href')).toBe('/help/ci/environments/index.md'); + }); +}); diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js index 3503834e24b..4a726cff3b6 100644 --- a/spec/frontend/ide/services/index_spec.js +++ b/spec/frontend/ide/services/index_spec.js @@ -2,9 +2,11 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql'; import Api from '~/api'; +import dismissUserCallout from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql'; import services from '~/ide/services'; -import { query } from '~/ide/services/gql'; +import { query, mutate } from '~/ide/services/gql'; import { escapeFileUrl } from '~/lib/utils/url_utility'; +import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.graphql'; import { projectData } from '../mock_data'; jest.mock('~/api'); @@ -299,4 +301,33 @@ describe('IDE services', () => { }); }); }); + describe('getCiConfig', () => { + const TEST_PROJECT_PATH = 'foo/bar'; + const TEST_CI_CONFIG = 'test config'; + + it('queries with the given CI config and project', () => { + const result = { data: { ciConfig: { test: 'data' } } }; + query.mockResolvedValue(result); + return services.getCiConfig(TEST_PROJECT_PATH, TEST_CI_CONFIG).then((data) => { + expect(data).toEqual(result.data.ciConfig); + expect(query).toHaveBeenCalledWith({ + query: ciConfig, + variables: { projectPath: TEST_PROJECT_PATH, content: TEST_CI_CONFIG }, + }); + }); + }); + }); + describe('dismissUserCallout', () => { + it('mutates the callout to dismiss', () => { + const result = { data: { callouts: { test: 'data' } } }; + mutate.mockResolvedValue(result); + return services.dismissUserCallout('test').then((data) => { + expect(data).toEqual(result.data); + expect(mutate).toHaveBeenCalledWith({ + mutation: dismissUserCallout, + variables: { input: { featureName: 'test' } }, + }); + }); + }); + }); }); diff --git a/spec/frontend/ide/stores/actions/alert_spec.js b/spec/frontend/ide/stores/actions/alert_spec.js new file mode 100644 index 00000000000..1321c402ebb --- /dev/null +++ b/spec/frontend/ide/stores/actions/alert_spec.js @@ -0,0 +1,46 @@ +import testAction from 'helpers/vuex_action_helper'; +import service from '~/ide/services'; +import { + detectEnvironmentsGuidance, + dismissEnvironmentsGuidance, +} from '~/ide/stores/actions/alert'; +import * as types from '~/ide/stores/mutation_types'; + +jest.mock('~/ide/services'); + +describe('~/ide/stores/actions/alert', () => { + describe('detectEnvironmentsGuidance', () => { + it('should try to fetch CI info', () => { + const stages = ['a', 'b', 'c']; + service.getCiConfig.mockResolvedValue({ stages }); + + return testAction( + detectEnvironmentsGuidance, + 'the content', + { currentProjectId: 'gitlab/test' }, + [{ type: types.DETECT_ENVIRONMENTS_GUIDANCE_ALERT, payload: stages }], + [], + () => expect(service.getCiConfig).toHaveBeenCalledWith('gitlab/test', 'the content'), + ); + }); + }); + describe('dismissCallout', () => { + it('should try to dismiss the given callout', () => { + const callout = { featureName: 'test', dismissedAt: 'now' }; + + service.dismissUserCallout.mockResolvedValue({ userCalloutCreate: { userCallout: callout } }); + + return testAction( + dismissEnvironmentsGuidance, + undefined, + {}, + [{ type: types.DISMISS_ENVIRONMENTS_GUIDANCE_ALERT }], + [], + () => + expect(service.dismissUserCallout).toHaveBeenCalledWith( + 'web_ide_ci_environments_guidance', + ), + ); + }); + }); +}); diff --git a/spec/frontend/ide/stores/actions_spec.js b/spec/frontend/ide/stores/actions_spec.js index d47dd88dd47..ad55313da93 100644 --- a/spec/frontend/ide/stores/actions_spec.js +++ b/spec/frontend/ide/stores/actions_spec.js @@ -4,6 +4,7 @@ import eventHub from '~/ide/eventhub'; import { createRouter } from '~/ide/ide_router'; import { createStore } from '~/ide/stores'; import { + init, stageAllChanges, unstageAllChanges, toggleFileFinder, @@ -54,15 +55,15 @@ describe('Multi-file store actions', () => { }); }); - describe('setInitialData', () => { - it('commits initial data', (done) => { - store - .dispatch('setInitialData', { canCommit: true }) - .then(() => { - expect(store.state.canCommit).toBeTruthy(); - done(); - }) - .catch(done.fail); + describe('init', () => { + it('commits initial data and requests user callouts', () => { + return testAction( + init, + { canCommit: true }, + store.state, + [{ type: 'SET_INITIAL_DATA', payload: { canCommit: true } }], + [], + ); }); }); diff --git a/spec/frontend/ide/stores/getters/alert_spec.js b/spec/frontend/ide/stores/getters/alert_spec.js new file mode 100644 index 00000000000..7068b8e637f --- /dev/null +++ b/spec/frontend/ide/stores/getters/alert_spec.js @@ -0,0 +1,46 @@ +import { getAlert } from '~/ide/lib/alerts'; +import EnvironmentsMessage from '~/ide/lib/alerts/environments.vue'; +import { createStore } from '~/ide/stores'; +import * as getters from '~/ide/stores/getters/alert'; +import { file } from '../../helpers'; + +describe('IDE store alert getters', () => { + let localState; + let localStore; + + beforeEach(() => { + localStore = createStore(); + localState = localStore.state; + }); + + describe('alerts', () => { + describe('shows an alert about environments', () => { + let alert; + + beforeEach(() => { + const f = file('.gitlab-ci.yml'); + localState.openFiles.push(f); + localState.currentActivityView = 'repo-commit-section'; + localState.environmentsGuidanceAlertDetected = true; + localState.environmentsGuidanceAlertDismissed = false; + + const alertKey = getters.getAlert(localState)(f); + alert = getAlert(alertKey); + }); + + it('has a message suggesting to use environments', () => { + expect(alert.message).toEqual(EnvironmentsMessage); + }); + + it('dispatches to dismiss the callout on dismiss', () => { + jest.spyOn(localStore, 'dispatch').mockImplementation(); + alert.dismiss(localStore); + expect(localStore.dispatch).toHaveBeenCalledWith('dismissEnvironmentsGuidance'); + }); + + it('should be a tip alert', () => { + expect(alert.props).toEqual({ variant: 'tip' }); + }); + }); + }); +}); diff --git a/spec/frontend/ide/stores/mutations/alert_spec.js b/spec/frontend/ide/stores/mutations/alert_spec.js new file mode 100644 index 00000000000..2840ec4ebb7 --- /dev/null +++ b/spec/frontend/ide/stores/mutations/alert_spec.js @@ -0,0 +1,26 @@ +import * as types from '~/ide/stores/mutation_types'; +import mutations from '~/ide/stores/mutations/alert'; + +describe('~/ide/stores/mutations/alert', () => { + const state = {}; + + describe(types.DETECT_ENVIRONMENTS_GUIDANCE_ALERT, () => { + it('checks the stages for any that configure environments', () => { + mutations[types.DETECT_ENVIRONMENTS_GUIDANCE_ALERT](state, { + nodes: [{ groups: { nodes: [{ jobs: { nodes: [{}] } }] } }], + }); + expect(state.environmentsGuidanceAlertDetected).toBe(true); + mutations[types.DETECT_ENVIRONMENTS_GUIDANCE_ALERT](state, { + nodes: [{ groups: { nodes: [{ jobs: { nodes: [{ environment: {} }] } }] } }], + }); + expect(state.environmentsGuidanceAlertDetected).toBe(false); + }); + }); + + describe(types.DISMISS_ENVIRONMENTS_GUIDANCE_ALERT, () => { + it('stops environments guidance', () => { + mutations[types.DISMISS_ENVIRONMENTS_GUIDANCE_ALERT](state); + expect(state.environmentsGuidanceAlertDismissed).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 7a83136e785..0c69cfb3bc5 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 @@ -19,7 +19,8 @@ const getFakeGroup = (status) => ({ new_name: 'group1', }, id: 1, - status, + validation_errors: [], + progress: { status }, }); const EXISTING_GROUP_TARGET_NAMESPACE = 'existing-group'; @@ -187,21 +188,25 @@ describe('import table row', () => { expect(wrapper.text()).toContain('Please choose a group URL with no special characters.'); }); - it('Reports invalid group name if group already exists', async () => { + it('Reports invalid group name if relevant validation error exists', async () => { + const FAKE_ERROR_MESSAGE = 'fake error'; + createComponent({ group: { ...getFakeGroup(STATUSES.NONE), - import_target: { - target_namespace: EXISTING_GROUP_TARGET_NAMESPACE, - new_name: EXISTING_GROUP_PATH, - }, + validation_errors: [ + { + field: 'new_name', + message: FAKE_ERROR_MESSAGE, + }, + ], }, }); jest.runOnlyPendingTimers(); await nextTick(); - expect(wrapper.text()).toContain('Name already exists.'); + expect(wrapper.text()).toContain(FAKE_ERROR_MESSAGE); }); }); }); 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 496c5cda7c7..99ef6d9a7fb 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,4 +1,5 @@ import { + GlButton, GlEmptyState, GlLoadingIcon, GlSearchBoxByClick, @@ -14,7 +15,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { STATUSES } from '~/import_entities/constants'; import ImportTable from '~/import_entities/import_groups/components/import_table.vue'; import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue'; -import importGroupMutation from '~/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql'; +import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql'; import setNewNameMutation from '~/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql'; import setTargetNamespaceMutation from '~/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; @@ -40,6 +41,7 @@ describe('import table', () => { ]; const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 }; + const findImportAllButton = () => wrapper.find('h1').find(GlButton); const findPaginationDropdown = () => wrapper.findComponent(GlDropdown); const findPaginationDropdownText = () => findPaginationDropdown().find({ ref: 'text' }).text(); @@ -72,7 +74,6 @@ describe('import table', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); it('renders loading icon while performing request', async () => { @@ -141,7 +142,7 @@ describe('import table', () => { event | payload | mutation | variables ${'update-target-namespace'} | ${'new-namespace'} | ${setTargetNamespaceMutation} | ${{ sourceGroupId: FAKE_GROUP.id, targetNamespace: 'new-namespace' }} ${'update-new-name'} | ${'new-name'} | ${setNewNameMutation} | ${{ sourceGroupId: FAKE_GROUP.id, newName: 'new-name' }} - ${'import-group'} | ${undefined} | ${importGroupMutation} | ${{ sourceGroupId: FAKE_GROUP.id }} + ${'import-group'} | ${undefined} | ${importGroupsMutation} | ${{ sourceGroupIds: [FAKE_GROUP.id] }} `('correctly maps $event to mutation', async ({ event, payload, mutation, variables }) => { jest.spyOn(apolloProvider.defaultClient, 'mutate'); wrapper.find(ImportTableRow).vm.$emit(event, payload); @@ -277,4 +278,66 @@ describe('import table', () => { ); }); }); + + describe('import all button', () => { + it('does not exists when no groups available', () => { + createComponent({ + bulkImportSourceGroups: () => new Promise(() => {}), + }); + + expect(findImportAllButton().exists()).toBe(false); + }); + + it('exists when groups are available for import', async () => { + createComponent({ + bulkImportSourceGroups: () => ({ + nodes: FAKE_GROUPS, + pageInfo: FAKE_PAGE_INFO, + }), + }); + await waitForPromises(); + + expect(findImportAllButton().exists()).toBe(true); + }); + + it('counts only not-imported groups', async () => { + const NEW_GROUPS = [ + generateFakeEntry({ id: 1, status: STATUSES.NONE }), + generateFakeEntry({ id: 2, status: STATUSES.NONE }), + generateFakeEntry({ id: 3, status: STATUSES.FINISHED }), + ]; + + createComponent({ + bulkImportSourceGroups: () => ({ + nodes: NEW_GROUPS, + pageInfo: FAKE_PAGE_INFO, + }), + }); + await waitForPromises(); + + expect(findImportAllButton().text()).toMatchInterpolatedText('Import 2 groups'); + }); + + it('disables button when any group has validation errors', async () => { + const NEW_GROUPS = [ + generateFakeEntry({ id: 1, status: STATUSES.NONE }), + generateFakeEntry({ + id: 2, + status: STATUSES.NONE, + validation_errors: [{ field: 'new_name', message: 'test validation error' }], + }), + generateFakeEntry({ id: 3, status: STATUSES.FINISHED }), + ]; + + createComponent({ + bulkImportSourceGroups: () => ({ + nodes: NEW_GROUPS, + pageInfo: FAKE_PAGE_INFO, + }), + }); + await waitForPromises(); + + expect(findImportAllButton().props().disabled).toBe(true); + }); + }); }); 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 1feff861c1e..ef83c9ebbc4 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 @@ -8,10 +8,15 @@ import { clientTypenames, createResolvers, } from '~/import_entities/import_groups/graphql/client_factory'; -import importGroupMutation from '~/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql'; +import addValidationErrorMutation from '~/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql'; +import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql'; +import removeValidationErrorMutation from '~/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql'; +import setImportProgressMutation from '~/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql'; import setNewNameMutation from '~/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql'; import setTargetNamespaceMutation from '~/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql'; +import updateImportStatusMutation from '~/import_entities/import_groups/graphql/mutations/update_import_status.mutation.graphql'; import availableNamespacesQuery from '~/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql'; +import bulkImportSourceGroupQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql'; import bulkImportSourceGroupsQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql'; import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller'; @@ -78,6 +83,31 @@ describe('Bulk import resolvers', () => { }); }); + describe('bulkImportSourceGroup', () => { + beforeEach(async () => { + axiosMockAdapter.onGet(FAKE_ENDPOINTS.status).reply(httpStatus.OK, statusEndpointFixture); + axiosMockAdapter + .onGet(FAKE_ENDPOINTS.availableNamespaces) + .reply(httpStatus.OK, availableNamespacesFixture); + + return client.query({ + query: bulkImportSourceGroupsQuery, + }); + }); + + it('returns group', async () => { + const { id } = statusEndpointFixture.importable_data[0]; + const { + data: { bulkImportSourceGroup: group }, + } = await client.query({ + query: bulkImportSourceGroupQuery, + variables: { id: id.toString() }, + }); + + expect(group).toMatchObject(statusEndpointFixture.importable_data[0]); + }); + }); + describe('bulkImportSourceGroups', () => { let results; @@ -89,8 +119,12 @@ describe('Bulk import resolvers', () => { }); it('respects cached import state when provided by group manager', async () => { + const FAKE_JOB_ID = '1'; const FAKE_STATUS = 'DEMO_STATUS'; - const FAKE_IMPORT_TARGET = {}; + const FAKE_IMPORT_TARGET = { + new_name: 'test-name', + target_namespace: 'test-namespace', + }; const TARGET_INDEX = 0; const clientWithMockedManager = createClient({ @@ -98,8 +132,11 @@ describe('Bulk import resolvers', () => { getImportStateFromStorageByGroupId(groupId) { if (groupId === statusEndpointFixture.importable_data[TARGET_INDEX].id) { return { - status: FAKE_STATUS, - importTarget: FAKE_IMPORT_TARGET, + jobId: FAKE_JOB_ID, + importState: { + status: FAKE_STATUS, + importTarget: FAKE_IMPORT_TARGET, + }, }; } @@ -113,8 +150,8 @@ describe('Bulk import resolvers', () => { }); const clientResults = clientResponse.data.bulkImportSourceGroups.nodes; - expect(clientResults[TARGET_INDEX].import_target).toBe(FAKE_IMPORT_TARGET); - expect(clientResults[TARGET_INDEX].status).toBe(FAKE_STATUS); + expect(clientResults[TARGET_INDEX].import_target).toStrictEqual(FAKE_IMPORT_TARGET); + expect(clientResults[TARGET_INDEX].progress.status).toBe(FAKE_STATUS); }); it('populates each result instance with empty import_target when there are no available namespaces', async () => { @@ -143,8 +180,8 @@ describe('Bulk import resolvers', () => { ).toBe(true); }); - it('populates each result instance with status field default to none', () => { - expect(results.every((r) => r.status === STATUSES.NONE)).toBe(true); + it('populates each result instance with status default to none', () => { + expect(results.every((r) => r.progress.status === STATUSES.NONE)).toBe(true); }); it('populates each result instance with import_target defaulted to first available namespace', () => { @@ -183,7 +220,6 @@ describe('Bulk import resolvers', () => { }); describe('mutations', () => { - let results; const GROUP_ID = 1; beforeEach(() => { @@ -195,7 +231,10 @@ describe('Bulk import resolvers', () => { { __typename: clientTypenames.BulkImportSourceGroup, id: GROUP_ID, - status: STATUSES.NONE, + progress: { + id: `test-${GROUP_ID}`, + status: STATUSES.NONE, + }, web_url: 'https://fake.host/1', full_path: 'fake_group_1', full_name: 'fake_name_1', @@ -203,6 +242,7 @@ describe('Bulk import resolvers', () => { target_namespace: 'root', new_name: 'group1', }, + validation_errors: [], }, ], pageInfo: { @@ -214,35 +254,42 @@ describe('Bulk import resolvers', () => { }, }, }); - - client - .watchQuery({ - query: bulkImportSourceGroupsQuery, - fetchPolicy: 'cache-only', - }) - .subscribe(({ data }) => { - results = data.bulkImportSourceGroups.nodes; - }); }); it('setTargetNamespaces updates group target namespace', async () => { const NEW_TARGET_NAMESPACE = 'target'; - await client.mutate({ + const { + data: { + setTargetNamespace: { + id: idInResponse, + import_target: { target_namespace: namespaceInResponse }, + }, + }, + } = await client.mutate({ mutation: setTargetNamespaceMutation, variables: { sourceGroupId: GROUP_ID, targetNamespace: NEW_TARGET_NAMESPACE }, }); - expect(results[0].import_target.target_namespace).toBe(NEW_TARGET_NAMESPACE); + expect(idInResponse).toBe(GROUP_ID); + expect(namespaceInResponse).toBe(NEW_TARGET_NAMESPACE); }); it('setNewName updates group target name', async () => { const NEW_NAME = 'new'; - await client.mutate({ + const { + data: { + setNewName: { + id: idInResponse, + import_target: { new_name: nameInResponse }, + }, + }, + } = await client.mutate({ mutation: setNewNameMutation, variables: { sourceGroupId: GROUP_ID, newName: NEW_NAME }, }); - expect(results[0].import_target.new_name).toBe(NEW_NAME); + expect(idInResponse).toBe(GROUP_ID); + expect(nameInResponse).toBe(NEW_NAME); }); describe('importGroup', () => { @@ -250,8 +297,8 @@ describe('Bulk import resolvers', () => { axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(() => new Promise(() => {})); client.mutate({ - mutation: importGroupMutation, - variables: { sourceGroupId: GROUP_ID }, + mutation: importGroupsMutation, + variables: { sourceGroupIds: [GROUP_ID] }, }); await waitForPromises(); @@ -261,33 +308,49 @@ describe('Bulk import resolvers', () => { query: bulkImportSourceGroupsQuery, }); - expect(intermediateResults[0].status).toBe(STATUSES.SCHEDULING); + expect(intermediateResults[0].progress.status).toBe(STATUSES.SCHEDULING); }); - 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 }, + describe('when request completes', () => { + let results; + + beforeEach(() => { + client + .watchQuery({ + query: bulkImportSourceGroupsQuery, + fetchPolicy: 'cache-only', + }) + .subscribe(({ data }) => { + results = data.bulkImportSourceGroups.nodes; + }); }); - expect(results[0].status).toBe(STATUSES.CREATED); - }); + it('sets import status to CREATED when request completes', async () => { + axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK, { id: 1 }); + await client.mutate({ + mutation: importGroupsMutation, + variables: { sourceGroupIds: [GROUP_ID] }, + }); + await waitForPromises(); - it('resets status to NONE if request fails', async () => { - axiosMockAdapter - .onPost(FAKE_ENDPOINTS.createBulkImport) - .reply(httpStatus.INTERNAL_SERVER_ERROR); + expect(results[0].progress.status).toBe(STATUSES.CREATED); + }); - client - .mutate({ - mutation: importGroupMutation, - variables: { sourceGroupId: GROUP_ID }, - }) - .catch(() => {}); - await waitForPromises(); + it('resets status to NONE if request fails', async () => { + axiosMockAdapter + .onPost(FAKE_ENDPOINTS.createBulkImport) + .reply(httpStatus.INTERNAL_SERVER_ERROR); + + client + .mutate({ + mutation: [importGroupsMutation], + variables: { sourceGroupIds: [GROUP_ID] }, + }) + .catch(() => {}); + await waitForPromises(); - expect(results[0].status).toBe(STATUSES.NONE); + expect(results[0].progress.status).toBe(STATUSES.NONE); + }); }); it('shows default error message when server error is not provided', async () => { @@ -297,8 +360,8 @@ describe('Bulk import resolvers', () => { client .mutate({ - mutation: importGroupMutation, - variables: { sourceGroupId: GROUP_ID }, + mutation: importGroupsMutation, + variables: { sourceGroupIds: [GROUP_ID] }, }) .catch(() => {}); await waitForPromises(); @@ -315,8 +378,8 @@ describe('Bulk import resolvers', () => { client .mutate({ - mutation: importGroupMutation, - variables: { sourceGroupId: GROUP_ID }, + mutation: importGroupsMutation, + variables: { sourceGroupIds: [GROUP_ID] }, }) .catch(() => {}); await waitForPromises(); @@ -324,5 +387,75 @@ describe('Bulk import resolvers', () => { expect(createFlash).toHaveBeenCalledWith({ message: CUSTOM_MESSAGE }); }); }); + + it('setImportProgress updates group progress', async () => { + const NEW_STATUS = 'dummy'; + const FAKE_JOB_ID = 5; + const { + data: { + setImportProgress: { progress }, + }, + } = await client.mutate({ + mutation: setImportProgressMutation, + variables: { sourceGroupId: GROUP_ID, status: NEW_STATUS, jobId: FAKE_JOB_ID }, + }); + + expect(progress).toMatchObject({ + id: FAKE_JOB_ID, + status: NEW_STATUS, + }); + }); + + it('updateImportStatus returns new status', async () => { + const NEW_STATUS = 'dummy'; + const FAKE_JOB_ID = 5; + const { + data: { updateImportStatus: statusInResponse }, + } = await client.mutate({ + mutation: updateImportStatusMutation, + variables: { id: FAKE_JOB_ID, status: NEW_STATUS }, + }); + + expect(statusInResponse).toMatchObject({ + id: FAKE_JOB_ID, + status: NEW_STATUS, + }); + }); + + it('addValidationError adds error to group', async () => { + const FAKE_FIELD = 'some-field'; + const FAKE_MESSAGE = 'some-message'; + const { + data: { + addValidationError: { validation_errors: validationErrors }, + }, + } = await client.mutate({ + mutation: addValidationErrorMutation, + variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD, message: FAKE_MESSAGE }, + }); + + expect(validationErrors).toMatchObject([{ field: FAKE_FIELD, message: FAKE_MESSAGE }]); + }); + + it('removeValidationError removes error from group', async () => { + const FAKE_FIELD = 'some-field'; + const FAKE_MESSAGE = 'some-message'; + + await client.mutate({ + mutation: addValidationErrorMutation, + variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD, message: FAKE_MESSAGE }, + }); + + const { + data: { + removeValidationError: { validation_errors: validationErrors }, + }, + } = await client.mutate({ + mutation: removeValidationErrorMutation, + variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD }, + }); + + expect(validationErrors).toMatchObject([]); + }); }); }); diff --git a/spec/frontend/import_entities/import_groups/graphql/fixtures.js b/spec/frontend/import_entities/import_groups/graphql/fixtures.js index 62e9581bd2d..6f66066b312 100644 --- a/spec/frontend/import_entities/import_groups/graphql/fixtures.js +++ b/spec/frontend/import_entities/import_groups/graphql/fixtures.js @@ -10,7 +10,11 @@ export const generateFakeEntry = ({ id, status, ...rest }) => ({ new_name: `group${id}`, }, id, - status, + progress: { + id: `test-${id}`, + status, + }, + validation_errors: [], ...rest, }); 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 5baa201906a..bae715edac0 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,6 +1,3 @@ -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 { KEY, SourceGroupsManager, @@ -10,25 +7,15 @@ const FAKE_SOURCE_URL = 'http://demo.host'; describe('SourceGroupsManager', () => { let manager; - let client; let storage; - const getFakeGroup = () => ({ - __typename: clientTypenames.BulkImportSourceGroup, - id: 5, - }); - beforeEach(() => { - client = { - readFragment: jest.fn(), - writeFragment: jest.fn(), - }; storage = { getItem: jest.fn(), setItem: jest.fn(), }; - manager = new SourceGroupsManager({ client, storage, sourceUrl: FAKE_SOURCE_URL }); + manager = new SourceGroupsManager({ storage, sourceUrl: FAKE_SOURCE_URL }); }); describe('storage management', () => { @@ -41,93 +28,37 @@ describe('SourceGroupsManager', () => { expect(storage.getItem).toHaveBeenCalledWith(KEY); }); - it('saves to storage when import is starting', () => { - manager.startImport({ - importId: IMPORT_ID, - group: FAKE_GROUP, - }); + it('saves to storage when createImportState is called', () => { + const FAKE_STATUS = 'fake;'; + manager.createImportState(IMPORT_ID, { status: FAKE_STATUS, groups: [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, + status: FAKE_STATUS, + groups: [ + { + id: FAKE_GROUP.id, + importTarget: IMPORT_TARGET, + }, + ], }); }); - it('saves to storage when import status is updated', () => { + it('updates storage when previous state is available', () => { const CHANGED_STATUS = 'changed'; - manager.startImport({ - importId: IMPORT_ID, - group: FAKE_GROUP, - }); + manager.createImportState(IMPORT_ID, { status: STATUS, groups: [FAKE_GROUP] }); - manager.setImportStatusByImportId(IMPORT_ID, CHANGED_STATUS); + manager.updateImportProgress(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, + groups: [ + { + id: FAKE_GROUP.id, + importTarget: IMPORT_TARGET, + }, + ], }); }); }); - - it('finds item by group id', () => { - const ID = 5; - - const FAKE_GROUP = getFakeGroup(); - client.readFragment.mockReturnValue(FAKE_GROUP); - const group = manager.findById(ID); - expect(group).toBe(FAKE_GROUP); - expect(client.readFragment).toHaveBeenCalledWith({ - fragment: ImportSourceGroupFragment, - id: defaultDataIdFromObject(getFakeGroup()), - }); - }); - - it('updates group with provided function', () => { - const UPDATED_GROUP = {}; - const fn = jest.fn().mockReturnValue(UPDATED_GROUP); - manager.update(getFakeGroup(), fn); - - expect(client.writeFragment).toHaveBeenCalledWith({ - fragment: ImportSourceGroupFragment, - id: defaultDataIdFromObject(getFakeGroup()), - data: UPDATED_GROUP, - }); - }); - - it('updates group by id with provided function', () => { - const UPDATED_GROUP = {}; - const fn = jest.fn().mockReturnValue(UPDATED_GROUP); - client.readFragment.mockReturnValue(getFakeGroup()); - manager.updateById(getFakeGroup().id, fn); - - expect(client.readFragment).toHaveBeenCalledWith({ - fragment: ImportSourceGroupFragment, - id: defaultDataIdFromObject(getFakeGroup()), - }); - - expect(client.writeFragment).toHaveBeenCalledWith({ - fragment: ImportSourceGroupFragment, - id: defaultDataIdFromObject(getFakeGroup()), - data: UPDATED_GROUP, - }); - }); - - it('sets import status when group is provided', () => { - client.readFragment.mockReturnValue(getFakeGroup()); - - const NEW_STATUS = 'NEW_STATUS'; - manager.setImportStatus(getFakeGroup(), NEW_STATUS); - - expect(client.writeFragment).toHaveBeenCalledWith({ - fragment: ImportSourceGroupFragment, - id: defaultDataIdFromObject(getFakeGroup()), - data: { - ...getFakeGroup(), - status: NEW_STATUS, - }, - }); - }); }); diff --git a/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js b/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js index 0d4809971ae..9c47647c430 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 @@ -21,17 +21,15 @@ const FAKE_POLL_PATH = '/fake/poll/path'; describe('Bulk import status poller', () => { let poller; let mockAdapter; - let groupManager; + let updateImportStatus; const getPollHistory = () => mockAdapter.history.get.filter((x) => x.url === FAKE_POLL_PATH); beforeEach(() => { mockAdapter = new MockAdapter(axios); mockAdapter.onGet(FAKE_POLL_PATH).reply(200, {}); - groupManager = { - setImportStatusByImportId: jest.fn(), - }; - poller = new StatusPoller({ groupManager, pollPath: FAKE_POLL_PATH }); + updateImportStatus = jest.fn(); + poller = new StatusPoller({ updateImportStatus, pollPath: FAKE_POLL_PATH }); }); it('creates poller with proper config', () => { @@ -96,9 +94,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 FAKE_RESPONSE = { id: FAKE_ID, status_name: STATUSES.FINISHED }; + pollConfig.successCallback({ data: [FAKE_RESPONSE] }); - pollConfig.successCallback({ data: [{ id: FAKE_ID, status_name: STATUSES.FINISHED }] }); - - expect(groupManager.setImportStatusByImportId).toHaveBeenCalledWith(FAKE_ID, STATUSES.FINISHED); + expect(updateImportStatus).toHaveBeenCalledWith(FAKE_RESPONSE); }); }); diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js index df681658081..c7286d70b94 100644 --- a/spec/frontend/incidents/components/incidents_list_spec.js +++ b/spec/frontend/incidents/components/incidents_list_spec.js @@ -270,22 +270,25 @@ describe('Incidents List', () => { const noneSort = 'none'; it.each` - selector | initialSort | firstSort | nextSort - ${TH_CREATED_AT_TEST_ID} | ${descSort} | ${ascSort} | ${descSort} - ${TH_SEVERITY_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} - ${TH_PUBLISHED_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} - ${TH_INCIDENT_SLA_TEST_ID} | ${noneSort} | ${ascSort} | ${descSort} - `('updates sort with new direction', async ({ selector, initialSort, firstSort, nextSort }) => { - const [[attr, value]] = Object.entries(selector); - const columnHeader = () => wrapper.find(`[${attr}="${value}"]`); - expect(columnHeader().attributes('aria-sort')).toBe(initialSort); - columnHeader().trigger('click'); - await wrapper.vm.$nextTick(); - expect(columnHeader().attributes('aria-sort')).toBe(firstSort); - columnHeader().trigger('click'); - await wrapper.vm.$nextTick(); - expect(columnHeader().attributes('aria-sort')).toBe(nextSort); - }); + description | selector | initialSort | firstSort | nextSort + ${'creation date'} | ${TH_CREATED_AT_TEST_ID} | ${descSort} | ${ascSort} | ${descSort} + ${'severity'} | ${TH_SEVERITY_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} + ${'publish date'} | ${TH_PUBLISHED_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} + ${'due date'} | ${TH_INCIDENT_SLA_TEST_ID} | ${noneSort} | ${ascSort} | ${descSort} + `( + 'updates sort with new direction when sorting by $description', + async ({ selector, initialSort, firstSort, nextSort }) => { + const [[attr, value]] = Object.entries(selector); + const columnHeader = () => wrapper.find(`[${attr}="${value}"]`); + expect(columnHeader().attributes('aria-sort')).toBe(initialSort); + columnHeader().trigger('click'); + await wrapper.vm.$nextTick(); + expect(columnHeader().attributes('aria-sort')).toBe(firstSort); + columnHeader().trigger('click'); + await wrapper.vm.$nextTick(); + expect(columnHeader().attributes('aria-sort')).toBe(nextSort); + }, + ); }); describe('Snowplow tracking', () => { 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 5796b3fa44e..85d21f231b1 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 @@ -90,7 +90,7 @@ exports[`Alert integration settings form default state should match the default checked="true" > <span> - Automatically close incidents when the associated Prometheus alert resolves. + Automatically close associated incident when a recovery alert notification resolves an alert </span> </gl-form-checkbox-stub> </gl-form-group-stub> diff --git a/spec/frontend/integrations/edit/components/active_checkbox_spec.js b/spec/frontend/integrations/edit/components/active_checkbox_spec.js index 76fd6dd3a48..0e56fb6454e 100644 --- a/spec/frontend/integrations/edit/components/active_checkbox_spec.js +++ b/spec/frontend/integrations/edit/components/active_checkbox_spec.js @@ -6,31 +6,27 @@ import { createStore } from '~/integrations/edit/store'; describe('ActiveCheckbox', () => { let wrapper; - const createComponent = (customStateProps = {}, isInheriting = false) => { + const createComponent = (customStateProps = {}, { isInheriting = false } = {}) => { wrapper = mount(ActiveCheckbox, { store: createStore({ customState: { ...customStateProps }, + override: !isInheriting, + defaultState: isInheriting ? {} : undefined, }), - computed: { - isInheriting: () => isInheriting, - }, }); }; afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); }); - const findGlFormCheckbox = () => wrapper.find(GlFormCheckbox); + const findGlFormCheckbox = () => wrapper.findComponent(GlFormCheckbox); const findInputInCheckbox = () => findGlFormCheckbox().find('input'); describe('template', () => { describe('is inheriting adminSettings', () => { it('renders GlFormCheckbox as disabled', () => { - createComponent({}, true); + createComponent({}, { isInheriting: true }); expect(findGlFormCheckbox().exists()).toBe(true); expect(findInputInCheckbox().attributes('disabled')).toBe('disabled'); diff --git a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js index d08a1904e06..f121a148f27 100644 --- a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js @@ -136,7 +136,7 @@ describe('JiraIssuesFields', () => { describe('Vulnerabilities creation', () => { beforeEach(() => { - createComponent({ provide: { glFeatures: { jiraForVulnerabilities: true } } }); + createComponent(); }); it.each([true, false])( @@ -178,18 +178,6 @@ describe('JiraIssuesFields', () => { expect(eventHubEmitSpy).toHaveBeenCalledWith('getJiraIssueTypes'); }); - - describe('with "jiraForVulnerabilities" feature flag disabled', () => { - beforeEach(async () => { - createComponent({ - provide: { glFeatures: { jiraForVulnerabilities: false } }, - }); - }); - - it('does not show section', () => { - expect(findJiraForVulnerabilities().exists()).toBe(false); - }); - }); }); }); }); diff --git a/spec/frontend/invite_member/components/invite_member_modal_spec.js b/spec/frontend/invite_member/components/invite_member_modal_spec.js deleted file mode 100644 index 03e3da2d5ef..00000000000 --- a/spec/frontend/invite_member/components/invite_member_modal_spec.js +++ /dev/null @@ -1,67 +0,0 @@ -import { GlLink, GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { stubComponent } from 'helpers/stub_component'; -import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper'; -import InviteMemberModal from '~/invite_member/components/invite_member_modal.vue'; - -const memberPath = 'member_path'; - -const GlEmoji = { template: '<img />' }; -const createComponent = () => { - return shallowMount(InviteMemberModal, { - propsData: { - membersPath: memberPath, - }, - stubs: { - GlEmoji, - GlModal: stubComponent(GlModal, { - template: '<div><slot name="modal-title"></slot><slot></slot></div>', - }), - }, - }); -}; - -describe('InviteMemberModal', () => { - let wrapper; - - beforeEach(() => { - wrapper = createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - const findLink = () => wrapper.find(GlLink); - - describe('rendering the modal', () => { - it('renders the modal with the correct title', () => { - expect(wrapper.text()).toContain("Oops, this feature isn't ready yet"); - }); - - describe('rendering the see who link', () => { - it('renders the correct link', () => { - expect(findLink().attributes('href')).toBe(memberPath); - }); - }); - }); - - describe('tracking', () => { - let trackingSpy; - - afterEach(() => { - unmockTracking(); - }); - - it('send an event when go to pipelines is clicked', () => { - trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); - - triggerEvent(findLink().element); - - expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_who_can_invite_link', { - label: 'invite_members_message', - }); - }); - }); -}); diff --git a/spec/frontend/invite_member/components/invite_member_trigger_mock_data.js b/spec/frontend/invite_member/components/invite_member_trigger_mock_data.js deleted file mode 100644 index 9b34a8027e9..00000000000 --- a/spec/frontend/invite_member/components/invite_member_trigger_mock_data.js +++ /dev/null @@ -1,7 +0,0 @@ -const triggerProvides = { - displayText: 'Invite member', - event: 'click_invite_members_version_b', - label: 'edit_assignee', -}; - -export default triggerProvides; diff --git a/spec/frontend/invite_member/components/invite_member_trigger_spec.js b/spec/frontend/invite_member/components/invite_member_trigger_spec.js deleted file mode 100644 index 630e2dbfc16..00000000000 --- a/spec/frontend/invite_member/components/invite_member_trigger_spec.js +++ /dev/null @@ -1,48 +0,0 @@ -import { GlLink } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper'; -import InviteMemberTrigger from '~/invite_member/components/invite_member_trigger.vue'; -import triggerProvides from './invite_member_trigger_mock_data'; - -const createComponent = () => { - return shallowMount(InviteMemberTrigger, { propsData: triggerProvides }); -}; - -describe('InviteMemberTrigger', () => { - let wrapper; - - beforeEach(() => { - wrapper = createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - const findLink = () => wrapper.find(GlLink); - - describe('displayText', () => { - it('includes the correct displayText for the link', () => { - expect(findLink().text()).toBe(triggerProvides.displayText); - }); - }); - - describe('tracking', () => { - let trackingSpy; - - afterEach(() => { - unmockTracking(); - }); - - it('send an event when go to pipelines is clicked', () => { - trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); - - triggerEvent(findLink().element); - - expect(trackingSpy).toHaveBeenCalledWith('_category_', triggerProvides.event, { - label: triggerProvides.label, - }); - }); - }); -}); diff --git a/spec/frontend/issuable/components/csv_export_modal_spec.js b/spec/frontend/issuable/components/csv_export_modal_spec.js index a327da2d63a..7eb85a946ae 100644 --- a/spec/frontend/issuable/components/csv_export_modal_spec.js +++ b/spec/frontend/issuable/components/csv_export_modal_spec.js @@ -13,6 +13,8 @@ describe('CsvExportModal', () => { mount(CsvExportModal, { propsData: { modalId: 'csv-export-modal', + exportCsvPath: 'export/csv/path', + issuableCount: 1, ...props, }, provide: { diff --git a/spec/frontend/merge_request/components/status_box_spec.js b/spec/frontend/issuable/components/status_box_spec.js index de0f3574ab2..990fac67f7e 100644 --- a/spec/frontend/merge_request/components/status_box_spec.js +++ b/spec/frontend/issuable/components/status_box_spec.js @@ -1,8 +1,6 @@ import { GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import StatusBox from '~/merge_request/components/status_box.vue'; -import mrEventHub from '~/merge_request/eventhub'; +import StatusBox from '~/issuable/components/status_box.vue'; let wrapper; @@ -70,18 +68,4 @@ describe('Merge request status box component', () => { }); }); }); - - it('updates with eventhub event', async () => { - factory({ - initialState: 'opened', - }); - - expect(wrapper.text()).toContain('Open'); - - mrEventHub.$emit('mr.state.updated', { state: 'closed' }); - - await nextTick(); - - expect(wrapper.text()).toContain('Closed'); - }); }); diff --git a/spec/frontend/issuable_form_spec.js b/spec/frontend/issuable_form_spec.js index 009ca28ff78..bc7a87eb65c 100644 --- a/spec/frontend/issuable_form_spec.js +++ b/spec/frontend/issuable_form_spec.js @@ -20,11 +20,6 @@ describe('IssuableForm', () => { describe('removeWip', () => { it.each` prefix - ${'wip '} - ${' wIP: '} - ${'[WIp] '} - ${'wIP:'} - ${' [WIp]'} ${'drAft '} ${'draFT: '} ${' [DRaft] '} @@ -34,7 +29,7 @@ describe('IssuableForm', () => { ${'dRaFt - '} ${'(draft) '} ${' (DrafT)'} - ${'wip wip: [wip] draft draft - draft: [draft] (draft)'} + ${'draft draft - draft: [draft] (draft)'} `('removes "$prefix" from the beginning of the title', ({ prefix }) => { instance.titleField.val(`${prefix}The Issuable's Title Value`); diff --git a/spec/frontend/issuable_list/components/issuable_item_spec.js b/spec/frontend/issuable_list/components/issuable_item_spec.js index 7281d2fde1d..e324f071966 100644 --- a/spec/frontend/issuable_list/components/issuable_item_spec.js +++ b/spec/frontend/issuable_list/components/issuable_item_spec.js @@ -453,5 +453,31 @@ describe('IssuableItem', () => { expect(updatedAtEl.find('span').attributes('title')).toBe('Sep 10, 2020 11:41am GMT+0000'); expect(updatedAtEl.text()).toBe(wrapper.vm.updatedAt); }); + + describe('when issuable is closed', () => { + it('renders issuable card with a closed style', () => { + wrapper = createComponent({ issuable: { ...mockIssuable, closedAt: '2020-12-10' } }); + + expect(wrapper.classes()).toContain('closed'); + }); + }); + + describe('when issuable was created within the past 24 hours', () => { + it('renders issuable card with a recently-created style', () => { + wrapper = createComponent({ + issuable: { ...mockIssuable, createdAt: '2020-12-10T12:34:56' }, + }); + + expect(wrapper.classes()).toContain('today'); + }); + }); + + describe('when issuable was created earlier than the past 24 hours', () => { + it('renders issuable card without a recently-created style', () => { + wrapper = createComponent({ issuable: { ...mockIssuable, createdAt: '2020-12-09' } }); + + expect(wrapper.classes()).not.toContain('today'); + }); + }); }); }); diff --git a/spec/frontend/issue_show/components/form_spec.js b/spec/frontend/issue_show/components/form_spec.js index fc2e224ad92..6d4807c4261 100644 --- a/spec/frontend/issue_show/components/form_spec.js +++ b/spec/frontend/issue_show/components/form_spec.js @@ -1,13 +1,15 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import Autosave from '~/autosave'; +import DescriptionTemplate from '~/issue_show/components/fields/description_template.vue'; import formComponent from '~/issue_show/components/form.vue'; +import LockedWarning from '~/issue_show/components/locked_warning.vue'; import eventHub from '~/issue_show/event_hub'; jest.mock('~/autosave'); describe('Inline edit form component', () => { - let vm; + let wrapper; const defaultProps = { canDestroy: true, formState: { @@ -24,22 +26,26 @@ describe('Inline edit form component', () => { }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); const createComponent = (props) => { - const Component = Vue.extend(formComponent); - - vm = mountComponent(Component, { - ...defaultProps, - ...props, + wrapper = shallowMount(formComponent, { + propsData: { + ...defaultProps, + ...props, + }, }); }; + const findDescriptionTemplate = () => wrapper.findComponent(DescriptionTemplate); + const findLockedWarning = () => wrapper.findComponent(LockedWarning); + const findAlert = () => wrapper.findComponent(GlAlert); + it('does not render template selector if no templates exist', () => { createComponent(); - expect(vm.$el.querySelector('.js-issuable-selector-wrap')).toBeNull(); + expect(findDescriptionTemplate().exists()).toBe(false); }); it('renders template selector when templates as array exists', () => { @@ -49,7 +55,7 @@ describe('Inline edit form component', () => { ], }); - expect(vm.$el.querySelector('.js-issuable-selector-wrap')).not.toBeNull(); + expect(findDescriptionTemplate().exists()).toBe(true); }); it('renders template selector when templates as hash exists', () => { @@ -59,19 +65,19 @@ describe('Inline edit form component', () => { }, }); - expect(vm.$el.querySelector('.js-issuable-selector-wrap')).not.toBeNull(); + expect(findDescriptionTemplate().exists()).toBe(true); }); it('hides locked warning by default', () => { createComponent(); - expect(vm.$el.querySelector('.alert')).toBeNull(); + expect(findLockedWarning().exists()).toBe(false); }); it('shows locked warning if formState is different', () => { createComponent({ formState: { ...defaultProps.formState, lockedWarningVisible: true } }); - expect(vm.$el.querySelector('.alert')).not.toBeNull(); + expect(findLockedWarning().exists()).toBe(true); }); it('hides locked warning when currently saving', () => { @@ -79,7 +85,7 @@ describe('Inline edit form component', () => { formState: { ...defaultProps.formState, updateLoading: true, lockedWarningVisible: true }, }); - expect(vm.$el.querySelector('.alert')).toBeNull(); + expect(findLockedWarning().exists()).toBe(false); }); describe('autosave', () => { @@ -110,5 +116,23 @@ describe('Inline edit form component', () => { expect(spy).toHaveBeenCalledTimes(6); }); + + describe('outdated description', () => { + it('does not show warning if lock version from server is the same as the local lock version', () => { + createComponent(); + expect(findAlert().exists()).toBe(false); + }); + + it('shows warning if lock version from server differs than the local lock version', async () => { + Autosave.prototype.getSavedLockVersion.mockResolvedValue('lock version from local storage'); + + createComponent({ + formState: { ...defaultProps.formState, lock_version: 'lock version from server' }, + }); + + await wrapper.vm.$nextTick(); + expect(findAlert().exists()).toBe(true); + }); + }); }); }); diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js index 476804bda12..5d83bf0142f 100644 --- a/spec/frontend/issues_list/components/issues_list_app_spec.js +++ b/spec/frontend/issues_list/components/issues_list_app_spec.js @@ -3,45 +3,51 @@ import { mount, shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; +import { apiParams, filteredTokens, locationSearch, urlParams } from 'jest/issues_list/mock_data'; import createFlash from '~/flash'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; +import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; import IssuableList from '~/issuable_list/components/issuable_list_root.vue'; import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants'; import IssuesListApp from '~/issues_list/components/issues_list_app.vue'; - import { + apiSortParams, CREATED_DESC, + DUE_DATE_OVERDUE, PAGE_SIZE, PAGE_SIZE_MANUAL, - RELATIVE_POSITION_ASC, - sortOptions, - sortParams, + PARAM_DUE_DATE, + RELATIVE_POSITION_DESC, + urlSortParams, } from '~/issues_list/constants'; import eventHub from '~/issues_list/eventhub'; +import { getSortOptions } from '~/issues_list/utils'; import axios from '~/lib/utils/axios_utils'; import { setUrlParams } from '~/lib/utils/url_utility'; jest.mock('~/flash'); describe('IssuesListApp component', () => { - const originalWindowLocation = window.location; let axiosMock; let wrapper; const defaultProvide = { + autocompleteUsersPath: 'autocomplete/users/path', calendarPath: 'calendar/path', canBulkUpdate: false, emptyStateSvgPath: 'empty-state.svg', endpoint: 'api/endpoint', exportCsvPath: 'export/csv/path', - fullPath: 'path/to/project', + hasBlockedIssuesFeature: true, hasIssues: true, + hasIssueWeightsFeature: true, isSignedIn: false, issuesPath: 'path/to/issues', jiraIntegrationPath: 'jira/integration/path', newIssuePath: 'new/issue/path', + projectLabelsPath: 'project/labels/path', + projectPath: 'path/to/project', rssPath: 'rss/path', - showImportButton: true, showNewIssueLink: true, signInPath: 'sign/in/path', }; @@ -63,6 +69,7 @@ describe('IssuesListApp component', () => { }; const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons); + const findIssuableByEmail = () => wrapper.findComponent(IssuableByEmail); const findGlButton = () => wrapper.findComponent(GlButton); const findGlButtons = () => wrapper.findAllComponents(GlButton); const findGlButtonAt = (index) => findGlButtons().at(index); @@ -86,7 +93,7 @@ describe('IssuesListApp component', () => { }); afterEach(() => { - window.location = originalWindowLocation; + global.jsdom.reconfigure({ url: TEST_HOST }); axiosMock.reset(); wrapper.destroy(); }); @@ -99,10 +106,10 @@ describe('IssuesListApp component', () => { it('renders', () => { expect(findIssuableList().props()).toMatchObject({ - namespace: defaultProvide.fullPath, + namespace: defaultProvide.projectPath, recentSearchesStorageKey: 'issues', searchInputPlaceholder: 'Search or filter results…', - sortOptions, + sortOptions: getSortOptions(true, true), initialSortBy: CREATED_DESC, tabs: IssuableListTabs, currentTab: IssuableStates.Opened, @@ -120,46 +127,58 @@ describe('IssuesListApp component', () => { describe('header action buttons', () => { it('renders rss button', () => { - wrapper = mountComponent(); + wrapper = mountComponent({ mountFn: mount }); + expect(findGlButtonAt(0).props('icon')).toBe('rss'); expect(findGlButtonAt(0).attributes()).toMatchObject({ href: defaultProvide.rssPath, - icon: 'rss', 'aria-label': IssuesListApp.i18n.rssLabel, }); }); it('renders calendar button', () => { - wrapper = mountComponent(); + wrapper = mountComponent({ mountFn: mount }); + expect(findGlButtonAt(1).props('icon')).toBe('calendar'); expect(findGlButtonAt(1).attributes()).toMatchObject({ href: defaultProvide.calendarPath, - icon: 'calendar', 'aria-label': IssuesListApp.i18n.calendarLabel, }); }); - it('renders csv import/export component', async () => { - const search = '?page=1&search=refactor'; + describe('csv import/export component', () => { + describe('when user is signed in', () => { + it('renders', async () => { + const search = '?page=1&search=refactor&state=opened&sort=created_date'; - Object.defineProperty(window, 'location', { - writable: true, - value: { search }, - }); + global.jsdom.reconfigure({ url: `${TEST_HOST}${search}` }); - wrapper = mountComponent(); + wrapper = mountComponent({ + provide: { ...defaultProvide, isSignedIn: true }, + mountFn: mount, + }); - await waitForPromises(); + await waitForPromises(); + + expect(findCsvImportExportButtons().props()).toMatchObject({ + exportCsvPath: `${defaultProvide.exportCsvPath}${search}`, + issuableCount: xTotal, + }); + }); + }); - expect(findCsvImportExportButtons().props()).toMatchObject({ - exportCsvPath: `${defaultProvide.exportCsvPath}${search}`, - issuableCount: xTotal, + describe('when user is not signed in', () => { + it('does not render', () => { + wrapper = mountComponent({ provide: { ...defaultProvide, isSignedIn: false } }); + + expect(findCsvImportExportButtons().exists()).toBe(false); + }); }); }); describe('bulk edit button', () => { it('renders when user has permissions', () => { - wrapper = mountComponent({ provide: { canBulkUpdate: true } }); + wrapper = mountComponent({ provide: { canBulkUpdate: true }, mountFn: mount }); expect(findGlButtonAt(2).text()).toBe('Edit issues'); }); @@ -170,20 +189,22 @@ describe('IssuesListApp component', () => { expect(findGlButtons().filter((button) => button.text() === 'Edit issues')).toHaveLength(0); }); - it('emits "issuables:enableBulkEdit" event to legacy bulk edit class', () => { - wrapper = mountComponent({ provide: { canBulkUpdate: true } }); + it('emits "issuables:enableBulkEdit" event to legacy bulk edit class', async () => { + wrapper = mountComponent({ provide: { canBulkUpdate: true }, mountFn: mount }); jest.spyOn(eventHub, '$emit'); findGlButtonAt(2).vm.$emit('click'); + await waitForPromises(); + expect(eventHub.$emit).toHaveBeenCalledWith('issuables:enableBulkEdit'); }); }); describe('new issue button', () => { it('renders when user has permissions', () => { - wrapper = mountComponent({ provide: { showNewIssueLink: true } }); + wrapper = mountComponent({ provide: { showNewIssueLink: true }, mountFn: mount }); expect(findGlButtonAt(2).text()).toBe('New issue'); expect(findGlButtonAt(2).attributes('href')).toBe(defaultProvide.newIssuePath); @@ -198,14 +219,21 @@ describe('IssuesListApp component', () => { }); describe('initial url params', () => { + describe('due_date', () => { + it('is set from the url params', () => { + global.jsdom.reconfigure({ url: `${TEST_HOST}?${PARAM_DUE_DATE}=${DUE_DATE_OVERDUE}` }); + + wrapper = mountComponent(); + + expect(findIssuableList().props('urlParams')).toMatchObject({ due_date: DUE_DATE_OVERDUE }); + }); + }); + describe('page', () => { it('is set from the url params', () => { const page = 5; - Object.defineProperty(window, 'location', { - writable: true, - value: { href: setUrlParams({ page }, TEST_HOST) }, - }); + global.jsdom.reconfigure({ url: setUrlParams({ page }, TEST_HOST) }); wrapper = mountComponent(); @@ -213,18 +241,25 @@ describe('IssuesListApp component', () => { }); }); + describe('search', () => { + it('is set from the url params', () => { + global.jsdom.reconfigure({ url: `${TEST_HOST}${locationSearch}` }); + + wrapper = mountComponent(); + + expect(findIssuableList().props('urlParams')).toMatchObject({ search: 'find issues' }); + }); + }); + describe('sort', () => { - it.each(Object.keys(sortParams))('is set as %s from the url params', (sortKey) => { - Object.defineProperty(window, 'location', { - writable: true, - value: { href: setUrlParams(sortParams[sortKey], TEST_HOST) }, - }); + it.each(Object.keys(urlSortParams))('is set as %s from the url params', (sortKey) => { + global.jsdom.reconfigure({ url: setUrlParams(urlSortParams[sortKey], TEST_HOST) }); wrapper = mountComponent(); expect(findIssuableList().props()).toMatchObject({ initialSortBy: sortKey, - urlParams: sortParams[sortKey], + urlParams: urlSortParams[sortKey], }); }); }); @@ -233,16 +268,23 @@ describe('IssuesListApp component', () => { it('is set from the url params', () => { const initialState = IssuableStates.All; - Object.defineProperty(window, 'location', { - writable: true, - value: { href: setUrlParams({ state: initialState }, TEST_HOST) }, - }); + global.jsdom.reconfigure({ url: setUrlParams({ state: initialState }, TEST_HOST) }); wrapper = mountComponent(); expect(findIssuableList().props('currentTab')).toBe(initialState); }); }); + + describe('filter tokens', () => { + it('is set from the url params', () => { + global.jsdom.reconfigure({ url: `${TEST_HOST}${locationSearch}` }); + + wrapper = mountComponent(); + + expect(findIssuableList().props('initialFilterValue')).toEqual(filteredTokens); + }); + }); }); describe('bulk edit', () => { @@ -262,16 +304,23 @@ describe('IssuesListApp component', () => { ); }); + describe('IssuableByEmail component', () => { + describe.each([true, false])(`when issue creation by email is enabled=%s`, (enabled) => { + it(`${enabled ? 'renders' : 'does not render'}`, () => { + wrapper = mountComponent({ provide: { initialEmail: enabled } }); + + expect(findIssuableByEmail().exists()).toBe(enabled); + }); + }); + }); + describe('empty states', () => { describe('when there are issues', () => { describe('when search returns no results', () => { beforeEach(async () => { - Object.defineProperty(window, 'location', { - writable: true, - value: { href: setUrlParams({ search: 'no results' }, TEST_HOST) }, - }); + global.jsdom.reconfigure({ url: `${TEST_HOST}?search=no+results` }); - wrapper = mountComponent({ provide: { hasIssues: true } }); + wrapper = mountComponent({ provide: { hasIssues: true }, mountFn: mount }); await waitForPromises(); }); @@ -286,8 +335,10 @@ describe('IssuesListApp component', () => { }); describe('when "Open" tab has no issues', () => { - beforeEach(() => { - wrapper = mountComponent({ provide: { hasIssues: true } }); + beforeEach(async () => { + wrapper = mountComponent({ provide: { hasIssues: true }, mountFn: mount }); + + await waitForPromises(); }); it('shows empty state', () => { @@ -301,12 +352,13 @@ describe('IssuesListApp component', () => { describe('when "Closed" tab has no issues', () => { beforeEach(async () => { - Object.defineProperty(window, 'location', { - writable: true, - value: { href: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST) }, + global.jsdom.reconfigure({ + url: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST), }); - wrapper = mountComponent({ provide: { hasIssues: true } }); + wrapper = mountComponent({ provide: { hasIssues: true }, mountFn: mount }); + + await waitForPromises(); }); it('shows empty state', () => { @@ -346,11 +398,11 @@ describe('IssuesListApp component', () => { it('shows Jira integration information', () => { const paragraphs = wrapper.findAll('p'); - expect(paragraphs.at(2).text()).toContain(IssuesListApp.i18n.jiraIntegrationTitle); - expect(paragraphs.at(3).text()).toContain( + expect(paragraphs.at(1).text()).toContain(IssuesListApp.i18n.jiraIntegrationTitle); + expect(paragraphs.at(2).text()).toContain( 'Enable the Jira integration to view your Jira issues in GitLab.', ); - expect(paragraphs.at(4).text()).toContain( + expect(paragraphs.at(3).text()).toContain( IssuesListApp.i18n.jiraIntegrationSecondaryMessage, ); expect(findGlLink().text()).toBe('Enable the Jira integration'); @@ -418,7 +470,7 @@ describe('IssuesListApp component', () => { }); it('fetches issues with expected params', () => { - expect(axiosMock.history.get[1].params).toEqual({ + expect(axiosMock.history.get[1].params).toMatchObject({ page, per_page: PAGE_SIZE, state, @@ -489,7 +541,7 @@ describe('IssuesListApp component', () => { }); describe('when "sort" event is emitted by IssuableList', () => { - it.each(Object.keys(sortParams))( + it.each(Object.keys(apiSortParams))( 'fetches issues with correct params with payload `%s`', async (sortKey) => { wrapper = mountComponent(); @@ -500,10 +552,10 @@ describe('IssuesListApp component', () => { expect(axiosMock.history.get[1].params).toEqual({ page: xPage, - per_page: sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE, + per_page: sortKey === RELATIVE_POSITION_DESC ? PAGE_SIZE_MANUAL : PAGE_SIZE, state, with_labels_details: true, - ...sortParams[sortKey], + ...apiSortParams[sortKey], }); }, ); @@ -525,21 +577,18 @@ describe('IssuesListApp component', () => { }); describe('when "filter" event is emitted by IssuableList', () => { - beforeEach(async () => { + beforeEach(() => { wrapper = mountComponent(); - const payload = [ - { type: 'filtered-search-term', value: { data: 'no' } }, - { type: 'filtered-search-term', value: { data: 'issues' } }, - ]; - - findIssuableList().vm.$emit('filter', payload); - - await waitForPromises(); + findIssuableList().vm.$emit('filter', filteredTokens); }); it('makes an API call to search for issues with the search term', () => { - expect(axiosMock.history.get[1].params).toMatchObject({ search: 'no issues' }); + expect(axiosMock.history.get[1].params).toMatchObject(apiParams); + }); + + it('updates IssuableList with url params', () => { + expect(findIssuableList().props('urlParams')).toMatchObject(urlParams); }); }); }); diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js new file mode 100644 index 00000000000..ce2880d177a --- /dev/null +++ b/spec/frontend/issues_list/mock_data.js @@ -0,0 +1,127 @@ +import { + OPERATOR_IS, + OPERATOR_IS_NOT, +} from '~/vue_shared/components/filtered_search_bar/constants'; + +export const locationSearch = [ + '?search=find+issues', + 'author_username=homer', + 'not[author_username]=marge', + 'assignee_username[]=bart', + 'assignee_username[]=lisa', + 'not[assignee_username][]=patty', + 'not[assignee_username][]=selma', + 'milestone_title=season+4', + 'not[milestone_title]=season+20', + 'label_name[]=cartoon', + 'label_name[]=tv', + 'not[label_name][]=live action', + 'not[label_name][]=drama', + 'my_reaction_emoji=thumbsup', + 'confidential=no', + 'iteration_title=season:+%234', + 'not[iteration_title]=season:+%2320', + 'epic_id=12', + 'not[epic_id]=34', + 'weight=1', + 'not[weight]=3', +].join('&'); + +export const locationSearchWithSpecialValues = [ + 'assignee_id=123', + 'assignee_username=bart', + 'my_reaction_emoji=None', + 'iteration_id=Current', + 'epic_id=None', + 'weight=None', +].join('&'); + +export const filteredTokens = [ + { type: 'author_username', value: { data: 'homer', operator: OPERATOR_IS } }, + { type: 'author_username', value: { data: 'marge', operator: OPERATOR_IS_NOT } }, + { type: 'assignee_username', value: { data: 'bart', operator: OPERATOR_IS } }, + { type: 'assignee_username', value: { data: 'lisa', operator: OPERATOR_IS } }, + { type: 'assignee_username', value: { data: 'patty', operator: OPERATOR_IS_NOT } }, + { type: 'assignee_username', value: { data: 'selma', operator: OPERATOR_IS_NOT } }, + { type: 'milestone', value: { data: 'season 4', operator: OPERATOR_IS } }, + { type: 'milestone', value: { data: 'season 20', operator: OPERATOR_IS_NOT } }, + { type: 'labels', value: { data: 'cartoon', operator: OPERATOR_IS } }, + { type: 'labels', value: { data: 'tv', operator: OPERATOR_IS } }, + { type: 'labels', value: { data: 'live action', operator: OPERATOR_IS_NOT } }, + { type: 'labels', value: { data: 'drama', operator: OPERATOR_IS_NOT } }, + { type: 'my_reaction_emoji', value: { data: 'thumbsup', operator: OPERATOR_IS } }, + { type: 'confidential', value: { data: 'no', operator: OPERATOR_IS } }, + { type: 'iteration', value: { data: 'season: #4', operator: OPERATOR_IS } }, + { type: 'iteration', value: { data: 'season: #20', operator: OPERATOR_IS_NOT } }, + { type: 'epic_id', value: { data: '12', operator: OPERATOR_IS } }, + { type: 'epic_id', value: { data: '34', operator: OPERATOR_IS_NOT } }, + { type: 'weight', value: { data: '1', operator: OPERATOR_IS } }, + { type: 'weight', value: { data: '3', operator: OPERATOR_IS_NOT } }, + { type: 'filtered-search-term', value: { data: 'find' } }, + { type: 'filtered-search-term', value: { data: 'issues' } }, +]; + +export const filteredTokensWithSpecialValues = [ + { type: 'assignee_username', value: { data: '123', operator: OPERATOR_IS } }, + { type: 'assignee_username', value: { data: 'bart', operator: OPERATOR_IS } }, + { type: 'my_reaction_emoji', value: { data: 'None', operator: OPERATOR_IS } }, + { type: 'iteration', value: { data: 'Current', operator: OPERATOR_IS } }, + { type: 'epic_id', value: { data: 'None', operator: OPERATOR_IS } }, + { type: 'weight', value: { data: 'None', operator: OPERATOR_IS } }, +]; + +export const apiParams = { + author_username: 'homer', + 'not[author_username]': 'marge', + assignee_username: ['bart', 'lisa'], + 'not[assignee_username]': ['patty', 'selma'], + milestone: 'season 4', + 'not[milestone]': 'season 20', + labels: ['cartoon', 'tv'], + 'not[labels]': ['live action', 'drama'], + my_reaction_emoji: 'thumbsup', + confidential: 'no', + iteration_title: 'season: #4', + 'not[iteration_title]': 'season: #20', + epic_id: '12', + 'not[epic_id]': '34', + weight: '1', + 'not[weight]': '3', +}; + +export const apiParamsWithSpecialValues = { + assignee_id: '123', + assignee_username: 'bart', + my_reaction_emoji: 'None', + iteration_id: 'Current', + epic_id: 'None', + weight: 'None', +}; + +export const urlParams = { + author_username: 'homer', + 'not[author_username]': 'marge', + 'assignee_username[]': ['bart', 'lisa'], + 'not[assignee_username][]': ['patty', 'selma'], + milestone_title: 'season 4', + 'not[milestone_title]': 'season 20', + 'label_name[]': ['cartoon', 'tv'], + 'not[label_name][]': ['live action', 'drama'], + my_reaction_emoji: 'thumbsup', + confidential: 'no', + iteration_title: 'season: #4', + 'not[iteration_title]': 'season: #20', + epic_id: '12', + 'not[epic_id]': '34', + weight: '1', + 'not[weight]': '3', +}; + +export const urlParamsWithSpecialValues = { + assignee_id: '123', + 'assignee_username[]': 'bart', + my_reaction_emoji: 'None', + iteration_id: 'Current', + epic_id: 'None', + weight: 'None', +}; diff --git a/spec/frontend/issues_list/utils_spec.js b/spec/frontend/issues_list/utils_spec.js new file mode 100644 index 00000000000..17127753972 --- /dev/null +++ b/spec/frontend/issues_list/utils_spec.js @@ -0,0 +1,109 @@ +import { + apiParams, + apiParamsWithSpecialValues, + filteredTokens, + filteredTokensWithSpecialValues, + locationSearch, + locationSearchWithSpecialValues, + urlParams, + urlParamsWithSpecialValues, +} from 'jest/issues_list/mock_data'; +import { API_PARAM, DUE_DATE_VALUES, URL_PARAM, urlSortParams } from '~/issues_list/constants'; +import { + convertToParams, + convertToSearchQuery, + getDueDateValue, + getFilterTokens, + getSortKey, + getSortOptions, +} from '~/issues_list/utils'; + +describe('getSortKey', () => { + it.each(Object.keys(urlSortParams))('returns %s given the correct inputs', (sortKey) => { + const { sort } = urlSortParams[sortKey]; + expect(getSortKey(sort)).toBe(sortKey); + }); +}); + +describe('getDueDateValue', () => { + it.each(DUE_DATE_VALUES)('returns the argument when it is `%s`', (value) => { + expect(getDueDateValue(value)).toBe(value); + }); + + it('returns undefined when the argument is invalid', () => { + expect(getDueDateValue('invalid value')).toBeUndefined(); + }); +}); + +describe('getSortOptions', () => { + describe.each` + hasIssueWeightsFeature | hasBlockedIssuesFeature | length | containsWeight | containsBlocking + ${false} | ${false} | ${8} | ${false} | ${false} + ${true} | ${false} | ${9} | ${true} | ${false} + ${false} | ${true} | ${9} | ${false} | ${true} + ${true} | ${true} | ${10} | ${true} | ${true} + `( + 'when hasIssueWeightsFeature=$hasIssueWeightsFeature and hasBlockedIssuesFeature=$hasBlockedIssuesFeature', + ({ + hasIssueWeightsFeature, + hasBlockedIssuesFeature, + length, + containsWeight, + containsBlocking, + }) => { + const sortOptions = getSortOptions(hasIssueWeightsFeature, hasBlockedIssuesFeature); + + it('returns the correct length of sort options', () => { + expect(sortOptions).toHaveLength(length); + }); + + it(`${containsWeight ? 'contains' : 'does not contain'} weight option`, () => { + expect(sortOptions.some((option) => option.title === 'Weight')).toBe(containsWeight); + }); + + it(`${containsBlocking ? 'contains' : 'does not contain'} blocking option`, () => { + expect(sortOptions.some((option) => option.title === 'Blocking')).toBe(containsBlocking); + }); + }, + ); +}); + +describe('getFilterTokens', () => { + it('returns filtered tokens given "window.location.search"', () => { + expect(getFilterTokens(locationSearch)).toEqual(filteredTokens); + }); + + it('returns filtered tokens given "window.location.search" with special values', () => { + expect(getFilterTokens(locationSearchWithSpecialValues)).toEqual( + filteredTokensWithSpecialValues, + ); + }); +}); + +describe('convertToParams', () => { + it('returns api params given filtered tokens', () => { + expect(convertToParams(filteredTokens, API_PARAM)).toEqual(apiParams); + }); + + it('returns api params given filtered tokens with special values', () => { + expect(convertToParams(filteredTokensWithSpecialValues, API_PARAM)).toEqual( + apiParamsWithSpecialValues, + ); + }); + + it('returns url params given filtered tokens', () => { + expect(convertToParams(filteredTokens, URL_PARAM)).toEqual(urlParams); + }); + + it('returns url params given filtered tokens with special values', () => { + expect(convertToParams(filteredTokensWithSpecialValues, URL_PARAM)).toEqual( + urlParamsWithSpecialValues, + ); + }); +}); + +describe('convertToSearchQuery', () => { + it('returns search string given filtered tokens', () => { + expect(convertToSearchQuery(filteredTokens)).toBe('find issues'); + }); +}); diff --git a/spec/frontend/jira_connect/components/groups_list_spec.js b/spec/frontend/jira_connect/components/groups_list_spec.js index f354cfe6a9b..4b875928a90 100644 --- a/spec/frontend/jira_connect/components/groups_list_spec.js +++ b/spec/frontend/jira_connect/components/groups_list_spec.js @@ -1,12 +1,24 @@ -import { GlAlert, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlSearchBoxByType, GlPagination } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { fetchGroups } from '~/jira_connect/api'; import GroupsList from '~/jira_connect/components/groups_list.vue'; import GroupsListItem from '~/jira_connect/components/groups_list_item.vue'; +import { DEFAULT_GROUPS_PER_PAGE } from '~/jira_connect/constants'; import { mockGroup1, mockGroup2 } from '../mock_data'; +const createMockGroup = (groupId) => { + return { + ...mockGroup1, + id: groupId, + }; +}; + +const createMockGroups = (count) => { + return [...new Array(count)].map((_, idx) => createMockGroup(idx)); +}; + jest.mock('~/jira_connect/api', () => { return { fetchGroups: jest.fn(), @@ -42,6 +54,7 @@ describe('GroupsList', () => { const findSecondItem = () => findAllItems().at(1); const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); const findGroupsList = () => wrapper.findByTestId('groups-list'); + const findPagination = () => wrapper.findComponent(GlPagination); describe('when groups are loading', () => { it('renders loading icon', async () => { @@ -130,14 +143,14 @@ describe('GroupsList', () => { }); it('calls `fetchGroups` with search term', () => { - expect(fetchGroups).toHaveBeenCalledWith(mockGroupsPath, { + expect(fetchGroups).toHaveBeenLastCalledWith(mockGroupsPath, { page: 1, - perPage: 10, + perPage: DEFAULT_GROUPS_PER_PAGE, search: mockSearchTeam, }); }); - it('disables GroupListItems', async () => { + it('disables GroupListItems', () => { findAllItems().wrappers.forEach((groupListItem) => { expect(groupListItem.props('disabled')).toBe(true); }); @@ -165,6 +178,122 @@ describe('GroupsList', () => { expect(findFirstItem().props('group')).toBe(mockGroup1); }); }); + + it.each` + userSearchTerm | finalSearchTerm + ${'gitl'} | ${'gitl'} + ${'git'} | ${'git'} + ${'gi'} | ${''} + ${'g'} | ${''} + ${''} | ${''} + ${undefined} | ${undefined} + `( + 'searches for "$finalSearchTerm" when user enters "$userSearchTerm"', + async ({ userSearchTerm, finalSearchTerm }) => { + fetchGroups.mockResolvedValue({ + data: [mockGroup1], + headers: { 'X-PAGE': 1, 'X-TOTAL': 1 }, + }); + + createComponent(); + await waitForPromises(); + + const searchBox = findSearchBox(); + searchBox.vm.$emit('input', userSearchTerm); + + expect(fetchGroups).toHaveBeenLastCalledWith(mockGroupsPath, { + page: 1, + perPage: DEFAULT_GROUPS_PER_PAGE, + search: finalSearchTerm, + }); + }, + ); + }); + + describe('when page=2', () => { + beforeEach(async () => { + const totalItems = DEFAULT_GROUPS_PER_PAGE + 1; + const mockGroups = createMockGroups(totalItems); + fetchGroups.mockResolvedValue({ + headers: { 'X-TOTAL': totalItems, 'X-PAGE': 1 }, + data: mockGroups, + }); + createComponent(); + await waitForPromises(); + + const paginationEl = findPagination(); + paginationEl.vm.$emit('input', 2); + }); + + it('should load results for page 2', () => { + expect(fetchGroups).toHaveBeenLastCalledWith(mockGroupsPath, { + page: 2, + perPage: DEFAULT_GROUPS_PER_PAGE, + search: '', + }); + }); + + it('resets page to 1 on search `input` event', () => { + const mockSearchTerm = 'gitlab'; + const searchBox = findSearchBox(); + + searchBox.vm.$emit('input', mockSearchTerm); + + expect(fetchGroups).toHaveBeenLastCalledWith(mockGroupsPath, { + page: 1, + perPage: DEFAULT_GROUPS_PER_PAGE, + search: mockSearchTerm, + }); + }); + }); + }); + + describe('pagination', () => { + it.each` + scenario | totalItems | shouldShowPagination + ${'renders pagination'} | ${DEFAULT_GROUPS_PER_PAGE + 1} | ${true} + ${'does not render pagination'} | ${DEFAULT_GROUPS_PER_PAGE} | ${false} + ${'does not render pagination'} | ${2} | ${false} + ${'does not render pagination'} | ${0} | ${false} + `('$scenario with $totalItems groups', async ({ totalItems, shouldShowPagination }) => { + const mockGroups = createMockGroups(totalItems); + fetchGroups.mockResolvedValue({ + headers: { 'X-TOTAL': totalItems, 'X-PAGE': 1 }, + data: mockGroups, + }); + createComponent(); + await waitForPromises(); + + const paginationEl = findPagination(); + + expect(paginationEl.exists()).toBe(shouldShowPagination); + if (shouldShowPagination) { + expect(paginationEl.props('totalItems')).toBe(totalItems); + } + }); + + describe('when `input` event triggered', () => { + beforeEach(async () => { + const MOCK_TOTAL_ITEMS = DEFAULT_GROUPS_PER_PAGE + 1; + fetchGroups.mockResolvedValue({ + headers: { 'X-TOTAL': MOCK_TOTAL_ITEMS, 'X-PAGE': 1 }, + data: createMockGroups(MOCK_TOTAL_ITEMS), + }); + + createComponent(); + await waitForPromises(); + }); + + it('executes `fetchGroups` with correct arguments', () => { + const paginationEl = findPagination(); + paginationEl.vm.$emit('input', 2); + + expect(fetchGroups).toHaveBeenLastCalledWith(mockGroupsPath, { + page: 2, + perPage: DEFAULT_GROUPS_PER_PAGE, + search: '', + }); + }); }); }); }); diff --git a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap index bea27c8877d..9f49cb4007a 100644 --- a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap +++ b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap @@ -24,7 +24,9 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = role="columnheader" scope="col" > - Jira display name + <div> + Jira display name + </div> </th> <th aria-colindex="2" @@ -32,14 +34,18 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = class="" role="columnheader" scope="col" - /> + > + <div /> + </th> <th aria-colindex="3" class="" role="columnheader" scope="col" > - GitLab username + <div> + GitLab username + </div> </th> </tr> </thead> diff --git a/spec/frontend/jobs/components/job_app_spec.js b/spec/frontend/jobs/components/job_app_spec.js index 2974e91e46d..3fcefde1aba 100644 --- a/spec/frontend/jobs/components/job_app_spec.js +++ b/spec/frontend/jobs/components/job_app_spec.js @@ -35,6 +35,7 @@ describe('Job App', () => { const props = { artifactHelpUrl: 'help/artifact', deploymentHelpUrl: 'help/deployment', + codeQualityHelpPath: '/help/code_quality', runnerSettingsUrl: 'settings/ci-cd/runners', variablesSettingsUrl: 'settings/ci-cd/variables', terminalPath: 'jobs/123/terminal', diff --git a/spec/frontend/jobs/components/table/cells.vue/duration_cell_spec.js b/spec/frontend/jobs/components/table/cells.vue/duration_cell_spec.js new file mode 100644 index 00000000000..763a4b0eaa2 --- /dev/null +++ b/spec/frontend/jobs/components/table/cells.vue/duration_cell_spec.js @@ -0,0 +1,81 @@ +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import DurationCell from '~/jobs/components/table/cells/duration_cell.vue'; + +describe('Duration Cell', () => { + let wrapper; + + const findJobDuration = () => wrapper.findByTestId('job-duration'); + const findJobFinishedTime = () => wrapper.findByTestId('job-finished-time'); + const findDurationIcon = () => wrapper.findByTestId('duration-icon'); + const findFinishedTimeIcon = () => wrapper.findByTestId('finished-time-icon'); + + const createComponent = (props) => { + wrapper = extendedWrapper( + shallowMount(DurationCell, { + propsData: { + job: { + ...props, + }, + }, + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('does not display duration or finished time when no properties are present', () => { + createComponent(); + + expect(findJobDuration().exists()).toBe(false); + expect(findJobFinishedTime().exists()).toBe(false); + }); + + it('displays duration and finished time when both properties are present', () => { + const props = { + duration: 7, + finishedAt: '2021-04-26T13:37:52Z', + }; + + createComponent(props); + + expect(findJobDuration().exists()).toBe(true); + expect(findJobFinishedTime().exists()).toBe(true); + }); + + it('displays only the duration of the job when the duration property is present', () => { + const props = { + duration: 7, + }; + + createComponent(props); + + expect(findJobDuration().exists()).toBe(true); + expect(findJobFinishedTime().exists()).toBe(false); + }); + + it('displays only the finished time of the job when the finshedAt property is present', () => { + const props = { + finishedAt: '2021-04-26T13:37:52Z', + }; + + createComponent(props); + + expect(findJobFinishedTime().exists()).toBe(true); + expect(findJobDuration().exists()).toBe(false); + }); + + it('displays icons for finished time and duration', () => { + const props = { + duration: 7, + finishedAt: '2021-04-26T13:37:52Z', + }; + + createComponent(props); + + expect(findFinishedTimeIcon().props('name')).toBe('calendar'); + expect(findDurationIcon().props('name')).toBe('timer'); + }); +}); diff --git a/spec/frontend/jobs/components/table/cells.vue/job_cell_spec.js b/spec/frontend/jobs/components/table/cells.vue/job_cell_spec.js new file mode 100644 index 00000000000..fc4e5586349 --- /dev/null +++ b/spec/frontend/jobs/components/table/cells.vue/job_cell_spec.js @@ -0,0 +1,140 @@ +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import JobCell from '~/jobs/components/table/cells/job_cell.vue'; +import { mockJobsInTable } from '../../../mock_data'; + +const mockJob = mockJobsInTable[0]; +const mockJobCreatedByTag = mockJobsInTable[1]; +const mockJobLimitedAccess = mockJobsInTable[2]; +const mockStuckJob = mockJobsInTable[3]; + +describe('Job Cell', () => { + let wrapper; + + const findJobIdLink = () => wrapper.findByTestId('job-id-link'); + const findJobIdNoLink = () => wrapper.findByTestId('job-id-limited-access'); + const findJobRef = () => wrapper.findByTestId('job-ref'); + const findJobSha = () => wrapper.findByTestId('job-sha'); + const findLabelIcon = () => wrapper.findByTestId('label-icon'); + const findForkIcon = () => wrapper.findByTestId('fork-icon'); + const findStuckIcon = () => wrapper.findByTestId('stuck-icon'); + const findAllTagBadges = () => wrapper.findAllByTestId('job-tag-badge'); + + const findBadgeById = (id) => wrapper.findByTestId(id); + + const createComponent = (jobData = mockJob) => { + wrapper = extendedWrapper( + shallowMount(JobCell, { + propsData: { + job: jobData, + }, + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Job Id', () => { + it('displays the job id and links to the job', () => { + createComponent(); + + const expectedJobId = `#${getIdFromGraphQLId(mockJob.id)}`; + + expect(findJobIdLink().text()).toBe(expectedJobId); + expect(findJobIdLink().attributes('href')).toBe(mockJob.detailedStatus.detailsPath); + expect(findJobIdNoLink().exists()).toBe(false); + }); + + it('display the job id with no link', () => { + createComponent(mockJobLimitedAccess); + + const expectedJobId = `#${getIdFromGraphQLId(mockJobLimitedAccess.id)}`; + + expect(findJobIdNoLink().text()).toBe(expectedJobId); + expect(findJobIdNoLink().exists()).toBe(true); + expect(findJobIdLink().exists()).toBe(false); + }); + }); + + describe('Ref of the job', () => { + it('displays the ref name and links to the ref', () => { + createComponent(); + + expect(findJobRef().text()).toBe(mockJob.refName); + expect(findJobRef().attributes('href')).toBe(mockJob.refPath); + }); + + it('displays fork icon when job is not created by tag', () => { + createComponent(); + + expect(findForkIcon().exists()).toBe(true); + expect(findLabelIcon().exists()).toBe(false); + }); + + it('displays label icon when job is created by a tag', () => { + createComponent(mockJobCreatedByTag); + + expect(findLabelIcon().exists()).toBe(true); + expect(findForkIcon().exists()).toBe(false); + }); + }); + + describe('Commit of the job', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays the sha and links to the commit', () => { + expect(findJobSha().text()).toBe(mockJob.shortSha); + expect(findJobSha().attributes('href')).toBe(mockJob.commitPath); + }); + }); + + describe('Job badges', () => { + it('displays tags of the job', () => { + const mockJobWithTags = { + tags: ['tag-1', 'tag-2', 'tag-3'], + }; + + createComponent(mockJobWithTags); + + expect(findAllTagBadges()).toHaveLength(mockJobWithTags.tags.length); + }); + + it.each` + testId | text + ${'manual-job-badge'} | ${'manual'} + ${'triggered-job-badge'} | ${'triggered'} + ${'fail-job-badge'} | ${'allowed to fail'} + ${'delayed-job-badge'} | ${'delayed'} + `('displays the static $text badge', ({ testId, text }) => { + createComponent({ + manualJob: true, + triggered: true, + allowFailure: true, + scheduledAt: '2021-03-09T14:58:50+00:00', + }); + + expect(findBadgeById(testId).exists()).toBe(true); + expect(findBadgeById(testId).text()).toBe(text); + }); + }); + + describe('Job icons', () => { + it('stuck icon is not shown if job is not stuck', () => { + createComponent(); + + expect(findStuckIcon().exists()).toBe(false); + }); + + it('stuck icon is shown if job is stuck', () => { + createComponent(mockStuckJob); + + expect(findStuckIcon().exists()).toBe(true); + expect(findStuckIcon().attributes('name')).toBe('warning'); + }); + }); +}); diff --git a/spec/frontend/jobs/components/table/cells.vue/pipeline_cell_spec.js b/spec/frontend/jobs/components/table/cells.vue/pipeline_cell_spec.js new file mode 100644 index 00000000000..1f5e0a7aa21 --- /dev/null +++ b/spec/frontend/jobs/components/table/cells.vue/pipeline_cell_spec.js @@ -0,0 +1,82 @@ +import { GlAvatar } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import PipelineCell from '~/jobs/components/table/cells/pipeline_cell.vue'; + +const mockJobWithoutUser = { + id: 'gid://gitlab/Ci::Build/2264', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/460', + path: '/root/ci-project/-/pipelines/460', + }, +}; + +const mockJobWithUser = { + id: 'gid://gitlab/Ci::Build/2264', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/460', + path: '/root/ci-project/-/pipelines/460', + user: { + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + webPath: '/root', + }, + }, +}; + +describe('Pipeline Cell', () => { + let wrapper; + + const findPipelineId = () => wrapper.findByTestId('pipeline-id'); + const findPipelineUserLink = () => wrapper.findByTestId('pipeline-user-link'); + const findUserAvatar = () => wrapper.findComponent(GlAvatar); + + const createComponent = (props = mockJobWithUser) => { + wrapper = extendedWrapper( + shallowMount(PipelineCell, { + propsData: { + job: props, + }, + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Pipeline Id', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays the pipeline id and links to the pipeline', () => { + const expectedPipelineId = `#${getIdFromGraphQLId(mockJobWithUser.pipeline.id)}`; + + expect(findPipelineId().text()).toBe(expectedPipelineId); + expect(findPipelineId().attributes('href')).toBe(mockJobWithUser.pipeline.path); + }); + }); + + describe('Pipeline created by', () => { + const apiWrapperText = 'API'; + + it('shows and links to the pipeline user', () => { + createComponent(); + + expect(findPipelineUserLink().exists()).toBe(true); + expect(findPipelineUserLink().attributes('href')).toBe(mockJobWithUser.pipeline.user.webPath); + expect(findUserAvatar().attributes('src')).toBe(mockJobWithUser.pipeline.user.avatarUrl); + expect(wrapper.text()).not.toContain(apiWrapperText); + }); + + it('shows pipeline was created by the API', () => { + createComponent(mockJobWithoutUser); + + expect(findPipelineUserLink().exists()).toBe(false); + expect(findUserAvatar().exists()).toBe(false); + expect(wrapper.text()).toContain(apiWrapperText); + }); + }); +}); diff --git a/spec/frontend/jobs/components/table/job_table_app_spec.js b/spec/frontend/jobs/components/table/job_table_app_spec.js new file mode 100644 index 00000000000..9d1135e26c8 --- /dev/null +++ b/spec/frontend/jobs/components/table/job_table_app_spec.js @@ -0,0 +1,110 @@ +import { GlSkeletonLoader, GlAlert, GlEmptyState } from '@gitlab/ui'; +import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import getJobsQuery from '~/jobs/components/table/graphql/queries/get_jobs.query.graphql'; +import JobsTable from '~/jobs/components/table/jobs_table.vue'; +import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue'; +import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue'; +import { mockJobsQueryResponse, mockJobsQueryEmptyResponse } from '../../mock_data'; + +const projectPath = 'gitlab-org/gitlab'; +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('Job table app', () => { + let wrapper; + + const successHandler = jest.fn().mockResolvedValue(mockJobsQueryResponse); + const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + const emptyHandler = jest.fn().mockResolvedValue(mockJobsQueryEmptyResponse); + + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findTable = () => wrapper.findComponent(JobsTable); + const findTabs = () => wrapper.findComponent(JobsTableTabs); + const findAlert = () => wrapper.findComponent(GlAlert); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + const createMockApolloProvider = (handler) => { + const requestHandlers = [[getJobsQuery, handler]]; + + return createMockApollo(requestHandlers); + }; + + const createComponent = (handler = successHandler, mountFn = shallowMount) => { + wrapper = mountFn(JobsTableApp, { + provide: { + projectPath, + }, + localVue, + apolloProvider: createMockApolloProvider(handler), + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('loading state', () => { + it('should display skeleton loader when loading', () => { + createComponent(); + + expect(findSkeletonLoader().exists()).toBe(true); + expect(findTable().exists()).toBe(false); + }); + }); + + describe('loaded state', () => { + beforeEach(async () => { + createComponent(); + + await waitForPromises(); + }); + + it('should display the jobs table with data', () => { + expect(findTable().exists()).toBe(true); + expect(findSkeletonLoader().exists()).toBe(false); + }); + + it('should retfech jobs query on fetchJobsByStatus event', async () => { + jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn()); + + expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); + + await findTabs().vm.$emit('fetchJobsByStatus'); + + expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('error state', () => { + it('should show an alert if there is an error fetching the data', async () => { + createComponent(failedHandler); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + }); + }); + + describe('empty state', () => { + it('should display empty state if there are no jobs and tab scope is null', async () => { + createComponent(emptyHandler, mount); + + await waitForPromises(); + + expect(findEmptyState().exists()).toBe(true); + expect(findTable().exists()).toBe(false); + }); + + it('should not display empty state if there are jobs and tab scope is not null', async () => { + createComponent(successHandler, mount); + + await waitForPromises(); + + expect(findEmptyState().exists()).toBe(false); + expect(findTable().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/jobs/components/table/jobs_table_empty_state_spec.js b/spec/frontend/jobs/components/table/jobs_table_empty_state_spec.js new file mode 100644 index 00000000000..05b066a9edc --- /dev/null +++ b/spec/frontend/jobs/components/table/jobs_table_empty_state_spec.js @@ -0,0 +1,37 @@ +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import JobsTableEmptyState from '~/jobs/components/table/jobs_table_empty_state.vue'; + +describe('Jobs table empty state', () => { + let wrapper; + + const pipelineEditorPath = '/root/project/-/ci/editor'; + const emptyStateSvgPath = 'assets/jobs-empty-state.svg'; + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + const createComponent = () => { + wrapper = shallowMount(JobsTableEmptyState, { + provide: { + pipelineEditorPath, + emptyStateSvgPath, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('displays empty state', () => { + expect(findEmptyState().exists()).toBe(true); + }); + + it('links to the pipeline editor', () => { + expect(findEmptyState().props('primaryButtonLink')).toBe(pipelineEditorPath); + }); + + it('shows an empty state image', () => { + expect(findEmptyState().props('svgPath')).toBe(emptyStateSvgPath); + }); +}); diff --git a/spec/frontend/jobs/components/table/jobs_table_spec.js b/spec/frontend/jobs/components/table/jobs_table_spec.js index db057efbfb4..ac8bef675f8 100644 --- a/spec/frontend/jobs/components/table/jobs_table_spec.js +++ b/spec/frontend/jobs/components/table/jobs_table_spec.js @@ -1,20 +1,29 @@ import { GlTable } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import JobsTable from '~/jobs/components/table/jobs_table.vue'; +import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; import { mockJobsInTable } from '../../mock_data'; describe('Jobs Table', () => { let wrapper; const findTable = () => wrapper.findComponent(GlTable); + const findStatusBadge = () => wrapper.findComponent(CiBadge); + const findTableRows = () => wrapper.findAllByTestId('jobs-table-row'); + const findJobStage = () => wrapper.findByTestId('job-stage-name'); + const findJobName = () => wrapper.findByTestId('job-name'); + const findAllCoverageJobs = () => wrapper.findAllByTestId('job-coverage'); const createComponent = (props = {}) => { - wrapper = shallowMount(JobsTable, { - propsData: { - jobs: mockJobsInTable, - ...props, - }, - }); + wrapper = extendedWrapper( + mount(JobsTable, { + propsData: { + jobs: mockJobsInTable, + ...props, + }, + }), + ); }; beforeEach(() => { @@ -25,7 +34,31 @@ describe('Jobs Table', () => { wrapper.destroy(); }); - it('displays a table', () => { + it('displays the jobs table', () => { expect(findTable().exists()).toBe(true); }); + + it('displays correct number of job rows', () => { + expect(findTableRows()).toHaveLength(mockJobsInTable.length); + }); + + it('displays job status', () => { + expect(findStatusBadge().exists()).toBe(true); + }); + + it('displays the job stage and name', () => { + const firstJob = mockJobsInTable[0]; + + expect(findJobStage().text()).toBe(firstJob.stage.name); + expect(findJobName().text()).toBe(firstJob.name); + }); + + it('displays the coverage for only jobs that have coverage', () => { + const jobsThatHaveCoverage = mockJobsInTable.filter((job) => job.coverage !== null); + + jobsThatHaveCoverage.forEach((job, index) => { + expect(findAllCoverageJobs().at(index).text()).toBe(`${job.coverage}%`); + }); + expect(findAllCoverageJobs()).toHaveLength(jobsThatHaveCoverage.length); + }); }); diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js index 1432c6d7e9b..57f0b852ff8 100644 --- a/spec/frontend/jobs/mock_data.js +++ b/spec/frontend/jobs/mock_data.js @@ -920,6 +920,7 @@ export default { cancel_path: '/root/ci-mock/-/jobs/4757/cancel', new_issue_path: '/root/ci-mock/issues/new', playable: false, + complete: true, created_at: threeWeeksAgo.toISOString(), updated_at: threeWeeksAgo.toISOString(), finished_at: threeWeeksAgo.toISOString(), @@ -1237,8 +1238,8 @@ export const mockPipelineWithAttachedMR = { title: 'Update README.md', source_branch: 'feature-1234', source_branch_path: '/root/detached-merge-request-pipelines/branches/feature-1234', - target_branch: 'master', - target_branch_path: '/root/detached-merge-request-pipelines/branches/master', + target_branch: 'main', + target_branch_path: '/root/detached-merge-request-pipelines/branches/main', }, ref: { name: 'test-branch', @@ -1269,8 +1270,8 @@ export const mockPipelineDetached = { title: 'Update README.md', source_branch: 'feature-1234', source_branch_path: '/root/detached-merge-request-pipelines/branches/feature-1234', - target_branch: 'master', - target_branch_path: '/root/detached-merge-request-pipelines/branches/master', + target_branch: 'main', + target_branch_path: '/root/detached-merge-request-pipelines/branches/main', }, ref: { name: 'test-branch', @@ -1292,11 +1293,12 @@ export const mockJobsInTable = [ title: 'Play', __typename: 'StatusAction', }, + detailsPath: '/root/ci-project/-/jobs/2004', __typename: 'DetailedStatus', }, id: 'gid://gitlab/Ci::Build/2004', - refName: 'master', - refPath: '/root/ci-project/-/commits/master', + refName: 'main', + refPath: '/root/ci-project/-/commits/main', tags: [], shortSha: '2d5d8323', commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe', @@ -1316,10 +1318,13 @@ export const mockJobsInTable = [ duration: null, finishedAt: null, coverage: null, + createdByTag: false, retryable: false, playable: true, cancelable: false, active: false, + stuck: false, + userPermissions: { readBuild: true, __typename: 'JobPermissions' }, __typename: 'CiJob', }, { @@ -1332,8 +1337,8 @@ export const mockJobsInTable = [ __typename: 'DetailedStatus', }, id: 'gid://gitlab/Ci::Build/2021', - refName: 'master', - refPath: '/root/ci-project/-/commits/master', + refName: 'main', + refPath: '/root/ci-project/-/commits/main', tags: [], shortSha: '2d5d8323', commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe', @@ -1353,10 +1358,13 @@ export const mockJobsInTable = [ duration: null, finishedAt: null, coverage: null, + createdByTag: true, retryable: false, playable: false, cancelable: false, active: false, + stuck: false, + userPermissions: { readBuild: true, __typename: 'JobPermissions' }, __typename: 'CiJob', }, { @@ -1376,8 +1384,8 @@ export const mockJobsInTable = [ __typename: 'DetailedStatus', }, id: 'gid://gitlab/Ci::Build/2015', - refName: 'master', - refPath: '/root/ci-project/-/commits/master', + refName: 'main', + refPath: '/root/ci-project/-/commits/main', tags: [], shortSha: '2d5d8323', commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe', @@ -1396,11 +1404,172 @@ export const mockJobsInTable = [ name: 'artifact_job', duration: 2, finishedAt: '2021-04-01T17:36:18Z', - coverage: null, + coverage: 82.71, + createdByTag: false, retryable: true, playable: false, cancelable: false, active: false, + stuck: false, + userPermissions: { readBuild: false, __typename: 'JobPermissions' }, + __typename: 'CiJob', + }, + { + artifacts: { nodes: [], __typename: 'CiJobArtifactConnection' }, + allowFailure: false, + status: 'PENDING', + scheduledAt: null, + manualJob: false, + triggered: null, + createdByTag: false, + detailedStatus: { + detailsPath: '/root/ci-project/-/jobs/2391', + group: 'pending', + icon: 'status_pending', + label: 'pending', + text: 'pending', + tooltip: 'pending', + action: { + buttonTitle: 'Cancel this job', + icon: 'cancel', + method: 'post', + path: '/root/ci-project/-/jobs/2391/cancel', + title: 'Cancel', + __typename: 'StatusAction', + }, + __typename: 'DetailedStatus', + }, + id: 'gid://gitlab/Ci::Build/2391', + refName: 'master', + refPath: '/root/ci-project/-/commits/master', + tags: [], + shortSha: '916330b4', + commitPath: '/root/ci-project/-/commit/916330b4fda5dae226524ceb51c756c0ed26679d', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/482', + path: '/root/ci-project/-/pipelines/482', + user: { + webPath: '/root', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + __typename: 'UserCore', + }, + __typename: 'Pipeline', + }, + stage: { name: 'build', __typename: 'CiStage' }, + name: 'build_job', + duration: null, + finishedAt: null, + coverage: null, + retryable: false, + playable: false, + cancelable: true, + active: true, + stuck: true, + userPermissions: { readBuild: true, __typename: 'JobPermissions' }, __typename: 'CiJob', }, ]; + +export const mockJobsQueryResponse = { + data: { + project: { + jobs: { + pageInfo: { + endCursor: 'eyJpZCI6IjIzMTcifQ', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'eyJpZCI6IjIzMzYifQ', + __typename: 'PageInfo', + }, + nodes: [ + { + artifacts: { + nodes: [ + { + downloadPath: '/root/ci-project/-/jobs/2336/artifacts/download?file_type=trace', + __typename: 'CiJobArtifact', + }, + { + downloadPath: + '/root/ci-project/-/jobs/2336/artifacts/download?file_type=metadata', + __typename: 'CiJobArtifact', + }, + { + downloadPath: '/root/ci-project/-/jobs/2336/artifacts/download?file_type=archive', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + allowFailure: false, + status: 'SUCCESS', + scheduledAt: null, + manualJob: false, + triggered: null, + createdByTag: false, + detailedStatus: { + detailsPath: '/root/ci-project/-/jobs/2336', + group: 'success', + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed', + action: { + buttonTitle: 'Retry this job', + icon: 'retry', + method: 'post', + path: '/root/ci-project/-/jobs/2336/retry', + title: 'Retry', + __typename: 'StatusAction', + }, + __typename: 'DetailedStatus', + }, + id: 'gid://gitlab/Ci::Build/2336', + refName: 'main', + refPath: '/root/ci-project/-/commits/main', + tags: [], + shortSha: '4408fa2a', + commitPath: '/root/ci-project/-/commit/4408fa2a27aaadfdf42d8dda3d6a9c01ce6cad78', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/473', + path: '/root/ci-project/-/pipelines/473', + user: { + webPath: '/root', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + __typename: 'UserCore', + }, + __typename: 'Pipeline', + }, + stage: { + name: 'deploy', + __typename: 'CiStage', + }, + name: 'artifact_job', + duration: 3, + finishedAt: '2021-04-29T14:19:50Z', + coverage: null, + retryable: true, + playable: false, + cancelable: false, + active: false, + stuck: false, + userPermissions: { readBuild: true, __typename: 'JobPermissions' }, + __typename: 'CiJob', + }, + ], + __typename: 'CiJobConnection', + }, + __typename: 'Project', + }, + }, +}; + +export const mockJobsQueryEmptyResponse = { + data: { + project: { + jobs: [], + }, + }, +}; diff --git a/spec/frontend/learn_gitlab/track_learn_gitlab_spec.js b/spec/frontend/learn_gitlab/track_learn_gitlab_spec.js new file mode 100644 index 00000000000..3fb38a74c70 --- /dev/null +++ b/spec/frontend/learn_gitlab/track_learn_gitlab_spec.js @@ -0,0 +1,21 @@ +import { mockTracking } from 'helpers/tracking_helper'; +import trackLearnGitlab from '~/learn_gitlab/track_learn_gitlab'; + +describe('trackTrialUserErrors', () => { + let spy; + + describe('when an error is present', () => { + beforeEach(() => { + spy = mockTracking('projects:learn_gitlab_index', document.body, jest.spyOn); + }); + + it('tracks the error message', () => { + trackLearnGitlab(); + + expect(spy).toHaveBeenCalledWith('projects:learn_gitlab:index', 'page_init', { + label: 'learn_gitlab', + property: 'Growth::Activation::Experiment::LearnGitLabB', + }); + }); + }); +}); diff --git a/spec/frontend/lib/utils/number_utility_spec.js b/spec/frontend/lib/utils/number_utility_spec.js index 4dcd9211697..f4483f5098b 100644 --- a/spec/frontend/lib/utils/number_utility_spec.js +++ b/spec/frontend/lib/utils/number_utility_spec.js @@ -10,6 +10,7 @@ import { changeInPercent, formattedChangeInPercent, isNumeric, + isPositiveInteger, } from '~/lib/utils/number_utils'; describe('Number Utils', () => { @@ -184,4 +185,29 @@ describe('Number Utils', () => { expect(isNumeric(value)).toBe(outcome); }); }); + + describe.each` + value | outcome + ${0} | ${true} + ${'0'} | ${true} + ${12345} | ${true} + ${'12345'} | ${true} + ${-1} | ${false} + ${'-1'} | ${false} + ${1.01} | ${false} + ${'1.01'} | ${false} + ${'abcd'} | ${false} + ${'100abcd'} | ${false} + ${'abcd100'} | ${false} + ${''} | ${false} + ${false} | ${false} + ${true} | ${false} + ${undefined} | ${false} + ${null} | ${false} + ${Infinity} | ${false} + `('isPositiveInteger', ({ value, outcome }) => { + it(`when called with ${typeof value} ${value} it returns ${outcome}`, () => { + expect(isPositiveInteger(value)).toBe(outcome); + }); + }); }); diff --git a/spec/frontend/lib/utils/recurrence_spec.js b/spec/frontend/lib/utils/recurrence_spec.js new file mode 100644 index 00000000000..fc22529dffc --- /dev/null +++ b/spec/frontend/lib/utils/recurrence_spec.js @@ -0,0 +1,333 @@ +import { create, free, recall } from '~/lib/utils/recurrence'; + +const HEX = /[a-f0-9]/i; +const HEX_RE = HEX.source; +const UUIDV4 = new RegExp( + `${HEX_RE}{8}-${HEX_RE}{4}-4${HEX_RE}{3}-[89ab]${HEX_RE}{3}-${HEX_RE}{12}`, + 'i', +); + +describe('recurrence', () => { + let recurInstance; + let id; + + beforeEach(() => { + recurInstance = create(); + id = recurInstance.id; + }); + + afterEach(() => { + id = null; + recurInstance.free(); + }); + + describe('create', () => { + it('returns an object with the correct external api', () => { + expect(recurInstance).toMatchObject( + expect.objectContaining({ + id: expect.stringMatching(UUIDV4), + count: 0, + handlers: {}, + free: expect.any(Function), + handle: expect.any(Function), + eject: expect.any(Function), + occur: expect.any(Function), + reset: expect.any(Function), + }), + ); + }); + }); + + describe('recall', () => { + it('returns a previously created RecurInstance', () => { + expect(recall(id).id).toBe(id); + }); + + it("returns undefined if the provided UUID doesn't refer to a stored RecurInstance", () => { + expect(recall('1234')).toBeUndefined(); + }); + }); + + describe('free', () => { + it('returns true when the RecurInstance exists', () => { + expect(free(id)).toBe(true); + }); + + it("returns false when the ID doesn't refer to a known RecurInstance", () => { + expect(free('1234')).toBe(false); + }); + + it('removes the correct RecurInstance from the list of references', () => { + const anotherInstance = create(); + + expect(recall(id)).toEqual(recurInstance); + expect(recall(anotherInstance.id)).toEqual(anotherInstance); + + free(id); + + expect(recall(id)).toBeUndefined(); + expect(recall(anotherInstance.id)).toEqual(anotherInstance); + + anotherInstance.free(); + }); + }); + + describe('RecurInstance (`create()` return value)', () => { + it.each` + property | value | alias + ${'id'} | ${expect.stringMatching(UUIDV4)} | ${'[a string matching the UUIDv4 specification]'} + ${'count'} | ${0} | ${0} + ${'handlers'} | ${{}} | ${{}} + `( + 'has the correct primitive value $alias for the member `$property` to start', + ({ property, value }) => { + expect(recurInstance[property]).toEqual(value); + }, + ); + + describe('id', () => { + it('cannot be changed manually', () => { + expect(() => { + recurInstance.id = 'new-id'; + }).toThrow(TypeError); + + expect(recurInstance.id).toBe(id); + }); + + it.each` + method + ${'free'} + ${'handle'} + ${'eject'} + ${'occur'} + ${'reset'} + `('does not change across any method call - like after `$method`', ({ method }) => { + recurInstance[method](); + + expect(recurInstance.id).toBe(id); + }); + }); + + describe('count', () => { + it('cannot be changed manually', () => { + expect(() => { + recurInstance.count = 9999; + }).toThrow(TypeError); + + expect(recurInstance.count).toBe(0); + }); + + it.each` + method + ${'free'} + ${'handle'} + ${'eject'} + ${'reset'} + `("doesn't change in unexpected scenarios - like after a call to `$method`", ({ method }) => { + recurInstance[method](); + + expect(recurInstance.count).toBe(0); + }); + + it('increments by one each time `.occur()` is called', () => { + expect(recurInstance.count).toBe(0); + recurInstance.occur(); + expect(recurInstance.count).toBe(1); + recurInstance.occur(); + expect(recurInstance.count).toBe(2); + }); + }); + + describe('handlers', () => { + it('cannot be changed manually', () => { + const fn = jest.fn(); + + recurInstance.handle(1, fn); + expect(() => { + recurInstance.handlers = {}; + }).toThrow(TypeError); + + expect(recurInstance.handlers).toStrictEqual({ + 1: fn, + }); + }); + + it.each` + method + ${'free'} + ${'occur'} + ${'eject'} + ${'reset'} + `("doesn't change in unexpected scenarios - like after a call to `$method`", ({ method }) => { + recurInstance[method](); + + expect(recurInstance.handlers).toEqual({}); + }); + + it('adds handlers to the correct slots', () => { + const fn1 = jest.fn(); + const fn2 = jest.fn(); + + recurInstance.handle(100, fn1); + recurInstance.handle(1000, fn2); + + expect(recurInstance.handlers).toMatchObject({ + 100: fn1, + 1000: fn2, + }); + }); + }); + + describe('free', () => { + it('removes itself from recallable memory', () => { + expect(recall(id)).toEqual(recurInstance); + + recurInstance.free(); + + expect(recall(id)).toBeUndefined(); + }); + }); + + describe('handle', () => { + it('adds a handler for the provided count', () => { + const fn = jest.fn(); + + recurInstance.handle(5, fn); + + expect(recurInstance.handlers[5]).toEqual(fn); + }); + + it("doesn't add any handlers if either the count or behavior aren't provided", () => { + const fn = jest.fn(); + + recurInstance.handle(null, fn); + // Note that it's not possible to react to something not happening (without timers) + recurInstance.handle(0, fn); + recurInstance.handle(5, null); + + expect(recurInstance.handlers).toEqual({}); + }); + }); + + describe('eject', () => { + it('removes the handler assigned to the particular count slot', () => { + recurInstance.handle(1, jest.fn()); + + expect(recurInstance.handlers[1]).toBeTruthy(); + + recurInstance.eject(1); + + expect(recurInstance.handlers).toEqual({}); + }); + + it("succeeds (or fails gracefully) when the count provided doesn't have a handler assigned", () => { + recurInstance.eject('abc'); + recurInstance.eject(1); + + expect(recurInstance.handlers).toEqual({}); + }); + + it('makes no changes if no count is provided', () => { + const fn = jest.fn(); + + recurInstance.handle(1, fn); + + recurInstance.eject(); + + expect(recurInstance.handlers[1]).toStrictEqual(fn); + }); + }); + + describe('occur', () => { + it('increments the .count property by 1', () => { + expect(recurInstance.count).toBe(0); + + recurInstance.occur(); + + expect(recurInstance.count).toBe(1); + }); + + it('calls the appropriate handlers', () => { + const fn1 = jest.fn(); + const fn5 = jest.fn(); + const fn10 = jest.fn(); + + recurInstance.handle(1, fn1); + recurInstance.handle(5, fn5); + recurInstance.handle(10, fn10); + + expect(fn1).not.toHaveBeenCalled(); + expect(fn5).not.toHaveBeenCalled(); + expect(fn10).not.toHaveBeenCalled(); + + recurInstance.occur(); + + expect(fn1).toHaveBeenCalledTimes(1); + expect(fn5).not.toHaveBeenCalled(); + expect(fn10).not.toHaveBeenCalled(); + + recurInstance.occur(); + recurInstance.occur(); + recurInstance.occur(); + recurInstance.occur(); + + expect(fn1).toHaveBeenCalledTimes(1); + expect(fn5).toHaveBeenCalledTimes(1); + expect(fn10).not.toHaveBeenCalled(); + + recurInstance.occur(); + recurInstance.occur(); + recurInstance.occur(); + recurInstance.occur(); + recurInstance.occur(); + + expect(fn1).toHaveBeenCalledTimes(1); + expect(fn5).toHaveBeenCalledTimes(1); + expect(fn10).toHaveBeenCalledTimes(1); + }); + }); + + describe('reset', () => { + it('resets the count only, by default', () => { + const fn = jest.fn(); + + recurInstance.handle(3, fn); + recurInstance.occur(); + recurInstance.occur(); + + expect(recurInstance.count).toBe(2); + + recurInstance.reset(); + + expect(recurInstance.count).toBe(0); + expect(recurInstance.handlers).toEqual({ 3: fn }); + }); + + it('also resets the handlers, by specific request', () => { + const fn = jest.fn(); + + recurInstance.handle(3, fn); + recurInstance.occur(); + recurInstance.occur(); + + expect(recurInstance.count).toBe(2); + + recurInstance.reset({ handlersList: true }); + + expect(recurInstance.count).toBe(0); + expect(recurInstance.handlers).toEqual({}); + }); + + it('leaves the count in place, by request', () => { + recurInstance.occur(); + recurInstance.occur(); + + expect(recurInstance.count).toBe(2); + + recurInstance.reset({ currentCount: false }); + + expect(recurInstance.count).toBe(2); + }); + }); + }); +}); diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js index b538257fac0..cad500039c0 100644 --- a/spec/frontend/lib/utils/text_markdown_spec.js +++ b/spec/frontend/lib/utils/text_markdown_spec.js @@ -51,6 +51,25 @@ describe('init markdown', () => { expect(textArea.value).toEqual(`${initialValue}- `); }); + it('inserts dollar signs correctly', () => { + const initialValue = ''; + + textArea.value = initialValue; + textArea.selectionStart = 0; + textArea.selectionEnd = 0; + + insertMarkdownText({ + textArea, + text: textArea.value, + tag: '```suggestion:-0+0\n{text}\n```', + blockTag: true, + selected: '# Does not parse the `$` currently.', + wrap: false, + }); + + expect(textArea.value).toContain('# Does not parse the `$` currently.'); + }); + it('inserts the tag on a new line if the current one is not empty', () => { const initialValue = 'some text'; diff --git a/spec/frontend/diffs/utils/uuids_spec.js b/spec/frontend/lib/utils/uuids_spec.js index 8d0a01e8cbd..a7770d37566 100644 --- a/spec/frontend/diffs/utils/uuids_spec.js +++ b/spec/frontend/lib/utils/uuids_spec.js @@ -1,4 +1,4 @@ -import { uuids } from '~/diffs/utils/uuids'; +import { uuids } from '~/lib/utils/uuids'; const HEX = /[a-f0-9]/i; const HEX_RE = HEX.source; diff --git a/spec/frontend/lib/utils/vuex_module_mappers_spec.js b/spec/frontend/lib/utils/vuex_module_mappers_spec.js new file mode 100644 index 00000000000..d7e51e4daca --- /dev/null +++ b/spec/frontend/lib/utils/vuex_module_mappers_spec.js @@ -0,0 +1,138 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { + mapVuexModuleActions, + mapVuexModuleGetters, + mapVuexModuleState, + REQUIRE_STRING_ERROR_MESSAGE, +} from '~/lib/utils/vuex_module_mappers'; + +const TEST_MODULE_NAME = 'testModuleName'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +// setup test component and store ---------------------------------------------- +// +// These are used to indirectly test `vuex_module_mappers`. +const TestComponent = Vue.extend({ + props: { + vuexModule: { + type: String, + required: true, + }, + }, + computed: { + ...mapVuexModuleState((vm) => vm.vuexModule, { name: 'name', value: 'count' }), + ...mapVuexModuleGetters((vm) => vm.vuexModule, ['hasValue', 'hasName']), + stateJson() { + return JSON.stringify({ + name: this.name, + value: this.value, + }); + }, + gettersJson() { + return JSON.stringify({ + hasValue: this.hasValue, + hasName: this.hasName, + }); + }, + }, + methods: { + ...mapVuexModuleActions((vm) => vm.vuexModule, ['increment']), + }, + template: ` +<div> + <pre data-testid="state">{{ stateJson }}</pre> + <pre data-testid="getters">{{ gettersJson }}</pre> +</div>`, +}); + +const createTestStore = () => { + return new Vuex.Store({ + modules: { + [TEST_MODULE_NAME]: { + namespaced: true, + state: { + name: 'Lorem', + count: 0, + }, + mutations: { + INCREMENT: (state, amount) => { + state.count += amount; + }, + }, + actions: { + increment({ commit }, amount) { + commit('INCREMENT', amount); + }, + }, + getters: { + hasValue: (state) => state.count > 0, + hasName: (state) => Boolean(state.name.length), + }, + }, + }, + }); +}; + +describe('~/lib/utils/vuex_module_mappers', () => { + let store; + let wrapper; + + const getJsonInTemplate = (testId) => + JSON.parse(wrapper.find(`[data-testid="${testId}"]`).text()); + const getMappedState = () => getJsonInTemplate('state'); + const getMappedGetters = () => getJsonInTemplate('getters'); + + beforeEach(() => { + store = createTestStore(); + + wrapper = mount(TestComponent, { + propsData: { + vuexModule: TEST_MODULE_NAME, + }, + store, + localVue, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('from module defined by prop', () => { + it('maps state', () => { + expect(getMappedState()).toEqual({ + name: store.state[TEST_MODULE_NAME].name, + value: store.state[TEST_MODULE_NAME].count, + }); + }); + + it('maps getters', () => { + expect(getMappedGetters()).toEqual({ + hasName: true, + hasValue: false, + }); + }); + + it('maps action', () => { + jest.spyOn(store, 'dispatch'); + + expect(store.dispatch).not.toHaveBeenCalled(); + + wrapper.vm.increment(10); + + expect(store.dispatch).toHaveBeenCalledWith(`${TEST_MODULE_NAME}/increment`, 10); + }); + }); + + describe('with non-string object value', () => { + it('throws helpful error', () => { + expect(() => mapVuexModuleActions((vm) => vm.bogus, { foo: () => {} })).toThrowError( + REQUIRE_STRING_ERROR_MESSAGE, + ); + }); + }); +}); diff --git a/spec/frontend/logs/components/log_advanced_filters_spec.js b/spec/frontend/logs/components/log_advanced_filters_spec.js index 111542ff33e..4e4052eb4d8 100644 --- a/spec/frontend/logs/components/log_advanced_filters_spec.js +++ b/spec/frontend/logs/components/log_advanced_filters_spec.js @@ -4,6 +4,7 @@ import { convertToFixedRange } from '~/lib/utils/datetime_range'; import LogAdvancedFilters from '~/logs/components/log_advanced_filters.vue'; import { TOKEN_TYPE_POD_NAME } from '~/logs/constants'; import { createStore } from '~/logs/stores'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import { defaultTimeRange } from '~/vue_shared/constants'; import { mockPods, mockSearch } from '../mock_data'; @@ -77,7 +78,7 @@ describe('LogAdvancedFilters', () => { expect(getSearchToken(TOKEN_TYPE_POD_NAME)).toMatchObject({ title: 'Pod name', unique: true, - operators: [expect.objectContaining({ value: '=' })], + operators: OPERATOR_IS_ONLY, }); }); diff --git a/spec/frontend/logs/stores/actions_spec.js b/spec/frontend/logs/stores/actions_spec.js index 92c2f82af27..d5118bbde8c 100644 --- a/spec/frontend/logs/stores/actions_spec.js +++ b/spec/frontend/logs/stores/actions_spec.js @@ -191,7 +191,7 @@ describe('Logs Store actions', () => { }); it('should commit RECEIVE_ENVIRONMENTS_DATA_SUCCESS mutation on correct data', () => { - mock.onGet(mockEnvironmentsEndpoint).replyOnce(200, { environments: mockEnvironments }); + mock.onGet(mockEnvironmentsEndpoint).replyOnce(200, mockEnvironments); return testAction( fetchEnvironments, mockEnvironmentsEndpoint, diff --git a/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js index af5434f7068..5e04e20801a 100644 --- a/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js +++ b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js @@ -3,6 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import MembersFilteredSearchBar from '~/members/components/filter_sort/members_filtered_search_bar.vue'; import { MEMBER_TYPES } from '~/members/constants'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; const localVue = createLocalVue(); @@ -65,7 +66,7 @@ describe('MembersFilteredSearchBar', () => { title: '2FA', token: GlFilteredSearchToken, unique: true, - operators: [{ value: '=', description: 'is' }], + operators: OPERATOR_IS_ONLY, options: [ { value: 'enabled', title: 'Enabled' }, { value: 'disabled', title: 'Disabled' }, @@ -99,7 +100,7 @@ describe('MembersFilteredSearchBar', () => { title: 'Membership', token: GlFilteredSearchToken, unique: true, - operators: [{ value: '=', description: 'is' }], + operators: OPERATOR_IS_ONLY, options: [ { value: 'exclude', title: 'Direct' }, { value: 'only', title: 'Inherited' }, @@ -146,6 +147,21 @@ describe('MembersFilteredSearchBar', () => { }, ]); }); + + it('parses and passes search param with multiple words to `FilteredSearchBar` component as `initialFilterValue` prop', () => { + window.location.search = '?search=foo+bar+baz'; + + createComponent(); + + expect(findFilteredSearchBar().props('initialFilterValue')).toEqual([ + { + type: 'filtered-search-term', + value: { + data: 'foo bar baz', + }, + }, + ]); + }); }); describe('when filter bar is submitted', () => { @@ -175,6 +191,17 @@ describe('MembersFilteredSearchBar', () => { expect(window.location.href).toBe('https://localhost/?two_factor=enabled&search=foobar'); }); + it('adds search query param with multiple words', () => { + createComponent(); + + findFilteredSearchBar().vm.$emit('onFilter', [ + { type: 'two_factor', value: { data: 'enabled', operator: '=' } }, + { type: 'filtered-search-term', value: { data: 'foo bar baz' } }, + ]); + + expect(window.location.href).toBe('https://localhost/?two_factor=enabled&search=foo+bar+baz'); + }); + it('adds sort query param', () => { window.location.search = '?sort=name_asc'; diff --git a/spec/frontend/members/components/members_tabs_spec.js b/spec/frontend/members/components/members_tabs_spec.js new file mode 100644 index 00000000000..28614b52706 --- /dev/null +++ b/spec/frontend/members/components/members_tabs_spec.js @@ -0,0 +1,194 @@ +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import MembersApp from '~/members/components/app.vue'; +import MembersTabs from '~/members/components/members_tabs.vue'; +import { MEMBER_TYPES } from '~/members/constants'; +import { pagination } from '../mock_data'; + +describe('MembersApp', () => { + Vue.use(Vuex); + + let wrapper; + + const createComponent = ({ totalItems = 10, options = {} } = {}) => { + const store = new Vuex.Store({ + modules: { + [MEMBER_TYPES.user]: { + namespaced: true, + state: { + pagination: { + ...pagination, + totalItems, + }, + filteredSearchBar: { + searchParam: 'search', + }, + }, + }, + [MEMBER_TYPES.group]: { + namespaced: true, + state: { + pagination: { + ...pagination, + totalItems, + paramName: 'groups_page', + }, + filteredSearchBar: { + searchParam: 'search_groups', + }, + }, + }, + [MEMBER_TYPES.invite]: { + namespaced: true, + state: { + pagination: { + ...pagination, + totalItems, + paramName: 'invited_page', + }, + filteredSearchBar: { + searchParam: 'search_invited', + }, + }, + }, + [MEMBER_TYPES.accessRequest]: { + namespaced: true, + state: { + pagination: { + ...pagination, + totalItems, + paramName: 'access_requests_page', + }, + filteredSearchBar: { + searchParam: 'search_access_requests', + }, + }, + }, + }, + }); + + wrapper = mountExtended(MembersTabs, { + store, + stubs: ['members-app'], + provide: { + canManageMembers: true, + }, + ...options, + }); + + return nextTick(); + }; + + const findTabs = () => wrapper.findAllByRole('tab').wrappers; + const findTabByText = (text) => findTabs().find((tab) => tab.text().includes(text)); + const findActiveTab = () => wrapper.findByRole('tab', { selected: true }); + + beforeEach(() => { + delete window.location; + window.location = new URL('https://localhost'); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when tabs have a count', () => { + it('renders tabs with count', async () => { + await createComponent(); + + const tabs = findTabs(); + + expect(tabs[0].text()).toBe('Members 10'); + expect(tabs[1].text()).toBe('Groups 10'); + expect(tabs[2].text()).toBe('Invited 10'); + expect(tabs[3].text()).toBe('Access requests 10'); + expect(findActiveTab().text()).toContain('Members'); + }); + + it('renders `MembersApp` and passes `namespace` prop', async () => { + await createComponent(); + + const membersApps = wrapper.findAllComponents(MembersApp).wrappers; + + expect(membersApps[0].attributes('namespace')).toBe(MEMBER_TYPES.user); + expect(membersApps[1].attributes('namespace')).toBe(MEMBER_TYPES.group); + expect(membersApps[2].attributes('namespace')).toBe(MEMBER_TYPES.invite); + expect(membersApps[3].attributes('namespace')).toBe(MEMBER_TYPES.accessRequest); + }); + }); + + describe('when tabs do not have a count', () => { + it('only renders `Members` tab', async () => { + await createComponent({ totalItems: 0 }); + + expect(findTabByText('Members')).not.toBeUndefined(); + expect(findTabByText('Groups')).toBeUndefined(); + expect(findTabByText('Invited')).toBeUndefined(); + expect(findTabByText('Access requests')).toBeUndefined(); + }); + }); + + describe('when url param matches `filteredSearchBar.searchParam`', () => { + beforeEach(() => { + window.location.search = '?search_groups=foo+bar'; + }); + + const expectGroupsTabActive = () => { + expect(findActiveTab().text()).toContain('Groups'); + }; + + describe('when tab has a count', () => { + it('sets tab that corresponds to search param as active tab', async () => { + await createComponent(); + + expectGroupsTabActive(); + }); + }); + + describe('when tab does not have a count', () => { + it('sets tab that corresponds to search param as active tab', async () => { + await createComponent({ totalItems: 0 }); + + expectGroupsTabActive(); + }); + }); + }); + + describe('when url param matches `pagination.paramName`', () => { + beforeEach(() => { + window.location.search = '?invited_page=2'; + }); + + const expectInvitedTabActive = () => { + expect(findActiveTab().text()).toContain('Invited'); + }; + + describe('when tab has a count', () => { + it('sets tab that corresponds to pagination param as active tab', async () => { + await createComponent(); + + expectInvitedTabActive(); + }); + }); + + describe('when tab does not have a count', () => { + it('sets tab that corresponds to pagination param as active tab', async () => { + await createComponent({ totalItems: 0 }); + + expectInvitedTabActive(); + }); + }); + }); + + describe('when `canManageMembers` is `false`', () => { + it('shows all tabs except `Invited` and `Access requests`', async () => { + await createComponent({ options: { provide: { canManageMembers: false } } }); + + expect(findTabByText('Members')).not.toBeUndefined(); + expect(findTabByText('Groups')).not.toBeUndefined(); + expect(findTabByText('Invited')).toBeUndefined(); + expect(findTabByText('Access requests')).toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js index 5cf1f40a8f4..5308d7651a3 100644 --- a/spec/frontend/members/components/table/members_table_spec.js +++ b/spec/frontend/members/components/table/members_table_spec.js @@ -1,4 +1,4 @@ -import { GlBadge, GlTable } from '@gitlab/ui'; +import { GlBadge, GlPagination, GlTable } from '@gitlab/ui'; import { getByText as getByTextHelper, getByTestId as getByTestIdHelper, @@ -6,6 +6,7 @@ import { } from '@testing-library/dom'; import { mount, createLocalVue, createWrapper } from '@vue/test-utils'; import Vuex from 'vuex'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import CreatedAt from '~/members/components/table/created_at.vue'; import ExpirationDatepicker from '~/members/components/table/expiration_datepicker.vue'; import ExpiresAt from '~/members/components/table/expires_at.vue'; @@ -16,7 +17,13 @@ import MembersTable from '~/members/components/table/members_table.vue'; import RoleDropdown from '~/members/components/table/role_dropdown.vue'; import { MEMBER_TYPES } from '~/members/constants'; import * as initUserPopovers from '~/user_popovers'; -import { member as memberMock, directMember, invite, accessRequest } from '../../mock_data'; +import { + member as memberMock, + directMember, + invite, + accessRequest, + pagination, +} from '../../mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -36,6 +43,7 @@ describe('MembersTable', () => { table: { 'data-qa-selector': 'members_list' }, tr: { 'data-qa-selector': 'member_row' }, }, + pagination, ...state, }, }, @@ -66,6 +74,8 @@ describe('MembersTable', () => { }); }; + const url = 'https://localhost/foo-bar/-/project_members'; + const getByText = (text, options) => createWrapper(getByTextHelper(wrapper.element, text, options)); @@ -78,6 +88,14 @@ describe('MembersTable', () => { `[data-label="${tableCellLabel}"][role="cell"]`, ); + const findPagination = () => extendedWrapper(wrapper.find(GlPagination)); + + const expectCorrectLinkToPage2 = () => { + expect(findPagination().findByText('2', { selector: 'a' }).attributes('href')).toBe( + `${url}?page=2`, + ); + }; + afterEach(() => { wrapper.destroy(); wrapper = null; @@ -219,4 +237,80 @@ describe('MembersTable', () => { expect(findTable().find('tbody tr').attributes('data-qa-selector')).toBe('member_row'); }); + + describe('when required pagination data is provided', () => { + beforeEach(() => { + delete window.location; + }); + + it('renders `gl-pagination` component with correct props', () => { + window.location = new URL(url); + + createComponent(); + + const glPagination = findPagination(); + + expect(glPagination.exists()).toBe(true); + expect(glPagination.props()).toMatchObject({ + value: pagination.currentPage, + perPage: pagination.perPage, + totalItems: pagination.totalItems, + prevText: 'Prev', + nextText: 'Next', + labelNextPage: 'Go to next page', + labelPrevPage: 'Go to previous page', + align: 'center', + }); + }); + + it('uses `pagination.paramName` to generate the pagination links', () => { + window.location = new URL(url); + + createComponent({ + pagination: { + currentPage: 1, + perPage: 5, + totalItems: 10, + paramName: 'page', + }, + }); + + expectCorrectLinkToPage2(); + }); + + it('removes any url params defined as `null` in the `params` attribute', () => { + window.location = new URL(`${url}?search_groups=foo`); + + createComponent({ + pagination: { + currentPage: 1, + perPage: 5, + totalItems: 10, + paramName: 'page', + params: { search_groups: null }, + }, + }); + + expectCorrectLinkToPage2(); + }); + }); + + describe.each` + attribute | value + ${'paramName'} | ${null} + ${'currentPage'} | ${null} + ${'perPage'} | ${null} + ${'totalItems'} | ${0} + `('when pagination.$attribute is $value', ({ attribute, value }) => { + it('does not render `gl-pagination`', () => { + createComponent({ + pagination: { + ...pagination, + [attribute]: value, + }, + }); + + expect(findPagination().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/members/index_spec.js b/spec/frontend/members/index_spec.js index 8b645d9b059..b07534ae4ed 100644 --- a/spec/frontend/members/index_spec.js +++ b/spec/frontend/members/index_spec.js @@ -2,7 +2,7 @@ import { createWrapper } from '@vue/test-utils'; import MembersApp from '~/members/components/app.vue'; import { MEMBER_TYPES } from '~/members/constants'; import { initMembersApp } from '~/members/index'; -import { membersJsonString, members } from './mock_data'; +import { members, pagination, dataAttribute } from './mock_data'; describe('initMembersApp', () => { let el; @@ -23,10 +23,7 @@ describe('initMembersApp', () => { beforeEach(() => { el = document.createElement('div'); - el.setAttribute('data-members', membersJsonString); - el.setAttribute('data-source-id', '234'); - el.setAttribute('data-can-manage-members', 'true'); - el.setAttribute('data-member-path', '/groups/foo-bar/-/group_members/:id'); + el.setAttribute('data-members-data', dataAttribute); window.gon = { current_user_id: 123 }; }); @@ -50,6 +47,12 @@ describe('initMembersApp', () => { expect(vm.$store.state[MEMBER_TYPES.user].members).toEqual(members); }); + it('parses and sets `pagination` in Vuex store', () => { + setup(); + + expect(vm.$store.state[MEMBER_TYPES.user].pagination).toEqual(pagination); + }); + it('sets `tableFields` in Vuex store', () => { setup(); diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js index a47b7ab2118..d0a7c36349b 100644 --- a/spec/frontend/members/mock_data.js +++ b/spec/frontend/members/mock_data.js @@ -79,3 +79,28 @@ export const directMember = { ...member, isDirectMember: true }; export const inheritedMember = { ...member, isDirectMember: false }; export const member2faEnabled = { ...member, user: { ...member.user, twoFactorEnabled: true } }; + +export const paginationData = { + current_page: 1, + per_page: 5, + total_items: 10, + param_name: 'page', + params: { search_groups: null }, +}; + +export const pagination = { + currentPage: 1, + perPage: 5, + totalItems: 10, + paramName: 'page', + params: { search_groups: null }, +}; + +export const dataAttribute = JSON.stringify({ + members, + pagination: paginationData, + source_id: 234, + can_manage_members: true, + member_path: '/groups/foo-bar/-/group_members/:id', + ldap_override_path: '/groups/ldap-group/-/group_members/:id/override', +}); diff --git a/spec/frontend/members/utils_spec.js b/spec/frontend/members/utils_spec.js index bfb5a4bc7d3..72696979722 100644 --- a/spec/frontend/members/utils_spec.js +++ b/spec/frontend/members/utils_spec.js @@ -20,8 +20,9 @@ import { member2faEnabled, group, invite, - membersJsonString, members, + pagination, + dataAttribute, } from './mock_data'; const IS_CURRENT_USER_ID = 123; @@ -258,20 +259,20 @@ describe('Members Utils', () => { beforeEach(() => { el = document.createElement('div'); - el.setAttribute('data-members', membersJsonString); - el.setAttribute('data-source-id', '234'); - el.setAttribute('data-can-manage-members', 'true'); + el.setAttribute('data-members-data', dataAttribute); }); afterEach(() => { el = null; }); - it('correctly parses the data attributes', () => { - expect(parseDataAttributes(el)).toEqual({ + it('correctly parses the data attribute', () => { + expect(parseDataAttributes(el)).toMatchObject({ members, + pagination, sourceId: 234, canManageMembers: true, + memberPath: '/groups/foo-bar/-/group_members/:id', }); }); }); diff --git a/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js b/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js index eaa3b1c5d53..a09edb50f20 100644 --- a/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js +++ b/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js @@ -57,7 +57,7 @@ describe('Merge Conflict Resolver App', () => { const title = findConflictsCount(); expect(title.exists()).toBe(true); - expect(title.text().trim()).toBe('Showing 3 conflicts between test-conflicts and master'); + expect(title.text().trim()).toBe('Showing 3 conflicts between test-conflicts and main'); }); describe('files', () => { @@ -82,20 +82,20 @@ describe('Merge Conflict Resolver App', () => { const interactiveButton = findFileInteractiveButton(findFiles().at(0)); const inlineButton = findFileInlineButton(findFiles().at(0)); - expect(interactiveButton.classes('active')).toBe(true); - expect(inlineButton.classes('active')).toBe(false); + expect(interactiveButton.props('selected')).toBe(true); + expect(inlineButton.props('selected')).toBe(false); }); it('clicking inline set inline as default', async () => { mountComponent(); const inlineButton = findFileInlineButton(findFiles().at(0)); - expect(inlineButton.classes('active')).toBe(false); + expect(inlineButton.props('selected')).toBe(false); - inlineButton.trigger('click'); + inlineButton.vm.$emit('click'); await wrapper.vm.$nextTick(); - expect(inlineButton.classes('active')).toBe(true); + expect(inlineButton.props('selected')).toBe(true); }); it('inline mode shows a inline-conflict-lines', () => { @@ -110,7 +110,7 @@ describe('Merge Conflict Resolver App', () => { it('parallel mode shows a parallel-conflict-lines', async () => { mountComponent(); - findSideBySideButton().trigger('click'); + findSideBySideButton().vm.$emit('click'); await wrapper.vm.$nextTick(); const parallelConflictLinesComponent = findParallelConflictLines(findFiles().at(0)); diff --git a/spec/frontend/merge_conflicts/mock_data.js b/spec/frontend/merge_conflicts/mock_data.js index 8948f2a3c1e..69ba46dbe60 100644 --- a/spec/frontend/merge_conflicts/mock_data.js +++ b/spec/frontend/merge_conflicts/mock_data.js @@ -1,9 +1,9 @@ export const conflictsMock = { - target_branch: 'master', + target_branch: 'main', source_branch: 'test-conflicts', commit_sha: '6dbf385a3c7bf01e09b5d2d9e5d72f8fb8c590a3', commit_message: - "Merge branch 'master' into 'test-conflicts'\n\n# Conflicts:\n# .gitlab-ci.yml\n# README.md", + "Merge branch 'main' into 'test-conflicts'\n\n# Conflicts:\n# .gitlab-ci.yml\n# README.md", files: [ { old_path: '.gitlab-ci.yml', diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap index e873edaad3b..98503636d33 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -9,6 +9,8 @@ exports[`Dashboard template matches the default snapshot 1`] = ` metricsendpoint="/monitoring/monitor-project/-/environments/1/additional_metrics.json" prometheusstatus="" > + <alerts-deprecation-warning-stub /> + <div class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light" > diff --git a/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js index 400ac2e8f85..8af6075a416 100644 --- a/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js +++ b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js @@ -28,6 +28,7 @@ describe('dashboard invalid url parameters', () => { }, }, options, + provide: { hasManagedPrometheus: false }, }); }; diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index 5c7042d4cb5..0c2f85c7298 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -1,3 +1,4 @@ +import { GlAlert } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import VueDraggable from 'vuedraggable'; @@ -7,7 +8,6 @@ import axios from '~/lib/utils/axios_utils'; import { ESC_KEY } from '~/lib/utils/keys'; import { objectToQuery } from '~/lib/utils/url_utility'; import Dashboard from '~/monitoring/components/dashboard.vue'; - import DashboardHeader from '~/monitoring/components/dashboard_header.vue'; import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; import EmptyState from '~/monitoring/components/empty_state.vue'; @@ -17,6 +17,7 @@ import LinksSection from '~/monitoring/components/links_section.vue'; import { dashboardEmptyStates, metricStates } from '~/monitoring/constants'; import { createStore } from '~/monitoring/stores'; import * as types from '~/monitoring/stores/mutation_types'; +import AlertDeprecationWarning from '~/vue_shared/components/alerts_deprecation_warning.vue'; import { metricsDashboardViewModel, metricsDashboardPanelCount, @@ -46,6 +47,7 @@ describe('Dashboard', () => { stubs: { DashboardHeader, }, + provide: { hasManagedPrometheus: false }, ...options, }); }; @@ -59,6 +61,9 @@ describe('Dashboard', () => { 'dashboard-panel': true, 'dashboard-header': DashboardHeader, }, + provide: { + hasManagedPrometheus: false, + }, ...options, }); }; @@ -812,4 +817,25 @@ describe('Dashboard', () => { expect(dashboardPanel.exists()).toBe(true); }); }); + + describe('deprecation notice', () => { + beforeEach(() => { + setupStoreWithData(store); + }); + + const findDeprecationNotice = () => + wrapper.find(AlertDeprecationWarning).findComponent(GlAlert); + + it('shows the deprecation notice when available', () => { + createMountedWrapper({}, { provide: { hasManagedPrometheus: true } }); + + expect(findDeprecationNotice().exists()).toBe(true); + }); + + it('hides the deprecation notice when not available', () => { + createMountedWrapper(); + + expect(findDeprecationNotice().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/monitoring/components/dashboard_url_time_spec.js b/spec/frontend/monitoring/components/dashboard_url_time_spec.js index 9830b6d047f..090613b0f1e 100644 --- a/spec/frontend/monitoring/components/dashboard_url_time_spec.js +++ b/spec/frontend/monitoring/components/dashboard_url_time_spec.js @@ -31,6 +31,7 @@ describe('dashboard invalid url parameters', () => { store, stubs: { 'graph-group': true, 'dashboard-panel': true, 'dashboard-header': DashboardHeader }, ...options, + provide: { hasManagedPrometheus: false }, }); }; diff --git a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js index c9241834789..589354e7849 100644 --- a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js +++ b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js @@ -6,7 +6,7 @@ import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue' import { dashboardGitResponse } from '../mock_data'; -const defaultBranch = 'master'; +const defaultBranch = 'main'; const starredDashboards = dashboardGitResponse.filter(({ starred }) => starred); const notStarredDashboards = dashboardGitResponse.filter(({ starred }) => !starred); diff --git a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js index 51b4106d4b1..0dd3afd7c83 100644 --- a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js +++ b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js @@ -16,7 +16,7 @@ const createMountedWrapper = (props = {}) => { }; describe('DuplicateDashboardForm', () => { - const defaultBranch = 'master'; + const defaultBranch = 'main'; const findByRef = (ref) => wrapper.find({ ref }); const setValue = (ref, val) => { diff --git a/spec/frontend/monitoring/components/duplicate_dashboard_modal_spec.js b/spec/frontend/monitoring/components/duplicate_dashboard_modal_spec.js index 1bc89e509b5..7e7a7a66d77 100644 --- a/spec/frontend/monitoring/components/duplicate_dashboard_modal_spec.js +++ b/spec/frontend/monitoring/components/duplicate_dashboard_modal_spec.js @@ -37,7 +37,7 @@ describe('duplicate dashboard modal', () => { return shallowMount(DuplicateDashboardModal, { propsData: { - defaultBranch: 'master', + defaultBranch: 'main', modalId: 'id', }, store, diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js index 29a7c86491d..00be5868ba3 100644 --- a/spec/frontend/monitoring/mock_data.js +++ b/spec/frontend/monitoring/mock_data.js @@ -15,7 +15,7 @@ const customDashboardsData = new Array(30).fill(null).map((_, idx) => ({ can_edit: true, system_dashboard: false, out_of_the_box_dashboard: false, - project_blob_path: `${mockProjectDir}/blob/master/dashboards/.gitlab/dashboards/dashboard_${idx}.yml`, + project_blob_path: `${mockProjectDir}/blob/main/dashboards/.gitlab/dashboards/dashboard_${idx}.yml`, path: `.gitlab/dashboards/dashboard_${idx}.yml`, starred: false, })); @@ -32,7 +32,7 @@ export const anomalyDeploymentData = [ iid: 3, sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', ref: { - name: 'master', + name: 'main', }, created_at: '2019-08-19T22:00:00.000Z', deployed_at: '2019-08-19T22:01:00.000Z', @@ -44,7 +44,7 @@ export const anomalyDeploymentData = [ iid: 2, sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', ref: { - name: 'master', + name: 'main', }, created_at: '2019-08-19T23:00:00.000Z', deployed_at: '2019-08-19T23:00:00.000Z', @@ -61,7 +61,7 @@ export const deploymentData = [ commitUrl: 'http://test.host/frontend-fixtures/environments-project/-/commit/f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', ref: { - name: 'master', + name: 'main', }, created_at: '2019-07-16T10:14:25.589Z', tag: false, @@ -75,7 +75,7 @@ export const deploymentData = [ commitUrl: 'http://test.host/frontend-fixtures/environments-project/-/commit/f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', ref: { - name: 'master', + name: 'main', }, created_at: '2019-07-16T11:14:25.589Z', tag: false, @@ -187,7 +187,7 @@ export const dashboardGitResponse = [ can_edit: true, system_dashboard: false, out_of_the_box_dashboard: false, - project_blob_path: `${mockProjectDir}/-/blob/master/.gitlab/dashboards/dashboard.yml`, + project_blob_path: `${mockProjectDir}/-/blob/main/.gitlab/dashboards/dashboard.yml`, path: '.gitlab/dashboards/dashboard.yml', starred: true, user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=.gitlab/dashboards/dashboard.yml`, @@ -224,7 +224,7 @@ export const selfMonitoringDashboardGitResponse = [ can_edit: true, system_dashboard: false, out_of_the_box_dashboard: false, - project_blob_path: `${mockProjectDir}/-/blob/master/.gitlab/dashboards/dashboard.yml`, + project_blob_path: `${mockProjectDir}/-/blob/main/.gitlab/dashboards/dashboard.yml`, path: '.gitlab/dashboards/dashboard.yml', starred: true, user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=.gitlab/dashboards/dashboard.yml`, @@ -572,7 +572,7 @@ export const storeVariables = [ ]; export const dashboardHeaderProps = { - defaultBranch: 'master', + defaultBranch: 'main', isRearrangingPanels: false, selectedTimeRange: { start: '2020-01-01T00:00:00.000Z', @@ -581,7 +581,7 @@ export const dashboardHeaderProps = { }; export const dashboardActionsMenuProps = { - defaultBranch: 'master', + defaultBranch: 'main', addingMetricsAvailable: true, customMetricsPath: 'https://path/to/customMetrics', validateQueryPath: 'https://path/to/validateQuery', diff --git a/spec/frontend/monitoring/router_spec.js b/spec/frontend/monitoring/router_spec.js index b027d60f61e..2a712d4361f 100644 --- a/spec/frontend/monitoring/router_spec.js +++ b/spec/frontend/monitoring/router_spec.js @@ -20,6 +20,8 @@ const MockApp = { template: `<router-view :dashboard-props="dashboardProps"/>`, }; +const provide = { hasManagedPrometheus: false }; + describe('Monitoring router', () => { let router; let store; @@ -37,6 +39,7 @@ describe('Monitoring router', () => { localVue, store, router, + provide, }); }; diff --git a/spec/frontend/nav/components/top_nav_app_spec.js b/spec/frontend/nav/components/top_nav_app_spec.js new file mode 100644 index 00000000000..06700ce748e --- /dev/null +++ b/spec/frontend/nav/components/top_nav_app_spec.js @@ -0,0 +1,68 @@ +import { GlNavItemDropdown, GlTooltip } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; +import TopNavApp from '~/nav/components/top_nav_app.vue'; +import TopNavDropdownMenu from '~/nav/components/top_nav_dropdown_menu.vue'; +import { TEST_NAV_DATA } from '../mock_data'; + +describe('~/nav/components/top_nav_app.vue', () => { + let wrapper; + + const createComponent = (mountFn = shallowMount) => { + wrapper = mountFn(TopNavApp, { + propsData: { + navData: TEST_NAV_DATA, + }, + }); + }; + + const findNavItemDropdown = () => wrapper.findComponent(GlNavItemDropdown); + const findMenu = () => wrapper.findComponent(TopNavDropdownMenu); + const findTooltip = () => wrapper.findComponent(GlTooltip); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders nav item dropdown', () => { + expect(findNavItemDropdown().attributes('href')).toBeUndefined(); + expect(findNavItemDropdown().attributes()).toMatchObject({ + icon: 'dot-grid', + text: TEST_NAV_DATA.activeTitle, + 'no-flip': '', + }); + }); + + it('renders top nav dropdown menu', () => { + expect(findMenu().props()).toStrictEqual({ + primary: TEST_NAV_DATA.primary, + secondary: TEST_NAV_DATA.secondary, + views: TEST_NAV_DATA.views, + }); + }); + + it('renders tooltip', () => { + expect(findTooltip().attributes()).toMatchObject({ + 'boundary-padding': '0', + placement: 'right', + title: TopNavApp.TOOLTIP, + }); + }); + }); + + describe('when full mounted', () => { + beforeEach(() => { + createComponent(mount); + }); + + it('has dropdown toggle as tooltip target', () => { + const targetFn = findTooltip().props('target'); + + expect(targetFn()).toBe(wrapper.find('.js-top-nav-dropdown-toggle').element); + }); + }); +}); diff --git a/spec/frontend/nav/components/top_nav_container_view_spec.js b/spec/frontend/nav/components/top_nav_container_view_spec.js new file mode 100644 index 00000000000..b08d75f36ce --- /dev/null +++ b/spec/frontend/nav/components/top_nav_container_view_spec.js @@ -0,0 +1,114 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import FrequentItemsApp from '~/frequent_items/components/app.vue'; +import { FREQUENT_ITEMS_PROJECTS } from '~/frequent_items/constants'; +import eventHub from '~/frequent_items/event_hub'; +import TopNavContainerView from '~/nav/components/top_nav_container_view.vue'; +import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue'; +import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue'; +import { TEST_NAV_DATA } from '../mock_data'; + +const DEFAULT_PROPS = { + frequentItemsDropdownType: FREQUENT_ITEMS_PROJECTS.namespace, + frequentItemsVuexModule: FREQUENT_ITEMS_PROJECTS.vuexModule, + linksPrimary: TEST_NAV_DATA.primary, + linksSecondary: TEST_NAV_DATA.secondary, +}; +const TEST_OTHER_PROPS = { + namespace: 'projects', + currentUserName: '', + currentItem: {}, +}; + +describe('~/nav/components/top_nav_container_view.vue', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(TopNavContainerView, { + propsData: { + ...DEFAULT_PROPS, + ...TEST_OTHER_PROPS, + ...props, + }, + }); + }; + + const findMenuItems = (parent = wrapper) => parent.findAll(TopNavMenuItem); + const findMenuItemsModel = (parent = wrapper) => + findMenuItems(parent).wrappers.map((x) => x.props()); + const findMenuItemGroups = () => wrapper.findAll('[data-testid="menu-item-group"]'); + const findMenuItemGroupsModel = () => findMenuItemGroups().wrappers.map(findMenuItemsModel); + const findFrequentItemsApp = () => { + const parent = wrapper.findComponent(VuexModuleProvider); + + return { + vuexModule: parent.props('vuexModule'), + props: parent.findComponent(FrequentItemsApp).props(), + }; + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it.each(['projects', 'groups'])( + 'emits frequent items event to event hub (%s)', + async (frequentItemsDropdownType) => { + const listener = jest.fn(); + eventHub.$on(`${frequentItemsDropdownType}-dropdownOpen`, listener); + createComponent({ frequentItemsDropdownType }); + + expect(listener).not.toHaveBeenCalled(); + + await nextTick(); + + expect(listener).toHaveBeenCalled(); + }, + ); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders frequent items app', () => { + expect(findFrequentItemsApp()).toEqual({ + vuexModule: DEFAULT_PROPS.frequentItemsVuexModule, + props: TEST_OTHER_PROPS, + }); + }); + + it('renders menu item groups', () => { + expect(findMenuItemGroupsModel()).toEqual([ + TEST_NAV_DATA.primary.map((menuItem) => ({ menuItem })), + TEST_NAV_DATA.secondary.map((menuItem) => ({ menuItem })), + ]); + }); + + it('only the first group does not have margin top', () => { + expect(findMenuItemGroups().wrappers.map((x) => x.classes('gl-mt-3'))).toEqual([false, true]); + }); + + it('only the first menu item does not have margin top', () => { + const actual = findMenuItems(findMenuItemGroups().at(1)).wrappers.map((x) => + x.classes('gl-mt-1'), + ); + + expect(actual).toEqual([false, ...TEST_NAV_DATA.secondary.slice(1).fill(true)]); + }); + }); + + describe('without secondary links', () => { + beforeEach(() => { + createComponent({ + linksSecondary: [], + }); + }); + + it('renders one menu item group', () => { + expect(findMenuItemGroupsModel()).toEqual([ + TEST_NAV_DATA.primary.map((menuItem) => ({ menuItem })), + ]); + }); + }); +}); diff --git a/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js b/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js new file mode 100644 index 00000000000..d9bba22238a --- /dev/null +++ b/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js @@ -0,0 +1,157 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import TopNavDropdownMenu from '~/nav/components/top_nav_dropdown_menu.vue'; +import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue'; +import { TEST_NAV_DATA } from '../mock_data'; + +const SECONDARY_GROUP_CLASSES = TopNavDropdownMenu.SECONDARY_GROUP_CLASS.split(' '); + +describe('~/nav/components/top_nav_dropdown_menu.vue', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(TopNavDropdownMenu, { + propsData: { + primary: TEST_NAV_DATA.primary, + secondary: TEST_NAV_DATA.secondary, + views: TEST_NAV_DATA.views, + ...props, + }, + }); + }; + + const findMenuItems = (parent = wrapper) => parent.findAll('[data-testid="menu-item"]'); + const findMenuItemsModel = (parent = wrapper) => + findMenuItems(parent).wrappers.map((x) => ({ + menuItem: x.props('menuItem'), + isActive: x.classes('active'), + })); + const findMenuItemGroups = () => wrapper.findAll('[data-testid="menu-item-group"]'); + const findMenuItemGroupsModel = () => + findMenuItemGroups().wrappers.map((x) => ({ + classes: x.classes(), + items: findMenuItemsModel(x), + })); + const findMenuSidebar = () => wrapper.find('[data-testid="menu-sidebar"]'); + const findMenuSubview = () => wrapper.findComponent(KeepAliveSlots); + const hasFullWidthMenuSidebar = () => findMenuSidebar().classes('gl-w-full'); + + const createItemsGroupModelExpectation = ({ + primary = TEST_NAV_DATA.primary, + secondary = TEST_NAV_DATA.secondary, + activeIndex = -1, + } = {}) => [ + { + classes: [], + items: primary.map((menuItem, index) => ({ isActive: index === activeIndex, menuItem })), + }, + { + classes: SECONDARY_GROUP_CLASSES, + items: secondary.map((menuItem) => ({ isActive: false, menuItem })), + }, + ]; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders menu item groups', () => { + expect(findMenuItemGroupsModel()).toEqual(createItemsGroupModelExpectation()); + }); + + it('has full width menu sidebar', () => { + expect(hasFullWidthMenuSidebar()).toBe(true); + }); + + it('renders hidden subview with no slot key', () => { + const subview = findMenuSubview(); + + expect(subview.isVisible()).toBe(false); + expect(subview.props()).toEqual({ slotKey: '' }); + }); + + it('the first menu item in a group does not render margin top', () => { + const actual = findMenuItems(findMenuItemGroups().at(0)).wrappers.map((x) => + x.classes('gl-mt-1'), + ); + + expect(actual).toEqual([false, ...TEST_NAV_DATA.primary.slice(1).fill(true)]); + }); + }); + + describe('with pre-initialized active view', () => { + const primaryWithActive = [ + TEST_NAV_DATA.primary[0], + { + ...TEST_NAV_DATA.primary[1], + active: true, + }, + ...TEST_NAV_DATA.primary.slice(2), + ]; + + beforeEach(() => { + createComponent({ + primary: primaryWithActive, + }); + }); + + it('renders menu item groups', () => { + expect(findMenuItemGroupsModel()).toEqual( + createItemsGroupModelExpectation({ primary: primaryWithActive, activeIndex: 1 }), + ); + }); + + it('does not have full width menu sidebar', () => { + expect(hasFullWidthMenuSidebar()).toBe(false); + }); + + it('renders visible subview with slot key', () => { + const subview = findMenuSubview(); + + expect(subview.isVisible()).toBe(true); + expect(subview.props('slotKey')).toBe(primaryWithActive[1].view); + }); + + it('does not change view if non-view menu item is clicked', async () => { + const secondaryLink = findMenuItems().at(primaryWithActive.length); + + // Ensure this doesn't have a view + expect(secondaryLink.props('menuItem').view).toBeUndefined(); + + secondaryLink.vm.$emit('click'); + + await nextTick(); + + expect(findMenuSubview().props('slotKey')).toBe(primaryWithActive[1].view); + }); + + describe('when other view menu item is clicked', () => { + let primaryLink; + + beforeEach(async () => { + primaryLink = findMenuItems().at(0); + primaryLink.vm.$emit('click'); + await nextTick(); + }); + + it('clicked on link with view', () => { + expect(primaryLink.props('menuItem').view).toBeTruthy(); + }); + + it('changes active view', () => { + expect(findMenuSubview().props('slotKey')).toBe(primaryWithActive[0].view); + }); + + it('changes active status on menu item', () => { + expect(findMenuItemGroupsModel()).toStrictEqual( + createItemsGroupModelExpectation({ primary: primaryWithActive, activeIndex: 0 }), + ); + }); + }); + }); +}); diff --git a/spec/frontend/nav/components/top_nav_menu_item_spec.js b/spec/frontend/nav/components/top_nav_menu_item_spec.js new file mode 100644 index 00000000000..579af13d08a --- /dev/null +++ b/spec/frontend/nav/components/top_nav_menu_item_spec.js @@ -0,0 +1,74 @@ +import { GlButton, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue'; + +const TEST_MENU_ITEM = { + title: 'Cheeseburger', + icon: 'search', + href: '/pretty/good/burger', + view: 'burger-view', +}; + +describe('~/nav/components/top_nav_menu_item.vue', () => { + let listener; + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(TopNavMenuItem, { + propsData: { + menuItem: TEST_MENU_ITEM, + ...props, + }, + listeners: { + click: listener, + }, + }); + }; + + const findButton = () => wrapper.find(GlButton); + const findButtonIcons = () => + findButton() + .findAllComponents(GlIcon) + .wrappers.map((x) => x.props('name')); + + beforeEach(() => { + listener = jest.fn(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders button href and text', () => { + const button = findButton(); + + expect(button.attributes('href')).toBe(TEST_MENU_ITEM.href); + expect(button.text()).toBe(TEST_MENU_ITEM.title); + }); + + it('passes listeners to button', () => { + expect(listener).not.toHaveBeenCalled(); + + findButton().vm.$emit('click', 'TEST'); + + expect(listener).toHaveBeenCalledWith('TEST'); + }); + }); + + describe.each` + desc | menuItem | expectedIcons + ${'default'} | ${TEST_MENU_ITEM} | ${[TEST_MENU_ITEM.icon, 'chevron-right']} + ${'with no icon'} | ${{ ...TEST_MENU_ITEM, icon: null }} | ${['chevron-right']} + ${'with no view'} | ${{ ...TEST_MENU_ITEM, view: null }} | ${[TEST_MENU_ITEM.icon]} + ${'with no icon or view'} | ${{ ...TEST_MENU_ITEM, view: null, icon: null }} | ${[]} + `('$desc', ({ menuItem, expectedIcons }) => { + beforeEach(() => { + createComponent({ menuItem }); + }); + + it(`renders expected icons ${JSON.stringify(expectedIcons)}`, () => { + expect(findButtonIcons()).toEqual(expectedIcons); + }); + }); +}); diff --git a/spec/frontend/nav/mock_data.js b/spec/frontend/nav/mock_data.js new file mode 100644 index 00000000000..2987d8deb16 --- /dev/null +++ b/spec/frontend/nav/mock_data.js @@ -0,0 +1,35 @@ +import { range } from 'lodash'; + +export const TEST_NAV_DATA = { + activeTitle: 'Test Active Title', + primary: [ + ...['projects', 'groups'].map((view) => ({ + id: view, + href: null, + title: view, + view, + })), + ...range(0, 2).map((idx) => ({ + id: `primary-link-${idx}`, + href: `/path/to/primary/${idx}`, + title: `Title ${idx}`, + })), + ], + secondary: range(0, 2).map((idx) => ({ + id: `secondary-link-${idx}`, + href: `/path/to/secondary/${idx}`, + title: `SecTitle ${idx}`, + })), + views: { + projects: { + namespace: 'projects', + currentUserName: '', + currentItem: {}, + }, + groups: { + namespace: 'groups', + currentUserName: '', + currentItem: {}, + }, + }, +}; diff --git a/spec/frontend/notebook/cells/markdown_spec.js b/spec/frontend/notebook/cells/markdown_spec.js index 219d74595bd..d250ffed1a9 100644 --- a/spec/frontend/notebook/cells/markdown_spec.js +++ b/spec/frontend/notebook/cells/markdown_spec.js @@ -27,7 +27,7 @@ describe('Markdown component', () => { return vm.$nextTick(); }); - it('does not render promot', () => { + it('does not render prompt', () => { expect(vm.$el.querySelector('.prompt span')).toBeNull(); }); @@ -50,6 +50,41 @@ describe('Markdown component', () => { expect(vm.$el.querySelector('a').getAttribute('href')).toBeNull(); }); + describe('tables', () => { + beforeEach(() => { + json = getJSONFixture('blob/notebook/markdown-table.json'); + }); + + it('renders images and text', () => { + vm = new Component({ + propsData: { + cell: json.cells[0], + }, + }).$mount(); + + return vm.$nextTick().then(() => { + const images = vm.$el.querySelectorAll('img'); + expect(images.length).toBe(5); + + const columns = vm.$el.querySelectorAll('td'); + expect(columns.length).toBe(6); + + expect(columns[0].textContent).toEqual('Hello '); + expect(columns[1].textContent).toEqual('Test '); + expect(columns[2].textContent).toEqual('World '); + expect(columns[3].textContent).toEqual('Fake '); + expect(columns[4].textContent).toEqual('External image: '); + expect(columns[5].textContent).toEqual('Empty'); + + expect(columns[0].innerHTML).toContain('<img src="data:image/jpeg;base64'); + expect(columns[1].innerHTML).toContain('<img src="data:image/png;base64'); + expect(columns[2].innerHTML).toContain('<img src="data:image/jpeg;base64'); + expect(columns[3].innerHTML).toContain('<img>'); + expect(columns[4].innerHTML).toContain('<img src="https://www.google.com/'); + }); + }); + }); + describe('katex', () => { beforeEach(() => { json = getJSONFixture('blob/notebook/math.json'); diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index b717bab7c3f..b140eea9439 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -437,6 +437,7 @@ describe('issue_comment_form component', () => { await findCloseReopenButton().trigger('click'); await wrapper.vm.$nextTick; + await wrapper.vm.$nextTick; expect(flash).toHaveBeenCalledWith( `Something went wrong while closing the ${type}. Please try again later.`, @@ -472,6 +473,7 @@ describe('issue_comment_form component', () => { await findCloseReopenButton().trigger('click'); await wrapper.vm.$nextTick; + await wrapper.vm.$nextTick; expect(flash).toHaveBeenCalledWith( `Something went wrong while reopening the ${type}. Please try again later.`, @@ -489,6 +491,8 @@ describe('issue_comment_form component', () => { await findCloseReopenButton().trigger('click'); + await wrapper.vm.$nextTick(); + expect(refreshUserMergeRequestCounts).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/notes/old_notes_spec.js b/spec/frontend/notes/old_notes_spec.js index 432b660c4b3..0cf43b8fd97 100644 --- a/spec/frontend/notes/old_notes_spec.js +++ b/spec/frontend/notes/old_notes_spec.js @@ -28,7 +28,7 @@ window.gl = window.gl || {}; gl.utils = gl.utils || {}; gl.utils.disableButtonIfEmptyField = () => {}; -// the following test is unreliable and failing in master 2-3 times a day +// the following test is unreliable and failing in main 2-3 times a day // see https://gitlab.com/gitlab-org/gitlab/issues/206906#note_290602581 // eslint-disable-next-line jest/no-disabled-tests describe.skip('Old Notes (~/notes.js)', () => { diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index f972ff0d2e4..9b7456d54bc 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -253,85 +253,6 @@ describe('Actions Notes Store', () => { }); }); - describe('fetchData', () => { - describe('given there are no notes', () => { - const lastFetchedAt = '13579'; - - beforeEach(() => { - axiosMock - .onGet(notesDataMock.notesPath) - .replyOnce(200, { notes: [], last_fetched_at: lastFetchedAt }); - }); - - it('should commit SET_LAST_FETCHED_AT', () => - testAction( - actions.fetchData, - undefined, - { notesData: notesDataMock }, - [{ type: 'SET_LAST_FETCHED_AT', payload: lastFetchedAt }], - [], - )); - }); - - describe('given there are notes', () => { - const lastFetchedAt = '12358'; - - beforeEach(() => { - axiosMock - .onGet(notesDataMock.notesPath) - .replyOnce(200, { notes: discussionMock.notes, last_fetched_at: lastFetchedAt }); - }); - - it('should dispatch updateOrCreateNotes, startTaskList and commit SET_LAST_FETCHED_AT', () => - testAction( - actions.fetchData, - undefined, - { notesData: notesDataMock }, - [{ type: 'SET_LAST_FETCHED_AT', payload: lastFetchedAt }], - [ - { type: 'updateOrCreateNotes', payload: discussionMock.notes }, - { type: 'startTaskList' }, - { type: 'updateResolvableDiscussionsCounts' }, - ], - )); - }); - - describe('paginated notes feature flag enabled', () => { - const lastFetchedAt = '12358'; - - beforeEach(() => { - window.gon = { features: { paginatedNotes: true } }; - - axiosMock.onGet(notesDataMock.notesPath).replyOnce(200, { - notes: discussionMock.notes, - more: false, - last_fetched_at: lastFetchedAt, - }); - }); - - afterEach(() => { - window.gon = null; - }); - - it('should dispatch setFetchingState, setNotesFetchedState, setLoadingState, updateOrCreateNotes, startTaskList and commit SET_LAST_FETCHED_AT', () => { - return testAction( - actions.fetchData, - null, - { notesData: notesDataMock, isFetching: true }, - [{ type: 'SET_LAST_FETCHED_AT', payload: lastFetchedAt }], - [ - { type: 'setFetchingState', payload: false }, - { type: 'setNotesFetchedState', payload: true }, - { type: 'setLoadingState', payload: false }, - { type: 'updateOrCreateNotes', payload: discussionMock.notes }, - { type: 'startTaskList' }, - { type: 'updateResolvableDiscussionsCounts' }, - ], - ); - }); - }); - }); - describe('poll', () => { beforeEach((done) => { axiosMock 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 a6bb9e868ee..8a2793c0010 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,16 +1,16 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`MavenInstallation gradle renders all the messages 1`] = ` +exports[`MavenInstallation groovy renders all the messages 1`] = ` <div> <installation-title-stub - options="[object Object],[object Object]" + options="[object Object],[object Object],[object Object]" packagetype="maven" /> <code-instruction-stub class="gl-mb-5" copytext="Copy Gradle Groovy DSL install command" - instruction="foo/gradle/install" + instruction="foo/gradle/groovy/install" label="Gradle Groovy DSL install command" trackingaction="copy_gradle_install_command" trackinglabel="code_instruction" @@ -18,7 +18,7 @@ exports[`MavenInstallation gradle renders all the messages 1`] = ` <code-instruction-stub copytext="Copy add Gradle Groovy DSL repository command" - instruction="foo/gradle/add/source" + instruction="foo/gradle/groovy/add/source" label="Add Gradle Groovy DSL repository command" multiline="true" trackingaction="copy_gradle_add_to_source_command" @@ -27,10 +27,37 @@ exports[`MavenInstallation gradle renders all the messages 1`] = ` </div> `; +exports[`MavenInstallation kotlin renders all the messages 1`] = ` +<div> + <installation-title-stub + options="[object Object],[object Object],[object Object]" + packagetype="maven" + /> + + <code-instruction-stub + class="gl-mb-5" + copytext="Copy Gradle Kotlin DSL install command" + instruction="foo/gradle/kotlin/install" + label="Gradle Kotlin DSL install command" + trackingaction="copy_kotlin_install_command" + trackinglabel="code_instruction" + /> + + <code-instruction-stub + copytext="Copy add Gradle Kotlin DSL repository command" + instruction="foo/gradle/kotlin/add/source" + label="Add Gradle Kotlin DSL repository command" + multiline="true" + trackingaction="copy_kotlin_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]" + options="[object Object],[object Object],[object Object]" packagetype="maven" /> 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 6903d342d6a..015c7b94dde 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 @@ -3,26 +3,18 @@ exports[`NpmInstallation renders all the messages 1`] = ` <div> <installation-title-stub - options="[object Object]" + options="[object Object],[object Object]" packagetype="npm" /> <code-instruction-stub copytext="Copy npm command" instruction="npm i @Test/package" - label="npm command" + label="" trackingaction="copy_npm_install_command" trackinglabel="code_instruction" /> - <code-instruction-stub - copytext="Copy yarn command" - instruction="yarn add @Test/package" - label="yarn command" - trackingaction="copy_yarn_install_command" - trackinglabel="code_instruction" - /> - <h3 class="gl-font-lg" > @@ -32,19 +24,11 @@ exports[`NpmInstallation renders all the messages 1`] = ` <code-instruction-stub copytext="Copy npm setup command" instruction="echo @Test:registry=undefined/ >> .npmrc" - label="npm command" + label="" trackingaction="copy_npm_setup_command" trackinglabel="code_instruction" /> - <code-instruction-stub - copytext="Copy yarn setup command" - instruction="echo \\\\\\"@Test:registry\\\\\\" \\\\\\"undefined/\\\\\\" >> .yarnrc" - label="yarn command" - trackingaction="copy_yarn_setup_command" - trackinglabel="code_instruction" - /> - <gl-sprintf-stub message="You may also need to setup authentication using an auth token. %{linkStart}See the documentation%{linkEnd} to find out more." /> diff --git a/spec/frontend/packages/details/components/maven_installation_spec.js b/spec/frontend/packages/details/components/maven_installation_spec.js index d49a7c0b561..4972fe70a3d 100644 --- a/spec/frontend/packages/details/components/maven_installation_spec.js +++ b/spec/frontend/packages/details/components/maven_installation_spec.js @@ -17,8 +17,10 @@ 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 gradleGroovyInstallCommandText = 'foo/gradle/groovy/install'; + const gradleGroovyAddSourceCommandText = 'foo/gradle/groovy/add/source'; + const gradleKotlinInstallCommandText = 'foo/gradle/kotlin/install'; + const gradleKotlinAddSourceCommandText = 'foo/gradle/kotlin/add/source'; const store = new Vuex.Store({ state: { @@ -31,6 +33,8 @@ describe('MavenInstallation', () => { mavenSetupXml: () => mavenSetupXml, gradleGroovyInstalCommand: () => gradleGroovyInstallCommandText, gradleGroovyAddSourceCommand: () => gradleGroovyAddSourceCommandText, + gradleKotlinInstalCommand: () => gradleKotlinInstallCommandText, + gradleKotlinAddSourceCommand: () => gradleKotlinAddSourceCommandText, }, }); @@ -59,8 +63,9 @@ describe('MavenInstallation', () => { expect(findInstallationTitle().props()).toMatchObject({ packageType: 'maven', options: [ - { value: 'maven', label: 'Show Maven commands' }, - { value: 'groovy', label: 'Show Gradle Groovy DSL commands' }, + { value: 'maven', label: 'Maven XML' }, + { value: 'groovy', label: 'Gradle Groovy DSL' }, + { value: 'kotlin', label: 'Gradle Kotlin DSL' }, ], }); }); @@ -117,9 +122,9 @@ describe('MavenInstallation', () => { }); }); - describe('gradle', () => { + describe('groovy', () => { beforeEach(() => { - createComponent({ data: { instructionType: 'gradle' } }); + createComponent({ data: { instructionType: 'groovy' } }); }); it('renders all the messages', () => { @@ -146,4 +151,34 @@ describe('MavenInstallation', () => { }); }); }); + + describe('kotlin', () => { + beforeEach(() => { + createComponent({ data: { instructionType: 'kotlin' } }); + }); + + 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: gradleKotlinInstallCommandText, + multiline: false, + trackingAction: TrackingActions.COPY_KOTLIN_INSTALL_COMMAND, + }); + }); + }); + + describe('setup commands', () => { + it('renders the correct gradle command', () => { + expect(findCodeInstructions().at(1).props()).toMatchObject({ + instruction: gradleKotlinAddSourceCommandText, + multiline: true, + trackingAction: TrackingActions.COPY_KOTLIN_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 09afcd4fd0a..1c49110bdf8 100644 --- a/spec/frontend/packages/details/components/npm_installation_spec.js +++ b/spec/frontend/packages/details/components/npm_installation_spec.js @@ -1,4 +1,5 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; import Vuex from 'vuex'; import { registryUrl as nugetPath } from 'jest/packages/details/mock_data'; import { npmPackage as packageEntity } from 'jest/packages/mock_data'; @@ -14,10 +15,13 @@ localVue.use(Vuex); describe('NpmInstallation', () => { let wrapper; + const npmInstallationCommandLabel = 'npm i @Test/package'; + const yarnInstallationCommandLabel = 'yarn add @Test/package'; + const findCodeInstructions = () => wrapper.findAll(CodeInstructions); const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); - function createComponent() { + function createComponent({ data = {} } = {}) { const store = new Vuex.Store({ state: { packageEntity, @@ -32,6 +36,9 @@ describe('NpmInstallation', () => { wrapper = shallowMount(NpmInstallation, { localVue, store, + data() { + return data; + }, }); } @@ -52,40 +59,61 @@ describe('NpmInstallation', () => { expect(findInstallationTitle().exists()).toBe(true); expect(findInstallationTitle().props()).toMatchObject({ packageType: 'npm', - options: [{ value: 'npm', label: 'Show NPM commands' }], + options: [ + { value: 'npm', label: 'Show NPM commands' }, + { value: 'yarn', label: 'Show Yarn commands' }, + ], }); }); + + it('on change event updates the instructions to show', async () => { + createComponent(); + + expect(findCodeInstructions().at(0).props('instruction')).toBe(npmInstallationCommandLabel); + findInstallationTitle().vm.$emit('change', 'yarn'); + + await nextTick(); + + expect(findCodeInstructions().at(0).props('instruction')).toBe(yarnInstallationCommandLabel); + }); }); - describe('installation commands', () => { - it('renders the correct npm command', () => { + describe('npm', () => { + beforeEach(() => { + createComponent(); + }); + it('renders the correct installation command', () => { expect(findCodeInstructions().at(0).props()).toMatchObject({ - instruction: 'npm i @Test/package', + instruction: npmInstallationCommandLabel, multiline: false, trackingAction: TrackingActions.COPY_NPM_INSTALL_COMMAND, }); }); - it('renders the correct yarn command', () => { + it('renders the correct setup command', () => { expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: 'yarn add @Test/package', + instruction: 'echo @Test:registry=undefined/ >> .npmrc', multiline: false, - trackingAction: TrackingActions.COPY_YARN_INSTALL_COMMAND, + trackingAction: TrackingActions.COPY_NPM_SETUP_COMMAND, }); }); }); - describe('setup commands', () => { - it('renders the correct npm command', () => { - expect(findCodeInstructions().at(2).props()).toMatchObject({ - instruction: 'echo @Test:registry=undefined/ >> .npmrc', + describe('yarn', () => { + beforeEach(() => { + createComponent({ data: { instructionType: 'yarn' } }); + }); + + it('renders the correct setup command', () => { + expect(findCodeInstructions().at(0).props()).toMatchObject({ + instruction: yarnInstallationCommandLabel, multiline: false, - trackingAction: TrackingActions.COPY_NPM_SETUP_COMMAND, + trackingAction: TrackingActions.COPY_YARN_INSTALL_COMMAND, }); }); - it('renders the correct yarn command', () => { - expect(findCodeInstructions().at(3).props()).toMatchObject({ + it('renders the correct registry command', () => { + expect(findCodeInstructions().at(1).props()).toMatchObject({ instruction: 'echo \\"@Test:registry\\" \\"undefined/\\" >> .yarnrc', multiline: false, trackingAction: TrackingActions.COPY_YARN_SETUP_COMMAND, diff --git a/spec/frontend/packages/details/components/package_files_spec.js b/spec/frontend/packages/details/components/package_files_spec.js index b4e62bac8a3..bcf1b6d56f0 100644 --- a/spec/frontend/packages/details/components/package_files_spec.js +++ b/spec/frontend/packages/details/components/package_files_spec.js @@ -11,8 +11,10 @@ describe('Package Files', () => { const findAllRows = () => wrapper.findAll('[data-testid="file-row"'); const findFirstRow = () => findAllRows().at(0); + const findSecondRow = () => findAllRows().at(1); const findFirstRowDownloadLink = () => findFirstRow().find('[data-testid="download-link"'); const findFirstRowCommitLink = () => findFirstRow().find('[data-testid="commit-link"'); + const findSecondRowCommitLink = () => findSecondRow().find('[data-testid="commit-link"'); const findFirstRowFileIcon = () => findFirstRow().find(FileIcon); const findFirstRowCreatedAt = () => findFirstRow().find(TimeAgoTooltip); @@ -126,5 +128,14 @@ describe('Package Files', () => { expect(findFirstRowCommitLink().exists()).toBe(false); }); }); + + describe('when only one file lacks an associated pipeline', () => { + it('renders the commit when it exists and not otherwise', () => { + createComponent([npmFiles[0], mavenFiles[0]]); + + expect(findFirstRowCommitLink().exists()).toBe(true); + expect(findSecondRowCommitLink().exists()).toBe(false); + }); + }); }); }); diff --git a/spec/frontend/packages/details/store/getters_spec.js b/spec/frontend/packages/details/store/getters_spec.js index 005adece56e..8210511bf8f 100644 --- a/spec/frontend/packages/details/store/getters_spec.js +++ b/spec/frontend/packages/details/store/getters_spec.js @@ -19,6 +19,8 @@ import { groupExists, gradleGroovyInstalCommand, gradleGroovyAddSourceCommand, + gradleKotlinInstalCommand, + gradleKotlinAddSourceCommand, } from '~/packages/details/store/getters'; import { conanPackage, @@ -259,6 +261,24 @@ describe('Getters PackageDetails Store', () => { }); }); + describe('gradle kotlin string getters', () => { + it('gets the correct gradleKotlinInstalCommand', () => { + setupState(); + + expect(gradleKotlinInstalCommand(state)).toMatchInlineSnapshot( + `"implementation(\\"com.test.app:test-app:1.0-SNAPSHOT\\")"`, + ); + }); + + it('gets the correct gradleKotlinAddSourceCommand', () => { + setupState(); + + expect(gradleKotlinAddSourceCommand(state)).toMatchInlineSnapshot( + `"maven(\\"foo/registry\\")"`, + ); + }); + }); + describe('check if group', () => { it('is set', () => { setupState({ groupListUrl: '/groups/composer/-/packages' }); diff --git a/spec/frontend/packages/list/stores/actions_spec.js b/spec/frontend/packages/list/stores/actions_spec.js index b5b0177eb4e..52966c1be5e 100644 --- a/spec/frontend/packages/list/stores/actions_spec.js +++ b/spec/frontend/packages/list/stores/actions_spec.js @@ -121,6 +121,32 @@ describe('Actions Package list store', () => { }, ); }); + + it('should force the terraform_module type when forceTerraform is true', (done) => { + testAction( + actions.requestPackagesList, + undefined, + { config: { isGroupPage: false, resourceId: 1, forceTerraform: true }, sorting, filter }, + [], + [ + { type: 'setLoading', payload: true }, + { type: 'receivePackagesListSuccess', payload: { data: 'foo', headers } }, + { type: 'setLoading', payload: false }, + ], + () => { + expect(Api.projectPackages).toHaveBeenCalledWith(1, { + params: { + page: 1, + per_page: 20, + sort: sorting.sort, + order_by: sorting.orderBy, + package_type: 'terraform_module', + }, + }); + done(); + }, + ); + }); }); describe('receivePackagesListSuccess', () => { 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 03b98478f3e..f4e617ecafe 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 @@ -34,6 +34,8 @@ exports[`packages_list_row renders 1`] = ` </gl-link-stub> <!----> + + <!----> </div> <!----> 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 fd54cd0f25d..bd15d48c4eb 100644 --- a/spec/frontend/packages/shared/components/package_list_row_spec.js +++ b/spec/frontend/packages/shared/components/package_list_row_spec.js @@ -1,8 +1,11 @@ +import { GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import PackagesListRow from '~/packages/shared/components/package_list_row.vue'; import PackagePath from '~/packages/shared/components/package_path.vue'; import PackageTags from '~/packages/shared/components/package_tags.vue'; +import { PACKAGE_ERROR_STATUS } from '~/packages/shared/constants'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import { packageList } from '../../mock_data'; @@ -20,7 +23,10 @@ describe('packages_list_row', () => { const findPackagePath = () => wrapper.find(PackagePath); const findDeleteButton = () => wrapper.find('[data-testid="action-delete"]'); const findPackageIconAndName = () => wrapper.find(PackageIconAndName); - const findInfrastructureIconAndName = () => wrapper.find(InfrastructureIconAndName); + const findInfrastructureIconAndName = () => wrapper.findComponent(InfrastructureIconAndName); + const findListItem = () => wrapper.findComponent(ListItem); + const findPackageLink = () => wrapper.findComponent(GlLink); + const findWarningIcon = () => wrapper.find('[data-testid="warning-icon"]'); const mountComponent = ({ isGroup = false, @@ -44,6 +50,9 @@ describe('packages_list_row', () => { showPackageType, disableDelete, }, + directives: { + GlTooltip: createMockDirective(), + }, }); }; @@ -146,4 +155,31 @@ describe('packages_list_row', () => { expect(findInfrastructureIconAndName().exists()).toBe(true); }); }); + + describe(`when the package is in ${PACKAGE_ERROR_STATUS} status`, () => { + beforeEach(() => { + mountComponent({ packageEntity: { ...packageWithoutTags, status: PACKAGE_ERROR_STATUS } }); + }); + + it('list item has a disabled prop', () => { + expect(findListItem().props('disabled')).toBe(true); + }); + + it('details link is disabled', () => { + expect(findPackageLink().attributes('disabled')).toBe('true'); + }); + + it('has a warning icon', () => { + const icon = findWarningIcon(); + const tooltip = getBinding(icon.element, 'gl-tooltip'); + expect(icon.props('icon')).toBe('warning'); + expect(tooltip.value).toMatchObject({ + title: 'Invalid Package: failed metadata extraction', + }); + }); + + it('delete button is disabled', () => { + expect(findDeleteButton().props('disabled')).toBe(true); + }); + }); }); diff --git a/spec/frontend/packages/shared/components/package_path_spec.js b/spec/frontend/packages/shared/components/package_path_spec.js index 3c9cd3387ba..edbdd55c1d7 100644 --- a/spec/frontend/packages/shared/components/package_path_spec.js +++ b/spec/frontend/packages/shared/components/package_path_spec.js @@ -39,48 +39,66 @@ describe('PackagePath', () => { const pathPieces = path.split('/').slice(1); const hasTooltip = shouldExist.includes(ELLIPSIS_ICON); - beforeEach(() => { - mountComponent({ path }); - }); + describe('not disabled component', () => { + beforeEach(() => { + mountComponent({ path }); + }); - it('should have a base icon', () => { - expect(findItem(BASE_ICON).exists()).toBe(true); - }); + it('should have a base icon', () => { + expect(findItem(BASE_ICON).exists()).toBe(true); + }); - it('should have a root link', () => { - const root = findItem(ROOT_LINK); - expect(root.exists()).toBe(true); - expect(root.attributes('href')).toBe(rootUrl); - }); + it('should have a root link', () => { + const root = findItem(ROOT_LINK); + expect(root.exists()).toBe(true); + expect(root.attributes('href')).toBe(rootUrl); + }); - if (hasTooltip) { - it('should have a tooltip', () => { - const tooltip = findTooltip(findItem(ELLIPSIS_ICON)); - expect(tooltip).toBeDefined(); - expect(tooltip.value).toMatchObject({ - title: path, + if (hasTooltip) { + it('should have a tooltip', () => { + const tooltip = findTooltip(findItem(ELLIPSIS_ICON)); + expect(tooltip).toBeDefined(); + expect(tooltip.value).toMatchObject({ + title: path, + }); }); - }); - } + } - if (shouldExist.length) { - it.each(shouldExist)(`should have %s`, (element) => { - expect(findItem(element).exists()).toBe(true); - }); - } + if (shouldExist.length) { + it.each(shouldExist)(`should have %s`, (element) => { + expect(findItem(element).exists()).toBe(true); + }); + } - if (shouldNotExist.length) { - it.each(shouldNotExist)(`should not have %s`, (element) => { - expect(findItem(element).exists()).toBe(false); + if (shouldNotExist.length) { + it.each(shouldNotExist)(`should not have %s`, (element) => { + expect(findItem(element).exists()).toBe(false); + }); + } + + if (shouldExist.includes(LEAF_LINK)) { + it('the last link should be the last piece of the path', () => { + const leaf = findItem(LEAF_LINK); + expect(leaf.attributes('href')).toBe(`/${path}`); + expect(leaf.text()).toBe(pathPieces[pathPieces.length - 1]); + }); + } + }); + + describe('disabled component', () => { + beforeEach(() => { + mountComponent({ path, disabled: true }); }); - } - if (shouldExist.includes(LEAF_LINK)) { - it('the last link should be the last piece of the path', () => { - const leaf = findItem(LEAF_LINK); - expect(leaf.attributes('href')).toBe(`/${path}`); - expect(leaf.text()).toBe(pathPieces[pathPieces.length - 1]); + it('root link is disabled', () => { + expect(findItem(ROOT_LINK).attributes('disabled')).toBe('true'); }); - } + + if (shouldExist.includes(LEAF_LINK)) { + it('the last link is disabled', () => { + expect(findItem(LEAF_LINK).attributes('disabled')).toBe('true'); + }); + } + }); }); }); diff --git a/spec/frontend/packages_and_registries/settings/group/components/__snapshots__/settings_titles_spec.js.snap b/spec/frontend/packages_and_registries/settings/group/components/__snapshots__/settings_titles_spec.js.snap new file mode 100644 index 00000000000..f2087733d2b --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/group/components/__snapshots__/settings_titles_spec.js.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`settings_titles renders properly 1`] = ` +<div> + <h5 + class="gl-border-b-solid gl-border-b-1 gl-border-gray-200" + > + + foo + + </h5> + + <p> + bar + </p> + +</div> +`; diff --git a/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js new file mode 100644 index 00000000000..0bbb1ce3436 --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js @@ -0,0 +1,146 @@ +import { GlSprintf, GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import component from '~/packages_and_registries/settings/group/components/duplicates_settings.vue'; + +import { + DUPLICATES_TOGGLE_LABEL, + DUPLICATES_ALLOWED_ENABLED, + DUPLICATES_ALLOWED_DISABLED, + DUPLICATES_SETTING_EXCEPTION_TITLE, + DUPLICATES_SETTINGS_EXCEPTION_LEGEND, +} from '~/packages_and_registries/settings/group/constants'; + +describe('Duplicates Settings', () => { + let wrapper; + + const defaultProps = { + duplicatesAllowed: false, + duplicateExceptionRegex: 'foo', + modelNames: { + allowed: 'allowedModel', + exception: 'exceptionModel', + }, + }; + + const mountComponent = (propsData = defaultProps) => { + wrapper = shallowMount(component, { + propsData, + stubs: { + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findToggle = () => wrapper.findComponent(GlToggle); + const findToggleLabel = () => wrapper.find('[data-testid="toggle-label"'); + + const findInputGroup = () => wrapper.findComponent(GlFormGroup); + const findInput = () => wrapper.findComponent(GlFormInput); + + it('has a toggle', () => { + mountComponent(); + + expect(findToggle().exists()).toBe(true); + expect(findToggle().props()).toMatchObject({ + label: DUPLICATES_TOGGLE_LABEL, + value: defaultProps.duplicatesAllowed, + }); + }); + + it('toggle emits an update event', () => { + mountComponent(); + + findToggle().vm.$emit('change', false); + + expect(wrapper.emitted('update')).toStrictEqual([ + [{ [defaultProps.modelNames.allowed]: false }], + ]); + }); + + describe('when the duplicates are disabled', () => { + it('the toggle has the disabled message', () => { + mountComponent(); + + expect(findToggleLabel().exists()).toBe(true); + expect(findToggleLabel().text()).toMatchInterpolatedText(DUPLICATES_ALLOWED_DISABLED); + }); + + it('shows a form group with an input field', () => { + mountComponent(); + + expect(findInputGroup().exists()).toBe(true); + + expect(findInputGroup().attributes()).toMatchObject({ + 'label-for': 'maven-duplicated-settings-regex-input', + label: DUPLICATES_SETTING_EXCEPTION_TITLE, + description: DUPLICATES_SETTINGS_EXCEPTION_LEGEND, + }); + }); + + it('shows an input field', () => { + mountComponent(); + + expect(findInput().exists()).toBe(true); + + expect(findInput().attributes()).toMatchObject({ + id: 'maven-duplicated-settings-regex-input', + value: defaultProps.duplicateExceptionRegex, + }); + }); + + it('input change event emits an update event', () => { + mountComponent(); + + findInput().vm.$emit('change', 'bar'); + + expect(wrapper.emitted('update')).toStrictEqual([ + [{ [defaultProps.modelNames.exception]: 'bar' }], + ]); + }); + + describe('valid state', () => { + it('form group has correct props', () => { + mountComponent(); + + expect(findInputGroup().attributes()).toMatchObject({ + state: 'true', + 'invalid-feedback': '', + }); + }); + }); + + describe('invalid state', () => { + it('form group has correct props', () => { + const propsWithError = { + ...defaultProps, + duplicateExceptionRegexError: 'some error string', + }; + + mountComponent(propsWithError); + + expect(findInputGroup().attributes()).toMatchObject({ + 'invalid-feedback': propsWithError.duplicateExceptionRegexError, + }); + }); + }); + }); + + describe('when the duplicates are enabled', () => { + it('has the correct toggle label', () => { + mountComponent({ ...defaultProps, duplicatesAllowed: true }); + + expect(findToggleLabel().exists()).toBe(true); + expect(findToggleLabel().text()).toMatchInterpolatedText(DUPLICATES_ALLOWED_ENABLED); + }); + + it('hides the form input group', () => { + mountComponent({ ...defaultProps, duplicatesAllowed: true }); + + expect(findInputGroup().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/group/components/generic_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/generic_settings_spec.js new file mode 100644 index 00000000000..4eafeedd55e --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/group/components/generic_settings_spec.js @@ -0,0 +1,54 @@ +import { shallowMount } from '@vue/test-utils'; +import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue'; +import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue'; + +describe('generic_settings', () => { + let wrapper; + + const mountComponent = () => { + wrapper = shallowMount(GenericSettings, { + scopedSlots: { + default: '<div data-testid="default-slot">{{props.modelNames}}</div>', + }, + }); + }; + + const findSettingsTitle = () => wrapper.findComponent(SettingsTitles); + const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('title component', () => { + it('has a title component', () => { + mountComponent(); + + expect(findSettingsTitle().exists()).toBe(true); + }); + + it('passes the correct props', () => { + mountComponent(); + + expect(findSettingsTitle().props()).toMatchObject({ + title: 'Generic', + subTitle: 'Settings for Generic packages', + }); + }); + }); + + describe('default slot', () => { + it('accept a default slots', () => { + mountComponent(); + + expect(findDefaultSlot().exists()).toBe(true); + }); + + it('binds model names', () => { + mountComponent(); + + expect(findDefaultSlot().text()).toContain('genericDuplicatesAllowed'); + expect(findDefaultSlot().text()).toContain('genericDuplicateExceptionRegex'); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js index be0d7114e6e..14ee3f3e3b8 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js @@ -3,6 +3,8 @@ 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 DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue'; +import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue'; import component from '~/packages_and_registries/settings/group/components/group_settings_app.vue'; import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue'; import { @@ -63,6 +65,8 @@ describe('Group Settings App', () => { stubs: { GlSprintf, SettingsBlock, + MavenSettings, + GenericSettings, }, mocks: { $toast: { @@ -78,14 +82,17 @@ describe('Group Settings App', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); - const findSettingsBlock = () => wrapper.find(SettingsBlock); + const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); const findDescription = () => wrapper.find('[data-testid="description"'); - const findLink = () => wrapper.find(GlLink); - const findMavenSettings = () => wrapper.find(MavenSettings); - const findAlert = () => wrapper.find(GlAlert); + const findLink = () => wrapper.findComponent(GlLink); + const findAlert = () => wrapper.findComponent(GlAlert); + const findMavenSettings = () => wrapper.findComponent(MavenSettings); + const findMavenDuplicatedSettings = () => findMavenSettings().findComponent(DuplicatesSettings); + const findGenericSettings = () => wrapper.findComponent(GenericSettings); + const findGenericDuplicatedSettings = () => + findGenericSettings().findComponent(DuplicatesSettings); const waitForApolloQueryAndRender = async () => { await waitForPromises(); @@ -93,7 +100,7 @@ describe('Group Settings App', () => { }; const emitSettingsUpdate = (override) => { - findMavenSettings().vm.$emit('update', { + findMavenDuplicatedSettings().vm.$emit('update', { mavenDuplicateExceptionRegex: ')', ...override, }); @@ -152,7 +159,7 @@ describe('Group Settings App', () => { it('assigns duplication allowness and exception props', async () => { mountComponent(); - expect(findMavenSettings().props('loading')).toBe(true); + expect(findMavenDuplicatedSettings().props('loading')).toBe(true); await waitForApolloQueryAndRender(); @@ -161,10 +168,10 @@ describe('Group Settings App', () => { mavenDuplicateExceptionRegex, } = groupPackageSettingsMock.data.group.packageSettings; - expect(findMavenSettings().props()).toMatchObject({ - mavenDuplicatesAllowed, - mavenDuplicateExceptionRegex, - mavenDuplicateExceptionRegexError: '', + expect(findMavenDuplicatedSettings().props()).toMatchObject({ + duplicatesAllowed: mavenDuplicatesAllowed, + duplicateExceptionRegex: mavenDuplicateExceptionRegex, + duplicateExceptionRegexError: '', loading: false, }); }); @@ -183,6 +190,49 @@ describe('Group Settings App', () => { }); }); + describe('generic settings', () => { + it('exists', () => { + mountComponent(); + + expect(findGenericSettings().exists()).toBe(true); + }); + + it('assigns duplication allowness and exception props', async () => { + mountComponent(); + + expect(findGenericDuplicatedSettings().props('loading')).toBe(true); + + await waitForApolloQueryAndRender(); + + const { + genericDuplicatesAllowed, + genericDuplicateExceptionRegex, + } = groupPackageSettingsMock.data.group.packageSettings; + + expect(findGenericDuplicatedSettings().props()).toMatchObject({ + duplicatesAllowed: genericDuplicatesAllowed, + duplicateExceptionRegex: genericDuplicateExceptionRegex, + duplicateExceptionRegexError: '', + loading: false, + }); + }); + + it('on update event calls the mutation', async () => { + const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()); + mountComponent({ mutationResolver }); + + await waitForApolloQueryAndRender(); + + findMavenDuplicatedSettings().vm.$emit('update', { + genericDuplicateExceptionRegex: ')', + }); + + expect(mutationResolver).toHaveBeenCalledWith({ + input: { genericDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' }, + }); + }); + }); + describe('settings update', () => { describe('success state', () => { it('shows a success alert', async () => { @@ -200,26 +250,26 @@ describe('Group Settings App', () => { }); it('has an optimistic response', async () => { - const mavenDuplicateExceptionRegex = 'latest[master]something'; + const mavenDuplicateExceptionRegex = 'latest[main]something'; mountComponent(); await waitForApolloQueryAndRender(); - expect(findMavenSettings().props('mavenDuplicateExceptionRegex')).toBe(''); + expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe(''); emitSettingsUpdate({ mavenDuplicateExceptionRegex }); // wait for apollo to update the model with the optimistic response await wrapper.vm.$nextTick(); - expect(findMavenSettings().props('mavenDuplicateExceptionRegex')).toBe( + expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe( mavenDuplicateExceptionRegex, ); // wait for the call to resolve await waitForPromises(); - expect(findMavenSettings().props('mavenDuplicateExceptionRegex')).toBe( + expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe( mavenDuplicateExceptionRegex, ); }); @@ -245,7 +295,7 @@ describe('Group Settings App', () => { await waitForApolloQueryAndRender(); // errors are bound to the component - expect(findMavenSettings().props('mavenDuplicateExceptionRegexError')).toBe( + expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe( groupPackageSettingsMutationErrorMock.errors[0].extensions.problems[0].message, ); @@ -258,7 +308,7 @@ describe('Group Settings App', () => { await wrapper.vm.$nextTick(); // errors are reset on mutation call - expect(findMavenSettings().props('mavenDuplicateExceptionRegexError')).toBe(''); + expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe(''); }); it.each` diff --git a/spec/frontend/packages_and_registries/settings/group/components/maven_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/maven_settings_spec.js index 859d3587223..22644b97b43 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/maven_settings_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/maven_settings_spec.js @@ -1,156 +1,54 @@ -import { GlSprintf, GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import component from '~/packages_and_registries/settings/group/components/maven_settings.vue'; +import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue'; +import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue'; -import { - MAVEN_TITLE, - MAVEN_SETTINGS_SUBTITLE, - MAVEN_DUPLICATES_ALLOWED_DISABLED, - MAVEN_DUPLICATES_ALLOWED_ENABLED, - MAVEN_SETTING_EXCEPTION_TITLE, - MAVEN_SETTINGS_EXCEPTION_LEGEND, -} from '~/packages_and_registries/settings/group/constants'; - -describe('Maven Settings', () => { +describe('maven_settings', () => { let wrapper; - const defaultProps = { - mavenDuplicatesAllowed: false, - mavenDuplicateExceptionRegex: 'foo', - }; - - const mountComponent = (propsData = defaultProps) => { - wrapper = shallowMount(component, { - propsData, - stubs: { - GlSprintf, + const mountComponent = () => { + wrapper = shallowMount(MavenSettings, { + scopedSlots: { + default: '<div data-testid="default-slot">{{props.modelNames}}</div>', }, }); }; + const findSettingsTitle = () => wrapper.findComponent(SettingsTitles); + const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]'); + afterEach(() => { wrapper.destroy(); - wrapper = null; }); - const findTitle = () => wrapper.find('h5'); - const findSubTitle = () => wrapper.find('p'); - const findToggle = () => wrapper.find(GlToggle); - const findToggleLabel = () => wrapper.find('[data-testid="toggle-label"'); - - const findInputGroup = () => wrapper.find(GlFormGroup); - const findInput = () => wrapper.find(GlFormInput); - - it('has a title', () => { - mountComponent(); - - expect(findTitle().exists()).toBe(true); - expect(findTitle().text()).toBe(MAVEN_TITLE); - }); - - it('has a subtitle', () => { - mountComponent(); - - expect(findSubTitle().exists()).toBe(true); - expect(findSubTitle().text()).toBe(MAVEN_SETTINGS_SUBTITLE); - }); - - it('has a toggle', () => { - mountComponent(); - - expect(findToggle().exists()).toBe(true); - expect(findToggle().props()).toMatchObject({ - label: component.i18n.MAVEN_TOGGLE_LABEL, - value: defaultProps.mavenDuplicatesAllowed, - }); - }); - - it('toggle emits an update event', () => { - mountComponent(); - - findToggle().vm.$emit('change', false); - - expect(wrapper.emitted('update')).toEqual([[{ mavenDuplicatesAllowed: false }]]); - }); - - describe('when the duplicates are disabled', () => { - it('the toggle has the disabled message', () => { + describe('title component', () => { + it('has a title component', () => { mountComponent(); - expect(findToggleLabel().exists()).toBe(true); - expect(findToggleLabel().text()).toMatchInterpolatedText(MAVEN_DUPLICATES_ALLOWED_DISABLED); + expect(findSettingsTitle().exists()).toBe(true); }); - it('shows a form group with an input field', () => { + it('passes the correct props', () => { mountComponent(); - expect(findInputGroup().exists()).toBe(true); - - expect(findInputGroup().attributes()).toMatchObject({ - 'label-for': 'maven-duplicated-settings-regex-input', - label: MAVEN_SETTING_EXCEPTION_TITLE, - description: MAVEN_SETTINGS_EXCEPTION_LEGEND, + expect(findSettingsTitle().props()).toMatchObject({ + title: 'Maven', + subTitle: 'Settings for Maven packages', }); }); + }); - it('shows an input field', () => { + describe('default slot', () => { + it('accept a default slots', () => { mountComponent(); - expect(findInput().exists()).toBe(true); - - expect(findInput().attributes()).toMatchObject({ - id: 'maven-duplicated-settings-regex-input', - value: defaultProps.mavenDuplicateExceptionRegex, - }); + expect(findDefaultSlot().exists()).toBe(true); }); - it('input change event emits an update event', () => { + it('binds model names', () => { mountComponent(); - findInput().vm.$emit('change', 'bar'); - - expect(wrapper.emitted('update')).toEqual([[{ mavenDuplicateExceptionRegex: 'bar' }]]); - }); - - describe('valid state', () => { - it('form group has correct props', () => { - mountComponent(); - - expect(findInputGroup().attributes()).toMatchObject({ - state: 'true', - 'invalid-feedback': '', - }); - }); - }); - - describe('invalid state', () => { - it('form group has correct props', () => { - const propsWithError = { - ...defaultProps, - mavenDuplicateExceptionRegexError: 'some error string', - }; - - mountComponent(propsWithError); - - expect(findInputGroup().attributes()).toMatchObject({ - 'invalid-feedback': propsWithError.mavenDuplicateExceptionRegexError, - }); - }); - }); - }); - - describe('when the duplicates are enabled', () => { - it('has the correct toggle label', () => { - mountComponent({ ...defaultProps, mavenDuplicatesAllowed: true }); - - expect(findToggleLabel().exists()).toBe(true); - expect(findToggleLabel().text()).toMatchInterpolatedText(MAVEN_DUPLICATES_ALLOWED_ENABLED); - }); - - it('hides the form input group', () => { - mountComponent({ ...defaultProps, mavenDuplicatesAllowed: true }); - - expect(findInputGroup().exists()).toBe(false); + expect(findDefaultSlot().text()).toContain('mavenDuplicatesAllowed'); + expect(findDefaultSlot().text()).toContain('mavenDuplicateExceptionRegex'); }); }); }); diff --git a/spec/frontend/packages_and_registries/settings/group/components/settings_titles_spec.js b/spec/frontend/packages_and_registries/settings/group/components/settings_titles_spec.js new file mode 100644 index 00000000000..a61edad8685 --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/group/components/settings_titles_spec.js @@ -0,0 +1,25 @@ +import { shallowMount } from '@vue/test-utils'; +import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue'; + +describe('settings_titles', () => { + let wrapper; + + const mountComponent = () => { + wrapper = shallowMount(SettingsTitles, { + propsData: { + title: 'foo', + subTitle: 'bar', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders properly', () => { + mountComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js b/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js index e1a46f97318..03133bf1158 100644 --- a/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js @@ -9,7 +9,7 @@ describe('Package and Registries settings group cache updates', () => { updateNamespacePackageSettings: { packageSettings: { mavenDuplicatesAllowed: false, - mavenDuplicateExceptionRegex: 'latest[master]something', + mavenDuplicateExceptionRegex: 'latest[main]something', }, }, }, diff --git a/spec/frontend/packages_and_registries/settings/group/mock_data.js b/spec/frontend/packages_and_registries/settings/group/mock_data.js index 777c0898de0..65119e288a1 100644 --- a/spec/frontend/packages_and_registries/settings/group/mock_data.js +++ b/spec/frontend/packages_and_registries/settings/group/mock_data.js @@ -4,6 +4,8 @@ export const groupPackageSettingsMock = { packageSettings: { mavenDuplicatesAllowed: true, mavenDuplicateExceptionRegex: '', + genericDuplicatesAllowed: true, + genericDuplicateExceptionRegex: '', }, }, }, @@ -14,7 +16,9 @@ export const groupPackageSettingsMutationMock = (override) => ({ updateNamespacePackageSettings: { packageSettings: { mavenDuplicatesAllowed: true, - mavenDuplicateExceptionRegex: 'latest[master]something', + mavenDuplicateExceptionRegex: 'latest[main]something', + genericDuplicatesAllowed: true, + genericDuplicateExceptionRegex: 'latest[main]somethingGeneric', }, errors: [], ...override, @@ -26,20 +30,20 @@ export const groupPackageSettingsMutationErrorMock = { errors: [ { message: - 'Variable $input of type UpdateNamespacePackageSettingsInput! was provided invalid value for mavenDuplicateExceptionRegex (latest[master]somethingj)) is an invalid regexp: unexpected ): latest[master]somethingj)))', + 'Variable $input of type UpdateNamespacePackageSettingsInput! was provided invalid value for mavenDuplicateExceptionRegex (latest[main]somethingj)) is an invalid regexp: unexpected ): latest[main]somethingj)))', locations: [{ line: 1, column: 41 }], extensions: { value: { namespacePath: 'gitlab-org', - mavenDuplicateExceptionRegex: 'latest[master]something))', + mavenDuplicateExceptionRegex: 'latest[main]something))', }, problems: [ { path: ['mavenDuplicateExceptionRegex'], explanation: - 'latest[master]somethingj)) is an invalid regexp: unexpected ): latest[master]something))', + 'latest[main]somethingj)) is an invalid regexp: unexpected ): latest[main]something))', message: - 'latest[master]somethingj)) is an invalid regexp: unexpected ): latest[master]something))', + 'latest[main]somethingj)) is an invalid regexp: unexpected ): latest[main]something))', }, ], }, diff --git a/spec/frontend/registry/settings/__snapshots__/utils_spec.js.snap b/spec/frontend/packages_and_registries/settings/project/settings/__snapshots__/utils_spec.js.snap index 7062773b46b..7062773b46b 100644 --- a/spec/frontend/registry/settings/__snapshots__/utils_spec.js.snap +++ b/spec/frontend/packages_and_registries/settings/project/settings/__snapshots__/utils_spec.js.snap diff --git a/spec/frontend/registry/settings/components/__snapshots__/settings_form_spec.js.snap b/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap index 7a52b4a5d0f..7a52b4a5d0f 100644 --- a/spec/frontend/registry/settings/components/__snapshots__/settings_form_spec.js.snap +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap diff --git a/spec/frontend/registry/settings/components/expiration_dropdown_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js index f777f7ec9de..c56244a9138 100644 --- a/spec/frontend/registry/settings/components/expiration_dropdown_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { GlFormGroup, GlFormSelect } from 'jest/registry/shared/stubs'; -import component from '~/registry/settings/components/expiration_dropdown.vue'; +import component from '~/packages_and_registries/settings/project/components/expiration_dropdown.vue'; describe('ExpirationDropdown', () => { let wrapper; diff --git a/spec/frontend/registry/settings/components/expiration_input_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_input_spec.js index b91599a2789..dd876d1d295 100644 --- a/spec/frontend/registry/settings/components/expiration_input_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_input_spec.js @@ -1,8 +1,8 @@ import { GlSprintf, GlFormInput, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { GlFormGroup } from 'jest/registry/shared/stubs'; -import component from '~/registry/settings/components/expiration_input.vue'; -import { NAME_REGEX_LENGTH } from '~/registry/settings/constants'; +import component from '~/packages_and_registries/settings/project/components/expiration_input.vue'; +import { NAME_REGEX_LENGTH } from '~/packages_and_registries/settings/project/constants'; describe('ExpirationInput', () => { let wrapper; diff --git a/spec/frontend/registry/settings/components/expiration_run_text_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_run_text_spec.js index 753bb10ad08..854830391c5 100644 --- a/spec/frontend/registry/settings/components/expiration_run_text_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_run_text_spec.js @@ -1,8 +1,11 @@ import { GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { GlFormGroup } from 'jest/registry/shared/stubs'; -import component from '~/registry/settings/components/expiration_run_text.vue'; -import { NEXT_CLEANUP_LABEL, NOT_SCHEDULED_POLICY_TEXT } from '~/registry/settings/constants'; +import component from '~/packages_and_registries/settings/project/components/expiration_run_text.vue'; +import { + NEXT_CLEANUP_LABEL, + NOT_SCHEDULED_POLICY_TEXT, +} from '~/packages_and_registries/settings/project/constants'; describe('ExpirationToggle', () => { let wrapper; diff --git a/spec/frontend/registry/settings/components/expiration_toggle_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js index 7598f6adc89..3a3eb089b43 100644 --- a/spec/frontend/registry/settings/components/expiration_toggle_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js @@ -1,11 +1,11 @@ import { GlToggle, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { GlFormGroup } from 'jest/registry/shared/stubs'; -import component from '~/registry/settings/components/expiration_toggle.vue'; +import component from '~/packages_and_registries/settings/project/components/expiration_toggle.vue'; import { ENABLED_TOGGLE_DESCRIPTION, DISABLED_TOGGLE_DESCRIPTION, -} from '~/registry/settings/constants'; +} from '~/packages_and_registries/settings/project/constants'; describe('ExpirationToggle', () => { let wrapper; diff --git a/spec/frontend/registry/settings/components/registry_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js index fd53efa884f..a725941f7f6 100644 --- a/spec/frontend/registry/settings/components/registry_settings_app_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js @@ -2,14 +2,14 @@ import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; -import component from '~/registry/settings/components/registry_settings_app.vue'; -import SettingsForm from '~/registry/settings/components/settings_form.vue'; +import component from '~/packages_and_registries/settings/project/components/registry_settings_app.vue'; +import SettingsForm from '~/packages_and_registries/settings/project/components/settings_form.vue'; import { FETCH_SETTINGS_ERROR_MESSAGE, UNAVAILABLE_FEATURE_INTRO_TEXT, UNAVAILABLE_USER_FEATURE_TEXT, -} from '~/registry/settings/constants'; -import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.query.graphql'; +} from '~/packages_and_registries/settings/project/constants'; +import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql'; import { expirationPolicyPayload, diff --git a/spec/frontend/registry/settings/components/settings_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js index ad94da6ca66..7e5383d7ff1 100644 --- a/spec/frontend/registry/settings/components/settings_form_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js @@ -2,15 +2,15 @@ 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 component from '~/registry/settings/components/settings_form.vue'; +import { GlCard, GlLoadingIcon } from 'jest/registry/shared/stubs'; +import component from '~/packages_and_registries/settings/project/components/settings_form.vue'; import { UPDATE_SETTINGS_ERROR_MESSAGE, UPDATE_SETTINGS_SUCCESS_MESSAGE, -} from '~/registry/settings/constants'; -import updateContainerExpirationPolicyMutation from '~/registry/settings/graphql/mutations/update_container_expiration_policy.mutation.graphql'; -import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.query.graphql'; +} from '~/packages_and_registries/settings/project/constants'; +import updateContainerExpirationPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_container_expiration_policy.mutation.graphql'; +import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql'; import Tracking from '~/tracking'; -import { GlCard, GlLoadingIcon } from '../../shared/stubs'; import { expirationPolicyPayload, expirationPolicyMutationPayload } from '../mock_data'; const localVue = createLocalVue(); diff --git a/spec/frontend/registry/settings/graphql/cache_updated_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/graphql/cache_updated_spec.js index 73655b6917b..4d6bd65bd93 100644 --- a/spec/frontend/registry/settings/graphql/cache_updated_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/graphql/cache_updated_spec.js @@ -1,5 +1,5 @@ -import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.query.graphql'; -import { updateContainerExpirationPolicy } from '~/registry/settings/graphql/utils/cache_update'; +import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql'; +import { updateContainerExpirationPolicy } from '~/packages_and_registries/settings/project/graphql/utils/cache_update'; describe('Registry settings cache update', () => { let client; diff --git a/spec/frontend/registry/settings/mock_data.js b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js index 9778f409010..9778f409010 100644 --- a/spec/frontend/registry/settings/mock_data.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js diff --git a/spec/frontend/registry/settings/utils_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/utils_spec.js index 7bc627908af..4c81671cd46 100644 --- a/spec/frontend/registry/settings/utils_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/utils_spec.js @@ -2,7 +2,7 @@ import { formOptionsGenerator, optionLabelGenerator, olderThanTranslationGenerator, -} from '~/registry/settings/utils'; +} from '~/packages_and_registries/settings/project/utils'; describe('Utils', () => { describe('optionLabelGenerator', () => { diff --git a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap index 9f02e5b9432..4c644a0d05f 100644 --- a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap +++ b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap @@ -8,6 +8,10 @@ exports[`User Operation confirmation modal renders modal with form included 1`] /> </p> + <oncall-schedules-list-stub + schedules="schedule1,schedule2" + /> + <p> <gl-sprintf-stub message="To confirm, type %{username}" diff --git a/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js b/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js index 318b6d16008..93d9ee43179 100644 --- a/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js +++ b/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js @@ -1,6 +1,7 @@ import { GlButton, GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import DeleteUserModal from '~/pages/admin/users/components/delete_user_modal.vue'; +import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; import ModalStub from './stubs/modal_stub'; const TEST_DELETE_USER_URL = 'delete-url'; @@ -17,13 +18,14 @@ describe('User Operation confirmation modal', () => { .filter((w) => w.attributes('variant') === variant && w.attributes('category') === category) .at(0); const findForm = () => wrapper.find('form'); - const findUsernameInput = () => wrapper.find(GlFormInput); + const findUsernameInput = () => wrapper.findComponent(GlFormInput); const findPrimaryButton = () => findButton('danger', 'primary'); const findSecondaryButton = () => findButton('danger', 'secondary'); const findAuthenticityToken = () => new FormData(findForm().element).get('authenticity_token'); const getUsername = () => findUsernameInput().attributes('value'); const getMethodParam = () => new FormData(findForm().element).get('_method'); const getFormAction = () => findForm().attributes('action'); + const findOnCallSchedulesList = () => wrapper.findComponent(OncallSchedulesList); const setUsername = (username) => { findUsernameInput().vm.$emit('input', username); @@ -31,6 +33,7 @@ describe('User Operation confirmation modal', () => { const username = 'username'; const badUsername = 'bad_username'; + const oncallSchedules = '["schedule1", "schedule2"]'; const createComponent = (props = {}) => { wrapper = shallowMount(DeleteUserModal, { @@ -43,6 +46,7 @@ describe('User Operation confirmation modal', () => { deleteUserUrl: TEST_DELETE_USER_URL, blockUserUrl: TEST_BLOCK_USER_URL, csrfToken: TEST_CSRF, + oncallSchedules, ...props, }, stubs: { @@ -145,4 +149,19 @@ describe('User Operation confirmation modal', () => { }); }); }); + + describe('Related oncall-schedules list', () => { + it('does NOT render the list when user has no related schedules', () => { + createComponent({ oncallSchedules: '[]' }); + expect(findOnCallSchedulesList().exists()).toBe(false); + }); + + it('renders the list when user has related schedules', () => { + createComponent(); + + const schedules = findOnCallSchedulesList(); + expect(schedules.exists()).toBe(true); + expect(schedules.props('schedules')).toEqual(JSON.parse(oncallSchedules)); + }); + }); }); 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 index 2992c7f0624..6d853120232 100644 --- a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js @@ -1,5 +1,5 @@ -import { GlForm, GlFormInputGroup, GlFormInput } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlFormInputGroup, GlFormInput, GlForm } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; import axios from 'axios'; import AxiosMockAdapter from 'axios-mock-adapter'; import { kebabCase } from 'lodash'; @@ -43,8 +43,8 @@ describe('ForkForm component', () => { axiosMock.onGet(DEFAULT_PROPS.endpoint).replyOnce(statusCode, data); }; - const createComponent = (props = {}, data = {}) => { - wrapper = shallowMount(ForkForm, { + const createComponentFactory = (mountFn) => (props = {}, data = {}) => { + wrapper = mountFn(ForkForm, { provide: { newGroupPath: 'some/groups/path', visibilityHelpPath: 'some/visibility/help/path', @@ -65,6 +65,9 @@ describe('ForkForm component', () => { }); }; + const createComponent = createComponentFactory(shallowMount); + const createFullComponent = createComponentFactory(mount); + beforeEach(() => { axiosMock = new AxiosMockAdapter(axios); window.gon = { @@ -99,44 +102,6 @@ describe('ForkForm component', () => { 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(); @@ -258,9 +223,7 @@ describe('ForkForm component', () => { projectVisibility: project, }, { - selectedNamespace: { - visibility: namespace, - }, + form: { fields: { namespace: { value: { visibility: namespace } } } }, }, ); @@ -274,34 +237,101 @@ describe('ForkForm component', () => { describe('onSubmit', () => { beforeEach(() => { jest.spyOn(urlUtility, 'redirectTo').mockImplementation(); + + mockGetRequest(); + createFullComponent( + {}, + { + namespaces: MOCK_NAMESPACES_RESPONSE, + form: { + state: true, + }, + }, + ); }); - it('redirect to POST web_url response', async () => { - const webUrl = `new/fork-project`; + const selectedMockNamespaceIndex = 1; + const namespaceId = MOCK_NAMESPACES_RESPONSE[selectedMockNamespaceIndex].id; - jest.spyOn(axios, 'post').mockResolvedValue({ data: { web_url: webUrl } }); + const fillForm = async () => { + const namespaceOptions = findForkUrlInput().findAll('option'); - mockGetRequest(); - createComponent(); + await namespaceOptions.at(selectedMockNamespaceIndex + 1).setSelected(); + }; - await wrapper.vm.onSubmit(); + const submitForm = async () => { + await fillForm(); + const form = wrapper.find(GlForm); - expect(urlUtility.redirectTo).toHaveBeenCalledWith(webUrl); + await form.trigger('submit'); + await wrapper.vm.$nextTick(); + }; + + describe('with invalid form', () => { + it('does not make POST request', async () => { + jest.spyOn(axios, 'post'); + + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('does not redirect the current page', async () => { + await submitForm(); + + expect(urlUtility.redirectTo).not.toHaveBeenCalled(); + }); }); - it('display flash when POST is unsuccessful', async () => { - const dummyError = 'Fork project failed'; + describe('with valid form', () => { + beforeEach(() => { + fillForm(); + }); - jest.spyOn(axios, 'post').mockRejectedValue(dummyError); + it('make POST request with project param', async () => { + jest.spyOn(axios, 'post'); + + await submitForm(); + + 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, + }; - mockGetRequest(); - createComponent(); + expect(axios.post).toHaveBeenCalledWith(url, project); + }); + + it('redirect to POST web_url response', async () => { + const webUrl = `new/fork-project`; + jest.spyOn(axios, 'post').mockResolvedValue({ data: { web_url: webUrl } }); + + await submitForm(); + + expect(urlUtility.redirectTo).toHaveBeenCalledWith(webUrl); + }); + + it('display flash when POST is unsuccessful', async () => { + const dummyError = 'Fork project failed'; + + jest.spyOn(axios, 'post').mockRejectedValue(dummyError); - await wrapper.vm.onSubmit(); + await submitForm(); - expect(urlUtility.redirectTo).not.toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledWith({ - message: dummyError, + 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 8b54a06ac7c..350669433f0 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 @@ -44,9 +44,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` class="progress-bar" role="progressbar" style="width: 22.22222222222222%;" - > - <!----> - </div> + /> </div> </div> @@ -68,7 +66,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` class="learn-gitlab-section-card-header" > <img - src="/assets/learn_gitlab/section_workspace.svg" + src="workspace.svg" /> <h2 @@ -134,9 +132,16 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` <span> <a class="gl-link" + data-track-action="click_link" + data-track-label="Set up CI/CD" + data-track-property="Growth::Conversion::Experiment::LearnGitLabA" href="http://example.com/" + rel="noopener noreferrer" + target="_blank" > - Set up CI/CD + + Set up CI/CD + </a> </span> @@ -148,9 +153,16 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` <span> <a class="gl-link" + data-track-action="click_link" + data-track-label="Start a free Ultimate trial" + data-track-property="Growth::Conversion::Experiment::LearnGitLabA" href="http://example.com/" + rel="noopener noreferrer" + target="_blank" > - Start a free Ultimate trial + + Start a free Ultimate trial + </a> </span> @@ -162,9 +174,16 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` <span> <a class="gl-link" + data-track-action="click_link" + data-track-label="Add code owners" + data-track-property="Growth::Conversion::Experiment::LearnGitLabA" href="http://example.com/" + rel="noopener noreferrer" + target="_blank" > - Add code owners + + Add code owners + </a> </span> @@ -183,9 +202,16 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` <span> <a class="gl-link" + data-track-action="click_link" + data-track-label="Add merge request approval" + data-track-property="Growth::Conversion::Experiment::LearnGitLabA" href="http://example.com/" + rel="noopener noreferrer" + target="_blank" > - Add merge request approval + + Add merge request approval + </a> </span> @@ -218,7 +244,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` class="learn-gitlab-section-card-header" > <img - src="/assets/learn_gitlab/section_plan.svg" + src="plan.svg" /> <h2 @@ -240,9 +266,16 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` <span> <a class="gl-link" + data-track-action="click_link" + data-track-label="Create an issue" + data-track-property="Growth::Conversion::Experiment::LearnGitLabA" href="http://example.com/" + rel="noopener noreferrer" + target="_blank" > - Create an issue + + Create an issue + </a> </span> @@ -254,9 +287,16 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` <span> <a class="gl-link" + data-track-action="click_link" + data-track-label="Submit a merge request" + data-track-property="Growth::Conversion::Experiment::LearnGitLabA" href="http://example.com/" + rel="noopener noreferrer" + target="_blank" > - Submit a merge request + + Submit a merge request + </a> </span> @@ -282,7 +322,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` class="learn-gitlab-section-card-header" > <img - src="/assets/learn_gitlab/section_deploy.svg" + src="deploy.svg" /> <h2 @@ -304,9 +344,16 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` <span> <a class="gl-link" + data-track-action="click_link" + data-track-label="Run a Security scan using CI/CD" + data-track-property="Growth::Conversion::Experiment::LearnGitLabA" href="http://example.com/" + rel="noopener noreferrer" + target="_blank" > - Run a Security scan using CI/CD + + Run a Security scan using CI/CD + </a> </span> 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 07c7f2df09e..c9d8ab4566c 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 @@ -44,9 +44,7 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` class="progress-bar" role="progressbar" style="width: 22.22222222222222%;" - > - <!----> - </div> + /> </div> </div> @@ -110,6 +108,9 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` <a class="gl-link" + data-track-action="click_link" + data-track-label="Invite your colleagues" + data-track-property="Growth::Activation::Experiment::LearnGitLabB" href="http://example.com/" rel="noopener noreferrer" target="_blank" @@ -168,6 +169,9 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` <a class="gl-link" + data-track-action="click_link" + data-track-label="Create or import a repository" + data-track-property="Growth::Activation::Experiment::LearnGitLabB" href="http://example.com/" rel="noopener noreferrer" target="_blank" @@ -218,6 +222,9 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` <a class="gl-link" + data-track-action="click_link" + data-track-label="Set-up CI/CD" + data-track-property="Growth::Activation::Experiment::LearnGitLabB" href="http://example.com/" rel="noopener noreferrer" target="_blank" @@ -268,6 +275,9 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` <a class="gl-link" + data-track-action="click_link" + data-track-label="Try GitLab Ultimate for free" + data-track-property="Growth::Activation::Experiment::LearnGitLabB" href="http://example.com/" rel="noopener noreferrer" target="_blank" @@ -323,6 +333,9 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` <a class="gl-link" + data-track-action="click_link" + data-track-label="Add code owners" + data-track-property="Growth::Activation::Experiment::LearnGitLabB" href="http://example.com/" rel="noopener noreferrer" target="_blank" @@ -378,6 +391,9 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` <a class="gl-link" + data-track-action="click_link" + data-track-label="Enable require merge approvals" + data-track-property="Growth::Activation::Experiment::LearnGitLabB" href="http://example.com/" rel="noopener noreferrer" target="_blank" @@ -444,6 +460,9 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` <a class="gl-link" + data-track-action="click_link" + data-track-label="Create an issue" + data-track-property="Growth::Activation::Experiment::LearnGitLabB" href="http://example.com/" rel="noopener noreferrer" target="_blank" @@ -494,6 +513,9 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` <a class="gl-link" + data-track-action="click_link" + data-track-label="Submit a merge request (MR)" + data-track-property="Growth::Activation::Experiment::LearnGitLabB" href="http://example.com/" rel="noopener noreferrer" target="_blank" @@ -560,6 +582,9 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` <a class="gl-link" + data-track-action="click_link" + data-track-label="Run a Security scan using CI/CD" + data-track-property="Growth::Activation::Experiment::LearnGitLabB" href="http://example.com/" rel="noopener noreferrer" target="_blank" diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_section_card_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_section_card_spec.js.snap index ad8db0822cc..9e00ace761c 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_section_card_spec.js.snap +++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_section_card_spec.js.snap @@ -11,7 +11,7 @@ exports[`Learn GitLab Section Card renders correctly 1`] = ` class="learn-gitlab-section-card-header" > <img - src="/assets/learn_gitlab/section_workspace.svg" + src="workspace.svg" /> <h2 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 64ace341038..ac997c1f237 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,13 +1,13 @@ import { GlProgressBar } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import LearnGitlabA from '~/pages/projects/learn_gitlab/components/learn_gitlab_a.vue'; -import { testActions } from './mock_data'; +import { testActions, testSections } from './mock_data'; describe('Learn GitLab Design A', () => { let wrapper; const createWrapper = () => { - wrapper = mount(LearnGitlabA, { propsData: { actions: testActions } }); + wrapper = mount(LearnGitlabA, { propsData: { actions: testActions, sections: testSections } }); }; beforeEach(() => { diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_card_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_card_spec.js index de6aca08235..3a511a009a9 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_card_spec.js +++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_card_spec.js @@ -3,6 +3,7 @@ import LearnGitlabSectionCard from '~/pages/projects/learn_gitlab/components/lea import { testActions } from './mock_data'; const defaultSection = 'workspace'; +const testImage = 'workspace.svg'; describe('Learn GitLab Section Card', () => { let wrapper; @@ -14,7 +15,7 @@ describe('Learn GitLab Section Card', () => { const createWrapper = () => { wrapper = shallowMount(LearnGitlabSectionCard, { - propsData: { section: defaultSection, actions: testActions }, + propsData: { section: defaultSection, actions: testActions, svg: testImage }, }); }; diff --git a/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js b/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js index d6ee2b00c8e..8d6ac737db8 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js +++ b/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js @@ -45,3 +45,15 @@ export const testActions = { svg: 'http://example.com/images/illustration.svg', }, }; + +export const testSections = { + workspace: { + svg: 'workspace.svg', + }, + deploy: { + svg: 'deploy.svg', + }, + plan: { + svg: 'plan.svg', + }, +}; diff --git a/spec/frontend/pages/projects/new/components/app_spec.js b/spec/frontend/pages/projects/new/components/app_spec.js new file mode 100644 index 00000000000..b604e636243 --- /dev/null +++ b/spec/frontend/pages/projects/new/components/app_spec.js @@ -0,0 +1,77 @@ +import { shallowMount } from '@vue/test-utils'; +import { assignGitlabExperiment } from 'helpers/experimentation_helper'; +import App from '~/pages/projects/new/components/app.vue'; +import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'; + +describe('Experimental new project creation app', () => { + let wrapper; + + const findNewNamespacePage = () => wrapper.findComponent(NewNamespacePage); + + const createComponent = (propsData) => { + wrapper = shallowMount(App, { propsData }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('new_repo experiment', () => { + it('passes new_repo experiment', () => { + createComponent(); + + expect(findNewNamespacePage().props().experiment).toBe('new_repo'); + }); + + describe('when in the candidate variant', () => { + assignGitlabExperiment('new_repo', 'candidate'); + + it('has "repository" in the panel title', () => { + createComponent(); + + expect(findNewNamespacePage().props().panels[0].title).toBe( + 'Create blank project/repository', + ); + }); + }); + + describe('when in the control variant', () => { + assignGitlabExperiment('new_repo', 'control'); + + it('has "project" in the panel title', () => { + createComponent(); + + expect(findNewNamespacePage().props().panels[0].title).toBe('Create blank project'); + }); + }); + }); + + it('passes custom new project guideline text to underlying component', () => { + const DEMO_GUIDELINES = 'Demo guidelines'; + const guidelineSelector = '#new-project-guideline'; + createComponent({ + newProjectGuidelines: DEMO_GUIDELINES, + }); + + expect(wrapper.find(guidelineSelector).text()).toBe(DEMO_GUIDELINES); + }); + + it.each` + isCiCdAvailable | outcome + ${false} | ${'do not show CI/CD panel'} + ${true} | ${'show CI/CD panel'} + `('$outcome when isCiCdAvailable is $isCiCdAvailable', ({ isCiCdAvailable }) => { + createComponent({ + isCiCdAvailable, + }); + + expect( + Boolean( + wrapper + .findComponent(NewNamespacePage) + .props() + .panels.find((p) => p.name === 'cicd_for_external_repo'), + ), + ).toBe(isCiCdAvailable); + }); +}); diff --git a/spec/frontend/projects/experiment_new_project_creation/components/new_project_push_tip_popover_spec.js b/spec/frontend/pages/projects/new/components/new_project_push_tip_popover_spec.js index 1ce16640d4a..d4cf8c78600 100644 --- a/spec/frontend/projects/experiment_new_project_creation/components/new_project_push_tip_popover_spec.js +++ b/spec/frontend/pages/projects/new/components/new_project_push_tip_popover_spec.js @@ -1,6 +1,6 @@ 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 NewProjectPushTipPopover from '~/pages/projects/new/components/new_project_push_tip_popover.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; describe('New project push tip popover', () => { diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js index 8ab0b87d2ee..1cac8ef8ee2 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -1,9 +1,16 @@ +import { GlLoadingIcon, GlModal } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import ContentEditor from '~/content_editor/components/content_editor.vue'; import WikiForm from '~/pages/shared/wikis/components/wiki_form.vue'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; describe('WikiForm', () => { let wrapper; + let mock; const findForm = () => wrapper.find('form'); const findTitle = () => wrapper.find('#wiki_title'); @@ -11,10 +18,28 @@ describe('WikiForm', () => { const findContent = () => wrapper.find('#wiki_content'); const findMessage = () => wrapper.find('#wiki_message'); const findSubmitButton = () => wrapper.findByTestId('wiki-submit-button'); - const findCancelButton = () => wrapper.findByTestId('wiki-cancel-button'); - const findTitleHelpLink = () => wrapper.findByTestId('wiki-title-help-link'); + const findCancelButton = () => wrapper.findByRole('link', { name: 'Cancel' }); + const findUseNewEditorButton = () => wrapper.findByRole('button', { name: 'Use new editor' }); + const findSwitchToOldEditorButton = () => + wrapper.findByRole('button', { name: 'Switch to old editor' }); + const findTitleHelpLink = () => wrapper.findByRole('link', { name: 'More Information.' }); const findMarkdownHelpLink = () => wrapper.findByTestId('wiki-markdown-help-link'); + const setFormat = (value) => { + const format = findFormat(); + format.find(`option[value=${value}]`).setSelected(); + format.element.dispatchEvent(new Event('change')); + }; + + const triggerFormSubmit = () => findForm().element.dispatchEvent(new Event('submit')); + + const dispatchBeforeUnload = () => { + const e = new Event('beforeunload'); + jest.spyOn(e, 'preventDefault'); + window.dispatchEvent(e); + return e; + }; + const pageInfoNew = { persisted: false, uploadsPath: '/project/path/-/wikis/attachments', @@ -35,7 +60,10 @@ describe('WikiForm', () => { path: '/project/path/-/wikis/home', }; - function createWrapper(persisted = false, pageInfo = {}) { + function createWrapper( + persisted = false, + { pageInfo, glFeatures } = { glFeatures: { wikiContentEditor: false } }, + ) { wrapper = extendedWrapper( mount( WikiForm, @@ -51,16 +79,20 @@ describe('WikiForm', () => { ...(persisted ? pageInfoPersisted : pageInfoNew), ...pageInfo, }, + glFeatures, }, }, { attachToDocument: true }, ), ); - - jest.spyOn(wrapper.vm, 'onBeforeUnload'); } + beforeEach(() => { + mock = new MockAdapter(axios); + }); + afterEach(() => { + mock.restore(); wrapper.destroy(); wrapper = null; }); @@ -101,7 +133,7 @@ describe('WikiForm', () => { `('updates the link help message when format=$value is selected', async ({ value, text }) => { createWrapper(); - findFormat().find(`option[value=${value}]`).setSelected(); + setFormat(value); await wrapper.vm.$nextTick(); @@ -113,9 +145,9 @@ describe('WikiForm', () => { await wrapper.vm.$nextTick(); - window.dispatchEvent(new Event('beforeunload')); - - expect(wrapper.vm.onBeforeUnload).not.toHaveBeenCalled(); + const e = dispatchBeforeUnload(); + expect(typeof e.returnValue).not.toBe('string'); + expect(e.preventDefault).not.toHaveBeenCalled(); }); it.each` @@ -156,19 +188,18 @@ describe('WikiForm', () => { }); it('sets before unload warning', () => { - window.dispatchEvent(new Event('beforeunload')); + const e = dispatchBeforeUnload(); - expect(wrapper.vm.onBeforeUnload).toHaveBeenCalled(); + expect(e.preventDefault).toHaveBeenCalledTimes(1); }); it('when form submitted, unsets before unload warning', async () => { - findForm().element.dispatchEvent(new Event('submit')); + triggerFormSubmit(); await wrapper.vm.$nextTick(); - window.dispatchEvent(new Event('beforeunload')); - - expect(wrapper.vm.onBeforeUnload).not.toHaveBeenCalled(); + const e = dispatchBeforeUnload(); + expect(e.preventDefault).not.toHaveBeenCalled(); }); }); @@ -219,4 +250,212 @@ describe('WikiForm', () => { }, ); }); + + describe('when feature flag wikiContentEditor is enabled', () => { + beforeEach(() => { + createWrapper(true, { glFeatures: { wikiContentEditor: true } }); + }); + + it.each` + format | buttonExists + ${'markdown'} | ${true} + ${'rdoc'} | ${false} + `( + 'switch to new editor button exists: $buttonExists if format is $format', + async ({ format, buttonExists }) => { + setFormat(format); + + await wrapper.vm.$nextTick(); + + expect(findUseNewEditorButton().exists()).toBe(buttonExists); + }, + ); + + const assertOldEditorIsVisible = () => { + expect(wrapper.findComponent(ContentEditor).exists()).toBe(false); + expect(wrapper.findComponent(MarkdownField).exists()).toBe(true); + expect(findSubmitButton().props('disabled')).toBe(false); + + expect(wrapper.text()).not.toContain( + "Switching will discard any changes you've made in the new editor.", + ); + expect(wrapper.text()).not.toContain( + "This editor is in beta and may not display the page's contents properly.", + ); + }; + + it('shows old editor by default', assertOldEditorIsVisible); + + describe('switch format to rdoc', () => { + beforeEach(async () => { + setFormat('rdoc'); + + await wrapper.vm.$nextTick(); + }); + + it('continues to show the old editor', assertOldEditorIsVisible); + + describe('switch format back to markdown', () => { + beforeEach(async () => { + setFormat('rdoc'); + + await wrapper.vm.$nextTick(); + }); + + it( + 'still shows the old editor and does not automatically switch to the content editor ', + assertOldEditorIsVisible, + ); + }); + }); + + describe('clicking "use new editor": editor fails to load', () => { + beforeEach(async () => { + mock.onPost(/preview-markdown/).reply(400); + + await findUseNewEditorButton().trigger('click'); + + // try waiting for content editor to load (but it will never actually load) + await waitForPromises(); + }); + + it('editor is shown in a perpetual loading state', () => { + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.findComponent(ContentEditor).exists()).toBe(false); + }); + + it('disables the submit button', () => { + expect(findSubmitButton().props('disabled')).toBe(true); + }); + + describe('clicking "switch to old editor"', () => { + beforeEach(() => { + return findSwitchToOldEditorButton().trigger('click'); + }); + + it('switches to old editor directly without showing a modal', () => { + expect(wrapper.findComponent(ContentEditor).exists()).toBe(false); + expect(wrapper.findComponent(MarkdownField).exists()).toBe(true); + }); + }); + }); + + describe('clicking "use new editor": editor loads successfully', () => { + beforeEach(() => { + mock.onPost(/preview-markdown/).reply(200, { body: '<p>hello <strong>world</strong></p>' }); + + findUseNewEditorButton().trigger('click'); + }); + + it('shows a loading indicator for the rich text editor', () => { + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + + it('shows warnings that the rich text editor is in beta and may not work properly', () => { + expect(wrapper.text()).toContain( + "Switching will discard any changes you've made in the new editor.", + ); + expect(wrapper.text()).toContain( + "This editor is in beta and may not display the page's contents properly.", + ); + }); + + it('shows the rich text editor when loading finishes', async () => { + // wait for content editor to load + await waitForPromises(); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.findComponent(ContentEditor).exists()).toBe(true); + }); + + it('disables the format dropdown', () => { + expect(findFormat().element.getAttribute('disabled')).toBeDefined(); + }); + + describe('when wiki content is updated', () => { + beforeEach(async () => { + // wait for content editor to load + await waitForPromises(); + + wrapper.vm.contentEditor.tiptapEditor.commands.setContent( + '<p>hello __world__ from content editor</p>', + true, + ); + + return wrapper.vm.$nextTick(); + }); + + it('sets before unload warning', () => { + const e = dispatchBeforeUnload(); + expect(e.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('unsets before unload warning on form submit', async () => { + triggerFormSubmit(); + + await wrapper.vm.$nextTick(); + + const e = dispatchBeforeUnload(); + expect(e.preventDefault).not.toHaveBeenCalled(); + }); + }); + + it('updates content from content editor on form submit', async () => { + // old value + expect(findContent().element.value).toBe('My page content'); + + // wait for content editor to load + await waitForPromises(); + + triggerFormSubmit(); + + await wrapper.vm.$nextTick(); + + expect(findContent().element.value).toBe('hello **world**'); + }); + + describe('clicking "switch to old editor"', () => { + let modal; + + beforeEach(async () => { + modal = wrapper.findComponent(GlModal); + jest.spyOn(modal.vm, 'show'); + + findSwitchToOldEditorButton().trigger('click'); + }); + + it('shows a modal confirming the change', () => { + expect(modal.vm.show).toHaveBeenCalled(); + }); + + describe('confirming "switch to old editor" in the modal', () => { + beforeEach(async () => { + wrapper.vm.contentEditor.tiptapEditor.commands.setContent( + '<p>hello __world__ from content editor</p>', + true, + ); + + wrapper.findComponent(GlModal).vm.$emit('primary'); + + await wrapper.vm.$nextTick(); + }); + + it('switches to old editor', () => { + expect(wrapper.findComponent(ContentEditor).exists()).toBe(false); + expect(wrapper.findComponent(MarkdownField).exists()).toBe(true); + }); + + it('does not show a warning about content editor', () => { + expect(wrapper.text()).not.toContain( + "This editor is in beta and may not display the page's contents properly.", + ); + }); + + it('the old editor retains its old value and does not use the content from the content editor', () => { + expect(findContent().element.value).toBe('My page content'); + }); + }); + }); + }); + }); }); diff --git a/spec/frontend/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js b/spec/frontend/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js new file mode 100644 index 00000000000..8a4f07c4d88 --- /dev/null +++ b/spec/frontend/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js @@ -0,0 +1,47 @@ +import { getByRole } from '@testing-library/dom'; +import { mount } from '@vue/test-utils'; +import FirstPipelineCard from '~/pipeline_editor/components/drawer/cards/first_pipeline_card.vue'; +import PipelineVisualReference from '~/pipeline_editor/components/drawer/ui/pipeline_visual_reference.vue'; + +describe('First pipeline card', () => { + let wrapper; + + const defaultProvide = { + ciExamplesHelpPagePath: '/pipelines/examples', + runnerHelpPagePath: '/help/runners', + }; + + const createComponent = () => { + wrapper = mount(FirstPipelineCard, { + provide: { + ...defaultProvide, + }, + }); + }; + + const getLinkByName = (name) => getByRole(wrapper.element, 'link', { name }).href; + const findPipelinesLink = () => getLinkByName(/examples and templates/i); + const findRunnersLink = () => getLinkByName(/make sure your instance has runners available/i); + const findVisualReference = () => wrapper.findComponent(PipelineVisualReference); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the title', () => { + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.title); + }); + + it('renders the content', () => { + expect(findVisualReference().exists()).toBe(true); + }); + + it('renders the links', () => { + expect(findRunnersLink()).toContain(defaultProvide.runnerHelpPagePath); + expect(findPipelinesLink()).toContain(defaultProvide.ciExamplesHelpPagePath); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/drawer/cards/getting_started_card_spec.js b/spec/frontend/pipeline_editor/components/drawer/cards/getting_started_card_spec.js new file mode 100644 index 00000000000..c592e959068 --- /dev/null +++ b/spec/frontend/pipeline_editor/components/drawer/cards/getting_started_card_spec.js @@ -0,0 +1,26 @@ +import { shallowMount } from '@vue/test-utils'; +import GettingStartedCard from '~/pipeline_editor/components/drawer/cards/getting_started_card.vue'; + +describe('Getting started card', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(GettingStartedCard); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the title', () => { + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.title); + }); + + it('renders the content', () => { + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.firstParagraph); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js b/spec/frontend/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js new file mode 100644 index 00000000000..3c8821d05a7 --- /dev/null +++ b/spec/frontend/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js @@ -0,0 +1,51 @@ +import { getByRole } from '@testing-library/dom'; +import { mount } from '@vue/test-utils'; +import PipelineConfigReferenceCard from '~/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue'; + +describe('Pipeline config reference card', () => { + let wrapper; + + const defaultProvide = { + ciExamplesHelpPagePath: 'help/ci/examples/', + ciHelpPagePath: 'help/ci/introduction', + needsHelpPagePath: 'help/ci/yaml#needs', + ymlHelpPagePath: 'help/ci/yaml', + }; + + const createComponent = () => { + wrapper = mount(PipelineConfigReferenceCard, { + provide: { + ...defaultProvide, + }, + }); + }; + + const getLinkByName = (name) => getByRole(wrapper.element, 'link', { name }).href; + const findCiExamplesLink = () => getLinkByName(/CI\/CD examples and templates/i); + const findCiIntroLink = () => getLinkByName(/GitLab CI\/CD concepts/i); + const findNeedsLink = () => getLinkByName(/Needs keyword/i); + const findYmlSyntaxLink = () => getLinkByName(/.gitlab-ci.yml syntax reference/i); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the title', () => { + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.title); + }); + + it('renders the content', () => { + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.firstParagraph); + }); + + it('renders the links', () => { + expect(findCiExamplesLink()).toContain(defaultProvide.ciExamplesHelpPagePath); + expect(findCiIntroLink()).toContain(defaultProvide.ciHelpPagePath); + expect(findNeedsLink()).toContain(defaultProvide.needsHelpPagePath); + expect(findYmlSyntaxLink()).toContain(defaultProvide.ymlHelpPagePath); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js b/spec/frontend/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js new file mode 100644 index 00000000000..bebd2484c1d --- /dev/null +++ b/spec/frontend/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js @@ -0,0 +1,26 @@ +import { shallowMount } from '@vue/test-utils'; +import VisualizeAndLintCard from '~/pipeline_editor/components/drawer/cards/getting_started_card.vue'; + +describe('Visual and Lint card', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(VisualizeAndLintCard); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the title', () => { + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.title); + }); + + it('renders the content', () => { + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.firstParagraph); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js b/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js new file mode 100644 index 00000000000..1b68cd3dc43 --- /dev/null +++ b/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js @@ -0,0 +1,142 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import FirstPipelineCard from '~/pipeline_editor/components/drawer/cards/first_pipeline_card.vue'; +import GettingStartedCard from '~/pipeline_editor/components/drawer/cards/getting_started_card.vue'; +import PipelineConfigReferenceCard from '~/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue'; +import VisualizeAndLintCard from '~/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue'; +import PipelineEditorDrawer from '~/pipeline_editor/components/drawer/pipeline_editor_drawer.vue'; +import { DRAWER_EXPANDED_KEY } from '~/pipeline_editor/constants'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; + +describe('Pipeline editor drawer', () => { + useLocalStorageSpy(); + + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(PipelineEditorDrawer, { + stubs: { LocalStorageSync }, + }); + }; + + const findFirstPipelineCard = () => wrapper.findComponent(FirstPipelineCard); + const findGettingStartedCard = () => wrapper.findComponent(GettingStartedCard); + const findPipelineConfigReferenceCard = () => wrapper.findComponent(PipelineConfigReferenceCard); + const findToggleBtn = () => wrapper.findComponent(GlButton); + const findVisualizeAndLintCard = () => wrapper.findComponent(VisualizeAndLintCard); + + const findArrowIcon = () => wrapper.find('[data-testid="toggle-icon"]'); + const findCollapseText = () => wrapper.find('[data-testid="collapse-text"]'); + const findDrawerContent = () => wrapper.find('[data-testid="drawer-content"]'); + + const clickToggleBtn = async () => findToggleBtn().vm.$emit('click'); + + afterEach(() => { + wrapper.destroy(); + localStorage.clear(); + }); + + it('it sets the drawer to be opened by default', async () => { + createComponent(); + + expect(findDrawerContent().exists()).toBe(false); + + await nextTick(); + + expect(findDrawerContent().exists()).toBe(true); + }); + + describe('when the drawer is collapsed', () => { + beforeEach(async () => { + createComponent(); + await clickToggleBtn(); + }); + + it('shows the left facing arrow icon', () => { + expect(findArrowIcon().props('name')).toBe('chevron-double-lg-left'); + }); + + it('does not show the collapse text', () => { + expect(findCollapseText().exists()).toBe(false); + }); + + it('does not show the drawer content', () => { + expect(findDrawerContent().exists()).toBe(false); + }); + + it('can open the drawer by clicking on the toggle button', async () => { + expect(findDrawerContent().exists()).toBe(false); + + await clickToggleBtn(); + + expect(findDrawerContent().exists()).toBe(true); + }); + }); + + describe('when the drawer is expanded', () => { + beforeEach(async () => { + createComponent(); + }); + + it('shows the right facing arrow icon', () => { + expect(findArrowIcon().props('name')).toBe('chevron-double-lg-right'); + }); + + it('shows the collapse text', () => { + expect(findCollapseText().exists()).toBe(true); + }); + + it('shows the drawer content', () => { + expect(findDrawerContent().exists()).toBe(true); + }); + + it('shows all the introduction cards', () => { + expect(findFirstPipelineCard().exists()).toBe(true); + expect(findGettingStartedCard().exists()).toBe(true); + expect(findPipelineConfigReferenceCard().exists()).toBe(true); + expect(findVisualizeAndLintCard().exists()).toBe(true); + }); + + it('can close the drawer by clicking on the toggle button', async () => { + expect(findDrawerContent().exists()).toBe(true); + + await clickToggleBtn(); + + expect(findDrawerContent().exists()).toBe(false); + }); + }); + + describe('local storage', () => { + it('saves the drawer expanded value to local storage', async () => { + localStorage.setItem(DRAWER_EXPANDED_KEY, 'false'); + + createComponent(); + await clickToggleBtn(); + + expect(localStorage.setItem.mock.calls).toEqual([ + [DRAWER_EXPANDED_KEY, 'false'], + [DRAWER_EXPANDED_KEY, 'true'], + ]); + }); + + it('loads the drawer collapsed when local storage is set to `false`, ', async () => { + localStorage.setItem(DRAWER_EXPANDED_KEY, false); + createComponent(); + + await nextTick(); + + expect(findDrawerContent().exists()).toBe(false); + }); + + it('loads the drawer expanded when local storage is set to `true`, ', async () => { + localStorage.setItem(DRAWER_EXPANDED_KEY, true); + createComponent(); + + await nextTick(); + + expect(findDrawerContent().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js b/spec/frontend/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js new file mode 100644 index 00000000000..edd2b45569a --- /dev/null +++ b/spec/frontend/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js @@ -0,0 +1,27 @@ +import { shallowMount } from '@vue/test-utils'; +import DemoJobPill from '~/pipeline_editor/components/drawer/ui/demo_job_pill.vue'; + +describe('Demo job pill', () => { + let wrapper; + const jobName = 'my-build-job'; + + const createComponent = () => { + wrapper = shallowMount(DemoJobPill, { + propsData: { + jobName, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the jobName', () => { + expect(wrapper.text()).toContain(jobName); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/drawer/ui/pipeline_visual_reference_spec.js b/spec/frontend/pipeline_editor/components/drawer/ui/pipeline_visual_reference_spec.js new file mode 100644 index 00000000000..e4834544484 --- /dev/null +++ b/spec/frontend/pipeline_editor/components/drawer/ui/pipeline_visual_reference_spec.js @@ -0,0 +1,31 @@ +import { shallowMount } from '@vue/test-utils'; +import DemoJobPill from '~/pipeline_editor/components/drawer/ui/demo_job_pill.vue'; +import PipelineVisualReference from '~/pipeline_editor/components/drawer/ui/pipeline_visual_reference.vue'; + +describe('Demo job pill', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(PipelineVisualReference); + }; + + const findAllDemoJobPills = () => wrapper.findAllComponents(DemoJobPill); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders all stage names', () => { + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.stageNames.build); + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.stageNames.test); + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.stageNames.deploy); + }); + + it('renders all job pills', () => { + expect(findAllDemoJobPills()).toHaveLength(4); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js index 3bf5a291c69..7a5b01fb04a 100644 --- a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js +++ b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js @@ -1,6 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import { EDITOR_READY_EVENT } from '~/editor/constants'; +import { EditorLiteExtension } from '~/editor/extensions/editor_lite_extension_base'; import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue'; import { mockCiConfigPath, @@ -59,6 +60,10 @@ describe('Pipeline Editor | Text editor component', () => { const findEditor = () => wrapper.findComponent(MockEditorLite); + beforeEach(() => { + EditorLiteExtension.deferRerender = jest.fn(); + }); + afterEach(() => { wrapper.destroy(); wrapper = null; diff --git a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js index fa937100982..d6763a7de41 100644 --- a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js +++ b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js @@ -1,11 +1,28 @@ -import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { + GlDropdown, + GlDropdownItem, + GlInfiniteScroll, + GlLoadingIcon, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import BranchSwitcher from '~/pipeline_editor/components/file_nav/branch_switcher.vue'; import { DEFAULT_FAILURE } from '~/pipeline_editor/constants'; -import { mockDefaultBranch, mockProjectBranches, mockProjectFullPath } from '../../mock_data'; +import getAvailableBranches from '~/pipeline_editor/graphql/queries/available_branches.graphql'; +import { + mockBranchPaginationLimit, + mockDefaultBranch, + mockEmptySearchBranches, + mockProjectBranches, + mockProjectFullPath, + mockSearchBranches, + mockTotalBranches, + mockTotalBranchResults, + mockTotalSearchResults, +} from '../../mock_data'; const localVue = createLocalVue(); localVue.use(VueApollo); @@ -15,30 +32,64 @@ describe('Pipeline editor branch switcher', () => { let mockApollo; let mockAvailableBranchQuery; - const createComponentWithApollo = () => { - const resolvers = { - Query: { - project: mockAvailableBranchQuery, + const createComponent = ( + { isQueryLoading, mountFn, options } = { + isQueryLoading: false, + mountFn: shallowMount, + options: {}, + }, + ) => { + wrapper = mountFn(BranchSwitcher, { + propsData: { + paginationLimit: mockBranchPaginationLimit, }, - }; - - mockApollo = createMockApollo([], resolvers); - wrapper = shallowMount(BranchSwitcher, { - localVue, - apolloProvider: mockApollo, provide: { projectFullPath: mockProjectFullPath, + totalBranches: mockTotalBranches, + }, + mocks: { + $apollo: { + queries: { + availableBranches: { + loading: isQueryLoading, + }, + }, + }, }, data() { return { + branches: ['main'], currentBranch: mockDefaultBranch, }; }, + ...options, + }); + }; + + const createComponentWithApollo = (mountFn = shallowMount) => { + const handlers = [[getAvailableBranches, mockAvailableBranchQuery]]; + mockApollo = createMockApollo(handlers); + + createComponent({ + mountFn, + options: { + localVue, + apolloProvider: mockApollo, + mocks: {}, + data() { + return { + currentBranch: mockDefaultBranch, + }; + }, + }, }); }; const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdownItems = () => wrapper.findAll(GlDropdownItem); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findInfiniteScroll = () => wrapper.findComponent(GlInfiniteScroll); beforeEach(() => { mockAvailableBranchQuery = jest.fn(); @@ -48,7 +99,7 @@ describe('Pipeline editor branch switcher', () => { wrapper.destroy(); }); - describe('while querying', () => { + describe('when querying for the first time', () => { beforeEach(() => { createComponentWithApollo(); }); @@ -61,41 +112,31 @@ describe('Pipeline editor branch switcher', () => { describe('after querying', () => { beforeEach(async () => { mockAvailableBranchQuery.mockResolvedValue(mockProjectBranches); - createComponentWithApollo(); + createComponentWithApollo(mount); await waitForPromises(); }); - it('query is called with correct variables', async () => { - expect(mockAvailableBranchQuery).toHaveBeenCalledTimes(1); - expect(mockAvailableBranchQuery).toHaveBeenCalledWith( - expect.anything(), - { - fullPath: mockProjectFullPath, - }, - expect.anything(), - expect.anything(), - ); + it('renders search box', () => { + expect(findSearchBox().exists()).toBe(true); }); it('renders list of branches', () => { expect(findDropdown().exists()).toBe(true); - expect(findDropdownItems()).toHaveLength(mockProjectBranches.repository.branches.length); + expect(findDropdownItems()).toHaveLength(mockTotalBranchResults); }); - it('renders current branch at the top of the list with a check mark', () => { - const firstDropdownItem = findDropdownItems().at(0); - const icon = firstDropdownItem.findComponent(GlIcon); + it('renders current branch with a check mark', () => { + const defaultBranchInDropdown = findDropdownItems().at(0); - expect(firstDropdownItem.text()).toBe(mockDefaultBranch); - expect(icon.exists()).toBe(true); - expect(icon.props('name')).toBe('check'); + expect(defaultBranchInDropdown.text()).toBe(mockDefaultBranch); + expect(defaultBranchInDropdown.props('isChecked')).toBe(true); }); it('does not render check mark for other branches', () => { - const secondDropdownItem = findDropdownItems().at(1); - const icon = secondDropdownItem.findComponent(GlIcon); + const nonDefaultBranch = findDropdownItems().at(1); - expect(icon.classes()).toContain('gl-visibility-hidden'); + expect(nonDefaultBranch.text()).not.toBe(mockDefaultBranch); + expect(nonDefaultBranch.props('isChecked')).toBe(false); }); }); @@ -120,4 +161,186 @@ describe('Pipeline editor branch switcher', () => { ]); }); }); + + describe('when switching branches', () => { + beforeEach(async () => { + jest.spyOn(window.history, 'pushState').mockImplementation(() => {}); + mockAvailableBranchQuery.mockResolvedValue(mockProjectBranches); + createComponentWithApollo(mount); + await waitForPromises(); + }); + + it('updates session history when selecting a different branch', async () => { + const branch = findDropdownItems().at(1); + await branch.vm.$emit('click'); + + expect(window.history.pushState).toHaveBeenCalled(); + expect(window.history.pushState.mock.calls[0][2]).toContain(`?branch_name=${branch.text()}`); + }); + + it('does not update session history when selecting current branch', async () => { + const branch = findDropdownItems().at(0); + await branch.vm.$emit('click'); + + expect(branch.text()).toBe(mockDefaultBranch); + expect(window.history.pushState).not.toHaveBeenCalled(); + }); + + it('emits the refetchContent event when selecting a different branch', async () => { + const branch = findDropdownItems().at(1); + + expect(branch.text()).not.toBe(mockDefaultBranch); + expect(wrapper.emitted('refetchContent')).toBeUndefined(); + + await branch.vm.$emit('click'); + + expect(wrapper.emitted('refetchContent')).toBeDefined(); + expect(wrapper.emitted('refetchContent')).toHaveLength(1); + }); + + it('does not emit the refetchContent event when selecting the current branch', async () => { + const branch = findDropdownItems().at(0); + + expect(branch.text()).toBe(mockDefaultBranch); + expect(wrapper.emitted('refetchContent')).toBeUndefined(); + + await branch.vm.$emit('click'); + + expect(wrapper.emitted('refetchContent')).toBeUndefined(); + }); + }); + + describe('when searching', () => { + beforeEach(async () => { + mockAvailableBranchQuery.mockResolvedValue(mockProjectBranches); + createComponentWithApollo(mount); + await waitForPromises(); + + mockAvailableBranchQuery.mockResolvedValue(mockSearchBranches); + }); + + describe('with a search term', () => { + it('calls query with correct variables', async () => { + findSearchBox().vm.$emit('input', 'te'); + await waitForPromises(); + + expect(mockAvailableBranchQuery).toHaveBeenCalledWith({ + limit: mockTotalBranches, // fetch all branches + offset: 0, + projectFullPath: mockProjectFullPath, + searchPattern: '*te*', + }); + }); + + it('fetches new list of branches', async () => { + expect(findDropdownItems()).toHaveLength(mockTotalBranchResults); + + findSearchBox().vm.$emit('input', 'te'); + await waitForPromises(); + + expect(findDropdownItems()).toHaveLength(mockTotalSearchResults); + }); + + it('does not hide dropdown when search result is empty', async () => { + mockAvailableBranchQuery.mockResolvedValue(mockEmptySearchBranches); + findSearchBox().vm.$emit('input', 'aaaaa'); + await waitForPromises(); + + expect(findDropdown().exists()).toBe(true); + expect(findDropdownItems()).toHaveLength(0); + }); + }); + + describe('without a search term', () => { + beforeEach(async () => { + findSearchBox().vm.$emit('input', 'te'); + await waitForPromises(); + + mockAvailableBranchQuery.mockResolvedValue(mockProjectBranches); + }); + + it('calls query with correct variables', async () => { + findSearchBox().vm.$emit('input', ''); + await waitForPromises(); + + expect(mockAvailableBranchQuery).toHaveBeenCalledWith({ + limit: mockBranchPaginationLimit, // only fetch first n branches first + offset: 0, + projectFullPath: mockProjectFullPath, + searchPattern: '*', + }); + }); + + it('fetches new list of branches', async () => { + expect(findDropdownItems()).toHaveLength(mockTotalSearchResults); + + findSearchBox().vm.$emit('input', ''); + await waitForPromises(); + + expect(findDropdownItems()).toHaveLength(mockTotalBranchResults); + }); + }); + }); + + describe('loading icon', () => { + test.each` + isQueryLoading | isRendered + ${true} | ${true} + ${false} | ${false} + `('checks if query is loading before rendering', ({ isQueryLoading, isRendered }) => { + createComponent({ isQueryLoading, mountFn: mount }); + + expect(findLoadingIcon().exists()).toBe(isRendered); + }); + }); + + describe('when scrolling to the bottom of the list', () => { + beforeEach(async () => { + mockAvailableBranchQuery.mockResolvedValue(mockProjectBranches); + createComponentWithApollo(); + await waitForPromises(); + }); + + afterEach(() => { + mockAvailableBranchQuery.mockClear(); + }); + + describe('when search term is empty', () => { + it('fetches more branches', async () => { + expect(mockAvailableBranchQuery).toHaveBeenCalledTimes(1); + + findInfiniteScroll().vm.$emit('bottomReached'); + await waitForPromises(); + + expect(mockAvailableBranchQuery).toHaveBeenCalledTimes(2); + }); + + it('calls the query with the correct variables', async () => { + findInfiniteScroll().vm.$emit('bottomReached'); + await waitForPromises(); + + expect(mockAvailableBranchQuery).toHaveBeenCalledWith({ + limit: mockBranchPaginationLimit, + offset: mockBranchPaginationLimit, // offset changed + projectFullPath: mockProjectFullPath, + searchPattern: '*', + }); + }); + }); + + describe('when search term exists', () => { + it('does not fetch more branches', async () => { + findSearchBox().vm.$emit('input', 'te'); + await waitForPromises(); + + expect(mockAvailableBranchQuery).toHaveBeenCalledTimes(2); + mockAvailableBranchQuery.mockClear(); + + findInfiniteScroll().vm.$emit('bottomReached'); + await waitForPromises(); + + expect(mockAvailableBranchQuery).not.toHaveBeenCalled(); + }); + }); + }); }); 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 27652bb268b..e1dc08b637f 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 @@ -7,16 +7,10 @@ import { mockCiYml, mockLintResponse } from '../../mock_data'; describe('Pipeline editor header', () => { let wrapper; - const mockProvide = { - glFeatures: { - pipelineStatusForPipelineEditor: true, - }, - }; const createComponent = ({ provide = {}, props = {} } = {}) => { wrapper = shallowMount(PipelineEditorHeader, { provide: { - ...mockProvide, ...provide, }, propsData: { @@ -56,18 +50,4 @@ describe('Pipeline editor header', () => { 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/pipeline_editor_tabs_spec.js b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js index eba853180cd..5cf8d47bc23 100644 --- a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js +++ b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js @@ -20,12 +20,6 @@ describe('Pipeline editor tabs component', () => { const MockTextEditor = { template: '<div />', }; - const mockProvide = { - glFeatures: { - ciConfigVisualizationTab: true, - ciConfigMergedTab: true, - }, - }; const createComponent = ({ props = {}, @@ -44,7 +38,7 @@ describe('Pipeline editor tabs component', () => { appStatus, }; }, - provide: { ...mockProvide, ...provide }, + provide: { ...provide }, stubs: { TextEditor: MockTextEditor, EditorTab, @@ -82,41 +76,24 @@ describe('Pipeline editor tabs component', () => { }); describe('visualization tab', () => { - describe('with feature flag on', () => { - describe('while loading', () => { - beforeEach(() => { - createComponent({ appStatus: EDITOR_APP_STATUS_LOADING }); - }); - - it('displays a loading icon if the lint query is loading', () => { - expect(findLoadingIcon().exists()).toBe(true); - expect(findPipelineGraph().exists()).toBe(false); - }); - }); - describe('after loading', () => { - beforeEach(() => { - createComponent(); - }); - - it('display the tab and visualization', () => { - expect(findVisualizationTab().exists()).toBe(true); - expect(findPipelineGraph().exists()).toBe(true); - }); + describe('while loading', () => { + beforeEach(() => { + createComponent({ appStatus: EDITOR_APP_STATUS_LOADING }); }); - }); - describe('with feature flag off', () => { + it('displays a loading icon if the lint query is loading', () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(findPipelineGraph().exists()).toBe(false); + }); + }); + describe('after loading', () => { beforeEach(() => { - createComponent({ - provide: { - glFeatures: { ciConfigVisualizationTab: false }, - }, - }); + createComponent(); }); - it('does not display the tab or component', () => { - expect(findVisualizationTab().exists()).toBe(false); - expect(findPipelineGraph().exists()).toBe(false); + it('display the tab and visualization', () => { + expect(findVisualizationTab().exists()).toBe(true); + expect(findPipelineGraph().exists()).toBe(true); }); }); }); @@ -148,51 +125,39 @@ describe('Pipeline editor tabs component', () => { }); describe('merged tab', () => { - describe('with feature flag on', () => { - describe('while loading', () => { - beforeEach(() => { - createComponent({ appStatus: EDITOR_APP_STATUS_LOADING }); - }); - - it('displays a loading icon if the lint query is loading', () => { - expect(findLoadingIcon().exists()).toBe(true); - }); + describe('while loading', () => { + beforeEach(() => { + createComponent({ appStatus: EDITOR_APP_STATUS_LOADING }); }); - describe('when there is a fetch error', () => { - beforeEach(() => { - createComponent({ appStatus: EDITOR_APP_STATUS_ERROR }); - }); - - it('show an error message', () => { - expect(findAlert().exists()).toBe(true); - expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts.loadMergedYaml); - }); + it('displays a loading icon if the lint query is loading', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); - it('does not render the `meged_preview` component', () => { - expect(findMergedPreview().exists()).toBe(false); - }); + describe('when there is a fetch error', () => { + beforeEach(() => { + createComponent({ appStatus: EDITOR_APP_STATUS_ERROR }); }); - describe('after loading', () => { - beforeEach(() => { - createComponent(); - }); + it('show an error message', () => { + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts.loadMergedYaml); + }); - it('display the tab and the merged preview component', () => { - expect(findMergedTab().exists()).toBe(true); - expect(findMergedPreview().exists()).toBe(true); - }); + it('does not render the `merged_preview` component', () => { + expect(findMergedPreview().exists()).toBe(false); }); }); - describe('with feature flag off', () => { + + describe('after loading', () => { beforeEach(() => { - createComponent({ provide: { glFeatures: { ciConfigMergedTab: false } } }); + createComponent(); }); - it('does not display the merged tab', () => { - expect(findMergedTab().exists()).toBe(false); - expect(findMergedPreview().exists()).toBe(false); + it('display the tab and the merged preview component', () => { + expect(findMergedTab().exists()).toBe(true); + expect(findMergedPreview().exists()).toBe(true); }); }); }); 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 index b444d9dcfea..76c68e21180 100644 --- 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 @@ -1,11 +1,13 @@ import { GlButton, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue'; describe('Pipeline editor empty state', () => { let wrapper; const defaultProvide = { glFeatures: { + pipelineEditorBranchSwitcher: true, pipelineEditorEmptyStateAction: false, }, emptyStateIllustrationPath: 'my/svg/path', @@ -17,6 +19,7 @@ describe('Pipeline editor empty state', () => { }); }; + const findFileNav = () => wrapper.findComponent(PipelineEditorFileNav); const findSvgImage = () => wrapper.find('img'); const findTitle = () => wrapper.find('h1'); const findConfirmButton = () => wrapper.findComponent(GlButton); @@ -45,6 +48,10 @@ describe('Pipeline editor empty state', () => { expect(findDescription().html()).toContain(wrapper.vm.$options.i18n.body); }); + it('renders the file nav', () => { + expect(findFileNav().exists()).toBe(true); + }); + describe('with feature flag off', () => { it('does not renders a CTA button', () => { expect(findConfirmButton().exists()).toBe(false); @@ -75,5 +82,17 @@ describe('Pipeline editor empty state', () => { await findConfirmButton().vm.$emit('click'); expect(wrapper.emitted(expectedEvent)).toHaveLength(1); }); + + describe('with branch switcher feature flag OFF', () => { + it('does not render the file nav', () => { + createComponent({ + provide: { + glFeatures: { pipelineEditorBranchSwitcher: false }, + }, + }); + + expect(findFileNav().exists()).toBe(false); + }); + }); }); }); diff --git a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js new file mode 100644 index 00000000000..93ebbc648fe --- /dev/null +++ b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js @@ -0,0 +1,137 @@ +import { GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { TEST_HOST } from 'helpers/test_constants'; +import CodeSnippetAlert from '~/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue'; +import { CODE_SNIPPET_SOURCES } from '~/pipeline_editor/components/code_snippet_alert/constants'; +import PipelineEditorMessages from '~/pipeline_editor/components/ui/pipeline_editor_messages.vue'; +import { + COMMIT_FAILURE, + COMMIT_SUCCESS, + DEFAULT_FAILURE, + DEFAULT_SUCCESS, + LOAD_FAILURE_UNKNOWN, +} from '~/pipeline_editor/constants'; + +describe('Pipeline Editor messages', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(PipelineEditorMessages, { + propsData: props, + }); + }; + + const findCodeSnippetAlert = () => wrapper.findComponent(CodeSnippetAlert); + const findAlert = () => wrapper.findComponent(GlAlert); + + describe('success alert', () => { + it('shows a message for successful commit type', () => { + createComponent({ successType: COMMIT_SUCCESS, showSuccess: true }); + + expect(findAlert().text()).toBe(wrapper.vm.$options.successTexts[COMMIT_SUCCESS]); + }); + + it('does not show alert when there is a successType but visibility is off', () => { + createComponent({ successType: COMMIT_SUCCESS, showSuccess: false }); + + expect(findAlert().exists()).toBe(false); + }); + + it('shows a success alert with default copy if `showSuccess` is true and the `successType` is not valid,', () => { + createComponent({ successType: 'random', showSuccess: true }); + + expect(findAlert().text()).toBe(wrapper.vm.$options.successTexts[DEFAULT_SUCCESS]); + }); + + it('emit `hide-success` event when clicking on the dismiss button', async () => { + const expectedEvent = 'hide-success'; + + createComponent({ successType: COMMIT_SUCCESS, showSuccess: true }); + expect(wrapper.emitted(expectedEvent)).not.toBeDefined(); + + await findAlert().vm.$emit('dismiss'); + + expect(wrapper.emitted(expectedEvent)).toBeDefined(); + }); + }); + + describe('failure alert', () => { + it.each` + failureType | message | expectedFailureType + ${COMMIT_FAILURE} | ${'failed commit'} | ${COMMIT_FAILURE} + ${LOAD_FAILURE_UNKNOWN} | ${'loading failure'} | ${LOAD_FAILURE_UNKNOWN} + ${'random'} | ${'error without a specified type'} | ${DEFAULT_FAILURE} + `('shows a message for $message', ({ failureType, expectedFailureType }) => { + createComponent({ failureType, showFailure: true }); + + expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[expectedFailureType]); + }); + + it('show failure reasons when there are some', () => { + const failureReasons = ['There was a problem', 'ouppps']; + createComponent({ failureType: COMMIT_FAILURE, failureReasons, showFailure: true }); + + expect(wrapper.html()).toContain(failureReasons[0]); + expect(wrapper.html()).toContain(failureReasons[1]); + }); + + it('does not show a message for error with a disabled visibility', () => { + createComponent({ failureType: 'random', showFailure: false }); + + expect(findAlert().exists()).toBe(false); + }); + + it('emit `hide-failure` event when clicking on the dismiss button', async () => { + const expectedEvent = 'hide-failure'; + + createComponent({ failureType: COMMIT_FAILURE, showFailure: true }); + expect(wrapper.emitted(expectedEvent)).not.toBeDefined(); + + await findAlert().vm.$emit('dismiss'); + + expect(wrapper.emitted(expectedEvent)).toBeDefined(); + }); + }); + + describe('code snippet alert', () => { + const setCodeSnippetUrlParam = (value) => { + global.jsdom.reconfigure({ + url: `${TEST_HOST}/?code_snippet_copied_from=${value}`, + }); + }; + + it('does not show by default', () => { + createComponent(); + + expect(findCodeSnippetAlert().exists()).toBe(false); + }); + + it.each(CODE_SNIPPET_SOURCES)('shows if URL param is %s, and cleans up URL', (source) => { + jest.spyOn(window.history, 'replaceState'); + setCodeSnippetUrlParam(source); + createComponent(); + + expect(findCodeSnippetAlert().exists()).toBe(true); + expect(window.history.replaceState).toHaveBeenCalledWith({}, document.title, `${TEST_HOST}/`); + }); + + it('does not show if URL param is invalid', () => { + setCodeSnippetUrlParam('foo_bar'); + createComponent(); + + expect(findCodeSnippetAlert().exists()).toBe(false); + }); + + it('disappears on dismiss', async () => { + setCodeSnippetUrlParam('api_fuzzing'); + createComponent(); + const alert = findCodeSnippetAlert(); + + expect(alert.exists()).toBe(true); + + await alert.vm.$emit('dismiss'); + + expect(alert.exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/pipeline_editor/graphql/__snapshots__/resolvers_spec.js.snap b/spec/frontend/pipeline_editor/graphql/__snapshots__/resolvers_spec.js.snap index 8670c44f6f6..ee5a3cb288f 100644 --- a/spec/frontend/pipeline_editor/graphql/__snapshots__/resolvers_spec.js.snap +++ b/spec/frontend/pipeline_editor/graphql/__snapshots__/resolvers_spec.js.snap @@ -17,7 +17,7 @@ Object { "environment": "prd", "except": Object { "refs": Array [ - "master@gitlab-org/gitlab", + "main@gitlab-org/gitlab", "/^release/.*$/@gitlab-org/gitlab", ], }, @@ -44,7 +44,7 @@ Object { "environment": "stg", "except": Object { "refs": Array [ - "master@gitlab-org/gitlab", + "main@gitlab-org/gitlab", "/^release/.*$/@gitlab-org/gitlab", ], }, diff --git a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js index f0932fc55d3..d39c0d80296 100644 --- a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js +++ b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js @@ -9,7 +9,6 @@ import { mockDefaultBranch, mockLintResponse, mockProjectFullPath, - mockProjectBranches, } from '../mock_data'; jest.mock('~/api', () => { @@ -47,23 +46,6 @@ describe('~/pipeline_editor/graphql/resolvers', () => { await expect(result.rawData).resolves.toBe(mockCiYml); }); }); - - describe('project', () => { - it('resolves project data with type names', async () => { - const result = await resolvers.Query.project(); - - // eslint-disable-next-line no-underscore-dangle - expect(result.__typename).toBe('Project'); - }); - - it('resolves project with available list of branches', async () => { - const result = await resolvers.Query.project(); - - expect(result.repository.branches).toHaveLength( - mockProjectBranches.repository.branches.length, - ); - }); - }); }); describe('Mutation', () => { diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index 7f651a42231..e08fce3ceb9 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -4,7 +4,7 @@ import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; export const mockProjectNamespace = 'user1'; export const mockProjectPath = 'project1'; export const mockProjectFullPath = `${mockProjectNamespace}/${mockProjectPath}`; -export const mockDefaultBranch = 'master'; +export const mockDefaultBranch = 'main'; export const mockNewMergeRequestPath = '/-/merge_requests/new'; export const mockCommitSha = 'aabbccdd'; export const mockCommitNextSha = 'eeffgghh'; @@ -139,19 +139,54 @@ export const mergeUnwrappedCiConfig = (mergedConfig) => { }; export const mockProjectBranches = { - __typename: 'Project', - repository: { - __typename: 'Repository', - branches: [ - { __typename: 'Branch', name: 'master' }, - { __typename: 'Branch', name: 'main' }, - { __typename: 'Branch', name: 'develop' }, - { __typename: 'Branch', name: 'production' }, - { __typename: 'Branch', name: 'test' }, - ], + data: { + project: { + repository: { + branchNames: [ + 'main', + 'develop', + 'production', + 'test', + 'better-feature', + 'feature-abc', + 'update-ci', + 'mock-feature', + 'test-merge-request', + 'staging', + ], + }, + }, }, }; +export const mockTotalBranchResults = + mockProjectBranches.data.project.repository.branchNames.length; + +export const mockSearchBranches = { + data: { + project: { + repository: { + branchNames: ['test', 'better-feature', 'update-ci', 'test-merge-request'], + }, + }, + }, +}; + +export const mockTotalSearchResults = mockSearchBranches.data.project.repository.branchNames.length; + +export const mockEmptySearchBranches = { + data: { + project: { + repository: { + branchNames: [], + }, + }, + }, +}; + +export const mockBranchPaginationLimit = 10; +export const mockTotalBranches = 20; // must be greater than mockBranchPaginationLimit to test pagination + export const mockProjectPipeline = { pipeline: { commitPath: '/-/commit/aabbccdd', @@ -186,7 +221,7 @@ export const mockLintResponse = { when: 'on_success', allow_failure: false, only: null, - except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, + except: { refs: ['main@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, }, { name: 'job_2', @@ -199,7 +234,7 @@ export const mockLintResponse = { when: 'on_success', allow_failure: true, only: { refs: ['web', 'chat', 'pushes'] }, - except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, + except: { refs: ['main@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, }, ], }; @@ -242,7 +277,7 @@ export const mockJobs = [ when: 'on_success', allowFailure: false, only: { refs: ['branches@gitlab-org/gitlab'] }, - except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, + except: { refs: ['main@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, }, ]; diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index d8e3436479c..c88fe159c0d 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -2,17 +2,15 @@ import { GlAlert, GlButton, GlLoadingIcon, GlTabs } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; -import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import httpStatusCodes from '~/lib/utils/http_status'; -import CodeSnippetAlert from '~/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue'; -import { CODE_SNIPPET_SOURCES } from '~/pipeline_editor/components/code_snippet_alert/constants'; 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 PipelineEditorMessages from '~/pipeline_editor/components/ui/pipeline_editor_messages.vue'; +import { COMMIT_SUCCESS, COMMIT_FAILURE } from '~/pipeline_editor/constants'; import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.graphql'; import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue'; import PipelineEditorHome from '~/pipeline_editor/pipeline_editor_home.vue'; @@ -56,6 +54,7 @@ describe('Pipeline editor app component', () => { CommitForm, PipelineEditorHome, PipelineEditorTabs, + PipelineEditorMessages, EditorLite: MockEditorLite, PipelineEditorEmptyState, }, @@ -92,6 +91,11 @@ describe('Pipeline editor app component', () => { const options = { localVue, + data() { + return { + currentBranch: mockDefaultBranch, + }; + }, mocks: {}, apolloProvider: mockApollo, }; @@ -108,7 +112,6 @@ describe('Pipeline editor app component', () => { const findEmptyState = () => wrapper.findComponent(PipelineEditorEmptyState); const findEmptyStateButton = () => wrapper.findComponent(PipelineEditorEmptyState).findComponent(GlButton); - const findCodeSnippetAlert = () => wrapper.findComponent(CodeSnippetAlert); beforeEach(() => { mockBlobContentData = jest.fn(); @@ -116,9 +119,6 @@ describe('Pipeline editor app component', () => { }); afterEach(() => { - mockBlobContentData.mockReset(); - mockCiConfigData.mockReset(); - wrapper.destroy(); }); @@ -131,48 +131,6 @@ describe('Pipeline editor app component', () => { }); }); - describe('code snippet alert', () => { - const setCodeSnippetUrlParam = (value) => { - global.jsdom.reconfigure({ - url: `${TEST_HOST}/?code_snippet_copied_from=${value}`, - }); - }; - - it('does not show by default', () => { - createComponent(); - - expect(findCodeSnippetAlert().exists()).toBe(false); - }); - - it.each(CODE_SNIPPET_SOURCES)('shows if URL param is %s, and cleans up URL', (source) => { - jest.spyOn(window.history, 'replaceState'); - setCodeSnippetUrlParam(source); - createComponent(); - - expect(findCodeSnippetAlert().exists()).toBe(true); - expect(window.history.replaceState).toHaveBeenCalledWith({}, document.title, `${TEST_HOST}/`); - }); - - it('does not show if URL param is invalid', () => { - setCodeSnippetUrlParam('foo_bar'); - createComponent(); - - expect(findCodeSnippetAlert().exists()).toBe(false); - }); - - it('disappears on dismiss', async () => { - setCodeSnippetUrlParam('api_fuzzing'); - createComponent(); - const alert = findCodeSnippetAlert(); - - expect(alert.exists()).toBe(true); - - await alert.vm.$emit('dismiss'); - - expect(alert.exists()).toBe(false); - }); - }); - describe('when queries are called', () => { beforeEach(() => { mockBlobContentData.mockResolvedValue(mockCiYml); @@ -233,11 +191,14 @@ describe('Pipeline editor app component', () => { describe('because of a fetching error', () => { it('shows a unkown error message', async () => { + const loadUnknownFailureText = 'The CI configuration was not loaded, please try again.'; + mockBlobContentData.mockRejectedValueOnce(new Error('My error!')); await createComponentWithApollo(); expect(findEmptyState().exists()).toBe(false); - expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[LOAD_FAILURE_UNKNOWN]); + + expect(findAlert().text()).toBe(loadUnknownFailureText); expect(findEditorHome().exists()).toBe(true); }); }); @@ -271,6 +232,7 @@ describe('Pipeline editor app component', () => { describe('when the user commits', () => { const updateFailureMessage = 'The GitLab CI configuration could not be updated.'; + const updateSuccessMessage = 'Your changes have been successfully committed.'; describe('and the commit mutation succeeds', () => { beforeEach(() => { @@ -281,7 +243,7 @@ describe('Pipeline editor app component', () => { }); it('shows a confirmation message', () => { - expect(findAlert().text()).toBe(wrapper.vm.$options.successTexts[COMMIT_SUCCESS]); + expect(findAlert().text()).toBe(updateSuccessMessage); }); it('scrolls to the top of the page to bring attention to the confirmation message', () => { @@ -337,4 +299,37 @@ describe('Pipeline editor app component', () => { }); }); }); + + describe('when refetching content', () => { + it('refetches blob content', async () => { + await createComponentWithApollo(); + jest + .spyOn(wrapper.vm.$apollo.queries.initialCiFileContent, 'refetch') + .mockImplementation(jest.fn()); + + expect(wrapper.vm.$apollo.queries.initialCiFileContent.refetch).toHaveBeenCalledTimes(0); + + await wrapper.vm.refetchContent(); + + expect(wrapper.vm.$apollo.queries.initialCiFileContent.refetch).toHaveBeenCalledTimes(1); + }); + + it('hides start screen when refetch fetches CI file', async () => { + mockBlobContentData.mockRejectedValue({ + response: { + status: httpStatusCodes.NOT_FOUND, + }, + }); + await createComponentWithApollo(); + + expect(findEmptyState().exists()).toBe(true); + expect(findEditorHome().exists()).toBe(false); + + mockBlobContentData.mockResolvedValue(mockCiYml); + await wrapper.vm.$apollo.queries.initialCiFileContent.refetch(); + + expect(findEmptyState().exists()).toBe(false); + expect(findEditorHome().exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js index a1e3d24acfa..7aba336b8e8 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js @@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue'; +import PipelineEditorDrawer from '~/pipeline_editor/components/drawer/pipeline_editor_drawer.vue'; import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue'; import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; @@ -13,7 +14,7 @@ import { mockLintResponse, mockCiYml } from './mock_data'; describe('Pipeline editor home wrapper', () => { let wrapper; - const createComponent = ({ props = {} } = {}) => { + const createComponent = ({ props = {}, glFeatures = {} } = {}) => { wrapper = shallowMount(PipelineEditorHome, { propsData: { ciConfigData: mockLintResponse, @@ -22,13 +23,20 @@ describe('Pipeline editor home wrapper', () => { isNewCiConfigFile: false, ...props, }, + provide: { + glFeatures: { + pipelineEditorDrawer: true, + ...glFeatures, + }, + }, }); }; - const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorHeader); - const findPipelineEditorTabs = () => wrapper.findComponent(PipelineEditorTabs); const findCommitSection = () => wrapper.findComponent(CommitSection); const findFileNav = () => wrapper.findComponent(PipelineEditorFileNav); + const findPipelineEditorDrawer = () => wrapper.findComponent(PipelineEditorDrawer); + const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorHeader); + const findPipelineEditorTabs = () => wrapper.findComponent(PipelineEditorTabs); afterEach(() => { wrapper.destroy(); @@ -55,6 +63,10 @@ describe('Pipeline editor home wrapper', () => { it('shows the commit section by default', () => { expect(findCommitSection().exists()).toBe(true); }); + + it('show the pipeline drawer', () => { + expect(findPipelineEditorDrawer().exists()).toBe(true); + }); }); describe('commit form toggle', () => { @@ -82,4 +94,12 @@ describe('Pipeline editor home wrapper', () => { expect(findCommitSection().exists()).toBe(true); }); }); + + describe('Pipeline drawer', () => { + it('hides the drawer when the feature flag is off', () => { + createComponent({ glFeatures: { pipelineEditorDrawer: false } }); + + expect(findPipelineEditorDrawer().exists()).toBe(false); + }); + }); }); 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 7ec5818010a..2a3f4f56f36 100644 --- a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js +++ b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js @@ -1,13 +1,22 @@ import { GlForm, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import CreditCardValidationRequiredAlert from 'ee_component/billings/components/cc_validation_required_alert.vue'; +import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; 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 RefsDropdown from '~/pipeline_new/components/refs_dropdown.vue'; -import { mockQueryParams, mockPostParams, mockProjectId, mockError, mockRefs } from '../mock_data'; +import { + mockQueryParams, + mockPostParams, + mockProjectId, + mockError, + mockRefs, + mockCreditCardValidationRequiredError, +} from '../mock_data'; jest.mock('~/lib/utils/url_utility', () => ({ redirectTo: jest.fn(), @@ -17,7 +26,7 @@ const projectRefsEndpoint = '/root/project/refs'; const pipelinesPath = '/root/project/-/pipelines'; const configVariablesPath = '/root/project/-/pipelines/config_variables'; const newPipelinePostResponse = { id: 1 }; -const defaultBranch = 'master'; +const defaultBranch = 'main'; describe('Pipeline New Form', () => { let wrapper; @@ -187,13 +196,13 @@ describe('Pipeline New Form', () => { await waitForPromises(); }); it('variables persist between ref changes', async () => { - selectBranch('master'); + selectBranch('main'); await waitForPromises(); - const masterInput = findKeyInputs().at(0); - masterInput.element.value = 'build_var'; - masterInput.trigger('change'); + const mainInput = findKeyInputs().at(0); + mainInput.element.value = 'build_var'; + mainInput.trigger('change'); await wrapper.vm.$nextTick(); @@ -207,7 +216,7 @@ describe('Pipeline New Form', () => { await wrapper.vm.$nextTick(); - selectBranch('master'); + selectBranch('main'); await waitForPromises(); @@ -376,6 +385,32 @@ describe('Pipeline New Form', () => { it('re-enables the submit button', () => { expect(findSubmitButton().props('disabled')).toBe(false); }); + + it('does not show the credit card validation required alert', () => { + expect(wrapper.findComponent(CreditCardValidationRequiredAlert).exists()).toBe(false); + }); + + describe('when the error response is credit card validation required', () => { + beforeEach(async () => { + mock + .onPost(pipelinesPath) + .reply(httpStatusCodes.BAD_REQUEST, mockCreditCardValidationRequiredError); + + window.gon = { + subscriptions_url: TEST_HOST, + payment_form_url: TEST_HOST, + }; + + findForm().vm.$emit('submit', dummySubmitEvent); + + await waitForPromises(); + }); + + it('shows credit card validation required alert', () => { + expect(findErrorAlert().exists()).toBe(false); + expect(wrapper.findComponent(CreditCardValidationRequiredAlert).exists()).toBe(true); + }); + }); }); describe('when the error response cannot be handled', () => { diff --git a/spec/frontend/pipeline_new/components/refs_dropdown_spec.js b/spec/frontend/pipeline_new/components/refs_dropdown_spec.js index 8dafbf230f9..826f2826d3c 100644 --- a/spec/frontend/pipeline_new/components/refs_dropdown_spec.js +++ b/spec/frontend/pipeline_new/components/refs_dropdown_spec.js @@ -10,8 +10,8 @@ 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'; +const refShortName = 'main'; +const refFullName = 'refs/heads/main'; jest.mock('~/flash'); diff --git a/spec/frontend/pipeline_new/mock_data.js b/spec/frontend/pipeline_new/mock_data.js index 4fb58cb8e62..e99684ff417 100644 --- a/spec/frontend/pipeline_new/mock_data.js +++ b/spec/frontend/pipeline_new/mock_data.js @@ -1,5 +1,5 @@ export const mockRefs = { - Branches: ['master', 'branch-1', 'branch-2'], + Branches: ['main', 'branch-1', 'branch-2'], Tags: ['1.0.0', '1.1.0', '1.2.0'], }; @@ -40,6 +40,28 @@ export const mockError = { total_warnings: 7, }; -export const mockBranchRefs = ['master', 'dev', 'release']; +export const mockCreditCardValidationRequiredError = { + errors: ['Credit card required to be on file in order to create a pipeline'], + warnings: [], + total_warnings: 0, +}; + +export const mockBranchRefs = ['main', 'dev', 'release']; export const mockTagRefs = ['1.0.0', '1.1.0', '1.2.0']; + +export const mockVariables = [ + { + uniqueId: 'var-refs/heads/main2', + variable_type: 'env_var', + key: 'var_without_value', + value: '', + }, + { + uniqueId: 'var-refs/heads/main3', + variable_type: 'env_var', + key: 'var_with_value', + value: 'test_value', + }, + { uniqueId: 'var-refs/heads/main4', variable_type: 'env_var', key: '', value: '' }, +]; diff --git a/spec/frontend/pipeline_new/utils/filter_variables_spec.js b/spec/frontend/pipeline_new/utils/filter_variables_spec.js new file mode 100644 index 00000000000..42bc6244456 --- /dev/null +++ b/spec/frontend/pipeline_new/utils/filter_variables_spec.js @@ -0,0 +1,21 @@ +import filterVariables from '~/pipeline_new/utils/filter_variables'; +import { mockVariables } from '../mock_data'; + +describe('Filter variables utility function', () => { + it('filters variables that do not contain a key', () => { + const expectedVaraibles = [ + { + variable_type: 'env_var', + key: 'var_without_value', + secret_value: '', + }, + { + variable_type: 'env_var', + key: 'var_with_value', + secret_value: 'test_value', + }, + ]; + + expect(filterVariables(mockVariables)).toEqual(expectedVaraibles); + }); +}); diff --git a/spec/frontend/pipeline_new/utils/format_refs_spec.js b/spec/frontend/pipeline_new/utils/format_refs_spec.js index 405a747c3ba..71190f55c16 100644 --- a/spec/frontend/pipeline_new/utils/format_refs_spec.js +++ b/spec/frontend/pipeline_new/utils/format_refs_spec.js @@ -5,7 +5,7 @@ import { mockBranchRefs, mockTagRefs } from '../mock_data'; describe('Format refs util', () => { it('formats branch ref correctly', () => { expect(formatRefs(mockBranchRefs, BRANCH_REF_TYPE)).toEqual([ - { fullName: 'refs/heads/master', shortName: 'master' }, + { fullName: 'refs/heads/main', shortName: 'main' }, { fullName: 'refs/heads/dev', shortName: 'dev' }, { fullName: 'refs/heads/release', shortName: 'release' }, ]); diff --git a/spec/frontend/pipelines/__snapshots__/parsing_utils_spec.js.snap b/spec/frontend/pipelines/__snapshots__/parsing_utils_spec.js.snap new file mode 100644 index 00000000000..60625d301c0 --- /dev/null +++ b/spec/frontend/pipelines/__snapshots__/parsing_utils_spec.js.snap @@ -0,0 +1,373 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DAG visualization parsing utilities generateColumnsFromLayersList matches the snapshot 1`] = ` +Array [ + Object { + "groups": Array [ + Object { + "__typename": "CiGroup", + "jobs": Array [ + Object { + "__typename": "CiJob", + "name": "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", + "needs": Array [], + "scheduledAt": null, + "status": Object { + "__typename": "DetailedStatus", + "action": Object { + "__typename": "StatusAction", + "buttonTitle": "Retry this job", + "icon": "retry", + "path": "/root/abcd-dag/-/jobs/1482/retry", + "title": "Retry", + }, + "detailsPath": "/root/abcd-dag/-/jobs/1482", + "group": "success", + "hasDetails": true, + "icon": "status_success", + "tooltip": "passed", + }, + }, + ], + "name": "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", + "size": 1, + "stageName": "build", + "status": Object { + "__typename": "DetailedStatus", + "group": "success", + "icon": "status_success", + "label": "passed", + }, + }, + Object { + "__typename": "CiGroup", + "jobs": Array [ + Object { + "__typename": "CiJob", + "name": "build_b", + "needs": Array [], + "scheduledAt": null, + "status": Object { + "__typename": "DetailedStatus", + "action": Object { + "__typename": "StatusAction", + "buttonTitle": "Retry this job", + "icon": "retry", + "path": "/root/abcd-dag/-/jobs/1515/retry", + "title": "Retry", + }, + "detailsPath": "/root/abcd-dag/-/jobs/1515", + "group": "success", + "hasDetails": true, + "icon": "status_success", + "tooltip": "passed", + }, + }, + ], + "name": "build_b", + "size": 1, + "stageName": "build", + "status": Object { + "__typename": "DetailedStatus", + "group": "success", + "icon": "status_success", + "label": "passed", + }, + }, + Object { + "__typename": "CiGroup", + "jobs": Array [ + Object { + "__typename": "CiJob", + "name": "build_c", + "needs": Array [], + "scheduledAt": null, + "status": Object { + "__typename": "DetailedStatus", + "action": Object { + "__typename": "StatusAction", + "buttonTitle": "Retry this job", + "icon": "retry", + "path": "/root/abcd-dag/-/jobs/1484/retry", + "title": "Retry", + }, + "detailsPath": "/root/abcd-dag/-/jobs/1484", + "group": "success", + "hasDetails": true, + "icon": "status_success", + "tooltip": "passed", + }, + }, + ], + "name": "build_c", + "size": 1, + "stageName": "build", + "status": Object { + "__typename": "DetailedStatus", + "group": "success", + "icon": "status_success", + "label": "passed", + }, + }, + Object { + "__typename": "CiGroup", + "jobs": Array [ + Object { + "__typename": "CiJob", + "name": "build_d 1/3", + "needs": Array [], + "scheduledAt": null, + "status": Object { + "__typename": "DetailedStatus", + "action": Object { + "__typename": "StatusAction", + "buttonTitle": "Retry this job", + "icon": "retry", + "path": "/root/abcd-dag/-/jobs/1485/retry", + "title": "Retry", + }, + "detailsPath": "/root/abcd-dag/-/jobs/1485", + "group": "success", + "hasDetails": true, + "icon": "status_success", + "tooltip": "passed", + }, + }, + Object { + "__typename": "CiJob", + "name": "build_d 2/3", + "needs": Array [], + "scheduledAt": null, + "status": Object { + "__typename": "DetailedStatus", + "action": Object { + "__typename": "StatusAction", + "buttonTitle": "Retry this job", + "icon": "retry", + "path": "/root/abcd-dag/-/jobs/1486/retry", + "title": "Retry", + }, + "detailsPath": "/root/abcd-dag/-/jobs/1486", + "group": "success", + "hasDetails": true, + "icon": "status_success", + "tooltip": "passed", + }, + }, + Object { + "__typename": "CiJob", + "name": "build_d 3/3", + "needs": Array [], + "scheduledAt": null, + "status": Object { + "__typename": "DetailedStatus", + "action": Object { + "__typename": "StatusAction", + "buttonTitle": "Retry this job", + "icon": "retry", + "path": "/root/abcd-dag/-/jobs/1487/retry", + "title": "Retry", + }, + "detailsPath": "/root/abcd-dag/-/jobs/1487", + "group": "success", + "hasDetails": true, + "icon": "status_success", + "tooltip": "passed", + }, + }, + ], + "name": "build_d", + "size": 3, + "stageName": "build", + "status": Object { + "__typename": "DetailedStatus", + "group": "success", + "icon": "status_success", + "label": "passed", + }, + }, + Object { + "__typename": "CiGroup", + "jobs": Array [ + Object { + "__typename": "CiJob", + "name": "test_c", + "needs": Array [], + "scheduledAt": null, + "status": Object { + "__typename": "DetailedStatus", + "action": null, + "detailsPath": "/root/kinder-pipe/-/pipelines/154", + "group": "success", + "hasDetails": true, + "icon": "status_success", + "tooltip": null, + }, + }, + ], + "name": "test_c", + "size": 1, + "stageName": "test", + "status": Object { + "__typename": "DetailedStatus", + "group": "success", + "icon": "status_success", + "label": null, + }, + }, + ], + "id": "layer-0", + "name": "", + "status": Object { + "action": null, + }, + }, + Object { + "groups": Array [ + Object { + "__typename": "CiGroup", + "jobs": Array [ + Object { + "__typename": "CiJob", + "name": "test_a", + "needs": Array [ + "build_c", + "build_b", + "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", + ], + "scheduledAt": null, + "status": Object { + "__typename": "DetailedStatus", + "action": Object { + "__typename": "StatusAction", + "buttonTitle": "Retry this job", + "icon": "retry", + "path": "/root/abcd-dag/-/jobs/1514/retry", + "title": "Retry", + }, + "detailsPath": "/root/abcd-dag/-/jobs/1514", + "group": "success", + "hasDetails": true, + "icon": "status_success", + "tooltip": "passed", + }, + }, + ], + "name": "test_a", + "size": 1, + "stageName": "test", + "status": Object { + "__typename": "DetailedStatus", + "group": "success", + "icon": "status_success", + "label": "passed", + }, + }, + Object { + "__typename": "CiGroup", + "jobs": Array [ + Object { + "__typename": "CiJob", + "name": "test_b 1/2", + "needs": Array [ + "build_d 3/3", + "build_d 2/3", + "build_d 1/3", + "build_b", + "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", + ], + "scheduledAt": null, + "status": Object { + "__typename": "DetailedStatus", + "action": Object { + "__typename": "StatusAction", + "buttonTitle": "Retry this job", + "icon": "retry", + "path": "/root/abcd-dag/-/jobs/1489/retry", + "title": "Retry", + }, + "detailsPath": "/root/abcd-dag/-/jobs/1489", + "group": "success", + "hasDetails": true, + "icon": "status_success", + "tooltip": "passed", + }, + }, + Object { + "__typename": "CiJob", + "name": "test_b 2/2", + "needs": Array [ + "build_d 3/3", + "build_d 2/3", + "build_d 1/3", + "build_b", + "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", + ], + "scheduledAt": null, + "status": Object { + "__typename": "DetailedStatus", + "action": Object { + "__typename": "StatusAction", + "buttonTitle": "Retry this job", + "icon": "retry", + "path": "/root/abcd-dag/-/jobs/1490/retry", + "title": "Retry", + }, + "detailsPath": "/root/abcd-dag/-/jobs/1490", + "group": "success", + "hasDetails": true, + "icon": "status_success", + "tooltip": "passed", + }, + }, + ], + "name": "test_b", + "size": 2, + "stageName": "test", + "status": Object { + "__typename": "DetailedStatus", + "group": "success", + "icon": "status_success", + "label": "passed", + }, + }, + Object { + "__typename": "CiGroup", + "jobs": Array [ + Object { + "__typename": "CiJob", + "name": "test_d", + "needs": Array [ + "build_b", + ], + "scheduledAt": null, + "status": Object { + "__typename": "DetailedStatus", + "action": null, + "detailsPath": "/root/abcd-dag/-/pipelines/153", + "group": "success", + "hasDetails": true, + "icon": "status_success", + "tooltip": null, + }, + }, + ], + "name": "test_d", + "size": 1, + "stageName": "test", + "status": Object { + "__typename": "DetailedStatus", + "group": "success", + "icon": "status_success", + "label": null, + }, + }, + ], + "id": "layer-1", + "name": "", + "status": Object { + "action": null, + }, + }, +] +`; diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js index e43aa2a02f5..b0dbba37b94 100644 --- a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js +++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js @@ -4,6 +4,7 @@ import MockAdapter from 'axios-mock-adapter'; import Api from '~/api'; import axios from '~/lib/utils/axios_utils'; import PipelinesFilteredSearch from '~/pipelines/components/pipelines_list/pipelines_filtered_search.vue'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import { users, mockSearch, branches, tags } from '../mock_data'; describe('Pipelines filtered search', () => { @@ -57,7 +58,7 @@ describe('Pipelines filtered search', () => { title: 'Trigger author', unique: true, projectId: '21', - operators: [expect.objectContaining({ value: '=' })], + operators: OPERATOR_IS_ONLY, }); expect(findBranchToken()).toMatchObject({ @@ -66,7 +67,7 @@ describe('Pipelines filtered search', () => { title: 'Branch name', unique: true, projectId: '21', - operators: [expect.objectContaining({ value: '=' })], + operators: OPERATOR_IS_ONLY, }); expect(findStatusToken()).toMatchObject({ @@ -74,7 +75,7 @@ describe('Pipelines filtered search', () => { icon: 'status', title: 'Status', unique: true, - operators: [expect.objectContaining({ value: '=' })], + operators: OPERATOR_IS_ONLY, }); expect(findTagToken()).toMatchObject({ @@ -82,7 +83,7 @@ describe('Pipelines filtered search', () => { icon: 'tag', title: 'Tag name', unique: true, - operators: [expect.objectContaining({ value: '=' })], + operators: OPERATOR_IS_ONLY, }); }); @@ -138,7 +139,7 @@ describe('Pipelines filtered search', () => { describe('Url query params', () => { const params = { username: 'deja.green', - ref: 'master', + ref: 'main', }; beforeEach(() => { diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js index e8fb036368a..30914ba99a5 100644 --- a/spec/frontend/pipelines/graph/graph_component_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_spec.js @@ -22,6 +22,7 @@ describe('graph component', () => { const defaultProps = { pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'), + showLinks: false, viewType: STAGE_VIEW, configPaths: { metricsPath: '', diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js index 8c469966be4..4914a9a1ced 100644 --- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -15,8 +15,10 @@ import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; import PipelineGraphWrapper from '~/pipelines/components/graph/graph_component_wrapper.vue'; import GraphViewSelector from '~/pipelines/components/graph/graph_view_selector.vue'; import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; +import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; import * as parsingUtils from '~/pipelines/components/parsing_utils'; -import { mockPipelineResponse } from './mock_data'; +import getUserCallouts from '~/pipelines/graphql/queries/get_user_callouts.query.graphql'; +import { mapCallouts, mockCalloutsResponse, mockPipelineResponse } from './mock_data'; const defaultProvide = { graphqlResourceEtag: 'frog/amphibirama/etag/', @@ -30,13 +32,16 @@ describe('Pipeline graph wrapper', () => { useLocalStorageSpy(); let wrapper; - const getAlert = () => wrapper.find(GlAlert); - const getLoadingIcon = () => wrapper.find(GlLoadingIcon); + const getAlert = () => wrapper.findComponent(GlAlert); + const getDependenciesToggle = () => wrapper.find('[data-testid="show-links-toggle"]'); + const getLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const getLinksLayer = () => wrapper.findComponent(LinksLayer); const getGraph = () => wrapper.find(PipelineGraph); const getStageColumnTitle = () => wrapper.find('[data-testid="stage-column-title"]'); const getAllStageColumnGroupsInColumn = () => wrapper.find(StageColumnComponent).findAll('[data-testid="stage-column-group"]'); const getViewSelector = () => wrapper.find(GraphViewSelector); + const getViewSelectorTrip = () => getViewSelector().findComponent(GlAlert); const createComponent = ({ apolloProvider, @@ -59,14 +64,22 @@ describe('Pipeline graph wrapper', () => { }; const createComponentWithApollo = ({ + calloutsList = [], + data = {}, getPipelineDetailsHandler = jest.fn().mockResolvedValue(mockPipelineResponse), mountFn = shallowMount, provide = {}, } = {}) => { - const requestHandlers = [[getPipelineDetails, getPipelineDetailsHandler]]; + const callouts = mapCallouts(calloutsList); + const getUserCalloutsHandler = jest.fn().mockResolvedValue(mockCalloutsResponse(callouts)); + + const requestHandlers = [ + [getPipelineDetails, getPipelineDetailsHandler], + [getUserCallouts, getUserCalloutsHandler], + ]; const apolloProvider = createMockApollo(requestHandlers); - createComponent({ apolloProvider, provide, mountFn }); + createComponent({ apolloProvider, data, provide, mountFn }); }; afterEach(() => { @@ -74,6 +87,15 @@ describe('Pipeline graph wrapper', () => { wrapper = null; }); + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + describe('when data is loading', () => { it('displays the loading icon', () => { createComponentWithApollo(); @@ -282,6 +304,87 @@ describe('Pipeline graph wrapper', () => { }); }); + describe('when pipelineGraphLayersView feature flag is on and layers view is selected', () => { + beforeEach(async () => { + createComponentWithApollo({ + provide: { + glFeatures: { + pipelineGraphLayersView: true, + }, + }, + data: { + currentViewType: LAYER_VIEW, + }, + mountFn: mount, + }); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + }); + + it('sets showLinks to true', async () => { + /* This spec uses .props for performance reasons. */ + expect(getLinksLayer().exists()).toBe(true); + expect(getLinksLayer().props('showLinks')).toBe(false); + expect(getViewSelector().props('type')).toBe(LAYER_VIEW); + await getDependenciesToggle().trigger('click'); + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + expect(wrapper.findComponent(LinksLayer).props('showLinks')).toBe(true); + }); + }); + + describe('when pipelineGraphLayersView feature flag is on, layers view is selected, and links are active', () => { + beforeEach(async () => { + createComponentWithApollo({ + provide: { + glFeatures: { + pipelineGraphLayersView: true, + }, + }, + data: { + currentViewType: LAYER_VIEW, + showLinks: true, + }, + mountFn: mount, + }); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + }); + + it('shows the hover tip in the view selector', async () => { + await getViewSelector().setData({ showLinksActive: true }); + expect(getViewSelectorTrip().exists()).toBe(true); + }); + }); + + describe('when hover tip would otherwise show, but it has been previously dismissed', () => { + beforeEach(async () => { + createComponentWithApollo({ + provide: { + glFeatures: { + pipelineGraphLayersView: true, + }, + }, + data: { + currentViewType: LAYER_VIEW, + showLinks: true, + }, + mountFn: mount, + calloutsList: ['pipeline_needs_hover_tip'.toUpperCase()], + }); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + }); + + it('does not show the hover tip', async () => { + await getViewSelector().setData({ showLinksActive: true }); + expect(getViewSelectorTrip().exists()).toBe(false); + }); + }); + describe('when feature flag is on and local storage is set', () => { beforeEach(async () => { localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW); @@ -299,10 +402,45 @@ describe('Pipeline graph wrapper', () => { await wrapper.vm.$nextTick(); }); + afterEach(() => { + localStorage.clear(); + }); + it('reads the view type from localStorage when available', () => { - expect(wrapper.find('[data-testid="pipeline-view-selector"] code').text()).toContain( - 'needs:', - ); + const viewSelectorNeedsSegment = wrapper + .findAll('[data-testid="pipeline-view-selector"] > label') + .at(1); + expect(viewSelectorNeedsSegment.classes()).toContain('active'); + }); + }); + + describe('when feature flag is on and local storage is set, but the graph does not use needs', () => { + beforeEach(async () => { + const nonNeedsResponse = { ...mockPipelineResponse }; + nonNeedsResponse.data.project.pipeline.usesNeeds = false; + + localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW); + + createComponentWithApollo({ + provide: { + glFeatures: { + pipelineGraphLayersView: true, + }, + }, + mountFn: mount, + getPipelineDetailsHandler: jest.fn().mockResolvedValue(nonNeedsResponse), + }); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('still passes stage type to graph', () => { + expect(getGraph().props('viewType')).toBe(STAGE_VIEW); }); }); diff --git a/spec/frontend/pipelines/graph/graph_view_selector_spec.js b/spec/frontend/pipelines/graph/graph_view_selector_spec.js new file mode 100644 index 00000000000..5b2a29de443 --- /dev/null +++ b/spec/frontend/pipelines/graph/graph_view_selector_spec.js @@ -0,0 +1,189 @@ +import { GlAlert, GlLoadingIcon, GlSegmentedControl } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import { LAYER_VIEW, STAGE_VIEW } from '~/pipelines/components/graph/constants'; +import GraphViewSelector from '~/pipelines/components/graph/graph_view_selector.vue'; + +describe('the graph view selector component', () => { + let wrapper; + + const findDependenciesToggle = () => wrapper.find('[data-testid="show-links-toggle"]'); + const findViewTypeSelector = () => wrapper.findComponent(GlSegmentedControl); + const findStageViewLabel = () => findViewTypeSelector().findAll('label').at(0); + const findLayersViewLabel = () => findViewTypeSelector().findAll('label').at(1); + const findSwitcherLoader = () => wrapper.find('[data-testid="switcher-loading-state"]'); + const findToggleLoader = () => findDependenciesToggle().find(GlLoadingIcon); + const findHoverTip = () => wrapper.findComponent(GlAlert); + + const defaultProps = { + showLinks: false, + tipPreviouslyDismissed: false, + type: STAGE_VIEW, + }; + + const defaultData = { + hoverTipDismissed: false, + isToggleLoading: false, + isSwitcherLoading: false, + showLinksActive: false, + }; + + const createComponent = ({ data = {}, mountFn = shallowMount, props = {} } = {}) => { + wrapper = mountFn(GraphViewSelector, { + propsData: { + ...defaultProps, + ...props, + }, + data() { + return { + ...defaultData, + ...data, + }; + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when showing stage view', () => { + beforeEach(() => { + createComponent({ mountFn: mount }); + }); + + it('shows the Stage view label as active in the selector', () => { + expect(findStageViewLabel().classes()).toContain('active'); + }); + + it('does not show the Job dependencies (links) toggle', () => { + expect(findDependenciesToggle().exists()).toBe(false); + }); + }); + + describe('when showing Job dependencies view', () => { + beforeEach(() => { + createComponent({ + mountFn: mount, + props: { + type: LAYER_VIEW, + }, + }); + }); + + it('shows the Job dependencies view label as active in the selector', () => { + expect(findLayersViewLabel().classes()).toContain('active'); + }); + + it('shows the Job dependencies (links) toggle', () => { + expect(findDependenciesToggle().exists()).toBe(true); + }); + }); + + describe('events', () => { + beforeEach(() => { + jest.useFakeTimers(); + createComponent({ + mountFn: mount, + props: { + type: LAYER_VIEW, + }, + }); + }); + + it('shows loading state and emits updateViewType when view type toggled', async () => { + expect(wrapper.emitted().updateViewType).toBeUndefined(); + expect(findSwitcherLoader().exists()).toBe(false); + + await findStageViewLabel().trigger('click'); + /* + Loading happens before the event is emitted or timers are run. + Then we run the timer because the event is emitted in setInterval + which is what gives the loader a chace to show up. + */ + expect(findSwitcherLoader().exists()).toBe(true); + jest.runOnlyPendingTimers(); + + expect(wrapper.emitted().updateViewType).toHaveLength(1); + expect(wrapper.emitted().updateViewType).toEqual([[STAGE_VIEW]]); + }); + + it('shows loading state and emits updateShowLinks when show links toggle is clicked', async () => { + expect(wrapper.emitted().updateShowLinksState).toBeUndefined(); + expect(findToggleLoader().exists()).toBe(false); + + await findDependenciesToggle().trigger('click'); + /* + Loading happens before the event is emitted or timers are run. + Then we run the timer because the event is emitted in setInterval + which is what gives the loader a chace to show up. + */ + expect(findToggleLoader().exists()).toBe(true); + jest.runOnlyPendingTimers(); + + expect(wrapper.emitted().updateShowLinksState).toHaveLength(1); + expect(wrapper.emitted().updateShowLinksState).toEqual([[true]]); + }); + }); + + describe('hover tip callout', () => { + describe('when links are live and it has not been previously dismissed', () => { + beforeEach(() => { + createComponent({ + props: { + showLinks: true, + }, + data: { + showLinksActive: true, + }, + mountFn: mount, + }); + }); + + it('is displayed', () => { + expect(findHoverTip().exists()).toBe(true); + expect(findHoverTip().text()).toBe(wrapper.vm.$options.i18n.hoverTipText); + }); + + it('emits dismissHoverTip event when the tip is dismissed', async () => { + expect(wrapper.emitted().dismissHoverTip).toBeUndefined(); + await findHoverTip().find('button').trigger('click'); + expect(wrapper.emitted().dismissHoverTip).toHaveLength(1); + }); + }); + + describe('when links are live and it has been previously dismissed', () => { + beforeEach(() => { + createComponent({ + props: { + showLinks: true, + tipPreviouslyDismissed: true, + }, + data: { + showLinksActive: true, + }, + }); + }); + + it('is not displayed', () => { + expect(findHoverTip().exists()).toBe(false); + }); + }); + + describe('when links are not live', () => { + beforeEach(() => { + createComponent({ + props: { + showLinks: true, + }, + data: { + showLinksActive: false, + }, + }); + }); + + it('is not displayed', () => { + expect(findHoverTip().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js index 8aecfc1b649..24cc6e76098 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js @@ -26,6 +26,7 @@ describe('Linked Pipelines Column', () => { const defaultProps = { columnTitle: 'Downstream', linkedPipelines: processedPipeline.downstream, + showLinks: false, type: DOWNSTREAM, viewType: STAGE_VIEW, configPaths: { @@ -120,6 +121,26 @@ describe('Linked Pipelines Column', () => { }); }); + describe('when graph does not use needs', () => { + beforeEach(() => { + const nonNeedsResponse = { ...wrappedPipelineReturn }; + nonNeedsResponse.data.project.pipeline.usesNeeds = false; + + createComponentWithApollo({ + props: { + viewType: LAYER_VIEW, + }, + getPipelineDetailsHandler: jest.fn().mockResolvedValue(nonNeedsResponse), + mountFn: mount, + }); + }); + + it('shows the stage view, even when the main graph view type is layers', async () => { + await clickExpandButtonAndAwaitTimers(); + expect(findPipelineGraph().props('viewType')).toBe(STAGE_VIEW); + }); + }); + describe('downstream', () => { describe('when successful', () => { beforeEach(() => { diff --git a/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js b/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js index 5756a666ff3..eb05669463b 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js @@ -3727,8 +3727,8 @@ export default { scheduled_actions: [], }, ref: { - name: 'master', - path: '/h5bp/html5-boilerplate/commits/master', + name: 'main', + path: '/h5bp/html5-boilerplate/commits/main', tag: false, branch: true, merge_request: false, diff --git a/spec/frontend/pipelines/graph/mock_data.js b/spec/frontend/pipelines/graph/mock_data.js index cf420f68f37..28fe3b67e7b 100644 --- a/spec/frontend/pipelines/graph/mock_data.js +++ b/spec/frontend/pipelines/graph/mock_data.js @@ -8,6 +8,7 @@ export const mockPipelineResponse = { __typename: 'Pipeline', id: 163, iid: '22', + complete: true, usesNeeds: true, downstream: null, upstream: null, @@ -570,6 +571,7 @@ export const wrappedPipelineReturn = { __typename: 'Pipeline', id: 'gid://gitlab/Ci::Pipeline/175', iid: '38', + complete: true, usesNeeds: true, downstream: { __typename: 'PipelineConnection', @@ -669,3 +671,22 @@ export const pipelineWithUpstreamDownstream = (base) => { return generateResponse(pip, 'root/abcd-dag'); }; + +export const mapCallouts = (callouts) => + callouts.map((callout) => { + return { featureName: callout, __typename: 'UserCallout' }; + }); + +export const mockCalloutsResponse = (mappedCallouts) => ({ + data: { + currentUser: { + id: 45, + __typename: 'User', + callouts: { + id: 5, + __typename: 'UserCalloutConnection', + nodes: mappedCallouts, + }, + }, + }, +}); diff --git a/spec/frontend/pipelines/graph/mock_data_legacy.js b/spec/frontend/pipelines/graph/mock_data_legacy.js index a4a5d78f906..e1c8b027121 100644 --- a/spec/frontend/pipelines/graph/mock_data_legacy.js +++ b/spec/frontend/pipelines/graph/mock_data_legacy.js @@ -221,22 +221,22 @@ export default { cancelable: false, }, ref: { - name: 'master', - path: '/root/ci-mock/tree/master', + name: 'main', + path: '/root/ci-mock/tree/main', tag: false, branch: true, }, commit: { id: '798e5f902592192afaba73f4668ae30e56eae492', short_id: '798e5f90', - title: "Merge branch 'new-branch' into 'master'\r", + title: "Merge branch 'new-branch' into 'main'\r", created_at: '2017-04-13T10:25:17.000+01:00', parent_ids: [ '54d483b1ed156fbbf618886ddf7ab023e24f8738', 'c8e2d38a6c538822e81c57022a6e3a0cfedebbcc', ], message: - "Merge branch 'new-branch' into 'master'\r\n\r\nAdd new file\r\n\r\nSee merge request !1", + "Merge branch 'new-branch' into 'main'\r\n\r\nAdd new file\r\n\r\nSee merge request !1", author_name: 'Root', author_email: 'admin@example.com', authored_date: '2017-04-13T10:25:17.000+01:00', diff --git a/spec/frontend/pipelines/graph_shared/__snapshots__/links_inner_spec.js.snap b/spec/frontend/pipelines/graph_shared/__snapshots__/links_inner_spec.js.snap index cf2b66dea5f..c67b91ae190 100644 --- a/spec/frontend/pipelines/graph_shared/__snapshots__/links_inner_spec.js.snap +++ b/spec/frontend/pipelines/graph_shared/__snapshots__/links_inner_spec.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Links Inner component with a large number of needs matches snapshot and has expected path 1`] = ` -"<div class=\\"gl-display-flex gl-relative\\"><svg id=\\"link-svg\\" viewBox=\\"0,0,1019,445\\" width=\\"1019px\\" height=\\"445px\\" class=\\"gl-absolute gl-pointer-events-none\\"> +"<div class=\\"gl-display-flex gl-relative\\" totalgroups=\\"10\\"><svg id=\\"link-svg\\" viewBox=\\"0,0,1019,445\\" width=\\"1019px\\" height=\\"445px\\" class=\\"gl-absolute gl-pointer-events-none\\"> <path d=\\"M202,118L42,118C72,118,72,138,102,138\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> <path d=\\"M202,118L52,118C82,118,82,148,112,148\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> <path d=\\"M222,138L62,138C92,138,92,158,122,158\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> @@ -11,13 +11,13 @@ exports[`Links Inner component with a large number of needs matches snapshot and `; exports[`Links Inner component with a parallel need matches snapshot and has expected path 1`] = ` -"<div class=\\"gl-display-flex gl-relative\\"><svg id=\\"link-svg\\" viewBox=\\"0,0,1019,445\\" width=\\"1019px\\" height=\\"445px\\" class=\\"gl-absolute gl-pointer-events-none\\"> +"<div class=\\"gl-display-flex gl-relative\\" totalgroups=\\"10\\"><svg id=\\"link-svg\\" viewBox=\\"0,0,1019,445\\" width=\\"1019px\\" height=\\"445px\\" class=\\"gl-absolute gl-pointer-events-none\\"> <path d=\\"M192,108L22,108C52,108,52,118,82,118\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> </svg> </div>" `; exports[`Links Inner component with one need matches snapshot and has expected path 1`] = ` -"<div class=\\"gl-display-flex gl-relative\\"><svg id=\\"link-svg\\" viewBox=\\"0,0,1019,445\\" width=\\"1019px\\" height=\\"445px\\" class=\\"gl-absolute gl-pointer-events-none\\"> +"<div class=\\"gl-display-flex gl-relative\\" totalgroups=\\"10\\"><svg id=\\"link-svg\\" viewBox=\\"0,0,1019,445\\" width=\\"1019px\\" height=\\"445px\\" class=\\"gl-absolute gl-pointer-events-none\\"> <path d=\\"M202,118L42,118C72,118,72,138,102,138\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> </svg> </div>" `; diff --git a/spec/frontend/pipelines/graph_shared/links_inner_spec.js b/spec/frontend/pipelines/graph_shared/links_inner_spec.js index e81f046c1eb..bb1f0965469 100644 --- a/spec/frontend/pipelines/graph_shared/links_inner_spec.js +++ b/spec/frontend/pipelines/graph_shared/links_inner_spec.js @@ -1,16 +1,7 @@ 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 Api from '~/pipelines/components/graph_shared/api'; import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; -import * as sentryUtils from '~/pipelines/utils'; +import { parseData } from '~/pipelines/components/parsing_utils'; import { createJobsHash } from '~/pipelines/utils'; import { jobRect, @@ -34,8 +25,13 @@ describe('Links Inner component', () => { let wrapper; const createComponent = (props) => { + const currentPipelineData = props?.pipelineData || defaultProps.pipelineData; wrapper = shallowMount(LinksInner, { - propsData: { ...defaultProps, ...props }, + propsData: { + ...defaultProps, + ...props, + parsedData: parseData(currentPipelineData.flatMap(({ groups }) => groups)), + }, }); }; @@ -206,141 +202,4 @@ 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 / 1000 }, - { 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 5e5365eef30..932a19f2f00 100644 --- a/spec/frontend/pipelines/graph_shared/links_layer_spec.js +++ b/spec/frontend/pipelines/graph_shared/links_layer_spec.js @@ -1,32 +1,33 @@ -import { GlAlert } from '@gitlab/ui'; -import { fireEvent, within } from '@testing-library/dom'; -import { mount, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +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 Api from '~/pipelines/components/graph_shared/api'; import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; +import * as sentryUtils from '~/pipelines/utils'; import { generateResponse, mockPipelineResponse } from '../graph/mock_data'; describe('links layer component', () => { let wrapper; - const withinComponent = () => within(wrapper.element); - const findAlert = () => wrapper.find(GlAlert); - const findShowAnyways = () => - withinComponent().getByText(wrapper.vm.$options.i18n.showLinksAnyways); const findLinksInner = () => wrapper.find(LinksInner); const pipeline = generateResponse(mockPipelineResponse, 'root/fungi-xoxo'); const containerId = `pipeline-links-container-${pipeline.id}`; const slotContent = "<div>Ceci n'est pas un graphique</div>"; - const tooManyStages = Array(101) - .fill(0) - .flatMap(() => pipeline.stages); - const defaultProps = { containerId, containerMeasurements: { width: 400, height: 400 }, pipelineId: pipeline.id, pipelineData: pipeline.stages, + showLinks: false, }; const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => { @@ -46,10 +47,9 @@ describe('links layer component', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); - describe('with data under max stages', () => { + describe('with show links off', () => { beforeEach(() => { createComponent(); }); @@ -58,62 +58,174 @@ describe('links layer component', () => { expect(wrapper.html()).toContain(slotContent); }); + it('does not render inner links component', () => { + expect(findLinksInner().exists()).toBe(false); + }); + }); + + describe('with show links on', () => { + beforeEach(() => { + createComponent({ + props: { + showLinks: true, + }, + }); + }); + + it('renders the default slot', () => { + expect(wrapper.html()).toContain(slotContent); + }); + it('renders the inner links component', () => { expect(findLinksInner().exists()).toBe(true); }); }); - describe('with more than the max number of stages', () => { - describe('rendering', () => { - beforeEach(() => { - createComponent({ props: { pipelineData: tooManyStages } }); - }); + 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('renders the default slot', () => { + expect(wrapper.html()).toContain(slotContent); + }); - it('renders the alert component', () => { - expect(findAlert().exists()).toBe(true); - }); + it('does not render the inner links component', () => { + expect(findLinksInner().exists()).toBe(false); + }); + }); - it('does not render the inner links component', () => { - expect(findLinksInner().exists()).toBe(false); - }); + describe('performance metrics', () => { + const metricsPath = '/root/project/-/ci/prometheus_metrics/histograms.json'; + let markAndMeasure; + let reportToSentry; + let reportPerformance; + let mock; + + beforeEach(() => { + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb()); + markAndMeasure = jest.spyOn(perfUtils, 'performanceMarkAndMeasure'); + reportToSentry = jest.spyOn(sentryUtils, 'reportToSentry'); + reportPerformance = jest.spyOn(Api, 'reportPerformance'); }); - describe('with width or height measurement at 0', () => { + describe('with no metrics config object', () => { beforeEach(() => { - createComponent({ props: { containerMeasurements: { width: 0, height: 100 } } }); + createComponent(); }); - it('renders the default slot', () => { - expect(wrapper.html()).toContain(slotContent); + it('is not called', () => { + expect(markAndMeasure).not.toHaveBeenCalled(); + expect(reportToSentry).not.toHaveBeenCalled(); + expect(reportPerformance).not.toHaveBeenCalled(); }); + }); - it('does not render the alert component', () => { - expect(findAlert().exists()).toBe(false); + describe('with metrics config set to false', () => { + beforeEach(() => { + createComponent({ + props: { + metricsConfig: { + collectMetrics: false, + metricsPath: '/path/to/metrics', + }, + }, + }); }); - it('does not render the inner links component', () => { - expect(findLinksInner().exists()).toBe(false); + it('is not called', () => { + expect(markAndMeasure).not.toHaveBeenCalled(); + expect(reportToSentry).not.toHaveBeenCalled(); + expect(reportPerformance).not.toHaveBeenCalled(); }); }); - describe('interactions', () => { + describe('with no metrics path', () => { beforeEach(() => { - createComponent({ mountFn: mount, props: { pipelineData: tooManyStages } }); + createComponent({ + props: { + metricsConfig: { + collectMetrics: true, + metricsPath: '', + }, + }, + }); }); - it('renders the disable button', () => { - expect(findShowAnyways()).not.toBe(null); + 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 duration = 875; + const numLinks = 7; + const totalGroups = 8; + const metricsData = { + histograms: [ + { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 }, + { name: PIPELINES_DETAIL_LINKS_TOTAL, value: numLinks }, + { + name: PIPELINES_DETAIL_LINKS_JOB_RATIO, + value: numLinks / totalGroups, + }, + ], + }; + + describe('when no duration is obtained', () => { + beforeEach(() => { + jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => { + return []; + }); + + createComponent({ + props: { + metricsConfig: { + collectMetrics: true, + path: metricsPath, + }, + }, + }); + }); + + it('attempts to collect metrics', () => { + expect(markAndMeasure).toHaveBeenCalled(); + expect(reportPerformance).not.toHaveBeenCalled(); + expect(reportToSentry).not.toHaveBeenCalled(); + }); }); - it('shows links when override is clicked', async () => { - expect(findLinksInner().exists()).toBe(false); - fireEvent(findShowAnyways(), new MouseEvent('click', { bubbles: true })); - await wrapper.vm.$nextTick(); - expect(findLinksInner().exists()).toBe(true); + describe('with duration and no error', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onPost(metricsPath).reply(200, {}); + + jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => { + return [{ duration }]; + }); + + createComponent({ + props: { + metricsConfig: { + collectMetrics: true, + path: metricsPath, + }, + }, + }); + }); + + afterEach(() => { + mock.restore(); + }); + + 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/mock_data.js b/spec/frontend/pipelines/mock_data.js index 337838c41b3..16f15b20824 100644 --- a/spec/frontend/pipelines/mock_data.js +++ b/spec/frontend/pipelines/mock_data.js @@ -387,7 +387,7 @@ export const tags = [ protected: false, }, { - name: 'master-tag', + name: 'main-tag', message: '', target: '66673b07efef254dab7d537f0433a40e61cf84fe', commit: { @@ -413,10 +413,10 @@ export const tags = [ export const mockSearch = [ { type: 'username', value: { data: 'root', operator: '=' } }, - { type: 'ref', value: { data: 'master', operator: '=' } }, + { type: 'ref', value: { data: 'main', operator: '=' } }, { type: 'status', value: { data: 'pending', operator: '=' } }, ]; export const mockBranchesAfterMap = ['branch-1', 'branch-10', 'branch-11']; -export const mockTagsAfterMap = ['tag-3', 'tag-2', 'tag-1', 'master-tag']; +export const mockTagsAfterMap = ['tag-3', 'tag-2', 'tag-1', 'main-tag']; diff --git a/spec/frontend/pipelines/components/dag/parsing_utils_spec.js b/spec/frontend/pipelines/parsing_utils_spec.js index 84ff83883b7..96748ae9e5c 100644 --- a/spec/frontend/pipelines/components/dag/parsing_utils_spec.js +++ b/spec/frontend/pipelines/parsing_utils_spec.js @@ -3,12 +3,15 @@ import { createNodeDict, makeLinksFromNodes, filterByAncestors, + generateColumnsFromLayersListBare, + listByLayers, parseData, removeOrphanNodes, getMaxNodes, } from '~/pipelines/components/parsing_utils'; -import { mockParsedGraphQLNodes } from './mock_data'; +import { mockParsedGraphQLNodes } from './components/dag/mock_data'; +import { generateResponse, mockPipelineResponse } from './graph/mock_data'; describe('DAG visualization parsing utilities', () => { const nodeDict = createNodeDict(mockParsedGraphQLNodes); @@ -108,4 +111,45 @@ describe('DAG visualization parsing utilities', () => { expect(getMaxNodes(layerNodes)).toBe(3); }); }); + + describe('generateColumnsFromLayersList', () => { + const pipeline = generateResponse(mockPipelineResponse, 'root/fungi-xoxo'); + const layers = listByLayers(pipeline); + const columns = generateColumnsFromLayersListBare(pipeline, layers); + + it('returns stage-like objects with default name, id, and status', () => { + columns.forEach((col, idx) => { + expect(col).toMatchObject({ + name: '', + status: { action: null }, + id: `layer-${idx}`, + }); + }); + }); + + it('creates groups that match the list created in listByLayers', () => { + columns.forEach((col, idx) => { + const groupNames = col.groups.map(({ name }) => name); + expect(groupNames).toEqual(layers[idx]); + }); + }); + + it('looks up the correct group object', () => { + columns.forEach((col) => { + col.groups.forEach((group) => { + const groupStage = pipeline.stages.find((el) => el.name === group.stageName); + const groupObject = groupStage.groups.find((el) => el.name === group.name); + expect(group).toBe(groupObject); + }); + }); + }); + + /* + Just as a fallback in case multiple functions change, so tests pass + but the implementation moves away from case. + */ + it('matches the snapshot', () => { + expect(columns).toMatchSnapshot(); + }); + }); }); diff --git a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js index 258f2bda829..7bac7036f46 100644 --- a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js +++ b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js @@ -1,18 +1,21 @@ import { GlAlert } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { setHTMLFixture } from 'helpers/fixtures'; import { CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants'; import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import StagePill from '~/pipelines/components/pipeline_graph/stage_pill.vue'; -import { DRAW_FAILURE } from '~/pipelines/constants'; -import { invalidNeedsData, pipelineData, singleStageData } from './mock_data'; +import { pipelineData, singleStageData } from './mock_data'; describe('pipeline graph component', () => { const defaultProps = { pipelineData }; let wrapper; + const containerId = 'pipeline-graph-container-0'; + setHTMLFixture(`<div id="${containerId}"></div>`); + const createComponent = (props = defaultProps) => { return shallowMount(PipelineGraph, { propsData: { @@ -55,18 +58,7 @@ describe('pipeline graph component', () => { it('renders the graph with no status error', () => { expect(findAlert().exists()).toBe(false); expect(findPipelineGraph().exists()).toBe(true); - }); - }); - - describe('with error while rendering the links with needs', () => { - beforeEach(() => { - wrapper = createComponent({ pipelineData: invalidNeedsData }); - }); - - it('renders the error that link could not be drawn', () => { expect(findLinksLayer().exists()).toBe(true); - expect(findAlert().exists()).toBe(true); - expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[DRAW_FAILURE]); }); }); diff --git a/spec/frontend/pipelines/pipeline_multi_actions_spec.js b/spec/frontend/pipelines/pipeline_multi_actions_spec.js new file mode 100644 index 00000000000..88b3ef2032a --- /dev/null +++ b/spec/frontend/pipelines/pipeline_multi_actions_spec.js @@ -0,0 +1,112 @@ +import { GlAlert, GlDropdown, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import PipelineMultiActions, { + i18n, +} from '~/pipelines/components/pipelines_list/pipeline_multi_actions.vue'; + +describe('Pipeline Multi Actions Dropdown', () => { + let wrapper; + let mockAxios; + + const artifacts = [ + { + name: 'job my-artifact', + path: '/download/path', + }, + { + name: 'job-2 my-artifact-2', + path: '/download/path-two', + }, + ]; + const artifactItemTestId = 'artifact-item'; + const artifactsEndpointPlaceholder = ':pipeline_artifacts_id'; + const artifactsEndpoint = `endpoint/${artifactsEndpointPlaceholder}/artifacts.json`; + const pipelineId = 108; + + const createComponent = ({ mockData = {} } = {}) => { + wrapper = extendedWrapper( + shallowMount(PipelineMultiActions, { + provide: { + artifactsEndpoint, + artifactsEndpointPlaceholder, + }, + propsData: { + pipelineId, + }, + data() { + return { + ...mockData, + }; + }, + stubs: { + GlSprintf, + }, + }), + ); + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findAllArtifactItems = () => wrapper.findAllByTestId(artifactItemTestId); + const findFirstArtifactItem = () => wrapper.findByTestId(artifactItemTestId); + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + }); + + afterEach(() => { + mockAxios.restore(); + + wrapper.destroy(); + }); + + it('should render the dropdown', () => { + createComponent(); + + expect(findDropdown().exists()).toBe(true); + }); + + describe('Artifacts', () => { + it('should fetch artifacts on dropdown click', async () => { + const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId); + mockAxios.onGet(endpoint).replyOnce(200, { artifacts }); + createComponent(); + findDropdown().vm.$emit('show'); + await waitForPromises(); + + expect(mockAxios.history.get).toHaveLength(1); + expect(wrapper.vm.artifacts).toEqual(artifacts); + }); + + it('should render all the provided artifacts', () => { + createComponent({ mockData: { artifacts } }); + + expect(findAllArtifactItems()).toHaveLength(artifacts.length); + }); + + it('should render the correct artifact name and path', () => { + createComponent({ mockData: { artifacts } }); + + expect(findFirstArtifactItem().attributes('href')).toBe(artifacts[0].path); + expect(findFirstArtifactItem().text()).toBe(`Download ${artifacts[0].name} artifact`); + }); + + describe('with a failing request', () => { + it('should render an error message', async () => { + const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId); + mockAxios.onGet(endpoint).replyOnce(500); + createComponent(); + findDropdown().vm.$emit('show'); + await waitForPromises(); + + const error = findAlert(); + expect(error.exists()).toBe(true); + expect(error.text()).toBe(i18n.artifactsFetchErrorMessage); + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/pipelines_artifacts_spec.js b/spec/frontend/pipelines/pipelines_artifacts_spec.js index d4a2db08d97..336255768d7 100644 --- a/spec/frontend/pipelines/pipelines_artifacts_spec.js +++ b/spec/frontend/pipelines/pipelines_artifacts_spec.js @@ -1,23 +1,43 @@ -import { GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlDropdown, GlDropdownItem, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import PipelineArtifacts, { + i18n, +} from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; describe('Pipelines Artifacts dropdown', () => { let wrapper; + let mockAxios; - const createComponent = () => { + const artifacts = [ + { + name: 'job my-artifact', + path: '/download/path', + }, + { + name: 'job-2 my-artifact-2', + path: '/download/path-two', + }, + ]; + const artifactsEndpointPlaceholder = ':pipeline_artifacts_id'; + const artifactsEndpoint = `endpoint/${artifactsEndpointPlaceholder}/artifacts.json`; + const pipelineId = 108; + + const createComponent = ({ mockData = {} } = {}) => { wrapper = shallowMount(PipelineArtifacts, { + provide: { + artifactsEndpoint, + artifactsEndpointPlaceholder, + }, propsData: { - artifacts: [ - { - name: 'job my-artifact', - path: '/download/path', - }, - { - name: 'job-2 my-artifact-2', - path: '/download/path-two', - }, - ], + pipelineId, + }, + data() { + return { + ...mockData, + }; }, stubs: { GlSprintf, @@ -25,11 +45,14 @@ describe('Pipelines Artifacts dropdown', () => { }); }; + const findAlert = () => wrapper.findComponent(GlAlert); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findFirstGlDropdownItem = () => wrapper.find(GlDropdownItem); const findAllGlDropdownItems = () => wrapper.find(GlDropdown).findAll(GlDropdownItem); beforeEach(() => { - createComponent(); + mockAxios = new MockAdapter(axios); }); afterEach(() => { @@ -37,13 +60,66 @@ describe('Pipelines Artifacts dropdown', () => { wrapper = null; }); + it('should render the dropdown', () => { + createComponent(); + + expect(findDropdown().exists()).toBe(true); + }); + + it('should fetch artifacts on dropdown click', async () => { + const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId); + mockAxios.onGet(endpoint).replyOnce(200, { artifacts }); + createComponent(); + findDropdown().vm.$emit('show'); + await waitForPromises(); + + expect(mockAxios.history.get).toHaveLength(1); + expect(wrapper.vm.artifacts).toEqual(artifacts); + }); + it('should render a dropdown with all the provided artifacts', () => { - expect(findAllGlDropdownItems()).toHaveLength(2); + createComponent({ mockData: { artifacts } }); + + expect(findAllGlDropdownItems()).toHaveLength(artifacts.length); }); it('should render a link with the provided path', () => { - expect(findFirstGlDropdownItem().attributes('href')).toBe('/download/path'); + createComponent({ mockData: { artifacts } }); - expect(findFirstGlDropdownItem().text()).toBe('Download job my-artifact artifact'); + expect(findFirstGlDropdownItem().attributes('href')).toBe(artifacts[0].path); + + expect(findFirstGlDropdownItem().text()).toBe(`Download ${artifacts[0].name} artifact`); + }); + + describe('with a failing request', () => { + it('should render an error message', async () => { + const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId); + mockAxios.onGet(endpoint).replyOnce(500); + createComponent(); + findDropdown().vm.$emit('show'); + await waitForPromises(); + + const error = findAlert(); + expect(error.exists()).toBe(true); + expect(error.text()).toBe(i18n.artifactsFetchErrorMessage); + }); + }); + + describe('with no artifacts received', () => { + it('should render empty alert message', () => { + createComponent({ mockData: { artifacts: [] } }); + + const emptyAlert = findAlert(); + expect(emptyAlert.exists()).toBe(true); + expect(emptyAlert.text()).toBe(i18n.noArtifacts); + }); + }); + + describe('when artifacts are loading', () => { + it('should show loading icon', () => { + createComponent({ mockData: { isLoading: true } }); + + expect(findLoadingIcon().exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/pipelines/pipelines_ci_templates_spec.js b/spec/frontend/pipelines/pipelines_ci_templates_spec.js index d4cf6027ff7..0c37bf2d84a 100644 --- a/spec/frontend/pipelines/pipelines_ci_templates_spec.js +++ b/spec/frontend/pipelines/pipelines_ci_templates_spec.js @@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import ExperimentTracking from '~/experimentation/experiment_tracking'; import PipelinesCiTemplate from '~/pipelines/components/pipelines_list/pipelines_ci_templates.vue'; -const addCiYmlPath = "/-/new/master?commit_message='Add%20.gitlab-ci.yml'"; +const addCiYmlPath = "/-/new/main?commit_message='Add%20.gitlab-ci.yml'"; const suggestedCiTemplates = [ { name: 'Android', logo: '/assets/illustrations/logos/android.svg' }, { name: 'Bash', logo: '/assets/illustrations/logos/bash.svg' }, diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index 84a25f42201..f9b59c5dc48 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -1,3 +1,4 @@ +import '~/commons'; import { GlButton, GlEmptyState, GlFilteredSearch, GlLoadingIcon, GlPagination } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; @@ -6,6 +7,7 @@ import { nextTick } from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import Api from '~/api'; +import { getExperimentVariant } from '~/experimentation/utils'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import NavigationControls from '~/pipelines/components/pipelines_list/nav_controls.vue'; @@ -19,6 +21,10 @@ import TablePagination from '~/vue_shared/components/pagination/table_pagination import { stageReply, users, mockSearch, branches } from './mock_data'; jest.mock('~/flash'); +jest.mock('~/experimentation/utils', () => ({ + ...jest.requireActual('~/experimentation/utils'), + getExperimentVariant: jest.fn().mockReturnValue('control'), +})); const mockProjectPath = 'twitter/flight'; const mockProjectId = '21'; @@ -41,6 +47,7 @@ describe('Pipelines', () => { ciLintPath: '/ci/lint', resetCachePath: `${mockProjectPath}/settings/ci_cd/reset_cache`, newPipelinePath: `${mockProjectPath}/pipelines/new`, + codeQualityPagePath: `${mockProjectPath}/-/new/master?commit_message=Add+.gitlab-ci.yml+and+create+a+code+quality+job&file_name=.gitlab-ci.yml&template=Code-Quality`, }; const noPermissions = { @@ -87,7 +94,10 @@ describe('Pipelines', () => { beforeAll(() => { origWindowLocation = window.location; delete window.location; - window.location = { search: '' }; + window.location = { + search: '', + protocol: 'https:', + }; }); afterAll(() => { @@ -289,7 +299,7 @@ describe('Pipelines', () => { page: '1', scope: 'all', username: 'root', - ref: 'master', + ref: 'main', status: 'pending', }; @@ -321,7 +331,7 @@ describe('Pipelines', () => { expect(window.history.pushState).toHaveBeenCalledWith( expect.anything(), expect.anything(), - `${window.location.pathname}?page=1&scope=all&username=root&ref=master&status=pending`, + `${window.location.pathname}?page=1&scope=all&username=root&ref=main&status=pending`, ); }); }); @@ -551,6 +561,19 @@ describe('Pipelines', () => { ); }); + describe('when the code_quality_walkthrough experiment is active', () => { + beforeAll(() => { + getExperimentVariant.mockReturnValue('candidate'); + }); + + it('renders another CTA button', () => { + expect(findEmptyState().findComponent(GlButton).text()).toBe('Add a code quality job'); + expect(findEmptyState().findComponent(GlButton).attributes('href')).toBe( + paths.codeQualityPagePath, + ); + }); + }); + it('does not render filtered search', () => { expect(findFilteredSearch().exists()).toBe(false); }); diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js index 70e47b98575..68b0dfc018e 100644 --- a/spec/frontend/pipelines/pipelines_table_spec.js +++ b/spec/frontend/pipelines/pipelines_table_spec.js @@ -1,3 +1,4 @@ +import '~/commons'; import { GlTable } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -5,11 +6,11 @@ import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mi 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 CiBadge from '~/vue_shared/components/ci_badge_link.vue'; import CommitComponent from '~/vue_shared/components/commit.vue'; jest.mock('~/pipelines/event_hub'); @@ -42,7 +43,7 @@ describe('Pipelines Table', () => { }; const findGlTable = () => wrapper.findComponent(GlTable); - const findStatusBadge = () => wrapper.findComponent(PipelinesStatusBadge); + const findStatusBadge = () => wrapper.findComponent(CiBadge); const findPipelineInfo = () => wrapper.findComponent(PipelineUrl); const findTriggerer = () => wrapper.findComponent(PipelineTriggerer); const findCommit = () => wrapper.findComponent(CommitComponent); diff --git a/spec/frontend/pipelines/test_reports/empty_state_spec.js b/spec/frontend/pipelines/test_reports/empty_state_spec.js new file mode 100644 index 00000000000..ee0f8a90a11 --- /dev/null +++ b/spec/frontend/pipelines/test_reports/empty_state_spec.js @@ -0,0 +1,45 @@ +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import EmptyState, { i18n } from '~/pipelines/components/test_reports/empty_state.vue'; + +describe('Test report empty state', () => { + let wrapper; + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + const createComponent = ({ hasTestReport = true } = {}) => { + wrapper = shallowMount(EmptyState, { + provide: { + emptyStateImagePath: '/image/path', + hasTestReport, + }, + stubs: { + GlEmptyState, + }, + }); + }; + + describe('when pipeline has a test report', () => { + it('should render empty test report message', () => { + createComponent(); + + expect(findEmptyState().props()).toMatchObject({ + primaryButtonText: i18n.noTestsButton, + description: i18n.noTestsDescription, + title: i18n.noTestsTitle, + }); + }); + }); + + describe('when pipeline does not have a test report', () => { + it('should render no test report message', () => { + createComponent({ hasTestReport: false }); + + expect(findEmptyState().props()).toMatchObject({ + primaryButtonText: i18n.noReportsButton, + description: i18n.noReportsDescription, + title: i18n.noReportsTitle, + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/test_case_details_spec.js b/spec/frontend/pipelines/test_reports/test_case_details_spec.js index e866586a2c3..c995eb864d1 100644 --- a/spec/frontend/pipelines/test_reports/test_case_details_spec.js +++ b/spec/frontend/pipelines/test_reports/test_case_details_spec.js @@ -1,5 +1,6 @@ import { GlModal } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import TestCaseDetails from '~/pipelines/components/test_reports/test_case_details.vue'; import CodeBlock from '~/vue_shared/components/code_block.vue'; @@ -13,29 +14,32 @@ describe('Test case details', () => { formattedTime: '10.04ms', recent_failures: { count: 2, - base_branch: 'master', + base_branch: 'main', }, system_output: 'Line 42 is broken', }; - const findModal = () => wrapper.find(GlModal); - const findName = () => wrapper.find('[data-testid="test-case-name"]'); - const findDuration = () => wrapper.find('[data-testid="test-case-duration"]'); - const findRecentFailures = () => wrapper.find('[data-testid="test-case-recent-failures"]'); - const findSystemOutput = () => wrapper.find('[data-testid="test-case-trace"]'); + const findModal = () => wrapper.findComponent(GlModal); + const findName = () => wrapper.findByTestId('test-case-name'); + const findDuration = () => wrapper.findByTestId('test-case-duration'); + const findRecentFailures = () => wrapper.findByTestId('test-case-recent-failures'); + const findAttachmentUrl = () => wrapper.findByTestId('test-case-attachment-url'); + const findSystemOutput = () => wrapper.findByTestId('test-case-trace'); const createComponent = (testCase = {}) => { - wrapper = shallowMount(TestCaseDetails, { - localVue, - propsData: { - modalId: 'my-modal', - testCase: { - ...defaultTestCase, - ...testCase, + wrapper = extendedWrapper( + shallowMount(TestCaseDetails, { + localVue, + propsData: { + modalId: 'my-modal', + testCase: { + ...defaultTestCase, + ...testCase, + }, }, - }, - stubs: { CodeBlock, GlModal }, - }); + stubs: { CodeBlock, GlModal }, + }), + ); }; afterEach(() => { @@ -91,6 +95,25 @@ describe('Test case details', () => { }); }); + describe('when test case has attachment URL', () => { + it('renders the attachment URL as a link', () => { + const expectedUrl = '/my/path.jpg'; + createComponent({ attachment_url: expectedUrl }); + const attachmentUrl = findAttachmentUrl(); + + expect(attachmentUrl.exists()).toBe(true); + expect(attachmentUrl.attributes('href')).toBe(expectedUrl); + }); + }); + + describe('when test case does not have attachment URL', () => { + it('does not render the attachment URL', () => { + createComponent({ attachment_url: null }); + + expect(findAttachmentUrl().exists()).toBe(false); + }); + }); + describe('when test case has system output', () => { it('renders the test case system output', () => { createComponent(); diff --git a/spec/frontend/pipelines/test_reports/test_reports_spec.js b/spec/frontend/pipelines/test_reports/test_reports_spec.js index da5763ddf8e..e44d59ba888 100644 --- a/spec/frontend/pipelines/test_reports/test_reports_spec.js +++ b/spec/frontend/pipelines/test_reports/test_reports_spec.js @@ -2,6 +2,8 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { getJSONFixture } from 'helpers/fixtures'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import EmptyState from '~/pipelines/components/test_reports/empty_state.vue'; import TestReports from '~/pipelines/components/test_reports/test_reports.vue'; import TestSummary from '~/pipelines/components/test_reports/test_summary.vue'; import TestSummaryTable from '~/pipelines/components/test_reports/test_summary_table.vue'; @@ -16,11 +18,11 @@ describe('Test reports app', () => { const testReports = getJSONFixture('pipelines/test_report.json'); - const loadingSpinner = () => wrapper.find(GlLoadingIcon); - const testsDetail = () => wrapper.find('[data-testid="tests-detail"]'); - const noTestsToShow = () => wrapper.find('[data-testid="no-tests-to-show"]'); - const testSummary = () => wrapper.find(TestSummary); - const testSummaryTable = () => wrapper.find(TestSummaryTable); + const loadingSpinner = () => wrapper.findComponent(GlLoadingIcon); + const testsDetail = () => wrapper.findByTestId('tests-detail'); + const emptyState = () => wrapper.findComponent(EmptyState); + const testSummary = () => wrapper.findComponent(TestSummary); + const testSummaryTable = () => wrapper.findComponent(TestSummaryTable); const actionSpies = { fetchTestSuite: jest.fn(), @@ -29,7 +31,7 @@ describe('Test reports app', () => { removeSelectedSuiteIndex: jest.fn(), }; - const createComponent = (state = {}) => { + const createComponent = ({ state = {} } = {}) => { store = new Vuex.Store({ state: { isLoading: false, @@ -41,10 +43,12 @@ describe('Test reports app', () => { getters, }); - wrapper = shallowMount(TestReports, { - store, - localVue, - }); + wrapper = extendedWrapper( + shallowMount(TestReports, { + store, + localVue, + }), + ); }; afterEach(() => { @@ -52,33 +56,28 @@ describe('Test reports app', () => { }); describe('when component is created', () => { - beforeEach(() => { + it('should call fetchSummary when pipeline has test report', () => { createComponent(); - }); - it('should call fetchSummary', () => { expect(actionSpies.fetchSummary).toHaveBeenCalled(); }); }); describe('when loading', () => { - beforeEach(() => createComponent({ isLoading: true })); + beforeEach(() => createComponent({ state: { isLoading: true } })); it('shows the loading spinner', () => { - expect(noTestsToShow().exists()).toBe(false); + expect(emptyState().exists()).toBe(false); expect(testsDetail().exists()).toBe(false); expect(loadingSpinner().exists()).toBe(true); }); }); describe('when the api returns no data', () => { - beforeEach(() => createComponent({ testReports: {} })); - - it('displays that there are no tests to show', () => { - const noTests = noTestsToShow(); + it('displays empty state component', () => { + createComponent({ state: { testReports: {} } }); - expect(noTests.exists()).toBe(true); - expect(noTests.text()).toBe('There are no tests to show.'); + expect(emptyState().exists()).toBe(true); }); }); @@ -97,7 +96,7 @@ describe('Test reports app', () => { describe('when a suite is clicked', () => { beforeEach(() => { - createComponent({ hasFullReport: true }); + createComponent({ state: { hasFullReport: true } }); testSummaryTable().vm.$emit('row-click', 0); }); @@ -109,7 +108,7 @@ describe('Test reports app', () => { describe('when clicking back to summary', () => { beforeEach(() => { - createComponent({ selectedSuiteIndex: 0 }); + createComponent({ state: { selectedSuiteIndex: 0 } }); testSummary().vm.$emit('on-back-click'); }); diff --git a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js index 2e32d62b4bd..2e44f40eda4 100644 --- a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js @@ -89,7 +89,7 @@ describe('Pipeline Branch Name Token', () => { }); it('renders only the branch searched for', () => { - const mockBranches = ['master']; + const mockBranches = ['main']; createComponent({ stubs }, { branches: mockBranches, loading: false }); expect(findAllFilteredSearchSuggestions()).toHaveLength(mockBranches.length); diff --git a/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js index 42c9dfc9ff0..b03dbb73b95 100644 --- a/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js @@ -89,7 +89,7 @@ describe('Pipeline Branch Name Token', () => { }); it('renders only the tag searched for', () => { - const mockTags = ['master-tag']; + const mockTags = ['main-tag']; createComponent({ stubs }, { tags: mockTags, loading: false }); expect(findAllFilteredSearchSuggestions()).toHaveLength(mockTags.length); diff --git a/spec/frontend/project_find_file_spec.js b/spec/frontend/project_find_file_spec.js index 5919910d791..106b41bcc02 100644 --- a/spec/frontend/project_find_file_spec.js +++ b/spec/frontend/project_find_file_spec.js @@ -10,9 +10,9 @@ jest.mock('~/lib/dompurify', () => ({ sanitize: jest.fn((val) => val), })); -const BLOB_URL_TEMPLATE = `${TEST_HOST}/namespace/project/blob/master`; -const FILE_FIND_URL = `${TEST_HOST}/namespace/project/files/master?format=json`; -const FIND_TREE_URL = `${TEST_HOST}/namespace/project/tree/master`; +const BLOB_URL_TEMPLATE = `${TEST_HOST}/namespace/project/blob/main`; +const FILE_FIND_URL = `${TEST_HOST}/namespace/project/files/main?format=json`; +const FIND_TREE_URL = `${TEST_HOST}/namespace/project/tree/main`; const TEMPLATE = `<div class="file-finder-holder tree-holder js-file-finder" data-blob-url-template="${BLOB_URL_TEMPLATE}" data-file-find-url="${FILE_FIND_URL}" data-find-tree-url="${FIND_TREE_URL}"> <input class="file-finder-input" id="file_find" /> <div class="tree-content-holder"> diff --git a/spec/frontend/projects/compare/components/app_legacy_spec.js b/spec/frontend/projects/compare/components/app_legacy_spec.js index 93e96c8b9f7..6fdf4014575 100644 --- a/spec/frontend/projects/compare/components/app_legacy_spec.js +++ b/spec/frontend/projects/compare/components/app_legacy_spec.js @@ -7,7 +7,7 @@ jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); const projectCompareIndexPath = 'some/path'; const refsProjectPath = 'some/refs/path'; -const paramsFrom = 'master'; +const paramsFrom = 'main'; const paramsTo = 'some-other-branch'; describe('CompareApp component', () => { diff --git a/spec/frontend/projects/compare/components/app_spec.js b/spec/frontend/projects/compare/components/app_spec.js index 6de06e4373c..7989a6f3d74 100644 --- a/spec/frontend/projects/compare/components/app_spec.js +++ b/spec/frontend/projects/compare/components/app_spec.js @@ -2,26 +2,19 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import CompareApp from '~/projects/compare/components/app.vue'; import RevisionCard from '~/projects/compare/components/revision_card.vue'; +import { appDefaultProps as defaultProps } from './mock_data'; 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 findSourceRevisionCard = () => wrapper.find('[data-testid="sourceRevisionCard"]'); + const findTargetRevisionCard = () => wrapper.find('[data-testid="targetRevisionCard"]'); const createComponent = (props = {}) => { wrapper = shallowMount(CompareApp, { propsData: { - projectCompareIndexPath, - refsProjectPath, - paramsFrom, - paramsTo, - projectMergeRequestPath: '', - createMrPath: '', + ...defaultProps, ...props, }, }); @@ -39,16 +32,16 @@ describe('CompareApp component', () => { it('renders component with prop', () => { expect(wrapper.props()).toEqual( expect.objectContaining({ - projectCompareIndexPath, - refsProjectPath, - paramsFrom, - paramsTo, + projectCompareIndexPath: defaultProps.projectCompareIndexPath, + refsProjectPath: defaultProps.refsProjectPath, + paramsFrom: defaultProps.paramsFrom, + paramsTo: defaultProps.paramsTo, }), ); }); it('contains the correct form attributes', () => { - expect(wrapper.attributes('action')).toBe(projectCompareIndexPath); + expect(wrapper.attributes('action')).toBe(defaultProps.projectCompareIndexPath); expect(wrapper.attributes('method')).toBe('POST'); }); @@ -87,6 +80,58 @@ describe('CompareApp component', () => { }); }); + it('sets the selected project when the "selectProject" event is emitted', async () => { + const project = { + name: 'some-to-name', + id: '1', + }; + + findTargetRevisionCard().vm.$emit('selectProject', { + direction: 'to', + project, + }); + + await wrapper.vm.$nextTick(); + + expect(findTargetRevisionCard().props('selectedProject')).toEqual( + expect.objectContaining(project), + ); + }); + + it('sets the selected revision when the "selectRevision" event is emitted', async () => { + const revision = 'some-revision'; + + findTargetRevisionCard().vm.$emit('selectRevision', { + direction: 'to', + revision, + }); + + await wrapper.vm.$nextTick(); + + expect(findSourceRevisionCard().props('paramsBranch')).toBe(revision); + }); + + describe('swap revisions button', () => { + const findSwapRevisionsButton = () => wrapper.find('[data-testid="swapRevisionsButton"]'); + + it('renders the swap revisions button', () => { + expect(findSwapRevisionsButton().exists()).toBe(true); + }); + + it('has the correct text', () => { + expect(findSwapRevisionsButton().text()).toBe('Swap revisions'); + }); + + it('swaps revisions when clicked', async () => { + findSwapRevisionsButton().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + expect(findTargetRevisionCard().props('paramsBranch')).toBe(defaultProps.paramsTo); + expect(findSourceRevisionCard().props('paramsBranch')).toBe(defaultProps.paramsFrom); + }); + }); + describe('merge request buttons', () => { const findProjectMrButton = () => wrapper.find('[data-testid="projectMrButton"]'); const findCreateMrButton = () => wrapper.find('[data-testid="createMrButton"]'); diff --git a/spec/frontend/projects/compare/components/mock_data.js b/spec/frontend/projects/compare/components/mock_data.js new file mode 100644 index 00000000000..61309928c26 --- /dev/null +++ b/spec/frontend/projects/compare/components/mock_data.js @@ -0,0 +1,37 @@ +const refsProjectPath = 'some/refs/path'; +const paramsName = 'to'; +const paramsBranch = 'main'; +const defaultProject = { + name: 'some-to-name', + id: '1', +}; + +export const appDefaultProps = { + projectCompareIndexPath: 'some/path', + projectMergeRequestPath: '', + projects: [defaultProject], + paramsFrom: 'main', + paramsTo: 'target/branch', + createMrPath: '', + refsProjectPath, + defaultProject, +}; + +export const revisionCardDefaultProps = { + selectedProject: defaultProject, + paramsBranch, + revisionText: 'Source', + refsProjectPath, + paramsName, +}; + +export const repoDropdownDefaultProps = { + selectedProject: defaultProject, + paramsName, +}; + +export const revisionDropdownDefaultProps = { + refsProjectPath, + paramsBranch, + paramsName, +}; diff --git a/spec/frontend/projects/compare/components/repo_dropdown_spec.js b/spec/frontend/projects/compare/components/repo_dropdown_spec.js index df8fea8fd32..27a7a32ebca 100644 --- a/spec/frontend/projects/compare/components/repo_dropdown_spec.js +++ b/spec/frontend/projects/compare/components/repo_dropdown_spec.js @@ -1,37 +1,17 @@ import { GlDropdown, GlDropdownItem } 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' }, - ], -}; +import { revisionCardDefaultProps as defaultProps } from './mock_data'; describe('RepoDropdown component', () => { let wrapper; - const createComponent = (props = {}, provide = {}) => { + const createComponent = (props = {}) => { wrapper = shallowMount(RepoDropdown, { propsData: { ...defaultProps, ...props, }, - provide: { - ...defaultProvide, - ...provide, - }, }); }; @@ -49,11 +29,11 @@ describe('RepoDropdown component', () => { }); it('set hidden input', () => { - expect(findHiddenInput().attributes('value')).toBe(projectToId); + expect(findHiddenInput().attributes('value')).toBe(defaultProps.selectedProject.id); }); it('displays the project name in the disabled dropdown', () => { - expect(findGlDropdown().props('text')).toBe(projectToName); + expect(findGlDropdown().props('text')).toBe(defaultProps.selectedProject.name); expect(findGlDropdown().props('disabled')).toBe(true); }); @@ -66,31 +46,39 @@ describe('RepoDropdown component', () => { describe('Target Revision', () => { beforeEach(() => { - createComponent({ paramsName: 'from' }); + const projects = [ + { + name: 'some-to-name', + id: '1', + }, + ]; + + createComponent({ paramsName: 'from', projects }); }); it('set hidden input of the selected project', () => { - expect(findHiddenInput().attributes('value')).toBe(projectToId); + expect(findHiddenInput().attributes('value')).toBe(defaultProps.selectedProject.id); }); it('displays matching project name of the source revision initially in the dropdown', () => { - expect(findGlDropdown().props('text')).toBe(projectToName); + expect(findGlDropdown().props('text')).toBe(defaultProps.selectedProject.name); }); - it('updates the hiddin input value when onClick method is triggered', async () => { - const repoId = '100'; + it('updates the hidden input value when onClick method is triggered', async () => { + const repoId = '1'; wrapper.vm.onClick({ id: repoId }); await wrapper.vm.$nextTick(); expect(findHiddenInput().attributes('value')).toBe(repoId); }); - it('emits `changeTargetProject` event when another target project is selected', async () => { - const index = 1; - const { projectsFrom } = defaultProvide; - findGlDropdown().findAll(GlDropdownItem).at(index).vm.$emit('click'); + it('emits `selectProject` event when another target project is selected', async () => { + findGlDropdown().findAll(GlDropdownItem).at(0).vm.$emit('click'); await wrapper.vm.$nextTick(); - expect(wrapper.emitted('changeTargetProject')[0][0]).toEqual(projectsFrom[index].name); + expect(wrapper.emitted('selectProject')[0][0]).toEqual({ + direction: 'from', + project: { id: '1', name: 'some-to-name' }, + }); }); }); }); diff --git a/spec/frontend/projects/compare/components/revision_card_spec.js b/spec/frontend/projects/compare/components/revision_card_spec.js index 83f858f4454..57906045337 100644 --- a/spec/frontend/projects/compare/components/revision_card_spec.js +++ b/spec/frontend/projects/compare/components/revision_card_spec.js @@ -3,13 +3,7 @@ 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', -}; +import { revisionCardDefaultProps as defaultProps } from './mock_data'; describe('RepoDropdown component', () => { let wrapper; diff --git a/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js index ca208395e82..38e13dc5462 100644 --- a/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js +++ b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js @@ -9,7 +9,7 @@ const defaultProps = { refsProjectPath: 'some/refs/path', revisionText: 'Target', paramsName: 'from', - paramsBranch: 'master', + paramsBranch: 'main', }; jest.mock('~/flash'); diff --git a/spec/frontend/projects/compare/components/revision_dropdown_spec.js b/spec/frontend/projects/compare/components/revision_dropdown_spec.js index aab9607ceae..118bb68585e 100644 --- a/spec/frontend/projects/compare/components/revision_dropdown_spec.js +++ b/spec/frontend/projects/compare/components/revision_dropdown_spec.js @@ -1,15 +1,10 @@ -import { GlDropdown, GlSearchBoxByType } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlSearchBoxByType } 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.vue'; - -const defaultProps = { - refsProjectPath: 'some/refs/path', - paramsName: 'from', - paramsBranch: 'master', -}; +import { revisionDropdownDefaultProps as defaultProps } from './mock_data'; jest.mock('~/flash'); @@ -142,4 +137,17 @@ describe('RevisionDropdown component', () => { expect(findGlDropdown().props('text')).toBe(defaultProps.paramsBranch); }); }); + + it('emits `selectRevision` event when another revision is selected', async () => { + createComponent(); + wrapper.vm.branches = ['some-branch']; + await wrapper.vm.$nextTick(); + + findGlDropdown().findAll(GlDropdownItem).at(0).vm.$emit('click'); + + expect(wrapper.emitted('selectRevision')[0][0]).toEqual({ + direction: 'to', + revision: 'some-branch', + }); + }); }); diff --git a/spec/frontend/projects/experiment_new_project_creation/components/app_spec.js b/spec/frontend/projects/experiment_new_project_creation/components/app_spec.js deleted file mode 100644 index 204e7a7c394..00000000000 --- a/spec/frontend/projects/experiment_new_project_creation/components/app_spec.js +++ /dev/null @@ -1,144 +0,0 @@ -import { GlBreadcrumb } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { assignGitlabExperiment } from 'helpers/experimentation_helper'; -import App from '~/projects/experiment_new_project_creation/components/app.vue'; -import LegacyContainer from '~/projects/experiment_new_project_creation/components/legacy_container.vue'; -import WelcomePage from '~/projects/experiment_new_project_creation/components/welcome.vue'; - -describe('Experimental new project creation app', () => { - let wrapper; - - const createComponent = (propsData) => { - wrapper = shallowMount(App, { propsData }); - }; - - afterEach(() => { - wrapper.destroy(); - window.location.hash = ''; - wrapper = null; - }); - - const findWelcomePage = () => wrapper.findComponent(WelcomePage); - const findPanel = (panelName) => - findWelcomePage() - .props() - .panels.find((p) => p.name === panelName); - const findPanelHeader = () => wrapper.find('h4'); - - describe('new_repo experiment', () => { - describe('when in the candidate variant', () => { - assignGitlabExperiment('new_repo', 'candidate'); - - it('has "repository" in the panel title', () => { - createComponent(); - - expect(findPanel('blank_project').title).toBe('Create blank project/repository'); - }); - - describe('when hash is not empty on load', () => { - beforeEach(() => { - window.location.hash = '#blank_project'; - createComponent(); - }); - - it('renders "project/repository"', () => { - expect(findPanelHeader().text()).toBe('Create blank project/repository'); - }); - }); - }); - - describe('when in the control variant', () => { - assignGitlabExperiment('new_repo', 'control'); - - it('has "project" in the panel title', () => { - createComponent(); - - expect(findPanel('blank_project').title).toBe('Create blank project'); - }); - - describe('when hash is not empty on load', () => { - beforeEach(() => { - window.location.hash = '#blank_project'; - createComponent(); - }); - - it('renders "project"', () => { - expect(findPanelHeader().text()).toBe('Create blank project'); - }); - }); - }); - }); - - describe('with empty hash', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders welcome page', () => { - expect(wrapper.find(WelcomePage).exists()).toBe(true); - }); - - it('does not render breadcrumbs', () => { - expect(wrapper.find(GlBreadcrumb).exists()).toBe(false); - }); - }); - - it('renders blank project container if there are errors', () => { - createComponent({ hasErrors: true }); - expect(wrapper.find(WelcomePage).exists()).toBe(false); - expect(wrapper.find(LegacyContainer).exists()).toBe(true); - }); - - describe('when hash is not empty on load', () => { - beforeEach(() => { - window.location.hash = '#blank_project'; - createComponent(); - }); - - it('renders relevant container', () => { - expect(wrapper.find(WelcomePage).exists()).toBe(false); - expect(wrapper.find(LegacyContainer).exists()).toBe(true); - }); - - it('renders breadcrumbs', () => { - expect(wrapper.find(GlBreadcrumb).exists()).toBe(true); - }); - }); - - describe('display custom new project guideline text', () => { - beforeEach(() => { - window.location.hash = '#blank_project'; - }); - - it('does not render new project guideline if undefined', () => { - createComponent(); - expect(wrapper.find('div#new-project-guideline').exists()).toBe(false); - }); - - it('render new project guideline if defined', () => { - const guidelineSelector = 'div#new-project-guideline'; - - createComponent({ - newProjectGuidelines: '<h4>Internal Guidelines</h4><p>lorem ipsum</p>', - }); - expect(wrapper.find(guidelineSelector).exists()).toBe(true); - expect(wrapper.find(guidelineSelector).html()).toContain('<h4>Internal Guidelines</h4>'); - expect(wrapper.find(guidelineSelector).html()).toContain('<p>lorem ipsum</p>'); - }); - }); - - it('renders relevant container when hash changes', () => { - createComponent(); - expect(wrapper.find(WelcomePage).exists()).toBe(true); - - window.location.hash = '#blank_project'; - const ev = document.createEvent('HTMLEvents'); - ev.initEvent('hashchange', false, false); - window.dispatchEvent(ev); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(WelcomePage).exists()).toBe(false); - expect(wrapper.find(LegacyContainer).exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/projects/pipelines/charts/components/app_spec.js b/spec/frontend/projects/pipelines/charts/components/app_spec.js index 0cf05d4ac37..987a215eb4c 100644 --- a/spec/frontend/projects/pipelines/charts/components/app_spec.js +++ b/spec/frontend/projects/pipelines/charts/components/app_spec.js @@ -54,8 +54,8 @@ describe('ProjectsPipelinesChartsApp', () => { expect(findGlTabs().exists()).toBe(true); expect(findGlTabAtIndex(0).attributes('title')).toBe('Pipelines'); - expect(findGlTabAtIndex(1).attributes('title')).toBe('Deployments'); - expect(findGlTabAtIndex(2).attributes('title')).toBe('Lead Time'); + expect(findGlTabAtIndex(1).attributes('title')).toBe('Deployment frequency'); + expect(findGlTabAtIndex(2).attributes('title')).toBe('Lead time'); }); it('renders the pipeline charts', () => { @@ -75,7 +75,7 @@ describe('ProjectsPipelinesChartsApp', () => { setWindowLocation(`${TEST_HOST}/gitlab-org/gitlab-test/-/pipelines/charts`); mergeUrlParams.mockImplementation(({ chart }, path) => { - expect(chart).toBe('deployments'); + expect(chart).toBe('deployment-frequency'); expect(path).toBe(window.location.pathname); chartsPath = `${path}?chart=${chart}`; return chartsPath; @@ -114,12 +114,12 @@ describe('ProjectsPipelinesChartsApp', () => { describe('when provided with a query param', () => { it.each` - chart | tab - ${'lead-time'} | ${'2'} - ${'deployments'} | ${'1'} - ${'pipelines'} | ${'0'} - ${'fake'} | ${'0'} - ${''} | ${'0'} + chart | tab + ${'lead-time'} | ${'2'} + ${'deployment-frequency'} | ${'1'} + ${'pipelines'} | ${'0'} + ${'fake'} | ${'0'} + ${''} | ${'0'} `('shows the correct tab for URL parameter "$chart"', ({ chart, tab }) => { setWindowLocation(`${TEST_HOST}/gitlab-org/gitlab-test/-/pipelines/charts?chart=${chart}`); getParameterValues.mockImplementation((name) => { @@ -152,7 +152,7 @@ describe('ProjectsPipelinesChartsApp', () => { getParameterValues.mockImplementationOnce((name) => { expect(name).toBe('chart'); - return ['deployments']; + return ['deployment-frequency']; }); popstateHandler(); diff --git a/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_area_chart_spec.js b/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_area_chart_spec.js index 64f80300237..2b523467379 100644 --- a/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_area_chart_spec.js +++ b/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_area_chart_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import CiCdAnalyticsAreaChart from '~/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue'; +import CiCdAnalyticsAreaChart from '~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue'; import { transformedAreaChartData } from '../mock_data'; describe('CiCdAnalyticsAreaChart', () => { diff --git a/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js b/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js index 037530ddd48..9adc6dba51e 100644 --- a/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js +++ b/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js @@ -1,8 +1,8 @@ import { GlSegmentedControl } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; -import CiCdAnalyticsAreaChart from '~/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue'; -import CiCdAnalyticsCharts from '~/projects/pipelines/charts/components/ci_cd_analytics_charts.vue'; +import CiCdAnalyticsAreaChart from '~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue'; +import CiCdAnalyticsCharts from '~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue'; import { transformedAreaChartData, chartOptions } from '../mock_data'; const DEFAULT_PROPS = { @@ -26,7 +26,7 @@ const DEFAULT_PROPS = { ], }; -describe('~/projects/pipelines/charts/components/ci_cd_analytics_charts.vue', () => { +describe('~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue', () => { let wrapper; const createWrapper = (props = {}) => diff --git a/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js b/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js index c5cfe783569..b5ee62f2042 100644 --- a/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js +++ b/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js @@ -2,11 +2,11 @@ import { GlColumnChart } 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 CiCdAnalyticsCharts from '~/projects/pipelines/charts/components/ci_cd_analytics_charts.vue'; import PipelineCharts from '~/projects/pipelines/charts/components/pipeline_charts.vue'; import StatisticsList from '~/projects/pipelines/charts/components/statistics_list.vue'; import getPipelineCountByStatus from '~/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql'; import getProjectPipelineStatistics from '~/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql'; +import CiCdAnalyticsCharts from '~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue'; import { mockPipelineCount, mockPipelineStatistics } from '../mock_data'; const projectPath = 'gitlab-org/gitlab'; diff --git a/spec/frontend/ref/stores/mutations_spec.js b/spec/frontend/ref/stores/mutations_spec.js index 11d4fe0e206..de1d5c557ce 100644 --- a/spec/frontend/ref/stores/mutations_spec.js +++ b/spec/frontend/ref/stores/mutations_spec.js @@ -108,7 +108,7 @@ describe('Ref selector Vuex store mutations', () => { const response = { data: [ { - name: 'master', + name: 'main', default: true, // everything except "name" and "default" should be stripped @@ -130,7 +130,7 @@ describe('Ref selector Vuex store mutations', () => { expect(state.matches.branches).toEqual({ list: [ { - name: 'master', + name: 'main', default: true, }, { 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 b50ed87a563..632f506f4ae 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,7 +1,10 @@ import { GlButton, GlIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; import { useFakeDate } from 'helpers/fake_date'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import waitForPromises from 'helpers/wait_for_promises'; import component from '~/registry/explorer/components/details_page/details_header.vue'; import { UNSCHEDULED_STATUS, @@ -16,15 +19,18 @@ import { ROOT_IMAGE_TEXT, ROOT_IMAGE_TOOLTIP, } from '~/registry/explorer/constants'; +import getContainerRepositoryTagCountQuery from '~/registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import { imageTagsCountMock } from '../../mock_data'; describe('Details Header', () => { let wrapper; + let apolloProvider; + let localVue; const defaultImage = { name: 'foo', updatedAt: '2020-11-03T13:29:21Z', - tagsCount: 10, canDelete: true, project: { visibility: 'public', @@ -51,12 +57,31 @@ describe('Details Header', () => { await wrapper.vm.$nextTick(); }; - const mountComponent = (propsData = { image: defaultImage }) => { + const mountComponent = ({ + propsData = { image: defaultImage }, + resolver = jest.fn().mockResolvedValue(imageTagsCountMock()), + $apollo = undefined, + } = {}) => { + const mocks = {}; + + if ($apollo) { + mocks.$apollo = $apollo; + } else { + localVue = createLocalVue(); + localVue.use(VueApollo); + + const requestHandlers = [[getContainerRepositoryTagCountQuery, resolver]]; + apolloProvider = createMockApollo(requestHandlers); + } + wrapper = shallowMount(component, { + localVue, + apolloProvider, propsData, directives: { GlTooltip: createMockDirective(), }, + mocks, stubs: { TitleArea, }, @@ -64,41 +89,48 @@ describe('Details Header', () => { }; afterEach(() => { + // if we want to mix createMockApollo and manual mocks we need to reset everything wrapper.destroy(); + apolloProvider = undefined; + localVue = undefined; wrapper = null; }); + describe('image name', () => { describe('missing image name', () => { - it('root image ', () => { - mountComponent({ image: { ...defaultImage, name: '' } }); + beforeEach(() => { + mountComponent({ propsData: { image: { ...defaultImage, name: '' } } }); + + return waitForPromises(); + }); + it('root image ', () => { expect(findTitle().text()).toBe(ROOT_IMAGE_TEXT); }); 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 ', () => { + beforeEach(() => { mountComponent(); + + return waitForPromises(); + }); + + it('shows image.name ', () => { expect(findTitle().text()).toContain('foo'); }); it('has no icon', () => { - mountComponent(); - expect(findInfoIcon().exists()).toBe(false); }); }); @@ -111,16 +143,10 @@ describe('Details Header', () => { expect(findDeleteButton().exists()).toBe(true); }); - it('is hidden while loading', () => { - mountComponent({ image: defaultImage, metadataLoading: true }); - - expect(findDeleteButton().exists()).toBe(false); - }); - it('has the correct text', () => { mountComponent(); - expect(findDeleteButton().text()).toBe('Delete'); + expect(findDeleteButton().text()).toBe('Delete image repository'); }); it('has the correct props', () => { @@ -149,7 +175,7 @@ describe('Details Header', () => { `( 'when canDelete is $canDelete and disabled is $disabled is $isDisabled that the button is disabled', ({ canDelete, disabled, isDisabled }) => { - mountComponent({ image: { ...defaultImage, canDelete }, disabled }); + mountComponent({ propsData: { image: { ...defaultImage, canDelete }, disabled } }); expect(findDeleteButton().props('disabled')).toBe(isDisabled); }, @@ -158,15 +184,32 @@ describe('Details Header', () => { describe('metadata items', () => { describe('tags count', () => { + it('displays "-- tags" while loading', async () => { + // here we are forced to mock apollo because `waitForMetadataItems` waits + // for two ticks, de facto allowing the promise to resolve, so there is + // no way to catch the component as both rendered and in loading state + mountComponent({ $apollo: { queries: { containerRepository: { loading: true } } } }); + + await waitForMetadataItems(); + + expect(findTagsCount().props('text')).toBe('-- tags'); + }); + it('when there is more than one tag has the correct text', async () => { mountComponent(); + + await waitForPromises(); await waitForMetadataItems(); - expect(findTagsCount().props('text')).toBe('10 tags'); + expect(findTagsCount().props('text')).toBe('13 tags'); }); it('when there is one tag has the correct text', async () => { - mountComponent({ image: { ...defaultImage, tagsCount: 1 } }); + mountComponent({ + resolver: jest.fn().mockResolvedValue(imageTagsCountMock({ tagsCount: 1 })), + }); + + await waitForPromises(); await waitForMetadataItems(); expect(findTagsCount().props('text')).toBe('1 tag'); @@ -208,11 +251,13 @@ describe('Details Header', () => { 'when the status is $status the text is $text and the tooltip is $tooltip', async ({ status, text, tooltip }) => { mountComponent({ - image: { - ...defaultImage, - expirationPolicyCleanupStatus: status, - project: { - containerExpirationPolicy: { enabled: true, nextRunAt: '2021-01-03T14:29:21Z' }, + propsData: { + image: { + ...defaultImage, + expirationPolicyCleanupStatus: status, + project: { + containerExpirationPolicy: { enabled: true, nextRunAt: '2021-01-03T14:29:21Z' }, + }, }, }, }); @@ -242,7 +287,9 @@ describe('Details Header', () => { expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye'); }); it('shows an eye slashed when the project is not public', async () => { - mountComponent({ image: { ...defaultImage, project: { visibility: 'private' } } }); + mountComponent({ + propsData: { image: { ...defaultImage, project: { visibility: 'private' } } }, + }); await waitForMetadataItems(); expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye-slash'); diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js index 8b70f84c1bd..dc9063bde2c 100644 --- a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js @@ -1,5 +1,6 @@ import { GlFormCheckbox, GlSprintf, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import DeleteButton from '~/registry/explorer/components/delete_button.vue'; @@ -72,8 +73,15 @@ describe('tags list row', () => { expect(findCheckbox().exists()).toBe(false); }); - it('is disabled when the digest is missing', () => { - mountComponent({ tag: { ...tag, digest: null } }); + it.each` + digest | disabled + ${'foo'} | ${true} + ${null} | ${false} + ${null} | ${true} + ${'foo'} | ${true} + `('is disabled when the digest $digest and disabled is $disabled', ({ digest, disabled }) => { + mountComponent({ tag: { ...tag, digest }, disabled }); + expect(findCheckbox().attributes('disabled')).toBe('true'); }); @@ -141,6 +149,12 @@ describe('tags list row', () => { title: tag.location, }); }); + + it('is disabled when the component is disabled', () => { + mountComponent({ ...defaultProps, disabled: true }); + + expect(findClipboardButton().attributes('disabled')).toBe('true'); + }); }); describe('warning icon', () => { @@ -266,15 +280,19 @@ describe('tags list row', () => { }); it.each` - canDelete | digest - ${true} | ${null} - ${false} | ${'foo'} - ${false} | ${null} - `('is disabled when canDelete is $canDelete and digest is $digest', ({ canDelete, digest }) => { - mountComponent({ ...defaultProps, tag: { ...tag, canDelete, digest } }); - - expect(findDeleteButton().attributes('disabled')).toBe('true'); - }); + canDelete | digest | disabled + ${true} | ${null} | ${true} + ${false} | ${'foo'} | ${true} + ${false} | ${null} | ${true} + ${true} | ${'foo'} | ${true} + `( + 'is disabled when canDelete is $canDelete and digest is $digest and disabled is $disabled', + ({ canDelete, digest, disabled }) => { + mountComponent({ ...defaultProps, tag: { ...tag, canDelete, digest }, disabled }); + + expect(findDeleteButton().attributes('disabled')).toBe('true'); + }, + ); it('delete event emits delete', () => { mountComponent(); @@ -287,13 +305,10 @@ describe('tags list row', () => { describe('details rows', () => { describe('when the tag has a digest', () => { - beforeEach(() => { + it('has 3 details rows', async () => { mountComponent(); + await nextTick(); - return wrapper.vm.$nextTick(); - }); - - it('has 3 details rows', () => { expect(findDetailsRows().length).toBe(3); }); @@ -303,17 +318,37 @@ describe('tags list row', () => { ${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:2cf3d2fdac1b04a14301d47d51cb88dcd26714c74f91440eeee99ce399089062'} | ${'log'} | ${true} ${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:c2613843ab33aabf847965442b13a8b55a56ae28837ce182627c0716eb08c02b'} | ${'cloud-gear'} | ${true} `('$name details row', ({ finderFunction, text, icon, clipboard }) => { - it(`has ${text} as text`, () => { + it(`has ${text} as text`, async () => { + mountComponent(); + await nextTick(); + expect(finderFunction().text()).toMatchInterpolatedText(text); }); - it(`has the ${icon} icon`, () => { + it(`has the ${icon} icon`, async () => { + mountComponent(); + await nextTick(); + expect(finderFunction().props('icon')).toBe(icon); }); - it(`is ${clipboard} that clipboard button exist`, () => { - expect(finderFunction().find(ClipboardButton).exists()).toBe(clipboard); - }); + if (clipboard) { + it(`clipboard button exist`, async () => { + mountComponent(); + await nextTick(); + + expect(finderFunction().find(ClipboardButton).exists()).toBe(clipboard); + }); + + it('is disabled when the component is disabled', async () => { + mountComponent({ ...defaultProps, disabled: true }); + await nextTick(); + + expect(finderFunction().findComponent(ClipboardButton).attributes('disabled')).toBe( + 'true', + ); + }); + } }); }); @@ -321,7 +356,7 @@ describe('tags list row', () => { it('hides the details rows', async () => { mountComponent({ tag: { ...tag, digest: null } }); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findDetailsRows().length).toBe(0); }); }); diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js index dc6760a17bd..51934cd074d 100644 --- a/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js @@ -1,22 +1,55 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlButton, GlKeysetPagination } from '@gitlab/ui'; +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 EmptyTagsState from '~/registry/explorer/components/details_page/empty_state.vue'; import component from '~/registry/explorer/components/details_page/tags_list.vue'; import TagsListRow from '~/registry/explorer/components/details_page/tags_list_row.vue'; +import TagsLoader from '~/registry/explorer/components/details_page/tags_loader.vue'; import { TAGS_LIST_TITLE, REMOVE_TAGS_BUTTON_TITLE } from '~/registry/explorer/constants/index'; -import { tagsMock } from '../../mock_data'; +import getContainerRepositoryTagsQuery from '~/registry/explorer/graphql/queries/get_container_repository_tags.query.graphql'; +import { tagsMock, imageTagsMock, tagsPageInfo } from '../../mock_data'; + +const localVue = createLocalVue(); describe('Tags List', () => { let wrapper; + let apolloProvider; const tags = [...tagsMock]; const readOnlyTags = tags.map((t) => ({ ...t, canDelete: false })); const findTagsListRow = () => wrapper.findAll(TagsListRow); const findDeleteButton = () => wrapper.find(GlButton); const findListTitle = () => wrapper.find('[data-testid="list-title"]'); + const findPagination = () => wrapper.find(GlKeysetPagination); + const findEmptyState = () => wrapper.find(EmptyTagsState); + const findTagsLoader = () => wrapper.find(TagsLoader); + + const waitForApolloRequestRender = async () => { + await waitForPromises(); + await nextTick(); + }; + + const mountComponent = ({ + propsData = { isMobile: false, id: 1 }, + resolver = jest.fn().mockResolvedValue(imageTagsMock()), + } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [[getContainerRepositoryTagsQuery, resolver]]; - const mountComponent = (propsData = { tags, isMobile: false }) => { + apolloProvider = createMockApollo(requestHandlers); wrapper = shallowMount(component, { + localVue, + apolloProvider, propsData, + provide() { + return { + config: {}, + }; + }, }); }; @@ -26,15 +59,19 @@ describe('Tags List', () => { }); describe('List title', () => { - it('exists', () => { + it('exists', async () => { mountComponent(); + await waitForApolloRequestRender(); + expect(findListTitle().exists()).toBe(true); }); - it('has the correct text', () => { + it('has the correct text', async () => { mountComponent(); + await waitForApolloRequestRender(); + expect(findListTitle().text()).toBe(TAGS_LIST_TITLE); }); }); @@ -48,21 +85,29 @@ describe('Tags List', () => { ${readOnlyTags} | ${true} | ${false} `( 'is $isVisible that delete button exists when tags is $inputTags and isMobile is $isMobile', - ({ inputTags, isMobile, isVisible }) => { - mountComponent({ tags: inputTags, isMobile }); + async ({ inputTags, isMobile, isVisible }) => { + mountComponent({ + propsData: { tags: inputTags, isMobile, id: 1 }, + resolver: jest.fn().mockResolvedValue(imageTagsMock(inputTags)), + }); + + await waitForApolloRequestRender(); expect(findDeleteButton().exists()).toBe(isVisible); }, ); - it('has the correct text', () => { + it('has the correct text', async () => { mountComponent(); + await waitForApolloRequestRender(); + expect(findDeleteButton().text()).toBe(REMOVE_TAGS_BUTTON_TITLE); }); - it('has the correct props', () => { + it('has the correct props', async () => { mountComponent(); + await waitForApolloRequestRender(); expect(findDeleteButton().attributes()).toMatchObject({ category: 'secondary', @@ -79,35 +124,44 @@ describe('Tags List', () => { `( 'is $buttonDisabled that the button is disabled when the component disabled state is $disabled and is $doSelect that the user selected a tag', async ({ disabled, buttonDisabled, doSelect }) => { - mountComponent({ tags, disabled, isMobile: false }); + mountComponent({ propsData: { tags, disabled, isMobile: false, id: 1 } }); + + await waitForApolloRequestRender(); if (doSelect) { findTagsListRow().at(0).vm.$emit('select'); - await wrapper.vm.$nextTick(); + await nextTick(); } expect(findDeleteButton().attributes('disabled')).toBe(buttonDisabled); }, ); - it('click event emits a deleted event with selected items', () => { + it('click event emits a deleted event with selected items', async () => { mountComponent(); - findTagsListRow().at(0).vm.$emit('select'); + await waitForApolloRequestRender(); + + findTagsListRow().at(0).vm.$emit('select'); findDeleteButton().vm.$emit('click'); - expect(wrapper.emitted('delete')).toEqual([[{ 'beta-24753': true }]]); + + expect(wrapper.emitted('delete')[0][0][0].name).toBe(tags[0].name); }); }); describe('list rows', () => { - it('one row exist for each tag', () => { + it('one row exist for each tag', async () => { mountComponent(); + await waitForApolloRequestRender(); + expect(findTagsListRow()).toHaveLength(tags.length); }); - it('the correct props are bound to it', () => { - mountComponent({ tags, disabled: true }); + it('the correct props are bound to it', async () => { + mountComponent({ propsData: { disabled: true, id: 1 } }); + + await waitForApolloRequestRender(); const rows = findTagsListRow(); @@ -120,16 +174,138 @@ describe('Tags List', () => { describe('events', () => { it('select event update the selected items', async () => { mountComponent(); + + await waitForApolloRequestRender(); + findTagsListRow().at(0).vm.$emit('select'); - await wrapper.vm.$nextTick(); + + await nextTick(); + expect(findTagsListRow().at(0).attributes('selected')).toBe('true'); }); - it('delete event emit a delete event', () => { + it('delete event emit a delete event', async () => { mountComponent(); + + await waitForApolloRequestRender(); + findTagsListRow().at(0).vm.$emit('delete'); - expect(wrapper.emitted('delete')).toEqual([[{ 'beta-24753': true }]]); + expect(wrapper.emitted('delete')[0][0][0].name).toBe(tags[0].name); + }); + }); + }); + + describe('when the list of tags is empty', () => { + const resolver = jest.fn().mockResolvedValue(imageTagsMock([])); + + it('has the empty state', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findEmptyState().exists()).toBe(true); + }); + + it('does not show the loader', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findTagsLoader().exists()).toBe(false); + }); + + it('does not show the list', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findTagsListRow().exists()).toBe(false); + expect(findListTitle().exists()).toBe(false); + }); + }); + + describe('pagination', () => { + it('exists', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findPagination().exists()).toBe(true); + }); + + it('is hidden when loading', () => { + mountComponent(); + + expect(findPagination().exists()).toBe(false); + }); + + it('is hidden when there are no more pages', async () => { + mountComponent({ resolver: jest.fn().mockResolvedValue(imageTagsMock([])) }); + + await waitForApolloRequestRender(); + + expect(findPagination().exists()).toBe(false); + }); + + it('is wired to the correct pagination props', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findPagination().props()).toMatchObject({ + hasNextPage: tagsPageInfo.hasNextPage, + hasPreviousPage: tagsPageInfo.hasPreviousPage, }); }); + + it('fetch next page when user clicks next', async () => { + const resolver = jest.fn().mockResolvedValue(imageTagsMock()); + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + findPagination().vm.$emit('next'); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ after: tagsPageInfo.endCursor }), + ); + }); + + it('fetch previous page when user clicks prev', async () => { + const resolver = jest.fn().mockResolvedValue(imageTagsMock()); + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + findPagination().vm.$emit('prev'); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ first: null, before: tagsPageInfo.startCursor }), + ); + }); + }); + + describe('loading state', () => { + it.each` + isImageLoading | queryExecuting | loadingVisible + ${true} | ${true} | ${true} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + ${false} | ${false} | ${false} + `( + 'when the isImageLoading is $isImageLoading, and is $queryExecuting that the query is still executing is $loadingVisible that the loader is shown', + async ({ isImageLoading, queryExecuting, loadingVisible }) => { + mountComponent({ propsData: { isImageLoading, isMobile: false, id: 1 } }); + + if (!queryExecuting) { + await waitForApolloRequestRender(); + } + + expect(findTagsLoader().exists()).toBe(loadingVisible); + expect(findTagsListRow().exists()).toBe(!loadingVisible); + expect(findListTitle().exists()).toBe(!loadingVisible); + expect(findPagination().exists()).toBe(!loadingVisible); + }, + ); }); }); 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 6c897b983f7..323d7b177e7 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 @@ -25,10 +25,11 @@ describe('Image List Row', () => { const findDetailsLink = () => wrapper.find('[data-testid="details-link"]'); const findTagsCount = () => wrapper.find('[data-testid="tags-count"]'); - const findDeleteBtn = () => wrapper.find(DeleteButton); - const findClipboardButton = () => wrapper.find(ClipboardButton); + const findDeleteBtn = () => wrapper.findComponent(DeleteButton); + const findClipboardButton = () => wrapper.findComponent(ClipboardButton); const findWarningIcon = () => wrapper.find('[data-testid="warning-icon"]'); - const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findListItemComponent = () => wrapper.findComponent(ListItem); const mountComponent = (props) => { wrapper = shallowMount(Component, { @@ -52,20 +53,28 @@ describe('Image List Row', () => { wrapper = null; }); - describe('main tooltip', () => { - it(`the title is ${ROW_SCHEDULED_FOR_DELETION}`, () => { - mountComponent(); + describe('list item component', () => { + describe('tooltip', () => { + it(`the title is ${ROW_SCHEDULED_FOR_DELETION}`, () => { + mountComponent(); + + const tooltip = getBinding(wrapper.element, 'gl-tooltip'); + expect(tooltip).toBeDefined(); + expect(tooltip.value.title).toBe(ROW_SCHEDULED_FOR_DELETION); + }); - const tooltip = getBinding(wrapper.element, 'gl-tooltip'); - expect(tooltip).toBeDefined(); - expect(tooltip.value.title).toBe(ROW_SCHEDULED_FOR_DELETION); + it('is disabled when item is being deleted', () => { + mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } }); + + const tooltip = getBinding(wrapper.element, 'gl-tooltip'); + expect(tooltip.value.disabled).toBe(false); + }); }); - it('is disabled when item is being deleted', () => { + it('is disabled when the item is in deleting status', () => { mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } }); - const tooltip = getBinding(wrapper.element, 'gl-tooltip'); - expect(tooltip.value.disabled).toBe(false); + expect(findListItemComponent().props('disabled')).toBe(true); }); }); @@ -118,6 +127,20 @@ describe('Image List Row', () => { }, ); }); + + describe('when the item is deleting', () => { + beforeEach(() => { + mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } }); + }); + + it('the router link is disabled', () => { + // we check the event prop as is the only workaround to disable a router link + expect(findDetailsLink().props('event')).toBe(''); + }); + it('the clipboard button is disabled', () => { + expect(findClipboardButton().attributes('disabled')).toBe('true'); + }); + }); }); describe('delete button', () => { diff --git a/spec/frontend/registry/explorer/mock_data.js b/spec/frontend/registry/explorer/mock_data.js index f4453912db4..fe258dcd4e8 100644 --- a/spec/frontend/registry/explorer/mock_data.js +++ b/spec/frontend/registry/explorer/mock_data.js @@ -113,7 +113,6 @@ export const containerRepositoryMock = { canDelete: true, createdAt: '2020-11-03T13:29:21Z', updatedAt: '2020-11-03T13:29:21Z', - tagsCount: 13, expirationPolicyStartedAt: null, expirationPolicyCleanupStatus: 'UNSCHEDULED', project: { @@ -161,6 +160,30 @@ export const tagsMock = [ }, ]; +export const imageTagsMock = (nodes = tagsMock) => ({ + data: { + containerRepository: { + id: containerRepositoryMock.id, + tags: { + nodes, + pageInfo: { ...tagsPageInfo }, + __typename: 'ContainerRepositoryTagConnection', + }, + __typename: 'ContainerRepositoryDetails', + }, + }, +}); + +export const imageTagsCountMock = (override) => ({ + data: { + containerRepository: { + id: containerRepositoryMock.id, + tagsCount: 13, + ...override, + }, + }, +}); + export const graphQLImageDetailsMock = (override) => ({ data: { containerRepository: { diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js index 76baf4f72c9..022f6e71fe6 100644 --- a/spec/frontend/registry/explorer/pages/details_spec.js +++ b/spec/frontend/registry/explorer/pages/details_spec.js @@ -28,12 +28,10 @@ import Tracking from '~/tracking'; import { graphQLImageDetailsMock, - graphQLImageDetailsEmptyTagsMock, graphQLDeleteImageRepositoryTagsMock, containerRepositoryMock, graphQLEmptyImageDetailsMock, tagsMock, - tagsPageInfo, } from '../mock_data'; import { DeleteModal } from '../stubs'; @@ -72,12 +70,6 @@ describe('Details Page', () => { await wrapper.vm.$nextTick(); }; - const tagsArrayToSelectedTags = (tags) => - tags.reduce((acc, c) => { - acc[c.name] = true; - return acc; - }, {}); - const mountComponent = ({ resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock()), mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock), @@ -138,12 +130,6 @@ describe('Details Page', () => { expect(findTagsList().exists()).toBe(false); }); - - it('does not show pagination', () => { - mountComponent(); - - expect(findPagination().exists()).toBe(false); - }); }); describe('when the image does not exist', () => { @@ -167,34 +153,6 @@ describe('Details Page', () => { }); }); - describe('when the list of tags is empty', () => { - const resolver = jest.fn().mockResolvedValue(graphQLImageDetailsEmptyTagsMock); - - it('has the empty state', async () => { - mountComponent({ resolver }); - - await waitForApolloRequestRender(); - - expect(findEmptyState().exists()).toBe(true); - }); - - it('does not show the loader', async () => { - mountComponent({ resolver }); - - await waitForApolloRequestRender(); - - expect(findTagsLoader().exists()).toBe(false); - }); - - it('does not show the list', async () => { - mountComponent({ resolver }); - - await waitForApolloRequestRender(); - - expect(findTagsList().exists()).toBe(false); - }); - }); - describe('list', () => { it('exists', async () => { mountComponent(); @@ -211,7 +169,6 @@ describe('Details Page', () => { expect(findTagsList().props()).toMatchObject({ isMobile: false, - tags: cleanTags, }); }); @@ -224,7 +181,7 @@ describe('Details Page', () => { await waitForApolloRequestRender(); [tagToBeDeleted] = cleanTags; - findTagsList().vm.$emit('delete', { [tagToBeDeleted.name]: true }); + findTagsList().vm.$emit('delete', [tagToBeDeleted]); }); it('open the modal', async () => { @@ -244,7 +201,7 @@ describe('Details Page', () => { await waitForApolloRequestRender(); - findTagsList().vm.$emit('delete', tagsArrayToSelectedTags(cleanTags)); + findTagsList().vm.$emit('delete', cleanTags); }); it('open the modal', () => { @@ -260,61 +217,6 @@ describe('Details Page', () => { }); }); - describe('pagination', () => { - it('exists', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findPagination().exists()).toBe(true); - }); - - it('is hidden when there are no more pages', async () => { - mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLImageDetailsEmptyTagsMock) }); - - await waitForApolloRequestRender(); - - expect(findPagination().exists()).toBe(false); - }); - - it('is wired to the correct pagination props', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findPagination().props()).toMatchObject({ - hasNextPage: tagsPageInfo.hasNextPage, - hasPreviousPage: tagsPageInfo.hasPreviousPage, - }); - }); - - it('fetch next page when user clicks next', async () => { - const resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock()); - mountComponent({ resolver }); - - await waitForApolloRequestRender(); - - findPagination().vm.$emit('next'); - - expect(resolver).toHaveBeenCalledWith( - expect.objectContaining({ after: tagsPageInfo.endCursor }), - ); - }); - - it('fetch previous page when user clicks prev', async () => { - const resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock()); - mountComponent({ resolver }); - - await waitForApolloRequestRender(); - - findPagination().vm.$emit('prev'); - - expect(resolver).toHaveBeenCalledWith( - expect.objectContaining({ first: null, before: tagsPageInfo.startCursor }), - ); - }); - }); - describe('modal', () => { it('exists', async () => { mountComponent(); @@ -349,7 +251,7 @@ describe('Details Page', () => { }); describe('when one item is selected to be deleted', () => { it('calls apollo mutation with the right parameters', async () => { - findTagsList().vm.$emit('delete', { [cleanTags[0].name]: true }); + findTagsList().vm.$emit('delete', [cleanTags[0]]); await wrapper.vm.$nextTick(); @@ -363,7 +265,7 @@ describe('Details Page', () => { describe('when more than one item is selected to be deleted', () => { it('calls apollo mutation with the right parameters', async () => { - findTagsList().vm.$emit('delete', { ...tagsArrayToSelectedTags(tagsMock) }); + findTagsList().vm.$emit('delete', tagsMock); await wrapper.vm.$nextTick(); @@ -390,7 +292,6 @@ describe('Details Page', () => { await waitForApolloRequestRender(); expect(findDetailsHeader().props()).toMatchObject({ - metadataLoading: false, image: { name: containerRepositoryMock.name, project: { diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap index c9f84be97c4..cad593b76ea 100644 --- a/spec/frontend/releases/__snapshots__/util_spec.js.snap +++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap @@ -129,6 +129,68 @@ Object { } `; +exports[`releases/util.js convertOneReleaseForEditingGraphQLResponse matches snapshot 1`] = ` +Object { + "data": Object { + "_links": Object { + "self": "http://localhost/releases-namespace/releases-project/-/releases/v1.1", + "selfUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1", + }, + "assets": Object { + "count": undefined, + "links": Array [ + Object { + "id": "gid://gitlab/Releases::Link/13", + "linkType": "image", + "name": "Image", + "url": "https://example.com/image", + }, + Object { + "id": "gid://gitlab/Releases::Link/12", + "linkType": "package", + "name": "Package", + "url": "https://example.com/package", + }, + Object { + "id": "gid://gitlab/Releases::Link/11", + "linkType": "runbook", + "name": "Runbook", + "url": "http://localhost/releases-namespace/releases-project/runbook", + }, + Object { + "id": "gid://gitlab/Releases::Link/10", + "linkType": "other", + "name": "linux-amd64 binaries", + "url": "https://downloads.example.com/bin/gitlab-linux-amd64", + }, + ], + "sources": Array [], + }, + "author": undefined, + "description": "Best. Release. **Ever.** :rocket:", + "evidences": Array [], + "milestones": Array [ + Object { + "issueStats": Object {}, + "stats": undefined, + "title": "12.3", + "webPath": undefined, + "webUrl": undefined, + }, + Object { + "issueStats": Object {}, + "stats": undefined, + "title": "12.4", + "webPath": undefined, + "webUrl": undefined, + }, + ], + "name": "The first release", + "tagName": "v1.1", + }, +} +`; + exports[`releases/util.js convertOneReleaseGraphQLResponse matches snapshot 1`] = ` Object { "data": Object { diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js index 7955b079cbc..3a28020c284 100644 --- a/spec/frontend/releases/components/app_index_spec.js +++ b/spec/frontend/releases/components/app_index_spec.js @@ -1,210 +1,231 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { range as rge } from 'lodash'; +import { shallowMount } from '@vue/test-utils'; +import { merge } from 'lodash'; +import Vue from 'vue'; import Vuex from 'vuex'; -import { getJSONFixture } from 'helpers/fixtures'; -import waitForPromises from 'helpers/wait_for_promises'; -import api from '~/api'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import ReleasesApp from '~/releases/components/app_index.vue'; +import { getParameterByName } from '~/lib/utils/common_utils'; +import AppIndex from '~/releases/components/app_index.vue'; +import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue'; import ReleasesPagination from '~/releases/components/releases_pagination.vue'; -import createStore from '~/releases/stores'; -import createIndexModule from '~/releases/stores/modules/index'; -import { pageInfoHeadersWithoutPagination, pageInfoHeadersWithPagination } from '../mock_data'; +import ReleasesSort from '~/releases/components/releases_sort.vue'; jest.mock('~/lib/utils/common_utils', () => ({ ...jest.requireActual('~/lib/utils/common_utils'), - getParameterByName: jest.fn().mockImplementation((paramName) => { - return `${paramName}_param_value`; - }), + getParameterByName: jest.fn(), })); -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); -const release = getJSONFixture('api/releases/release.json'); -const releases = [release]; - -describe('Releases App ', () => { +describe('app_index.vue', () => { let wrapper; - let fetchReleaseSpy; - - const paginatedReleases = rge(21).map((index) => ({ - ...convertObjectPropsToCamelCase(release, { deep: true }), - tagName: `${index}.00`, - })); - - const defaultInitialState = { - projectId: 'gitlab-ce', - projectPath: 'gitlab-org/gitlab-ce', - documentationPath: 'help/releases', - illustrationPath: 'illustration/path', + let fetchReleasesSpy; + let urlParams; + + const createComponent = (storeUpdates) => { + wrapper = shallowMount(AppIndex, { + store: new Vuex.Store({ + modules: { + index: merge( + { + namespaced: true, + actions: { + fetchReleases: fetchReleasesSpy, + }, + state: { + isLoading: true, + releases: [], + }, + }, + storeUpdates, + ), + }, + }), + }); }; - const createComponent = (stateUpdates = {}) => { - const indexModule = createIndexModule({ - ...defaultInitialState, - ...stateUpdates, + beforeEach(() => { + fetchReleasesSpy = jest.fn(); + getParameterByName.mockImplementation((paramName) => urlParams[paramName]); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + // Finders + const findLoadingIndicator = () => wrapper.find(ReleaseSkeletonLoader); + const findEmptyState = () => wrapper.find('[data-testid="empty-state"]'); + const findSuccessState = () => wrapper.find('[data-testid="success-state"]'); + const findPagination = () => wrapper.find(ReleasesPagination); + const findSortControls = () => wrapper.find(ReleasesSort); + const findNewReleaseButton = () => wrapper.find('[data-testid="new-release-button"]'); + + // Expectations + const expectLoadingIndicator = (shouldExist) => { + it(`${shouldExist ? 'renders' : 'does not render'} a loading indicator`, () => { + expect(findLoadingIndicator().exists()).toBe(shouldExist); }); + }; - fetchReleaseSpy = jest.spyOn(indexModule.actions, 'fetchReleases'); + const expectEmptyState = (shouldExist) => { + it(`${shouldExist ? 'renders' : 'does not render'} an empty state`, () => { + expect(findEmptyState().exists()).toBe(shouldExist); + }); + }; - const store = createStore({ - modules: { index: indexModule }, - featureFlags: { - graphqlReleaseData: true, - graphqlReleasesPage: false, - graphqlMilestoneStats: true, - }, + const expectSuccessState = (shouldExist) => { + it(`${shouldExist ? 'renders' : 'does not render'} the success state`, () => { + expect(findSuccessState().exists()).toBe(shouldExist); }); + }; - wrapper = shallowMount(ReleasesApp, { - store, - localVue, + const expectPagination = (shouldExist) => { + it(`${shouldExist ? 'renders' : 'does not render'} the pagination controls`, () => { + expect(findPagination().exists()).toBe(shouldExist); }); }; - afterEach(() => { - wrapper.destroy(); - }); + const expectNewReleaseButton = (shouldExist) => { + it(`${shouldExist ? 'renders' : 'does not render'} the "New release" button`, () => { + expect(findNewReleaseButton().exists()).toBe(shouldExist); + }); + }; + // Tests describe('on startup', () => { - beforeEach(() => { - jest - .spyOn(api, 'releases') - .mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination }); + it.each` + before | after + ${null} | ${null} + ${'before_param_value'} | ${null} + ${null} | ${'after_param_value'} + `( + 'calls fetchRelease with the correct parameters based on the curent query parameters: before: $before, after: $after', + ({ before, after }) => { + urlParams = { before, after }; + + createComponent(); + + expect(fetchReleasesSpy).toHaveBeenCalledTimes(1); + expect(fetchReleasesSpy).toHaveBeenCalledWith(expect.anything(), urlParams); + }, + ); + }); + describe('when the request to fetch releases has not yet completed', () => { + beforeEach(() => { createComponent(); }); - it('calls fetchRelease with the page, before, and after parameters', () => { - expect(fetchReleaseSpy).toHaveBeenCalledTimes(1); - expect(fetchReleaseSpy).toHaveBeenCalledWith(expect.anything(), { - page: 'page_param_value', - before: 'before_param_value', - after: 'after_param_value', - }); - }); + expectLoadingIndicator(true); + expectEmptyState(false); + expectSuccessState(false); + expectPagination(false); }); - describe('while loading', () => { + describe('when the request fails', () => { beforeEach(() => { - jest - .spyOn(api, 'releases') - // Need to defer the return value here to the next stack, - // otherwise the loading state disappears before our test even starts. - .mockImplementation(() => waitForPromises().then(() => ({ data: [], headers: {} }))); - - createComponent(); + createComponent({ + state: { + isLoading: false, + hasError: true, + }, + }); }); - it('renders loading icon', () => { - expect(wrapper.find('.js-loading').exists()).toBe(true); - expect(wrapper.find('.js-empty-state').exists()).toBe(false); - expect(wrapper.find('.js-success-state').exists()).toBe(false); - expect(wrapper.find(ReleasesPagination).exists()).toBe(false); - }); + expectLoadingIndicator(false); + expectEmptyState(false); + expectSuccessState(false); + expectPagination(true); }); - describe('with successful request', () => { + describe('when the request succeeds but returns no releases', () => { beforeEach(() => { - jest - .spyOn(api, 'releases') - .mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination }); - - createComponent(); + createComponent({ + state: { + isLoading: false, + }, + }); }); - it('renders success state', () => { - expect(wrapper.find('.js-loading').exists()).toBe(false); - expect(wrapper.find('.js-empty-state').exists()).toBe(false); - expect(wrapper.find('.js-success-state').exists()).toBe(true); - expect(wrapper.find(ReleasesPagination).exists()).toBe(true); - }); + expectLoadingIndicator(false); + expectEmptyState(true); + expectSuccessState(false); + expectPagination(true); }); - describe('with successful request and pagination', () => { + describe('when the request succeeds and includes at least one release', () => { beforeEach(() => { - jest - .spyOn(api, 'releases') - .mockResolvedValue({ data: paginatedReleases, headers: pageInfoHeadersWithPagination }); - - createComponent(); + createComponent({ + state: { + isLoading: false, + releases: [{}], + }, + }); }); - it('renders success state', () => { - expect(wrapper.find('.js-loading').exists()).toBe(false); - expect(wrapper.find('.js-empty-state').exists()).toBe(false); - expect(wrapper.find('.js-success-state').exists()).toBe(true); - expect(wrapper.find(ReleasesPagination).exists()).toBe(true); - }); + expectLoadingIndicator(false); + expectEmptyState(false); + expectSuccessState(true); + expectPagination(true); }); - describe('with empty request', () => { + describe('sorting', () => { beforeEach(() => { - jest.spyOn(api, 'releases').mockResolvedValue({ data: [], headers: {} }); - createComponent(); }); - it('renders empty state', () => { - expect(wrapper.find('.js-loading').exists()).toBe(false); - expect(wrapper.find('.js-empty-state').exists()).toBe(true); - expect(wrapper.find('.js-success-state').exists()).toBe(false); + it('renders the sort controls', () => { + expect(findSortControls().exists()).toBe(true); }); - }); - describe('"New release" button', () => { - const findNewReleaseButton = () => wrapper.find('.js-new-release-btn'); + it('calls the fetchReleases store method when the sort is updated', () => { + fetchReleasesSpy.mockClear(); - beforeEach(() => { - jest.spyOn(api, 'releases').mockResolvedValue({ data: [], headers: {} }); + findSortControls().vm.$emit('sort:changed'); + + expect(fetchReleasesSpy).toHaveBeenCalledTimes(1); }); + }); - describe('when the user is allowed to create a new Release', () => { - const newReleasePath = 'path/to/new/release'; + describe('"New release" button', () => { + describe('when the user is allowed to create releases', () => { + const newReleasePath = 'path/to/new/release/page'; beforeEach(() => { - createComponent({ newReleasePath }); + createComponent({ state: { newReleasePath } }); }); - it('renders the "New release" button', () => { - expect(findNewReleaseButton().exists()).toBe(true); - }); + expectNewReleaseButton(true); - it('renders the "New release" button with the correct href', () => { + it('renders the button with the correct href', () => { expect(findNewReleaseButton().attributes('href')).toBe(newReleasePath); }); }); - describe('when the user is not allowed to create a new Release', () => { - beforeEach(() => createComponent()); - - it('does not render the "New release" button', () => { - expect(findNewReleaseButton().exists()).toBe(false); + describe('when the user is not allowed to create releases', () => { + beforeEach(() => { + createComponent(); }); + + expectNewReleaseButton(false); }); }); - describe('when the back button is pressed', () => { + describe("when the browser's back button is pressed", () => { beforeEach(() => { - jest - .spyOn(api, 'releases') - .mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination }); + urlParams = { + before: 'before_param_value', + }; createComponent(); - fetchReleaseSpy.mockClear(); + fetchReleasesSpy.mockClear(); window.dispatchEvent(new PopStateEvent('popstate')); }); - it('calls fetchRelease with the page parameter', () => { - expect(fetchReleaseSpy).toHaveBeenCalledTimes(1); - expect(fetchReleaseSpy).toHaveBeenCalledWith(expect.anything(), { - page: 'page_param_value', - before: 'before_param_value', - after: 'after_param_value', - }); + it('calls the fetchRelease store method with the parameters from the URL query', () => { + expect(fetchReleasesSpy).toHaveBeenCalledTimes(1); + expect(fetchReleasesSpy).toHaveBeenCalledWith(expect.anything(), urlParams); }); }); }); diff --git a/spec/frontend/releases/components/app_show_spec.js b/spec/frontend/releases/components/app_show_spec.js index 425cb9d0059..7ea7a6ffe94 100644 --- a/spec/frontend/releases/components/app_show_spec.js +++ b/spec/frontend/releases/components/app_show_spec.js @@ -7,12 +7,12 @@ import createFlash from '~/flash'; import ReleaseShowApp from '~/releases/components/app_show.vue'; import ReleaseBlock from '~/releases/components/release_block.vue'; import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue'; -import oneReleaseQuery from '~/releases/queries/one_release.query.graphql'; +import oneReleaseQuery from '~/releases/graphql/queries/one_release.query.graphql'; jest.mock('~/flash'); const oneReleaseQueryResponse = getJSONFixture( - 'graphql/releases/queries/one_release.query.graphql.json', + 'graphql/releases/graphql/queries/one_release.query.graphql.json', ); Vue.use(VueApollo); diff --git a/spec/frontend/releases/components/releases_pagination_graphql_spec.js b/spec/frontend/releases/components/releases_pagination_graphql_spec.js deleted file mode 100644 index 5b2dd4bc784..00000000000 --- a/spec/frontend/releases/components/releases_pagination_graphql_spec.js +++ /dev/null @@ -1,175 +0,0 @@ -import { mount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import { historyPushState } from '~/lib/utils/common_utils'; -import ReleasesPaginationGraphql from '~/releases/components/releases_pagination_graphql.vue'; -import createStore from '~/releases/stores'; -import createIndexModule from '~/releases/stores/modules/index'; - -jest.mock('~/lib/utils/common_utils', () => ({ - ...jest.requireActual('~/lib/utils/common_utils'), - historyPushState: jest.fn(), -})); - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('~/releases/components/releases_pagination_graphql.vue', () => { - let wrapper; - let indexModule; - - const cursors = { - startCursor: 'startCursor', - endCursor: 'endCursor', - }; - - const projectPath = 'my/project'; - - const createComponent = (pageInfo) => { - indexModule = createIndexModule({ projectPath }); - - indexModule.state.graphQlPageInfo = pageInfo; - - indexModule.actions.fetchReleases = jest.fn(); - - wrapper = mount(ReleasesPaginationGraphql, { - store: createStore({ - modules: { - index: indexModule, - }, - featureFlags: {}, - }), - localVue, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - const findPrevButton = () => wrapper.find('[data-testid="prevButton"]'); - const findNextButton = () => wrapper.find('[data-testid="nextButton"]'); - - const expectDisabledPrev = () => { - expect(findPrevButton().attributes().disabled).toBe('disabled'); - }; - const expectEnabledPrev = () => { - expect(findPrevButton().attributes().disabled).toBe(undefined); - }; - const expectDisabledNext = () => { - expect(findNextButton().attributes().disabled).toBe('disabled'); - }; - const expectEnabledNext = () => { - expect(findNextButton().attributes().disabled).toBe(undefined); - }; - - describe('when there is only one page of results', () => { - beforeEach(() => { - createComponent({ - hasPreviousPage: false, - hasNextPage: false, - }); - }); - - it('does not render anything', () => { - expect(wrapper.html()).toBe(''); - }); - }); - - describe('when there is a next page, but not a previous page', () => { - beforeEach(() => { - createComponent({ - hasPreviousPage: false, - hasNextPage: true, - }); - }); - - it('renders a disabled "Prev" button', () => { - expectDisabledPrev(); - }); - - it('renders an enabled "Next" button', () => { - expectEnabledNext(); - }); - }); - - describe('when there is a previous page, but not a next page', () => { - beforeEach(() => { - createComponent({ - hasPreviousPage: true, - hasNextPage: false, - }); - }); - - it('renders a enabled "Prev" button', () => { - expectEnabledPrev(); - }); - - it('renders an disabled "Next" button', () => { - expectDisabledNext(); - }); - }); - - describe('when there is both a previous page and a next page', () => { - beforeEach(() => { - createComponent({ - hasPreviousPage: true, - hasNextPage: true, - }); - }); - - it('renders a enabled "Prev" button', () => { - expectEnabledPrev(); - }); - - it('renders an enabled "Next" button', () => { - expectEnabledNext(); - }); - }); - - describe('button behavior', () => { - beforeEach(() => { - createComponent({ - hasPreviousPage: true, - hasNextPage: true, - ...cursors, - }); - }); - - describe('next button behavior', () => { - beforeEach(() => { - findNextButton().trigger('click'); - }); - - it('calls fetchReleases with the correct after cursor', () => { - expect(indexModule.actions.fetchReleases.mock.calls).toEqual([ - [expect.anything(), { after: cursors.endCursor }], - ]); - }); - - it('calls historyPushState with the new URL', () => { - expect(historyPushState.mock.calls).toEqual([ - [expect.stringContaining(`?after=${cursors.endCursor}`)], - ]); - }); - }); - - describe('previous button behavior', () => { - beforeEach(() => { - findPrevButton().trigger('click'); - }); - - it('calls fetchReleases with the correct before cursor', () => { - expect(indexModule.actions.fetchReleases.mock.calls).toEqual([ - [expect.anything(), { before: cursors.startCursor }], - ]); - }); - - it('calls historyPushState with the new URL', () => { - expect(historyPushState.mock.calls).toEqual([ - [expect.stringContaining(`?before=${cursors.startCursor}`)], - ]); - }); - }); - }); -}); diff --git a/spec/frontend/releases/components/releases_pagination_rest_spec.js b/spec/frontend/releases/components/releases_pagination_rest_spec.js deleted file mode 100644 index 7d45176967b..00000000000 --- a/spec/frontend/releases/components/releases_pagination_rest_spec.js +++ /dev/null @@ -1,72 +0,0 @@ -import { GlPagination } from '@gitlab/ui'; -import { mount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import * as commonUtils from '~/lib/utils/common_utils'; -import ReleasesPaginationRest from '~/releases/components/releases_pagination_rest.vue'; -import createStore from '~/releases/stores'; -import createIndexModule from '~/releases/stores/modules/index'; - -commonUtils.historyPushState = jest.fn(); - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('~/releases/components/releases_pagination_rest.vue', () => { - let wrapper; - let indexModule; - - const projectId = 19; - - const createComponent = (pageInfo) => { - indexModule = createIndexModule({ projectId }); - - indexModule.state.restPageInfo = pageInfo; - - indexModule.actions.fetchReleases = jest.fn(); - - wrapper = mount(ReleasesPaginationRest, { - store: createStore({ - modules: { - index: indexModule, - }, - featureFlags: {}, - }), - localVue, - }); - }; - - const findGlPagination = () => wrapper.find(GlPagination); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('when a page number is clicked', () => { - const newPage = 2; - - beforeEach(() => { - createComponent({ - perPage: 20, - page: 1, - total: 40, - totalPages: 2, - nextPage: 2, - }); - - findGlPagination().vm.$emit('input', newPage); - }); - - it('calls fetchReleases with the correct page', () => { - expect(indexModule.actions.fetchReleases.mock.calls).toEqual([ - [expect.anything(), { page: newPage }], - ]); - }); - - it('calls historyPushState with the new URL', () => { - expect(commonUtils.historyPushState.mock.calls).toEqual([ - [expect.stringContaining(`?page=${newPage}`)], - ]); - }); - }); -}); diff --git a/spec/frontend/releases/components/releases_pagination_spec.js b/spec/frontend/releases/components/releases_pagination_spec.js index 1d47da31f38..2d08f72ad8b 100644 --- a/spec/frontend/releases/components/releases_pagination_spec.js +++ b/spec/frontend/releases/components/releases_pagination_spec.js @@ -1,23 +1,46 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlKeysetPagination } from '@gitlab/ui'; +import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import { historyPushState } from '~/lib/utils/common_utils'; import ReleasesPagination from '~/releases/components/releases_pagination.vue'; -import ReleasesPaginationGraphql from '~/releases/components/releases_pagination_graphql.vue'; -import ReleasesPaginationRest from '~/releases/components/releases_pagination_rest.vue'; +import createStore from '~/releases/stores'; +import createIndexModule from '~/releases/stores/modules/index'; + +jest.mock('~/lib/utils/common_utils', () => ({ + ...jest.requireActual('~/lib/utils/common_utils'), + historyPushState: jest.fn(), +})); const localVue = createLocalVue(); localVue.use(Vuex); describe('~/releases/components/releases_pagination.vue', () => { let wrapper; + let indexModule; - const createComponent = (useGraphQLEndpoint) => { - const store = new Vuex.Store({ - getters: { - useGraphQLEndpoint: () => useGraphQLEndpoint, - }, - }); + const cursors = { + startCursor: 'startCursor', + endCursor: 'endCursor', + }; + + const projectPath = 'my/project'; + + const createComponent = (pageInfo) => { + indexModule = createIndexModule({ projectPath }); + + indexModule.state.pageInfo = pageInfo; - wrapper = shallowMount(ReleasesPagination, { store, localVue }); + indexModule.actions.fetchReleases = jest.fn(); + + wrapper = mount(ReleasesPagination, { + store: createStore({ + modules: { + index: indexModule, + }, + featureFlags: {}, + }), + localVue, + }); }; afterEach(() => { @@ -25,28 +48,130 @@ describe('~/releases/components/releases_pagination.vue', () => { wrapper = null; }); - const findRestPagination = () => wrapper.find(ReleasesPaginationRest); - const findGraphQlPagination = () => wrapper.find(ReleasesPaginationGraphql); + const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination); + const findPrevButton = () => findGlKeysetPagination().find('[data-testid="prevButton"]'); + const findNextButton = () => findGlKeysetPagination().find('[data-testid="nextButton"]'); + + const expectDisabledPrev = () => { + expect(findPrevButton().attributes().disabled).toBe('disabled'); + }; + const expectEnabledPrev = () => { + expect(findPrevButton().attributes().disabled).toBe(undefined); + }; + const expectDisabledNext = () => { + expect(findNextButton().attributes().disabled).toBe('disabled'); + }; + const expectEnabledNext = () => { + expect(findNextButton().attributes().disabled).toBe(undefined); + }; + + describe('when there is only one page of results', () => { + beforeEach(() => { + createComponent({ + hasPreviousPage: false, + hasNextPage: false, + }); + }); - describe('when one of necessary feature flags is disabled', () => { + it('does not render a GlKeysetPagination', () => { + expect(findGlKeysetPagination().exists()).toBe(false); + }); + }); + + describe('when there is a next page, but not a previous page', () => { beforeEach(() => { - createComponent(false); + createComponent({ + hasPreviousPage: false, + hasNextPage: true, + }); }); - it('renders the REST pagination component', () => { - expect(findRestPagination().exists()).toBe(true); - expect(findGraphQlPagination().exists()).toBe(false); + it('renders a disabled "Prev" button', () => { + expectDisabledPrev(); + }); + + it('renders an enabled "Next" button', () => { + expectEnabledNext(); }); }); - describe('when all the necessary feature flags are enabled', () => { + describe('when there is a previous page, but not a next page', () => { beforeEach(() => { - createComponent(true); + createComponent({ + hasPreviousPage: true, + hasNextPage: false, + }); }); - it('renders the GraphQL pagination component', () => { - expect(findGraphQlPagination().exists()).toBe(true); - expect(findRestPagination().exists()).toBe(false); + it('renders a enabled "Prev" button', () => { + expectEnabledPrev(); + }); + + it('renders an disabled "Next" button', () => { + expectDisabledNext(); + }); + }); + + describe('when there is both a previous page and a next page', () => { + beforeEach(() => { + createComponent({ + hasPreviousPage: true, + hasNextPage: true, + }); + }); + + it('renders a enabled "Prev" button', () => { + expectEnabledPrev(); + }); + + it('renders an enabled "Next" button', () => { + expectEnabledNext(); + }); + }); + + describe('button behavior', () => { + beforeEach(() => { + createComponent({ + hasPreviousPage: true, + hasNextPage: true, + ...cursors, + }); + }); + + describe('next button behavior', () => { + beforeEach(() => { + findNextButton().trigger('click'); + }); + + it('calls fetchReleases with the correct after cursor', () => { + expect(indexModule.actions.fetchReleases.mock.calls).toEqual([ + [expect.anything(), { after: cursors.endCursor }], + ]); + }); + + it('calls historyPushState with the new URL', () => { + expect(historyPushState.mock.calls).toEqual([ + [expect.stringContaining(`?after=${cursors.endCursor}`)], + ]); + }); + }); + + describe('previous button behavior', () => { + beforeEach(() => { + findPrevButton().trigger('click'); + }); + + it('calls fetchReleases with the correct before cursor', () => { + expect(indexModule.actions.fetchReleases.mock.calls).toEqual([ + [expect.anything(), { before: cursors.startCursor }], + ]); + }); + + it('calls historyPushState with the new URL', () => { + expect(historyPushState.mock.calls).toEqual([ + [expect.stringContaining(`?before=${cursors.startCursor}`)], + ]); + }); }); }); }); diff --git a/spec/frontend/releases/components/tag_field_new_spec.js b/spec/frontend/releases/components/tag_field_new_spec.js index f1608ca31b4..114e46ce64b 100644 --- a/spec/frontend/releases/components/tag_field_new_spec.js +++ b/spec/frontend/releases/components/tag_field_new_spec.js @@ -10,29 +10,35 @@ 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; let wrapper; + let RefSelectorStub; + + const createComponent = ( + mountFn = shallowMount, + { searchQuery } = { searchQuery: NONEXISTENT_TAG_NAME }, + ) => { + // A mock version of the RefSelector component that just renders the + // #footer slot, so that the content inside this slot can be tested. + RefSelectorStub = Vue.component('RefSelectorStub', { + data() { + return { + footerSlotProps: { + isLoading: false, + matches: { + tags: { + totalCount: 1, + list: [{ name: TEST_TAG_NAME }], + }, + }, + query: searchQuery, + }, + }; + }, + template: '<div><slot name="footer" v-bind="footerSlotProps"></slot></div>', + }); - const createComponent = (mountFn = shallowMount) => { wrapper = mountFn(TagFieldNew, { store, stubs: { @@ -84,8 +90,6 @@ describe('releases/components/tag_field_new', () => { 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", () => { @@ -102,8 +106,6 @@ describe('releases/components/tag_field_new', () => { beforeEach(async () => { findTagNameDropdown().vm.$emit('input', updatedTagName); - - await wrapper.vm.$nextTick(); }); it("updates the store's release.tagName property", () => { @@ -116,6 +118,28 @@ describe('releases/components/tag_field_new', () => { }); }); + describe('"Create tag" option', () => { + describe('when the search query exactly matches one of the search results', () => { + beforeEach(async () => { + createComponent(mount, { searchQuery: TEST_TAG_NAME }); + }); + + it('does not show the "Create tag" option', () => { + expect(findCreateNewTagOption().exists()).toBe(false); + }); + }); + + describe('when the search query does not exactly match one of the search results', () => { + beforeEach(async () => { + createComponent(mount, { searchQuery: NONEXISTENT_TAG_NAME }); + }); + + it('shows the "Create tag" option', () => { + expect(findCreateNewTagOption().exists()).toBe(true); + }); + }); + }); + describe('validation', () => { beforeEach(() => { createComponent(mount); @@ -176,8 +200,6 @@ describe('releases/components/tag_field_new', () => { const updatedCreateFrom = 'update-create-from'; findCreateFromDropdown().vm.$emit('input', updatedCreateFrom); - await wrapper.vm.$nextTick(); - expect(store.state.editNew.createFrom).toBe(updatedCreateFrom); }); }); diff --git a/spec/frontend/releases/stores/getters_spec.js b/spec/frontend/releases/stores/getters_spec.js deleted file mode 100644 index 01e10567cf0..00000000000 --- a/spec/frontend/releases/stores/getters_spec.js +++ /dev/null @@ -1,22 +0,0 @@ -import * as getters from '~/releases/stores/getters'; - -describe('~/releases/stores/getters.js', () => { - it.each` - graphqlReleaseData | graphqlReleasesPage | graphqlMilestoneStats | result - ${false} | ${false} | ${false} | ${false} - ${false} | ${false} | ${true} | ${false} - ${false} | ${true} | ${false} | ${false} - ${false} | ${true} | ${true} | ${false} - ${true} | ${false} | ${false} | ${false} - ${true} | ${false} | ${true} | ${false} - ${true} | ${true} | ${false} | ${false} - ${true} | ${true} | ${true} | ${true} - `( - 'returns $result with feature flag values graphqlReleaseData=$graphqlReleaseData, graphqlReleasesPage=$graphqlReleasesPage, and graphqlMilestoneStats=$graphqlMilestoneStats', - ({ result: expectedResult, ...featureFlags }) => { - const actualResult = getters.useGraphQLEndpoint({ featureFlags }); - - expect(actualResult).toBe(expectedResult); - }, - ); -}); diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js index b116d601ca4..688ec4c0a50 100644 --- a/spec/frontend/releases/stores/modules/detail/actions_spec.js +++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js @@ -1,18 +1,16 @@ -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; import { cloneDeep } from 'lodash'; import { getJSONFixture } from 'helpers/fixtures'; import testAction from 'helpers/vuex_action_helper'; -import api from '~/api'; import { deprecatedCreateFlash as createFlash } from '~/flash'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import httpStatus from '~/lib/utils/http_status'; import { redirectTo } from '~/lib/utils/url_utility'; import { ASSET_LINK_TYPE } from '~/releases/constants'; +import createReleaseAssetLinkMutation from '~/releases/graphql/mutations/create_release_link.mutation.graphql'; +import deleteReleaseAssetLinkMutation from '~/releases/graphql/mutations/delete_release_link.mutation.graphql'; +import updateReleaseMutation from '~/releases/graphql/mutations/update_release.mutation.graphql'; import * as actions from '~/releases/stores/modules/edit_new/actions'; import * as types from '~/releases/stores/modules/edit_new/mutation_types'; import createState from '~/releases/stores/modules/edit_new/state'; -import { releaseToApiJson, apiJsonToRelease } from '~/releases/util'; +import { gqClient, convertOneReleaseGraphQLResponse } from '~/releases/util'; jest.mock('~/flash'); @@ -21,12 +19,21 @@ jest.mock('~/lib/utils/url_utility', () => ({ joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths, })); -const originalRelease = getJSONFixture('api/releases/release.json'); +jest.mock('~/releases/util', () => ({ + ...jest.requireActual('~/releases/util'), + gqClient: { + query: jest.fn(), + mutate: jest.fn(), + }, +})); + +const originalOneReleaseForEditingQueryResponse = getJSONFixture( + 'graphql/releases/graphql/queries/one_release_for_editing.query.graphql.json', +); describe('Release edit/new actions', () => { let state; - let release; - let mock; + let releaseResponse; let error; const setupState = (updates = {}) => { @@ -34,38 +41,26 @@ describe('Release edit/new actions', () => { isExistingRelease: true, }; - const rootState = { - featureFlags: { - graphqlIndividualReleasePage: false, - }, - }; - state = { ...createState({ projectId: '18', - tagName: release.tag_name, + tagName: releaseResponse.tag_name, releasesPagePath: 'path/to/releases/page', markdownDocsPath: 'path/to/markdown/docs', markdownPreviewPath: 'path/to/markdown/preview', }), ...getters, - ...rootState, ...updates, }; }; beforeEach(() => { - release = cloneDeep(originalRelease); - mock = new MockAdapter(axios); + releaseResponse = cloneDeep(originalOneReleaseForEditingQueryResponse); gon.api_version = 'v4'; - error = { message: 'An error occurred' }; + error = new Error('Yikes!'); createFlash.mockClear(); }); - afterEach(() => { - mock.restore(); - }); - describe('when creating a new release', () => { beforeEach(() => { setupState({ isExistingRelease: false }); @@ -118,15 +113,9 @@ describe('Release edit/new actions', () => { beforeEach(setupState); describe('fetchRelease', () => { - let getReleaseUrl; - - beforeEach(() => { - getReleaseUrl = `/api/v4/projects/${state.projectId}/releases/${state.tagName}`; - }); - describe('when the network request to the Release API is successful', () => { beforeEach(() => { - mock.onGet(getReleaseUrl).replyOnce(httpStatus.OK, release); + gqClient.query.mockResolvedValue(releaseResponse); }); it(`commits ${types.REQUEST_RELEASE} and then commits ${types.RECEIVE_RELEASE_SUCCESS} with the converted release object`, () => { @@ -136,15 +125,15 @@ describe('Release edit/new actions', () => { }, { type: types.RECEIVE_RELEASE_SUCCESS, - payload: apiJsonToRelease(release, { deep: true }), + payload: convertOneReleaseGraphQLResponse(releaseResponse).data, }, ]); }); }); - describe('when the network request to the Release API fails', () => { + describe('when the GraphQL network request fails', () => { beforeEach(() => { - mock.onGet(getReleaseUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + gqClient.query.mockRejectedValue(error); }); it(`commits ${types.REQUEST_RELEASE} and then commits ${types.RECEIVE_RELEASE_ERROR} with an error object`, () => { @@ -282,44 +271,50 @@ describe('Release edit/new actions', () => { describe('receiveSaveReleaseSuccess', () => { it(`commits ${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () => - testAction(actions.receiveSaveReleaseSuccess, release, state, [ + testAction(actions.receiveSaveReleaseSuccess, releaseResponse, state, [ { type: types.RECEIVE_SAVE_RELEASE_SUCCESS }, ])); it("redirects to the release's dedicated page", () => { - actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state }, release); + const { selfUrl } = releaseResponse.data.project.release.links; + actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state }, selfUrl); expect(redirectTo).toHaveBeenCalledTimes(1); - expect(redirectTo).toHaveBeenCalledWith(release._links.self); + expect(redirectTo).toHaveBeenCalledWith(selfUrl); }); }); describe('createRelease', () => { - let createReleaseUrl; let releaseLinksToCreate; beforeEach(() => { - const camelCasedRelease = convertObjectPropsToCamelCase(release); + const { data: release } = convertOneReleaseGraphQLResponse( + originalOneReleaseForEditingQueryResponse, + ); - releaseLinksToCreate = camelCasedRelease.assets.links.slice(0, 1); + releaseLinksToCreate = release.assets.links.slice(0, 1); setupState({ - release: camelCasedRelease, + release, releaseLinksToCreate, }); - - createReleaseUrl = `/api/v4/projects/${state.projectId}/releases`; }); - describe('when the network request to the Release API is successful', () => { + describe('when the GraphQL request is successful', () => { + const selfUrl = 'url/to/self'; + beforeEach(() => { - const expectedRelease = releaseToApiJson({ - ...state.release, - assets: { - links: releaseLinksToCreate, + gqClient.mutate.mockResolvedValue({ + data: { + releaseCreate: { + release: { + links: { + selfUrl, + }, + }, + errors: [], + }, }, }); - - mock.onPost(createReleaseUrl, expectedRelease).replyOnce(httpStatus.CREATED, release); }); it(`dispatches "receiveSaveReleaseSuccess" with the converted release object`, () => { @@ -331,16 +326,16 @@ describe('Release edit/new actions', () => { [ { type: 'receiveSaveReleaseSuccess', - payload: apiJsonToRelease(release, { deep: true }), + payload: selfUrl, }, ], ); }); }); - describe('when the network request to the Release API fails', () => { + describe('when the GraphQL network request fails', () => { beforeEach(() => { - mock.onPost(createReleaseUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + gqClient.mutate.mockRejectedValue(error); }); it(`commits ${types.RECEIVE_SAVE_RELEASE_ERROR} with an error object`, () => { @@ -358,7 +353,7 @@ describe('Release edit/new actions', () => { .then(() => { expect(createFlash).toHaveBeenCalledTimes(1); expect(createFlash).toHaveBeenCalledWith( - 'Something went wrong while creating a new release', + 'Something went wrong while creating a new release.', ); }); }); @@ -369,112 +364,209 @@ describe('Release edit/new actions', () => { let getters; let dispatch; let commit; - let callOrder; + let release; beforeEach(() => { getters = { releaseLinksToDelete: [{ id: '1' }, { id: '2' }], - releaseLinksToCreate: [{ id: 'new-link-1' }, { id: 'new-link-2' }], + releaseLinksToCreate: [ + { id: 'new-link-1', name: 'Link 1', url: 'https://example.com/1', linkType: 'Other' }, + { id: 'new-link-2', name: 'Link 2', url: 'https://example.com/2', linkType: 'Package' }, + ], + releaseUpdateMutatationVariables: {}, }; + release = convertOneReleaseGraphQLResponse(releaseResponse).data; + setupState({ - release: convertObjectPropsToCamelCase(release), + release, ...getters, }); dispatch = jest.fn(); commit = jest.fn(); - callOrder = []; - jest.spyOn(api, 'updateRelease').mockImplementation(() => { - callOrder.push('updateRelease'); - return Promise.resolve({ data: release }); - }); - jest.spyOn(api, 'deleteReleaseLink').mockImplementation(() => { - callOrder.push('deleteReleaseLink'); - return Promise.resolve(); - }); - jest.spyOn(api, 'createReleaseLink').mockImplementation(() => { - callOrder.push('createReleaseLink'); - return Promise.resolve(); + gqClient.mutate.mockResolvedValue({ + data: { + releaseUpdate: { + errors: [], + }, + releaseAssetLinkDelete: { + errors: [], + }, + releaseAssetLinkCreate: { + errors: [], + }, + }, }); }); describe('when the network request to the Release API is successful', () => { - it('dispatches receiveSaveReleaseSuccess', () => { - return actions.updateRelease({ commit, dispatch, state, getters }).then(() => { - expect(dispatch.mock.calls).toEqual([ - ['receiveSaveReleaseSuccess', apiJsonToRelease(release)], - ]); - }); + it('dispatches receiveSaveReleaseSuccess', async () => { + await actions.updateRelease({ commit, dispatch, state, getters }); + expect(dispatch.mock.calls).toEqual([['receiveSaveReleaseSuccess', release._links.self]]); }); - it('updates the Release, then deletes all existing links, and then recreates new links', () => { - return actions.updateRelease({ dispatch, state, getters }).then(() => { - expect(callOrder).toEqual([ - 'updateRelease', - 'deleteReleaseLink', - 'deleteReleaseLink', - 'createReleaseLink', - 'createReleaseLink', - ]); + it('updates the Release, then deletes all existing links, and then recreates new links', async () => { + await actions.updateRelease({ commit, dispatch, state, getters }); - expect(api.updateRelease.mock.calls).toEqual([ - [ - state.projectId, - state.tagName, - releaseToApiJson({ - ...state.release, - assets: { - links: getters.releaseLinksToCreate, - }, - }), - ], - ]); + // First, update the release + expect(gqClient.mutate.mock.calls[0]).toEqual([ + { + mutation: updateReleaseMutation, + variables: getters.releaseUpdateMutatationVariables, + }, + ]); - expect(api.deleteReleaseLink).toHaveBeenCalledTimes( - getters.releaseLinksToDelete.length, - ); - getters.releaseLinksToDelete.forEach((link) => { - expect(api.deleteReleaseLink).toHaveBeenCalledWith( - state.projectId, - state.tagName, - link.id, - ); - }); + // Then, delete the first asset link + expect(gqClient.mutate.mock.calls[1]).toEqual([ + { + mutation: deleteReleaseAssetLinkMutation, + variables: { input: { id: getters.releaseLinksToDelete[0].id } }, + }, + ]); - expect(api.createReleaseLink).toHaveBeenCalledTimes( - getters.releaseLinksToCreate.length, - ); - getters.releaseLinksToCreate.forEach((link) => { - expect(api.createReleaseLink).toHaveBeenCalledWith( - state.projectId, - state.tagName, - link, - ); - }); - }); + // And the second + expect(gqClient.mutate.mock.calls[2]).toEqual([ + { + mutation: deleteReleaseAssetLinkMutation, + variables: { input: { id: getters.releaseLinksToDelete[1].id } }, + }, + ]); + + // Recreate the first asset link + expect(gqClient.mutate.mock.calls[3]).toEqual([ + { + mutation: createReleaseAssetLinkMutation, + variables: { + input: { + projectPath: state.projectPath, + tagName: state.tagName, + name: getters.releaseLinksToCreate[0].name, + url: getters.releaseLinksToCreate[0].url, + linkType: getters.releaseLinksToCreate[0].linkType.toUpperCase(), + }, + }, + }, + ]); + + // And finally, recreate the second + expect(gqClient.mutate.mock.calls[4]).toEqual([ + { + mutation: createReleaseAssetLinkMutation, + variables: { + input: { + projectPath: state.projectPath, + tagName: state.tagName, + name: getters.releaseLinksToCreate[1].name, + url: getters.releaseLinksToCreate[1].url, + linkType: getters.releaseLinksToCreate[1].linkType.toUpperCase(), + }, + }, + }, + ]); }); }); - describe('when the network request to the Release API fails', () => { + describe('when the GraphQL network request fails', () => { beforeEach(() => { - jest.spyOn(api, 'updateRelease').mockRejectedValue(error); + gqClient.mutate.mockRejectedValue(error); }); - it('dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object', () => { - return actions.updateRelease({ commit, dispatch, state, getters }).then(() => { - expect(commit.mock.calls).toEqual([[types.RECEIVE_SAVE_RELEASE_ERROR, error]]); - }); + it('dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object', async () => { + await actions.updateRelease({ commit, dispatch, state, getters }); + + expect(commit.mock.calls).toEqual([[types.RECEIVE_SAVE_RELEASE_ERROR, error]]); + }); + + it('shows a flash message', async () => { + await actions.updateRelease({ commit, dispatch, state, getters }); + + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith( + 'Something went wrong while saving the release details.', + ); }); + }); + + describe('when the GraphQL mutation returns errors-as-data', () => { + const expectCorrectErrorHandling = () => { + it('dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object', async () => { + await actions.updateRelease({ commit, dispatch, state, getters }); + + expect(commit.mock.calls).toEqual([ + [types.RECEIVE_SAVE_RELEASE_ERROR, expect.any(Error)], + ]); + }); + + it('shows a flash message', async () => { + await actions.updateRelease({ commit, dispatch, state, getters }); - it('shows a flash message', () => { - return actions.updateRelease({ commit, dispatch, state, getters }).then(() => { expect(createFlash).toHaveBeenCalledTimes(1); expect(createFlash).toHaveBeenCalledWith( - 'Something went wrong while saving the release details', + 'Something went wrong while saving the release details.', ); }); + }; + + describe('when the releaseUpdate mutation returns errors-as-data', () => { + beforeEach(() => { + gqClient.mutate.mockResolvedValue({ + data: { + releaseUpdate: { + errors: ['Something went wrong!'], + }, + releaseAssetLinkDelete: { + errors: [], + }, + releaseAssetLinkCreate: { + errors: [], + }, + }, + }); + }); + + expectCorrectErrorHandling(); + }); + + describe('when the releaseAssetLinkDelete mutation returns errors-as-data', () => { + beforeEach(() => { + gqClient.mutate.mockResolvedValue({ + data: { + releaseUpdate: { + errors: [], + }, + releaseAssetLinkDelete: { + errors: ['Something went wrong!'], + }, + releaseAssetLinkCreate: { + errors: [], + }, + }, + }); + }); + + expectCorrectErrorHandling(); + }); + + describe('when the releaseAssetLinkCreate mutation returns errors-as-data', () => { + beforeEach(() => { + gqClient.mutate.mockResolvedValue({ + data: { + releaseUpdate: { + errors: [], + }, + releaseAssetLinkDelete: { + errors: [], + }, + releaseAssetLinkCreate: { + errors: ['Something went wrong!'], + }, + }, + }); + }); + + expectCorrectErrorHandling(); }); }); }); diff --git a/spec/frontend/releases/stores/modules/detail/getters_spec.js b/spec/frontend/releases/stores/modules/detail/getters_spec.js index 1449c064d77..66f24ac9559 100644 --- a/spec/frontend/releases/stores/modules/detail/getters_spec.js +++ b/spec/frontend/releases/stores/modules/detail/getters_spec.js @@ -257,4 +257,93 @@ describe('Release edit/new getters', () => { }); }); }); + + describe.each([ + [ + 'returns all the data needed for the releaseUpdate GraphQL query', + { + projectPath: 'projectPath', + release: { + tagName: 'release.tagName', + name: 'release.name', + description: 'release.description', + milestones: ['release.milestone[0].title'], + }, + }, + { + projectPath: 'projectPath', + tagName: 'release.tagName', + name: 'release.name', + description: 'release.description', + milestones: ['release.milestone[0].title'], + }, + ], + [ + 'trims whitespace from the release name', + { release: { name: ' name \t\n' } }, + { name: 'name' }, + ], + [ + 'returns the name as null if the name is nothing but whitespace', + { release: { name: ' \t\n' } }, + { name: null }, + ], + ['returns the name as null if the name is undefined', { release: {} }, { name: null }], + [ + 'returns just the milestone titles even if the release includes full milestone objects', + { release: { milestones: [{ title: 'release.milestone[0].title' }] } }, + { milestones: ['release.milestone[0].title'] }, + ], + ])('releaseUpdateMutatationVariables', (description, state, expectedVariables) => { + it(description, () => { + const expectedVariablesObject = { input: expect.objectContaining(expectedVariables) }; + + const actualVariables = getters.releaseUpdateMutatationVariables(state); + + expect(actualVariables).toEqual(expectedVariablesObject); + }); + }); + + describe('releaseCreateMutatationVariables', () => { + it('returns all the data needed for the releaseCreate GraphQL query', () => { + const state = { + createFrom: 'main', + }; + + const otherGetters = { + releaseUpdateMutatationVariables: { + input: { + name: 'release.name', + }, + }, + releaseLinksToCreate: [ + { + name: 'link.name', + url: 'link.url', + linkType: 'link.linkType', + }, + ], + }; + + const expectedVariables = { + input: { + name: 'release.name', + ref: 'main', + assets: { + links: [ + { + name: 'link.name', + url: 'link.url', + linkType: 'LINK.LINKTYPE', + }, + ], + }, + }, + }; + + const actualVariables = getters.releaseCreateMutatationVariables(state, otherGetters); + + expect(actualVariables).toEqual(expectedVariables); + }); + }); }); diff --git a/spec/frontend/releases/stores/modules/list/actions_spec.js b/spec/frontend/releases/stores/modules/list/actions_spec.js index 4dc996174bc..af520c2eb20 100644 --- a/spec/frontend/releases/stores/modules/list/actions_spec.js +++ b/spec/frontend/releases/stores/modules/list/actions_spec.js @@ -1,43 +1,29 @@ import { cloneDeep } from 'lodash'; import { getJSONFixture } from 'helpers/fixtures'; import testAction from 'helpers/vuex_action_helper'; -import api from '~/api'; -import { - normalizeHeaders, - parseIntPagination, - convertObjectPropsToCamelCase, -} from '~/lib/utils/common_utils'; import { PAGE_SIZE } from '~/releases/constants'; -import allReleasesQuery from '~/releases/queries/all_releases.query.graphql'; +import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; import { fetchReleases, - fetchReleasesGraphQl, - fetchReleasesRest, receiveReleasesError, setSorting, } from '~/releases/stores/modules/index/actions'; import * as types from '~/releases/stores/modules/index/mutation_types'; import createState from '~/releases/stores/modules/index/state'; import { gqClient, convertAllReleasesGraphQLResponse } from '~/releases/util'; -import { pageInfoHeadersWithoutPagination } from '../../../mock_data'; - -const originalRelease = getJSONFixture('api/releases/release.json'); -const originalReleases = [originalRelease]; const originalGraphqlReleasesResponse = getJSONFixture( - 'graphql/releases/queries/all_releases.query.graphql.json', + 'graphql/releases/graphql/queries/all_releases.query.graphql.json', ); describe('Releases State actions', () => { let mockedState; - let releases; let graphqlReleasesResponse; const projectPath = 'root/test-project'; const projectId = 19; const before = 'testBeforeCursor'; const after = 'testAfterCursor'; - const page = 2; beforeEach(() => { mockedState = { @@ -47,57 +33,10 @@ describe('Releases State actions', () => { }), }; - releases = convertObjectPropsToCamelCase(originalReleases, { deep: true }); graphqlReleasesResponse = cloneDeep(originalGraphqlReleasesResponse); }); - describe('when all the necessary GraphQL feature flags are enabled', () => { - beforeEach(() => { - mockedState.useGraphQLEndpoint = true; - }); - - describe('fetchReleases', () => { - it('dispatches fetchReleasesGraphQl with before and after parameters', () => { - return testAction( - fetchReleases, - { before, after, page }, - mockedState, - [], - [ - { - type: 'fetchReleasesGraphQl', - payload: { before, after }, - }, - ], - ); - }); - }); - }); - - describe('when at least one of the GraphQL feature flags is disabled', () => { - beforeEach(() => { - mockedState.useGraphQLEndpoint = false; - }); - - describe('fetchReleases', () => { - it('dispatches fetchReleasesRest with a page parameter', () => { - return testAction( - fetchReleases, - { before, after, page }, - mockedState, - [], - [ - { - type: 'fetchReleasesRest', - payload: { page }, - }, - ], - ); - }); - }); - }); - - describe('fetchReleasesGraphQl', () => { + describe('fetchReleases', () => { describe('GraphQL query variables', () => { let vuexParams; @@ -109,7 +48,7 @@ describe('Releases State actions', () => { describe('when neither a before nor an after parameter is provided', () => { beforeEach(() => { - fetchReleasesGraphQl(vuexParams, { before: undefined, after: undefined }); + fetchReleases(vuexParams, { before: undefined, after: undefined }); }); it('makes a GraphQl query with a first variable', () => { @@ -122,7 +61,7 @@ describe('Releases State actions', () => { describe('when only a before parameter is provided', () => { beforeEach(() => { - fetchReleasesGraphQl(vuexParams, { before, after: undefined }); + fetchReleases(vuexParams, { before, after: undefined }); }); it('makes a GraphQl query with last and before variables', () => { @@ -135,7 +74,7 @@ describe('Releases State actions', () => { describe('when only an after parameter is provided', () => { beforeEach(() => { - fetchReleasesGraphQl(vuexParams, { before: undefined, after }); + fetchReleases(vuexParams, { before: undefined, after }); }); it('makes a GraphQl query with first and after variables', () => { @@ -148,12 +87,12 @@ describe('Releases State actions', () => { describe('when both before and after parameters are provided', () => { it('throws an error', () => { - const callFetchReleasesGraphQl = () => { - fetchReleasesGraphQl(vuexParams, { before, after }); + const callFetchReleases = () => { + fetchReleases(vuexParams, { before, after }); }; - expect(callFetchReleasesGraphQl).toThrowError( - 'Both a `before` and an `after` parameter were provided to fetchReleasesGraphQl. These parameters cannot be used together.', + expect(callFetchReleases).toThrowError( + 'Both a `before` and an `after` parameter were provided to fetchReleases. These parameters cannot be used together.', ); }); }); @@ -171,7 +110,7 @@ describe('Releases State actions', () => { mockedState.sorting.sort = sort; mockedState.sorting.orderBy = orderBy; - fetchReleasesGraphQl(vuexParams, { before: undefined, after: undefined }); + fetchReleases(vuexParams, { before: undefined, after: undefined }); expect(gqClient.query).toHaveBeenCalledWith({ query: allReleasesQuery, @@ -191,7 +130,7 @@ describe('Releases State actions', () => { const convertedResponse = convertAllReleasesGraphQLResponse(graphqlReleasesResponse); return testAction( - fetchReleasesGraphQl, + fetchReleases, {}, mockedState, [ @@ -202,7 +141,7 @@ describe('Releases State actions', () => { type: types.RECEIVE_RELEASES_SUCCESS, payload: { data: convertedResponse.data, - graphQlPageInfo: convertedResponse.paginationInfo, + pageInfo: convertedResponse.paginationInfo, }, }, ], @@ -218,90 +157,7 @@ describe('Releases State actions', () => { it(`commits ${types.REQUEST_RELEASES} and dispatch receiveReleasesError`, () => { return testAction( - fetchReleasesGraphQl, - {}, - mockedState, - [ - { - type: types.REQUEST_RELEASES, - }, - ], - [ - { - type: 'receiveReleasesError', - }, - ], - ); - }); - }); - }); - - describe('fetchReleasesRest', () => { - describe('REST query parameters', () => { - let vuexParams; - - beforeEach(() => { - jest - .spyOn(api, 'releases') - .mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination }); - - vuexParams = { dispatch: jest.fn(), commit: jest.fn(), state: mockedState }; - }); - - describe('when a page parameter is provided', () => { - beforeEach(() => { - fetchReleasesRest(vuexParams, { page: 2 }); - }); - - it('makes a REST query with a page query parameter', () => { - expect(api.releases).toHaveBeenCalledWith(projectId, { - page, - order_by: 'released_at', - sort: 'desc', - }); - }); - }); - }); - - describe('when the request is successful', () => { - beforeEach(() => { - jest - .spyOn(api, 'releases') - .mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination }); - }); - - it(`commits ${types.REQUEST_RELEASES} and ${types.RECEIVE_RELEASES_SUCCESS}`, () => { - return testAction( - fetchReleasesRest, - {}, - mockedState, - [ - { - type: types.REQUEST_RELEASES, - }, - { - type: types.RECEIVE_RELEASES_SUCCESS, - payload: { - data: convertObjectPropsToCamelCase(releases, { deep: true }), - restPageInfo: parseIntPagination( - normalizeHeaders(pageInfoHeadersWithoutPagination), - ), - }, - }, - ], - [], - ); - }); - }); - - describe('when the request fails', () => { - beforeEach(() => { - jest.spyOn(api, 'releases').mockRejectedValue(new Error('Something went wrong!')); - }); - - it(`commits ${types.REQUEST_RELEASES} and dispatch receiveReleasesError`, () => { - return testAction( - fetchReleasesRest, + fetchReleases, {}, mockedState, [ diff --git a/spec/frontend/releases/stores/modules/list/mutations_spec.js b/spec/frontend/releases/stores/modules/list/mutations_spec.js index 8b35ba5d7ac..08d803b3c2c 100644 --- a/spec/frontend/releases/stores/modules/list/mutations_spec.js +++ b/spec/frontend/releases/stores/modules/list/mutations_spec.js @@ -1,28 +1,25 @@ import { getJSONFixture } from 'helpers/fixtures'; -import { parseIntPagination, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import * as types from '~/releases/stores/modules/index/mutation_types'; import mutations from '~/releases/stores/modules/index/mutations'; import createState from '~/releases/stores/modules/index/state'; import { convertAllReleasesGraphQLResponse } from '~/releases/util'; -import { pageInfoHeadersWithoutPagination } from '../../../mock_data'; const originalRelease = getJSONFixture('api/releases/release.json'); const originalReleases = [originalRelease]; const graphqlReleasesResponse = getJSONFixture( - 'graphql/releases/queries/all_releases.query.graphql.json', + 'graphql/releases/graphql/queries/all_releases.query.graphql.json', ); describe('Releases Store Mutations', () => { let stateCopy; - let restPageInfo; - let graphQlPageInfo; + let pageInfo; let releases; beforeEach(() => { stateCopy = createState({}); - restPageInfo = parseIntPagination(pageInfoHeadersWithoutPagination); - graphQlPageInfo = convertAllReleasesGraphQLResponse(graphqlReleasesResponse).paginationInfo; + pageInfo = convertAllReleasesGraphQLResponse(graphqlReleasesResponse).paginationInfo; releases = convertObjectPropsToCamelCase(originalReleases, { deep: true }); }); @@ -37,8 +34,7 @@ describe('Releases Store Mutations', () => { describe('RECEIVE_RELEASES_SUCCESS', () => { beforeEach(() => { mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, { - restPageInfo, - graphQlPageInfo, + pageInfo, data: releases, }); }); @@ -55,20 +51,15 @@ describe('Releases Store Mutations', () => { expect(stateCopy.releases).toEqual(releases); }); - it('sets restPageInfo', () => { - expect(stateCopy.restPageInfo).toEqual(restPageInfo); - }); - - it('sets graphQlPageInfo', () => { - expect(stateCopy.graphQlPageInfo).toEqual(graphQlPageInfo); + it('sets pageInfo', () => { + expect(stateCopy.pageInfo).toEqual(pageInfo); }); }); describe('RECEIVE_RELEASES_ERROR', () => { it('resets data', () => { mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, { - restPageInfo, - graphQlPageInfo, + pageInfo, data: releases, }); @@ -76,8 +67,7 @@ describe('Releases Store Mutations', () => { expect(stateCopy.isLoading).toEqual(false); expect(stateCopy.releases).toEqual([]); - expect(stateCopy.restPageInfo).toEqual({}); - expect(stateCopy.graphQlPageInfo).toEqual({}); + expect(stateCopy.pageInfo).toEqual({}); }); }); diff --git a/spec/frontend/releases/util_spec.js b/spec/frontend/releases/util_spec.js index fd00a524628..36e7be369d3 100644 --- a/spec/frontend/releases/util_spec.js +++ b/spec/frontend/releases/util_spec.js @@ -1,121 +1,22 @@ import { cloneDeep } from 'lodash'; import { getJSONFixture } from 'helpers/fixtures'; import { - releaseToApiJson, - apiJsonToRelease, convertGraphQLRelease, convertAllReleasesGraphQLResponse, convertOneReleaseGraphQLResponse, } from '~/releases/util'; const originalAllReleasesQueryResponse = getJSONFixture( - 'graphql/releases/queries/all_releases.query.graphql.json', + 'graphql/releases/graphql/queries/all_releases.query.graphql.json', ); const originalOneReleaseQueryResponse = getJSONFixture( - 'graphql/releases/queries/one_release.query.graphql.json', + 'graphql/releases/graphql/queries/one_release.query.graphql.json', +); +const originalOneReleaseForEditingQueryResponse = getJSONFixture( + 'graphql/releases/graphql/queries/one_release_for_editing.query.graphql.json', ); describe('releases/util.js', () => { - describe('releaseToApiJson', () => { - it('converts a release JavaScript object into JSON that the Release API can accept', () => { - const release = { - tagName: 'tag-name', - name: 'Release name', - description: 'Release description', - milestones: ['13.2', '13.3'], - assets: { - links: [{ url: 'https://gitlab.example.com/link', linkType: 'other' }], - }, - }; - - const expectedJson = { - tag_name: 'tag-name', - ref: null, - name: 'Release name', - description: 'Release description', - milestones: ['13.2', '13.3'], - assets: { - links: [{ url: 'https://gitlab.example.com/link', link_type: 'other' }], - }, - }; - - expect(releaseToApiJson(release)).toEqual(expectedJson); - }); - - describe('when createFrom is provided', () => { - it('adds the provided createFrom ref to the JSON as a "ref" property', () => { - const createFrom = 'main'; - - const release = {}; - - const expectedJson = { - ref: createFrom, - }; - - expect(releaseToApiJson(release, createFrom)).toMatchObject(expectedJson); - }); - }); - - describe('release.name', () => { - it.each` - input | output - ${null} | ${null} - ${''} | ${null} - ${' \t\n\r\n'} | ${null} - ${' Release name '} | ${'Release name'} - `('converts a name like `$input` to `$output`', ({ input, output }) => { - const release = { name: input }; - - const expectedJson = { - name: output, - }; - - expect(releaseToApiJson(release)).toMatchObject(expectedJson); - }); - }); - - describe('when milestones contains full milestone objects', () => { - it('converts the milestone objects into titles', () => { - const release = { - milestones: [{ title: '13.2' }, { title: '13.3' }, '13.4'], - }; - - const expectedJson = { milestones: ['13.2', '13.3', '13.4'] }; - - expect(releaseToApiJson(release)).toMatchObject(expectedJson); - }); - }); - }); - - describe('apiJsonToRelease', () => { - it('converts JSON received from the Release API into an object usable by the Vue application', () => { - const json = { - tag_name: 'tag-name', - assets: { - links: [ - { - link_type: 'other', - }, - ], - }, - }; - - const expectedRelease = { - tagName: 'tag-name', - assets: { - links: [ - { - linkType: 'other', - }, - ], - }, - milestones: [], - }; - - expect(apiJsonToRelease(json)).toEqual(expectedRelease); - }); - }); - describe('convertGraphQLRelease', () => { let releaseFromResponse; let convertedRelease; @@ -135,6 +36,26 @@ describe('releases/util.js', () => { expect(convertedRelease.assets.links[0].linkType).toBeUndefined(); }); + + it('handles assets that have no links', () => { + expect(convertedRelease.assets.links[0]).not.toBeUndefined(); + + delete releaseFromResponse.assets.links; + + convertedRelease = convertGraphQLRelease(releaseFromResponse); + + expect(convertedRelease.assets.links).toEqual([]); + }); + + it('handles assets that have no sources', () => { + expect(convertedRelease.assets.sources[0]).not.toBeUndefined(); + + delete releaseFromResponse.assets.sources; + + convertedRelease = convertGraphQLRelease(releaseFromResponse); + + expect(convertedRelease.assets.sources).toEqual([]); + }); }); describe('_links', () => { @@ -160,6 +81,33 @@ describe('releases/util.js', () => { expect(convertedRelease.commit).toBeUndefined(); }); }); + + describe('milestones', () => { + it("handles releases that don't have any milestone stats", () => { + expect(convertedRelease.milestones[0].issueStats).not.toBeUndefined(); + + releaseFromResponse.milestones.nodes = releaseFromResponse.milestones.nodes.map((n) => ({ + ...n, + stats: undefined, + })); + + convertedRelease = convertGraphQLRelease(releaseFromResponse); + + expect(convertedRelease.milestones[0].issueStats).toEqual({}); + }); + }); + + describe('evidences', () => { + it("handles releases that don't have any evidences", () => { + expect(convertedRelease.evidences).not.toBeUndefined(); + + delete releaseFromResponse.evidences; + + convertedRelease = convertGraphQLRelease(releaseFromResponse); + + expect(convertedRelease.evidences).toEqual([]); + }); + }); }); describe('convertAllReleasesGraphQLResponse', () => { @@ -173,4 +121,12 @@ describe('releases/util.js', () => { expect(convertOneReleaseGraphQLResponse(originalOneReleaseQueryResponse)).toMatchSnapshot(); }); }); + + describe('convertOneReleaseForEditingGraphQLResponse', () => { + it('matches snapshot', () => { + expect( + convertOneReleaseGraphQLResponse(originalOneReleaseForEditingQueryResponse), + ).toMatchSnapshot(); + }); + }); }); diff --git a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js index f0b23bb7b58..b8299d44f13 100644 --- a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js +++ b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js @@ -3,7 +3,7 @@ import Vuex from 'vuex'; import CodequalityIssueBody from '~/reports/codequality_report/components/codequality_issue_body.vue'; import GroupedCodequalityReportsApp from '~/reports/codequality_report/grouped_codequality_reports_app.vue'; import { getStoreConfig } from '~/reports/codequality_report/store'; -import { mockParsedHeadIssues, mockParsedBaseIssues } from './mock_data'; +import { parsedReportIssues } from './mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -80,7 +80,7 @@ describe('Grouped code quality reports app', () => { describe('with issues', () => { describe('with new issues', () => { beforeEach(() => { - mockStore.state.newIssues = [mockParsedHeadIssues[0]]; + mockStore.state.newIssues = parsedReportIssues.newIssues; mockStore.state.resolvedIssues = []; }); @@ -89,14 +89,14 @@ describe('Grouped code quality reports app', () => { }); it('renders custom codequality issue body', () => { - expect(findIssueBody().props('issue')).toEqual(mockParsedHeadIssues[0]); + expect(findIssueBody().props('issue')).toEqual(parsedReportIssues.newIssues[0]); }); }); describe('with resolved issues', () => { beforeEach(() => { mockStore.state.newIssues = []; - mockStore.state.resolvedIssues = [mockParsedBaseIssues[0]]; + mockStore.state.resolvedIssues = parsedReportIssues.resolvedIssues; }); it('renders summary text', () => { @@ -104,14 +104,14 @@ describe('Grouped code quality reports app', () => { }); it('renders custom codequality issue body', () => { - expect(findIssueBody().props('issue')).toEqual(mockParsedBaseIssues[0]); + expect(findIssueBody().props('issue')).toEqual(parsedReportIssues.resolvedIssues[0]); }); }); describe('with new and resolved issues', () => { beforeEach(() => { - mockStore.state.newIssues = [mockParsedHeadIssues[0]]; - mockStore.state.resolvedIssues = [mockParsedBaseIssues[0]]; + mockStore.state.newIssues = parsedReportIssues.newIssues; + mockStore.state.resolvedIssues = parsedReportIssues.resolvedIssues; }); it('renders summary text', () => { @@ -121,7 +121,7 @@ describe('Grouped code quality reports app', () => { }); it('renders custom codequality issue body', () => { - expect(findIssueBody().props('issue')).toEqual(mockParsedHeadIssues[0]); + expect(findIssueBody().props('issue')).toEqual(parsedReportIssues.newIssues[0]); }); }); }); diff --git a/spec/frontend/reports/codequality_report/mock_data.js b/spec/frontend/reports/codequality_report/mock_data.js index c5cecb34509..2c994116db6 100644 --- a/spec/frontend/reports/codequality_report/mock_data.js +++ b/spec/frontend/reports/codequality_report/mock_data.js @@ -1,94 +1,3 @@ -export const headIssues = [ - { - check_name: 'Rubocop/Lint/UselessAssignment', - description: 'Insecure Dependency', - location: { - path: 'lib/six.rb', - lines: { - begin: 6, - end: 7, - }, - }, - fingerprint: 'e879dd9bbc0953cad5037cde7ff0f627', - }, - { - categories: ['Security'], - check_name: 'Insecure Dependency', - description: 'Insecure Dependency', - location: { - path: 'Gemfile.lock', - lines: { - begin: 22, - end: 22, - }, - }, - fingerprint: 'ca2e59451e98ae60ba2f54e3857c50e5', - }, -]; - -export const mockParsedHeadIssues = [ - { - ...headIssues[1], - name: 'Insecure Dependency', - path: 'lib/six.rb', - urlPath: 'headPath/lib/six.rb#L6', - line: 6, - }, -]; - -export const baseIssues = [ - { - categories: ['Security'], - check_name: 'Insecure Dependency', - description: 'Insecure Dependency', - location: { - path: 'Gemfile.lock', - lines: { - begin: 22, - end: 22, - }, - }, - fingerprint: 'ca2e59451e98ae60ba2f54e3857c50e5', - }, - { - categories: ['Security'], - check_name: 'Insecure Dependency', - description: 'Insecure Dependency', - location: { - path: 'Gemfile.lock', - lines: { - begin: 21, - end: 21, - }, - }, - fingerprint: 'ca2354534dee94ae60ba2f54e3857c50e5', - }, -]; - -export const mockParsedBaseIssues = [ - { - ...baseIssues[1], - name: 'Insecure Dependency', - path: 'Gemfile.lock', - line: 21, - urlPath: 'basePath/Gemfile.lock#L21', - }, -]; - -export const issueDiff = [ - { - categories: ['Security'], - check_name: 'Insecure Dependency', - description: 'Insecure Dependency', - fingerprint: 'ca2e59451e98ae60ba2f54e3857c50e5', - line: 6, - location: { lines: { begin: 22, end: 22 }, path: 'Gemfile.lock' }, - name: 'Insecure Dependency', - path: 'lib/six.rb', - urlPath: 'headPath/lib/six.rb#L6', - }, -]; - export const reportIssues = { status: 'failed', new_errors: [ diff --git a/spec/frontend/reports/codequality_report/store/actions_spec.js b/spec/frontend/reports/codequality_report/store/actions_spec.js index a2b256448ef..1b83d071d17 100644 --- a/spec/frontend/reports/codequality_report/store/actions_spec.js +++ b/spec/frontend/reports/codequality_report/store/actions_spec.js @@ -5,30 +5,7 @@ import axios from '~/lib/utils/axios_utils'; import createStore from '~/reports/codequality_report/store'; import * as actions from '~/reports/codequality_report/store/actions'; import * as types from '~/reports/codequality_report/store/mutation_types'; -import { - headIssues, - baseIssues, - mockParsedHeadIssues, - mockParsedBaseIssues, - reportIssues, - parsedReportIssues, -} from '../mock_data'; - -// mock codequality comparison worker -jest.mock('~/reports/codequality_report/workers/codequality_comparison_worker', () => - jest.fn().mockImplementation(() => { - return { - addEventListener: (eventName, callback) => { - callback({ - data: { - newIssues: [mockParsedHeadIssues[0]], - resolvedIssues: [mockParsedBaseIssues[0]], - }, - }); - }, - }; - }), -); +import { reportIssues, parsedReportIssues } from '../mock_data'; describe('Codequality Reports actions', () => { let localState; @@ -43,9 +20,6 @@ describe('Codequality Reports actions', () => { it('should commit SET_PATHS mutation', (done) => { const paths = { basePath: 'basePath', - headPath: 'headPath', - baseBlobPath: 'baseBlobPath', - headBlobPath: 'headBlobPath', reportsPath: 'reportsPath', helpPath: 'codequalityHelpPath', }; @@ -63,119 +37,64 @@ describe('Codequality Reports actions', () => { describe('fetchReports', () => { let mock; - let diffFeatureFlagEnabled; - describe('with codequalityBackendComparison feature flag enabled', () => { - beforeEach(() => { - diffFeatureFlagEnabled = true; - localState.reportsPath = `${TEST_HOST}/codequality_reports.json`; - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - describe('on success', () => { - it('commits REQUEST_REPORTS and dispatches receiveReportsSuccess', (done) => { - mock.onGet(`${TEST_HOST}/codequality_reports.json`).reply(200, reportIssues); - - testAction( - actions.fetchReports, - diffFeatureFlagEnabled, - localState, - [{ type: types.REQUEST_REPORTS }], - [ - { - payload: parsedReportIssues, - type: 'receiveReportsSuccess', - }, - ], - done, - ); - }); - }); - - describe('on error', () => { - it('commits REQUEST_REPORTS and dispatches receiveReportsError', (done) => { - mock.onGet(`${TEST_HOST}/codequality_reports.json`).reply(500); - - testAction( - actions.fetchReports, - diffFeatureFlagEnabled, - localState, - [{ type: types.REQUEST_REPORTS }], - [{ type: 'receiveReportsError', payload: expect.any(Error) }], - done, - ); - }); - }); + beforeEach(() => { + localState.reportsPath = `${TEST_HOST}/codequality_reports.json`; + localState.basePath = '/base/path'; + mock = new MockAdapter(axios); }); - describe('with codequalityBackendComparison feature flag disabled', () => { - beforeEach(() => { - diffFeatureFlagEnabled = false; - localState.headPath = `${TEST_HOST}/head.json`; - localState.basePath = `${TEST_HOST}/base.json`; - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); + afterEach(() => { + mock.restore(); + }); - describe('on success', () => { - it('commits REQUEST_REPORTS and dispatches receiveReportsSuccess', (done) => { - mock.onGet(`${TEST_HOST}/head.json`).reply(200, headIssues); - mock.onGet(`${TEST_HOST}/base.json`).reply(200, baseIssues); - - testAction( - actions.fetchReports, - diffFeatureFlagEnabled, - localState, - [{ type: types.REQUEST_REPORTS }], - [ - { - payload: { - newIssues: [mockParsedHeadIssues[0]], - resolvedIssues: [mockParsedBaseIssues[0]], - }, - type: 'receiveReportsSuccess', - }, - ], - done, - ); - }); + describe('on success', () => { + it('commits REQUEST_REPORTS and dispatches receiveReportsSuccess', (done) => { + mock.onGet(`${TEST_HOST}/codequality_reports.json`).reply(200, reportIssues); + + testAction( + actions.fetchReports, + null, + localState, + [{ type: types.REQUEST_REPORTS }], + [ + { + payload: parsedReportIssues, + type: 'receiveReportsSuccess', + }, + ], + done, + ); }); + }); - describe('on error', () => { - it('commits REQUEST_REPORTS and dispatches receiveReportsError', (done) => { - mock.onGet(`${TEST_HOST}/head.json`).reply(500); - - testAction( - actions.fetchReports, - diffFeatureFlagEnabled, - localState, - [{ type: types.REQUEST_REPORTS }], - [{ type: 'receiveReportsError', payload: expect.any(Error) }], - done, - ); - }); + describe('on error', () => { + it('commits REQUEST_REPORTS and dispatches receiveReportsError', (done) => { + mock.onGet(`${TEST_HOST}/codequality_reports.json`).reply(500); + + testAction( + actions.fetchReports, + null, + localState, + [{ type: types.REQUEST_REPORTS }], + [{ type: 'receiveReportsError', payload: expect.any(Error) }], + done, + ); }); + }); - describe('with no base path', () => { - it('commits REQUEST_REPORTS and dispatches receiveReportsError', (done) => { - localState.basePath = null; - - testAction( - actions.fetchReports, - diffFeatureFlagEnabled, - localState, - [{ type: types.REQUEST_REPORTS }], - [{ type: 'receiveReportsError' }], - done, - ); - }); + describe('with no base path', () => { + it('commits REQUEST_REPORTS and dispatches receiveReportsError', (done) => { + localState.basePath = null; + + testAction( + actions.fetchReports, + null, + localState, + [{ type: types.REQUEST_REPORTS }], + [{ type: 'receiveReportsError' }], + done, + ); }); }); }); diff --git a/spec/frontend/reports/codequality_report/store/mutations_spec.js b/spec/frontend/reports/codequality_report/store/mutations_spec.js index 05a16cd6f82..9d4c05afd36 100644 --- a/spec/frontend/reports/codequality_report/store/mutations_spec.js +++ b/spec/frontend/reports/codequality_report/store/mutations_spec.js @@ -13,23 +13,17 @@ describe('Codequality Reports mutations', () => { describe('SET_PATHS', () => { it('sets paths to given values', () => { const basePath = 'base.json'; - const headPath = 'head.json'; - const baseBlobPath = 'base/blob/path/'; - const headBlobPath = 'head/blob/path/'; + const reportsPath = 'reports.json'; const helpPath = 'help.html'; mutations.SET_PATHS(localState, { basePath, - headPath, - baseBlobPath, - headBlobPath, + reportsPath, helpPath, }); expect(localState.basePath).toEqual(basePath); - expect(localState.headPath).toEqual(headPath); - expect(localState.baseBlobPath).toEqual(baseBlobPath); - expect(localState.headBlobPath).toEqual(headBlobPath); + expect(localState.reportsPath).toEqual(reportsPath); expect(localState.helpPath).toEqual(helpPath); }); }); diff --git a/spec/frontend/reports/codequality_report/store/utils/codequality_comparison_spec.js b/spec/frontend/reports/codequality_report/store/utils/codequality_comparison_spec.js deleted file mode 100644 index 389e9b4a1f6..00000000000 --- a/spec/frontend/reports/codequality_report/store/utils/codequality_comparison_spec.js +++ /dev/null @@ -1,153 +0,0 @@ -import { - parseCodeclimateMetrics, - doCodeClimateComparison, -} from '~/reports/codequality_report/store/utils/codequality_comparison'; -import { - baseIssues, - mockParsedHeadIssues, - mockParsedBaseIssues, - reportIssues, - parsedReportIssues, -} from '../../mock_data'; - -jest.mock('~/reports/codequality_report/workers/codequality_comparison_worker', () => { - let mockPostMessageCallback; - return jest.fn().mockImplementation(() => { - return { - addEventListener: (_, callback) => { - mockPostMessageCallback = callback; - }, - postMessage: (data) => { - if (!data.headIssues) return mockPostMessageCallback({ data: {} }); - if (!data.baseIssues) throw new Error(); - const key = 'fingerprint'; - return mockPostMessageCallback({ - data: { - newIssues: data.headIssues.filter( - (item) => !data.baseIssues.find((el) => el[key] === item[key]), - ), - resolvedIssues: data.baseIssues.filter( - (item) => !data.headIssues.find((el) => el[key] === item[key]), - ), - }, - }); - }, - }; - }); -}); - -describe('Codequality report store utils', () => { - let result; - - describe('parseCodeclimateMetrics', () => { - it('should parse the issues from codeclimate artifacts', () => { - [result] = parseCodeclimateMetrics(baseIssues, 'path'); - - expect(result.name).toEqual(baseIssues[0].check_name); - expect(result.path).toEqual(baseIssues[0].location.path); - expect(result.line).toEqual(baseIssues[0].location.lines.begin); - }); - - it('should parse the issues from backend codequality diff', () => { - [result] = parseCodeclimateMetrics(reportIssues.new_errors, 'path'); - - expect(result.name).toEqual(parsedReportIssues.newIssues[0].name); - expect(result.path).toEqual(parsedReportIssues.newIssues[0].path); - expect(result.line).toEqual(parsedReportIssues.newIssues[0].line); - }); - - describe('when an issue has no location or path', () => { - const issue = { description: 'Insecure Dependency' }; - - beforeEach(() => { - [result] = parseCodeclimateMetrics([issue], 'path'); - }); - - it('is parsed', () => { - expect(result.name).toEqual(issue.description); - }); - }); - - describe('when an issue has a path but no line', () => { - const issue = { description: 'Insecure Dependency', location: { path: 'Gemfile.lock' } }; - - beforeEach(() => { - [result] = parseCodeclimateMetrics([issue], 'path'); - }); - - it('is parsed', () => { - expect(result.name).toEqual(issue.description); - expect(result.path).toEqual(issue.location.path); - expect(result.urlPath).toEqual(`path/${issue.location.path}`); - }); - }); - - describe('when an issue has a line nested in positions', () => { - const issue = { - description: 'Insecure Dependency', - location: { - path: 'Gemfile.lock', - positions: { begin: { line: 84 } }, - }, - }; - - beforeEach(() => { - [result] = parseCodeclimateMetrics([issue], 'path'); - }); - - it('is parsed', () => { - expect(result.name).toEqual(issue.description); - expect(result.path).toEqual(issue.location.path); - expect(result.urlPath).toEqual( - `path/${issue.location.path}#L${issue.location.positions.begin.line}`, - ); - }); - }); - - describe('with an empty issue array', () => { - beforeEach(() => { - result = parseCodeclimateMetrics([], 'path'); - }); - - it('returns an empty array', () => { - expect(result).toEqual([]); - }); - }); - }); - - describe('doCodeClimateComparison', () => { - describe('when the comparison worker finds changed issues', () => { - beforeEach(async () => { - result = await doCodeClimateComparison(mockParsedHeadIssues, mockParsedBaseIssues); - }); - - it('returns the new and resolved issues', () => { - expect(result.resolvedIssues[0]).toEqual(mockParsedBaseIssues[0]); - expect(result.newIssues[0]).toEqual(mockParsedHeadIssues[0]); - }); - }); - - describe('when the comparison worker finds no changed issues', () => { - beforeEach(async () => { - result = await doCodeClimateComparison([], []); - }); - - it('returns the empty issue arrays', () => { - expect(result.newIssues).toEqual([]); - expect(result.resolvedIssues).toEqual([]); - }); - }); - - describe('when the comparison worker is given malformed data', () => { - it('rejects the promise', () => { - return expect(doCodeClimateComparison(null)).rejects.toEqual({}); - }); - }); - - describe('when the comparison worker encounters an error', () => { - it('rejects the promise and throws an error', () => { - return expect(doCodeClimateComparison([], null)).rejects.toThrow(); - }); - }); - }); -}); diff --git a/spec/frontend/reports/codequality_report/store/utils/codequality_parser_spec.js b/spec/frontend/reports/codequality_report/store/utils/codequality_parser_spec.js new file mode 100644 index 00000000000..ba95294ab0a --- /dev/null +++ b/spec/frontend/reports/codequality_report/store/utils/codequality_parser_spec.js @@ -0,0 +1,74 @@ +import { reportIssues, parsedReportIssues } from 'jest/reports/codequality_report/mock_data'; +import { parseCodeclimateMetrics } from '~/reports/codequality_report/store/utils/codequality_parser'; + +describe('Codequality report store utils', () => { + let result; + + describe('parseCodeclimateMetrics', () => { + it('should parse the issues from backend codequality diff', () => { + [result] = parseCodeclimateMetrics(reportIssues.new_errors, 'path'); + + expect(result.name).toEqual(parsedReportIssues.newIssues[0].name); + expect(result.path).toEqual(parsedReportIssues.newIssues[0].path); + expect(result.line).toEqual(parsedReportIssues.newIssues[0].line); + }); + + describe('when an issue has no location or path', () => { + const issue = { description: 'Insecure Dependency' }; + + beforeEach(() => { + [result] = parseCodeclimateMetrics([issue], 'path'); + }); + + it('is parsed', () => { + expect(result.name).toEqual(issue.description); + }); + }); + + describe('when an issue has a path but no line', () => { + const issue = { description: 'Insecure Dependency', location: { path: 'Gemfile.lock' } }; + + beforeEach(() => { + [result] = parseCodeclimateMetrics([issue], 'path'); + }); + + it('is parsed', () => { + expect(result.name).toEqual(issue.description); + expect(result.path).toEqual(issue.location.path); + expect(result.urlPath).toEqual(`path/${issue.location.path}`); + }); + }); + + describe('when an issue has a line nested in positions', () => { + const issue = { + description: 'Insecure Dependency', + location: { + path: 'Gemfile.lock', + positions: { begin: { line: 84 } }, + }, + }; + + beforeEach(() => { + [result] = parseCodeclimateMetrics([issue], 'path'); + }); + + it('is parsed', () => { + expect(result.name).toEqual(issue.description); + expect(result.path).toEqual(issue.location.path); + expect(result.urlPath).toEqual( + `path/${issue.location.path}#L${issue.location.positions.begin.line}`, + ); + }); + }); + + describe('with an empty issue array', () => { + beforeEach(() => { + result = parseCodeclimateMetrics([], 'path'); + }); + + it('returns an empty array', () => { + expect(result).toEqual([]); + }); + }); + }); +}); diff --git a/spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js b/spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js index 55bb7dbe5c0..d29048d640c 100644 --- a/spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js +++ b/spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js @@ -279,9 +279,7 @@ describe('Grouped test reports app', () => { }); it('renders the recent failures count on the test case', () => { - expect(findIssueRecentFailures().text()).toBe( - 'Failed 8 times in master in the last 14 days', - ); + expect(findIssueRecentFailures().text()).toBe('Failed 8 times in main in the last 14 days'); }); }); diff --git a/spec/frontend/reports/grouped_test_report/store/mutations_spec.js b/spec/frontend/reports/grouped_test_report/store/mutations_spec.js index d8642a9b440..b2890d7285f 100644 --- a/spec/frontend/reports/grouped_test_report/store/mutations_spec.js +++ b/spec/frontend/reports/grouped_test_report/store/mutations_spec.js @@ -52,7 +52,7 @@ describe('Reports Store Mutations', () => { system_output: "Failure/Error: is_expected.to eq('gitlab')", recent_failures: { count: 4, - base_branch: 'master', + base_branch: 'main', }, }, ], diff --git a/spec/frontend/reports/mock_data/mock_data.js b/spec/frontend/reports/mock_data/mock_data.js index 68c7439df47..2599b0ac365 100644 --- a/spec/frontend/reports/mock_data/mock_data.js +++ b/spec/frontend/reports/mock_data/mock_data.js @@ -7,7 +7,7 @@ export const failedIssue = { "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', + base_branch: 'main', }, }; diff --git a/spec/frontend/reports/mock_data/recent_failures_report.json b/spec/frontend/reports/mock_data/recent_failures_report.json index bc86d788ee2..c4a5fb78dcd 100644 --- a/spec/frontend/reports/mock_data/recent_failures_report.json +++ b/spec/frontend/reports/mock_data/recent_failures_report.json @@ -12,7 +12,7 @@ "system_output": "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in <top (required)>'", "recent_failures": { "count": 8, - "base_branch": "master" + "base_branch": "main" } }, { @@ -38,7 +38,7 @@ "execution_time": 0.000562, "recent_failures": { "count": 3, - "base_branch": "master" + "base_branch": "main" } } ], diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index b662a1d20a9..f03df8cf2ac 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -1,14 +1,17 @@ import { GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import BlobContent from '~/blob/components/blob_content.vue'; import BlobHeader from '~/blob/components/blob_header.vue'; import BlobContentViewer from '~/repository/components/blob_content_viewer.vue'; +import BlobHeaderEdit from '~/repository/components/blob_header_edit.vue'; let wrapper; -const mockData = { +const simpleMockData = { name: 'some_file.js', size: 123, - rawBlob: 'raw content', + rawSize: 123, + rawTextBlob: 'raw content', type: 'text', fileType: 'text', tooLarge: false, @@ -25,62 +28,160 @@ const mockData = { lockLink: 'some_file.js/lock', canModifyBlob: true, forkPath: 'some_file.js/fork', - simpleViewer: {}, - richViewer: {}, + simpleViewer: { + fileType: 'text', + tooLarge: false, + type: 'simple', + renderError: null, + }, + richViewer: null, +}; +const richMockData = { + ...simpleMockData, + richViewer: { + fileType: 'markup', + tooLarge: false, + type: 'rich', + renderError: null, + }, }; -function factory(path, loading = false) { - wrapper = shallowMount(BlobContentViewer, { +const createFactory = (mountFn) => ( + { props = {}, mockData = {}, stubs = {} } = {}, + loading = false, +) => { + wrapper = mountFn(BlobContentViewer, { propsData: { - path, + path: 'some_file.js', + projectPath: 'some/path', + ...props, }, mocks: { $apollo: { queries: { - blobInfo: { + project: { loading, }, }, }, }, + stubs, }); - wrapper.setData({ blobInfo: mockData }); -} + wrapper.setData(mockData); +}; + +const factory = createFactory(shallowMount); +const fullFactory = createFactory(mount); describe('Blob content viewer component', () => { const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findBlobHeader = () => wrapper.find(BlobHeader); + const findBlobHeaderEdit = () => wrapper.find(BlobHeaderEdit); const findBlobContent = () => wrapper.find(BlobContent); afterEach(() => { wrapper.destroy(); }); - beforeEach(() => { - factory('some_file.js'); - }); - it('renders a GlLoadingIcon component', () => { - factory('some_file.js', true); + factory({ mockData: { blobInfo: simpleMockData } }, true); expect(findLoadingIcon().exists()).toBe(true); }); - it('renders a BlobHeader component', () => { - expect(findBlobHeader().exists()).toBe(true); + describe('simple viewer', () => { + beforeEach(() => { + factory({ mockData: { blobInfo: simpleMockData } }); + }); + + it('renders a BlobHeader component', () => { + expect(findBlobHeader().props('activeViewerType')).toEqual('simple'); + expect(findBlobHeader().props('hasRenderError')).toEqual(false); + expect(findBlobHeader().props('hideViewerSwitcher')).toEqual(true); + expect(findBlobHeader().props('blob')).toEqual(simpleMockData); + }); + + it('renders a BlobContent component', () => { + expect(findBlobContent().props('loading')).toEqual(false); + expect(findBlobContent().props('content')).toEqual('raw content'); + expect(findBlobContent().props('isRawContent')).toBe(true); + expect(findBlobContent().props('activeViewer')).toEqual({ + fileType: 'text', + tooLarge: false, + type: 'simple', + renderError: null, + }); + }); + }); + + describe('rich viewer', () => { + beforeEach(() => { + factory({ + mockData: { blobInfo: richMockData, activeViewerType: 'rich' }, + }); + }); + + it('renders a BlobHeader component', () => { + expect(findBlobHeader().props('activeViewerType')).toEqual('rich'); + expect(findBlobHeader().props('hasRenderError')).toEqual(false); + expect(findBlobHeader().props('hideViewerSwitcher')).toEqual(false); + expect(findBlobHeader().props('blob')).toEqual(richMockData); + }); + + it('renders a BlobContent component', () => { + expect(findBlobContent().props('loading')).toEqual(false); + expect(findBlobContent().props('content')).toEqual('raw content'); + expect(findBlobContent().props('isRawContent')).toBe(true); + expect(findBlobContent().props('activeViewer')).toEqual({ + fileType: 'markup', + tooLarge: false, + type: 'rich', + renderError: null, + }); + }); + + it('updates viewer type when viewer changed is clicked', async () => { + expect(findBlobContent().props('activeViewer')).toEqual( + expect.objectContaining({ + type: 'rich', + }), + ); + expect(findBlobHeader().props('activeViewerType')).toEqual('rich'); + + findBlobHeader().vm.$emit('viewer-changed', 'simple'); + await nextTick(); + + expect(findBlobHeader().props('activeViewerType')).toEqual('simple'); + expect(findBlobContent().props('activeViewer')).toEqual( + expect.objectContaining({ + type: 'simple', + }), + ); + }); }); - it('renders a BlobContent component', () => { - expect(findBlobContent().exists()).toBe(true); + describe('BlobHeader action slot', () => { + it('renders BlobHeaderEdit button in simple viewer', async () => { + fullFactory({ + mockData: { blobInfo: simpleMockData }, + stubs: { + BlobContent: true, + }, + }); + await nextTick(); + expect(findBlobHeaderEdit().props('editPath')).toEqual('some_file.js/edit'); + }); - expect(findBlobContent().props('loading')).toEqual(false); - expect(findBlobContent().props('content')).toEqual('raw content'); - expect(findBlobContent().props('isRawContent')).toBe(true); - expect(findBlobContent().props('activeViewer')).toEqual({ - fileType: 'text', - tooLarge: false, - type: 'text', + it('renders BlobHeaderEdit button in rich viewer', async () => { + fullFactory({ + mockData: { blobInfo: richMockData }, + stubs: { + BlobContent: true, + }, + }); + await nextTick(); + expect(findBlobHeaderEdit().props('editPath')).toEqual('some_file.js/edit'); }); }); }); diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js index af263f43d7d..e9e51abaf0f 100644 --- a/spec/frontend/repository/components/table/index_spec.js +++ b/spec/frontend/repository/components/table/index_spec.js @@ -55,8 +55,8 @@ describe('Repository table component', () => { it.each` path | ref - ${'/'} | ${'master'} - ${'app/assets'} | ${'master'} + ${'/'} | ${'main'} + ${'app/assets'} | ${'main'} ${'/'} | ${'test'} `('renders table caption for $ref in $path', ({ path, ref }) => { factory({ path }); diff --git a/spec/frontend/repository/components/table/parent_row_spec.js b/spec/frontend/repository/components/table/parent_row_spec.js index cf1ed272634..9daae8c36ef 100644 --- a/spec/frontend/repository/components/table/parent_row_spec.js +++ b/spec/frontend/repository/components/table/parent_row_spec.js @@ -12,7 +12,7 @@ function factory(path, loadingPath) { vm = shallowMount(ParentRow, { propsData: { - commitRef: 'master', + commitRef: 'main', path, loadingPath, }, @@ -32,10 +32,10 @@ describe('Repository parent row component', () => { it.each` path | to - ${'app'} | ${'/-/tree/master/'} - ${'app/assets'} | ${'/-/tree/master/app'} - ${'app/assets#/test'} | ${'/-/tree/master/app/assets%23'} - ${'app/assets#/test/world'} | ${'/-/tree/master/app/assets%23/test'} + ${'app'} | ${'/-/tree/main/'} + ${'app/assets'} | ${'/-/tree/main/app'} + ${'app/assets#/test'} | ${'/-/tree/main/app/assets%23'} + ${'app/assets#/test/world'} | ${'/-/tree/main/app/assets%23/test'} `('renders link in $path to $to', ({ path, to }) => { factory(path); @@ -50,7 +50,7 @@ describe('Repository parent row component', () => { vm.find('td').trigger('click'); expect($router.push).toHaveBeenCalledWith({ - path: '/-/tree/master/app', + path: '/-/tree/main/app', }); }); @@ -62,7 +62,7 @@ describe('Repository parent row component', () => { vm.find('a').trigger('click'); expect($router.push).not.toHaveBeenCalledWith({ - path: '/-/tree/master/app', + path: '/-/tree/main/app', }); }); diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js index 3ebffbedcdb..6ba6f993db1 100644 --- a/spec/frontend/repository/components/table/row_spec.js +++ b/spec/frontend/repository/components/table/row_spec.js @@ -30,7 +30,7 @@ function factory(propsData = {}) { }, }); - vm.setData({ escapedRef: 'master' }); + vm.setData({ escapedRef: 'main' }); } describe('Repository table row component', () => { @@ -115,7 +115,7 @@ describe('Repository table row component', () => { return vm.vm.$nextTick().then(() => { expect(vm.find({ ref: 'link' }).props('to')).toEqual({ - path: `/-/tree/master/${encodeURIComponent(path)}`, + path: `/-/tree/main/${encodeURIComponent(path)}`, }); }); }); @@ -130,7 +130,7 @@ describe('Repository table row component', () => { }); return vm.vm.$nextTick().then(() => { - expect(vm.find('.tree-item-link').props('to')).toEqual({ path: '/-/tree/master/test%23' }); + expect(vm.find('.tree-item-link').props('to')).toEqual({ path: '/-/tree/main/test%23' }); }); }); diff --git a/spec/frontend/repository/components/upload_blob_modal_spec.js b/spec/frontend/repository/components/upload_blob_modal_spec.js index 935ed08f67a..ec85d5666fb 100644 --- a/spec/frontend/repository/components/upload_blob_modal_spec.js +++ b/spec/frontend/repository/components/upload_blob_modal_spec.js @@ -20,8 +20,8 @@ jest.mock('~/lib/utils/url_utility', () => ({ const initialProps = { modalId: 'upload-blob', commitMessage: 'Upload New File', - targetBranch: 'master', - originalBranch: 'master', + targetBranch: 'main', + originalBranch: 'main', canPushCode: true, path: 'new_upload', }; @@ -111,7 +111,7 @@ describe('UploadBlobModal', () => { if (canPushCode) { describe('when changing the branch name', () => { it('displays the MR toggle', async () => { - wrapper.setData({ target: 'Not master' }); + wrapper.setData({ target: 'Not main' }); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/repository/log_tree_spec.js b/spec/frontend/repository/log_tree_spec.js index ddc95feccd6..a842053caad 100644 --- a/spec/frontend/repository/log_tree_spec.js +++ b/spec/frontend/repository/log_tree_spec.js @@ -53,7 +53,7 @@ describe('fetchLogsTree', () => { client = { readQuery: () => ({ projectPath: 'gitlab-org/gitlab-foss', - escapedRef: 'master', + escapedRef: 'main', commits: [], }), writeQuery: jest.fn(), @@ -71,7 +71,7 @@ describe('fetchLogsTree', () => { it('calls axios get', () => fetchLogsTree(client, '', '0', resolver).then(() => { - expect(axios.get).toHaveBeenCalledWith('/gitlab-org/gitlab-foss/-/refs/master/logs_tree/', { + expect(axios.get).toHaveBeenCalledWith('/gitlab-org/gitlab-foss/-/refs/main/logs_tree/', { params: { format: 'json', offset: '0' }, }); })); @@ -114,7 +114,7 @@ describe('fetchLogsTree', () => { query: expect.anything(), data: { projectPath: 'gitlab-org/gitlab-foss', - escapedRef: 'master', + escapedRef: 'main', commits: [ expect.objectContaining({ __typename: 'LogTreeCommit', diff --git a/spec/frontend/repository/pages/blob_spec.js b/spec/frontend/repository/pages/blob_spec.js index 3e7ead4ad00..41ab4d616b8 100644 --- a/spec/frontend/repository/pages/blob_spec.js +++ b/spec/frontend/repository/pages/blob_spec.js @@ -11,7 +11,9 @@ describe('Repository blob page component', () => { const path = 'file.js'; beforeEach(() => { - wrapper = shallowMount(BlobPage, { propsData: { path } }); + wrapper = shallowMount(BlobPage, { + propsData: { path, projectPath: 'some/path' }, + }); }); afterEach(() => { diff --git a/spec/frontend/repository/router_spec.js b/spec/frontend/repository/router_spec.js index 3354b2315fc..bb82fa706fd 100644 --- a/spec/frontend/repository/router_spec.js +++ b/spec/frontend/repository/router_spec.js @@ -5,14 +5,14 @@ import createRouter from '~/repository/router'; describe('Repository router spec', () => { it.each` - path | branch | component | componentName - ${'/'} | ${'master'} | ${IndexPage} | ${'IndexPage'} - ${'/tree/master'} | ${'master'} | ${TreePage} | ${'TreePage'} - ${'/tree/feat(test)'} | ${'feat(test)'} | ${TreePage} | ${'TreePage'} - ${'/-/tree/master'} | ${'master'} | ${TreePage} | ${'TreePage'} - ${'/-/tree/master/app/assets'} | ${'master'} | ${TreePage} | ${'TreePage'} - ${'/-/tree/123/app/assets'} | ${'master'} | ${null} | ${'null'} - ${'/-/blob/master/file.md'} | ${'master'} | ${BlobPage} | ${'BlobPage'} + path | branch | component | componentName + ${'/'} | ${'main'} | ${IndexPage} | ${'IndexPage'} + ${'/tree/main'} | ${'main'} | ${TreePage} | ${'TreePage'} + ${'/tree/feat(test)'} | ${'feat(test)'} | ${TreePage} | ${'TreePage'} + ${'/-/tree/main'} | ${'main'} | ${TreePage} | ${'TreePage'} + ${'/-/tree/main/app/assets'} | ${'main'} | ${TreePage} | ${'TreePage'} + ${'/-/tree/123/app/assets'} | ${'main'} | ${null} | ${'null'} + ${'/-/blob/main/file.md'} | ${'main'} | ${BlobPage} | ${'BlobPage'} `('sets component as $componentName for path "$path"', ({ path, component, branch }) => { const router = createRouter('', branch); diff --git a/spec/frontend/repository/utils/title_spec.js b/spec/frontend/repository/utils/title_spec.js index a1213c13be8..d5206bdea92 100644 --- a/spec/frontend/repository/utils/title_spec.js +++ b/spec/frontend/repository/utils/title_spec.js @@ -8,9 +8,9 @@ describe('setTitle', () => { ${'app/assets'} | ${'app/assets'} ${'app/assets/javascripts'} | ${'app/assets/javascripts'} `('sets document title as $title for $path', ({ path, title }) => { - setTitle(path, 'master', 'GitLab Org / GitLab'); + setTitle(path, 'main', 'GitLab Org / GitLab'); - expect(document.title).toEqual(`${title} · master · GitLab Org / GitLab · GitLab`); + expect(document.title).toEqual(`${title} · main · GitLab Org / GitLab · GitLab`); }); }); diff --git a/spec/frontend/runner/components/runner_type_badge_spec.js b/spec/frontend/runner/components/runner_type_badge_spec.js new file mode 100644 index 00000000000..8e52d3398bd --- /dev/null +++ b/spec/frontend/runner/components/runner_type_badge_spec.js @@ -0,0 +1,40 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerTypeBadge from '~/runner/components/runner_type_badge.vue'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; + +describe('RunnerTypeBadge', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(RunnerTypeBadge, { + propsData: { + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + type | text | variant + ${INSTANCE_TYPE} | ${'shared'} | ${'success'} + ${GROUP_TYPE} | ${'group'} | ${'success'} + ${PROJECT_TYPE} | ${'specific'} | ${'info'} + `('displays $type runner with as "$text" with a $variant variant ', ({ type, text, variant }) => { + createComponent({ props: { type } }); + + expect(findBadge().text()).toBe(text); + expect(findBadge().props('variant')).toBe(variant); + }); + + it('does not display a badge when type is unknown', () => { + createComponent({ props: { type: 'AN_UNKNOWN_VALUE' } }); + + expect(findBadge().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/runner/runner_detail/runner_detail_app_spec.js b/spec/frontend/runner/runner_detail/runner_detail_app_spec.js deleted file mode 100644 index 5caa37c8cb3..00000000000 --- a/spec/frontend/runner/runner_detail/runner_detail_app_spec.js +++ /dev/null @@ -1,29 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import RunnerDetailsApp from '~/runner/runner_details/runner_details_app.vue'; - -const mockRunnerId = '55'; - -describe('RunnerDetailsApp', () => { - let wrapper; - - const createComponent = (props) => { - wrapper = shallowMount(RunnerDetailsApp, { - propsData: { - runnerId: mockRunnerId, - ...props, - }, - }); - }; - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('displays the runner id', () => { - expect(wrapper.text()).toContain('Runner #55'); - }); -}); diff --git a/spec/frontend/runner/runner_detail/runner_details_app_spec.js b/spec/frontend/runner/runner_detail/runner_details_app_spec.js new file mode 100644 index 00000000000..c61cb647ae6 --- /dev/null +++ b/spec/frontend/runner/runner_detail/runner_details_app_spec.js @@ -0,0 +1,71 @@ +import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +import RunnerTypeBadge from '~/runner/components/runner_type_badge.vue'; +import { INSTANCE_TYPE } from '~/runner/constants'; +import getRunnerQuery from '~/runner/graphql/get_runner.query.graphql'; +import RunnerDetailsApp from '~/runner/runner_details/runner_details_app.vue'; + +const mockRunnerId = '55'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('RunnerDetailsApp', () => { + let wrapper; + let mockRunnerQuery; + + const findRunnerTypeBadge = () => wrapper.findComponent(RunnerTypeBadge); + + const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => { + const handlers = [[getRunnerQuery, mockRunnerQuery]]; + + wrapper = mountFn(RunnerDetailsApp, { + localVue, + apolloProvider: createMockApollo(handlers), + propsData: { + runnerId: mockRunnerId, + ...props, + }, + }); + + return waitForPromises(); + }; + + beforeEach(async () => { + mockRunnerQuery = jest.fn().mockResolvedValue({ + data: { + runner: { + id: `gid://gitlab/Ci::Runner/${mockRunnerId}`, + runnerType: INSTANCE_TYPE, + __typename: 'CiRunner', + }, + }, + }); + }); + + afterEach(() => { + mockRunnerQuery.mockReset(); + wrapper.destroy(); + }); + + it('expect GraphQL ID to be requested', async () => { + await createComponentWithApollo(); + + expect(mockRunnerQuery).toHaveBeenCalledWith({ id: `gid://gitlab/Ci::Runner/${mockRunnerId}` }); + }); + + it('displays the runner id', async () => { + await createComponentWithApollo(); + + expect(wrapper.text()).toContain('Runner #55'); + }); + + it('displays the runner type', async () => { + await createComponentWithApollo({ mountFn: mount }); + + expect(findRunnerTypeBadge().text()).toBe('shared'); + }); +}); diff --git a/spec/frontend/security_configuration/components/feature_card_spec.js b/spec/frontend/security_configuration/components/feature_card_spec.js new file mode 100644 index 00000000000..c69e135012e --- /dev/null +++ b/spec/frontend/security_configuration/components/feature_card_spec.js @@ -0,0 +1,245 @@ +import { GlIcon } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import FeatureCard from '~/security_configuration/components/feature_card.vue'; +import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; +import { makeFeature } from './utils'; + +describe('FeatureCard component', () => { + let feature; + let wrapper; + + const createComponent = (propsData) => { + wrapper = extendedWrapper( + mount(FeatureCard, { + propsData, + stubs: { + ManageViaMr: true, + }, + }), + ); + }; + + const findLinks = ({ text, href }) => + wrapper.findAll(`a[href="${href}"]`).filter((link) => link.text() === text); + + const findEnableLinks = () => + findLinks({ + text: `Enable ${feature.shortName ?? feature.name}`, + href: feature.configurationPath, + }); + const findConfigureLinks = () => + findLinks({ + text: `Configure ${feature.shortName ?? feature.name}`, + href: feature.configurationPath, + }); + const findManageViaMr = () => wrapper.findComponent(ManageViaMr); + const findConfigGuideLinks = () => + findLinks({ text: 'Configuration guide', href: feature.configurationHelpPath }); + + const findSecondarySection = () => wrapper.findByTestId('secondary-feature'); + + const expectAction = (action) => { + const expectEnableAction = action === 'enable'; + const expectConfigureAction = action === 'configure'; + const expectCreateMrAction = action === 'create-mr'; + const expectGuideAction = action === 'guide'; + + const enableLinks = findEnableLinks(); + expect(enableLinks.exists()).toBe(expectEnableAction); + if (expectEnableAction) { + expect(enableLinks).toHaveLength(1); + expect(enableLinks.at(0).props('category')).toBe('primary'); + } + + const configureLinks = findConfigureLinks(); + expect(configureLinks.exists()).toBe(expectConfigureAction); + if (expectConfigureAction) { + expect(configureLinks).toHaveLength(1); + expect(configureLinks.at(0).props('category')).toBe('secondary'); + } + + const manageViaMr = findManageViaMr(); + expect(manageViaMr.exists()).toBe(expectCreateMrAction); + if (expectCreateMrAction) { + expect(manageViaMr.props('feature')).toBe(feature); + } + + const configGuideLinks = findConfigGuideLinks(); + expect(configGuideLinks.exists()).toBe(expectGuideAction); + if (expectGuideAction) { + expect(configGuideLinks).toHaveLength(1); + } + }; + + afterEach(() => { + wrapper.destroy(); + feature = undefined; + }); + + describe('basic structure', () => { + beforeEach(() => { + feature = makeFeature(); + createComponent({ feature }); + }); + + it('shows the name', () => { + expect(wrapper.text()).toContain(feature.name); + }); + + it('shows the description', () => { + expect(wrapper.text()).toContain(feature.description); + }); + + it('shows the help link', () => { + const links = findLinks({ text: 'Learn more', href: feature.helpPath }); + expect(links.exists()).toBe(true); + expect(links).toHaveLength(1); + }); + }); + + describe('status', () => { + describe.each` + context | available | configured | expectedStatus + ${'a configured feature'} | ${true} | ${true} | ${'Enabled'} + ${'an unconfigured feature'} | ${true} | ${false} | ${'Not enabled'} + ${'an available feature with unknown status'} | ${true} | ${undefined} | ${''} + ${'an unavailable feature'} | ${false} | ${false} | ${'Available with Ultimate'} + ${'an unavailable feature with unknown status'} | ${false} | ${undefined} | ${'Available with Ultimate'} + `('given $context', ({ available, configured, expectedStatus }) => { + beforeEach(() => { + feature = makeFeature({ available, configured }); + createComponent({ feature }); + }); + + it(`shows the status "${expectedStatus}"`, () => { + expect(wrapper.findByTestId('feature-status').text()).toBe(expectedStatus); + }); + + if (configured) { + it('shows a success icon', () => { + expect(wrapper.findComponent(GlIcon).props('name')).toBe('check-circle-filled'); + }); + } + }); + }); + + describe('actions', () => { + describe.each` + context | available | configured | configurationPath | canEnableByMergeRequest | action + ${'unavailable'} | ${false} | ${false} | ${null} | ${false} | ${null} + ${'available'} | ${true} | ${false} | ${null} | ${false} | ${'guide'} + ${'configured'} | ${true} | ${true} | ${null} | ${false} | ${'guide'} + ${'available, can enable by MR'} | ${true} | ${false} | ${null} | ${true} | ${'create-mr'} + ${'configured, can enable by MR'} | ${true} | ${true} | ${null} | ${true} | ${'guide'} + ${'available with config path'} | ${true} | ${false} | ${'foo'} | ${false} | ${'enable'} + ${'available with config path, can enable by MR'} | ${true} | ${false} | ${'foo'} | ${true} | ${'enable'} + ${'configured with config path'} | ${true} | ${true} | ${'foo'} | ${false} | ${'configure'} + ${'configured with config path, can enable by MR'} | ${true} | ${true} | ${'foo'} | ${true} | ${'configure'} + `( + 'given $context feature', + ({ available, configured, configurationPath, canEnableByMergeRequest, action }) => { + beforeEach(() => { + feature = makeFeature({ + available, + configured, + configurationPath, + canEnableByMergeRequest, + }); + createComponent({ feature }); + }); + + it(`shows ${action} action`, () => { + expectAction(action); + }); + }, + ); + }); + + describe('secondary feature', () => { + describe('basic structure', () => { + describe('given no secondary', () => { + beforeEach(() => { + feature = makeFeature(); + createComponent({ feature }); + }); + + it('does not show a secondary feature', () => { + expect(findSecondarySection().exists()).toBe(false); + }); + }); + + describe('given a secondary', () => { + beforeEach(() => { + feature = makeFeature({ + secondary: { + name: 'secondary name', + description: 'secondary description', + configurationText: 'manage secondary', + }, + }); + createComponent({ feature }); + }); + + it('shows a secondary feature', () => { + const secondaryText = findSecondarySection().text(); + expect(secondaryText).toContain(feature.secondary.name); + expect(secondaryText).toContain(feature.secondary.description); + }); + }); + }); + + describe('actions', () => { + describe('given available feature with secondary', () => { + beforeEach(() => { + feature = makeFeature({ + available: true, + secondary: { + name: 'secondary name', + description: 'secondary description', + configurationPath: '/secondary', + configurationText: 'manage secondary', + }, + }); + createComponent({ feature }); + }); + + it('shows the secondary action', () => { + const links = findLinks({ + text: feature.secondary.configurationText, + href: feature.secondary.configurationPath, + }); + expect(links.exists()).toBe(true); + expect(links).toHaveLength(1); + }); + }); + + describe.each` + context | available | secondaryConfigPath + ${'available feature without config path'} | ${true} | ${null} + ${'unavailable feature with config path'} | ${false} | ${'/secondary'} + `('given $context', ({ available, secondaryConfigPath }) => { + beforeEach(() => { + feature = makeFeature({ + available, + secondary: { + name: 'secondary name', + description: 'secondary description', + configurationPath: secondaryConfigPath, + configurationText: 'manage secondary', + }, + }); + createComponent({ feature }); + }); + + it('does not show the secondary action', () => { + const links = findLinks({ + text: feature.secondary.configurationText, + href: feature.secondary.configurationPath, + }); + expect(links.exists()).toBe(false); + }); + }); + }); + }); +}); diff --git a/spec/frontend/security_configuration/components/utils.js b/spec/frontend/security_configuration/components/utils.js new file mode 100644 index 00000000000..a4e992afb15 --- /dev/null +++ b/spec/frontend/security_configuration/components/utils.js @@ -0,0 +1,8 @@ +export const makeFeature = (changes = {}) => ({ + name: 'Foo Feature', + description: 'Lorem ipsum Foo', + type: 'foo_scanning', + helpPath: '/help/foo', + configurationHelpPath: '/help/foo#configure', + ...changes, +}); diff --git a/spec/frontend/security_configuration/configuration_table_spec.js b/spec/frontend/security_configuration/configuration_table_spec.js index a1789052c92..fbd72265c4b 100644 --- a/spec/frontend/security_configuration/configuration_table_spec.js +++ b/spec/frontend/security_configuration/configuration_table_spec.js @@ -1,7 +1,7 @@ import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import ConfigurationTable from '~/security_configuration/components/configuration_table.vue'; -import { scanners, UPGRADE_CTA } from '~/security_configuration/components/scanners_constants'; +import { scanners, UPGRADE_CTA } from '~/security_configuration/components/constants'; import { REPORT_TYPE_SAST, @@ -12,7 +12,13 @@ describe('Configuration Table Component', () => { let wrapper; const createComponent = () => { - wrapper = extendedWrapper(mount(ConfigurationTable, {})); + wrapper = extendedWrapper( + mount(ConfigurationTable, { + provide: { + projectPath: 'testProjectPath', + }, + }), + ); }; const findHelpLinks = () => wrapper.findAll('[data-testid="help-link"]'); @@ -30,8 +36,10 @@ describe('Configuration Table Component', () => { 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()).toBe('Configure via Merge Request'); + } else if (scanner.type === REPORT_TYPE_SECRET_DETECTION) { + expect(wrapper.findByTestId(scanner.type).exists()).toBe(false); + } else { expect(wrapper.findByTestId(scanner.type).text()).toMatchInterpolatedText(UPGRADE_CTA); } }); diff --git a/spec/frontend/security_configuration/manage_sast_spec.js b/spec/frontend/security_configuration/manage_sast_spec.js deleted file mode 100644 index 15a57210246..00000000000 --- a/spec/frontend/security_configuration/manage_sast_spec.js +++ /dev/null @@ -1,136 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { redirectTo } from '~/lib/utils/url_utility'; -import ManageSast from '~/security_configuration/components/manage_sast.vue'; -import configureSastMutation from '~/security_configuration/graphql/configure_sast.mutation.graphql'; - -jest.mock('~/lib/utils/url_utility', () => ({ - redirectTo: jest.fn(), -})); - -Vue.use(VueApollo); - -describe('Manage Sast Component', () => { - let wrapper; - - const findButton = () => wrapper.findComponent(GlButton); - const successHandler = async () => { - return { - data: { - configureSast: { - successPath: 'testSuccessPath', - errors: [], - __typename: 'ConfigureSastPayload', - }, - }, - }; - }; - - const noSuccessPathHandler = async () => { - return { - data: { - configureSast: { - successPath: '', - errors: [], - __typename: 'ConfigureSastPayload', - }, - }, - }; - }; - - const errorHandler = async () => { - return { - data: { - configureSast: { - successPath: 'testSuccessPath', - errors: ['foo'], - __typename: 'ConfigureSastPayload', - }, - }, - }; - }; - - const pendingHandler = () => new Promise(() => {}); - - function createMockApolloProvider(handler) { - const requestHandlers = [[configureSastMutation, handler]]; - - return createMockApollo(requestHandlers); - } - - function createComponent(options = {}) { - const { mockApollo } = options; - wrapper = extendedWrapper( - mount(ManageSast, { - apolloProvider: mockApollo, - }), - ); - } - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('should render Button with correct text', () => { - createComponent(); - expect(findButton().text()).toContain('Configure via merge request'); - }); - - describe('given a successful response', () => { - beforeEach(() => { - const mockApollo = createMockApolloProvider(successHandler); - createComponent({ mockApollo }); - }); - - it('should call redirect helper with correct value', async () => { - await wrapper.trigger('click'); - await waitForPromises(); - expect(redirectTo).toHaveBeenCalledTimes(1); - expect(redirectTo).toHaveBeenCalledWith('testSuccessPath'); - // This is done for UX reasons. If the loading prop is set to false - // on success, then there's a period where the button is clickable - // again. Instead, we want the button to display a loading indicator - // for the remainder of the lifetime of the page (i.e., until the - // browser can start painting the new page it's been redirected to). - expect(findButton().props().loading).toBe(true); - }); - }); - - describe('given a pending response', () => { - beforeEach(() => { - const mockApollo = createMockApolloProvider(pendingHandler); - createComponent({ mockApollo }); - }); - - it('renders spinner correctly', async () => { - expect(findButton().props('loading')).toBe(false); - await wrapper.trigger('click'); - await waitForPromises(); - expect(findButton().props('loading')).toBe(true); - }); - }); - - describe.each` - handler | message - ${noSuccessPathHandler} | ${'SAST merge request creation mutation failed'} - ${errorHandler} | ${'foo'} - `('given an error response', ({ handler, message }) => { - beforeEach(() => { - const mockApollo = createMockApolloProvider(handler); - createComponent({ mockApollo }); - }); - - it('should catch and emit error', async () => { - await wrapper.trigger('click'); - await waitForPromises(); - expect(wrapper.emitted('error')).toEqual([[message]]); - expect(findButton().props('loading')).toBe(false); - }); - }); -}); diff --git a/spec/frontend/security_configuration/upgrade_spec.js b/spec/frontend/security_configuration/upgrade_spec.js index 1f0cc795fc5..20bb38aa469 100644 --- a/spec/frontend/security_configuration/upgrade_spec.js +++ b/spec/frontend/security_configuration/upgrade_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import { UPGRADE_CTA } from '~/security_configuration/components/scanners_constants'; +import { UPGRADE_CTA } from '~/security_configuration/components/constants'; import Upgrade from '~/security_configuration/components/upgrade.vue'; const TEST_URL = 'http://www.example.test'; diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js index 403f9509f84..82fc06e1166 100644 --- a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js +++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js @@ -23,7 +23,6 @@ describe('SetStatusModalWrapper', () => { currentEmoji: defaultEmoji, currentMessage: defaultMessage, defaultEmoji, - canSetUserAvailability: true, }; const createComponent = (props = {}) => { @@ -278,16 +277,4 @@ describe('SetStatusModalWrapper', () => { }); }); }); - - describe('with canSetUserAvailability=false', () => { - beforeEach(async () => { - mockEmoji = await initEmojiMock(); - wrapper = createComponent({ canSetUserAvailability: false }); - return initModal(); - }); - - it('hides the set availability checkbox', () => { - expect(findAvailabilityCheckbox().exists()).toBe(false); - }); - }); }); diff --git a/spec/frontend/sidebar/assignees_realtime_spec.js b/spec/frontend/sidebar/assignees_realtime_spec.js index f0a6fa40d67..ecf33d6de37 100644 --- a/spec/frontend/sidebar/assignees_realtime_spec.js +++ b/spec/frontend/sidebar/assignees_realtime_spec.js @@ -1,41 +1,44 @@ -import ActionCable from '@rails/actioncable'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; -import { assigneesQueries } from '~/sidebar/constants'; +import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql'; import SidebarMediator from '~/sidebar/sidebar_mediator'; -import Mock from './mock_data'; +import getIssueAssigneesQuery from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql'; +import Mock, { issuableQueryResponse, subscriptionNullResponse } from './mock_data'; -jest.mock('@rails/actioncable', () => { - const mockConsumer = { - subscriptions: { create: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }) }, - }; - return { - createConsumer: jest.fn().mockReturnValue(mockConsumer), - }; -}); +const localVue = createLocalVue(); +localVue.use(VueApollo); describe('Assignees Realtime', () => { let wrapper; let mediator; + let fakeApollo; + + const issuableQueryHandler = jest.fn().mockResolvedValue(issuableQueryResponse); + const subscriptionInitialHandler = jest.fn().mockResolvedValue(subscriptionNullResponse); - const createComponent = (issuableType = 'issue') => { + const createComponent = ({ + issuableType = 'issue', + issuableId = 1, + subscriptionHandler = subscriptionInitialHandler, + } = {}) => { + fakeApollo = createMockApollo([ + [getIssueAssigneesQuery, issuableQueryHandler], + [issuableAssigneesSubscription, subscriptionHandler], + ]); wrapper = shallowMount(AssigneesRealtime, { propsData: { - issuableIid: '1', - mediator, - projectPath: 'path/to/project', issuableType, - }, - mocks: { - $apollo: { - query: assigneesQueries[issuableType].query, - queries: { - workspace: { - refetch: jest.fn(), - }, - }, + issuableId, + queryVariables: { + issuableIid: '1', + projectPath: 'path/to/project', }, + mediator, }, + apolloProvider: fakeApollo, + localVue, }); }; @@ -45,59 +48,24 @@ describe('Assignees Realtime', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; + fakeApollo = null; SidebarMediator.singleton = null; }); - describe('when handleFetchResult is called from smart query', () => { - it('sets assignees to the store', () => { - const data = { - workspace: { - issuable: { - assignees: { - nodes: [{ id: 'gid://gitlab/Environments/123', avatarUrl: 'url' }], - }, - }, - }, - }; - const expected = [{ id: 123, avatar_url: 'url', avatarUrl: 'url' }]; - createComponent(); + it('calls the query with correct variables', () => { + createComponent(); - wrapper.vm.handleFetchResult({ data }); - - expect(mediator.store.assignees).toEqual(expected); + expect(issuableQueryHandler).toHaveBeenCalledWith({ + issuableIid: '1', + projectPath: 'path/to/project', }); }); - describe('when mounted', () => { - it('calls create subscription', () => { - const cable = ActionCable.createConsumer(); - - createComponent(); - - return wrapper.vm.$nextTick().then(() => { - expect(cable.subscriptions.create).toHaveBeenCalledTimes(1); - expect(cable.subscriptions.create).toHaveBeenCalledWith( - { - channel: 'IssuesChannel', - iid: wrapper.props('issuableIid'), - project_path: wrapper.props('projectPath'), - }, - { received: wrapper.vm.received }, - ); - }); - }); - }); - - describe('when subscription is recieved', () => { - it('refetches the GraphQL project query', () => { - createComponent(); - - wrapper.vm.received({ event: 'updated' }); + it('calls the subscription with correct variable for issue', () => { + createComponent(); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.$apollo.queries.workspace.refetch).toHaveBeenCalledTimes(1); - }); + expect(subscriptionInitialHandler).toHaveBeenCalledWith({ + issuableId: 'gid://gitlab/Issue/1', }); }); }); diff --git a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js index 824f6d49c65..0e052abffeb 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js @@ -1,27 +1,20 @@ import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { cloneDeep } from 'lodash'; 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 searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphql'; import { IssuableType } from '~/issue_show/constants'; import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarInviteMembers from '~/sidebar/components/assignees/sidebar_invite_members.vue'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants'; -import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; -import getIssueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; +import getIssueAssigneesQuery from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql'; import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql'; -import { - issuableQueryResponse, - searchQueryResponse, - updateIssueAssigneesMutationResponse, -} from '../../mock_data'; +import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; +import { issuableQueryResponse, updateIssueAssigneesMutationResponse } from '../../mock_data'; jest.mock('~/flash'); @@ -50,31 +43,19 @@ describe('Sidebar assignees widget', () => { const findAssignees = () => wrapper.findComponent(IssuableAssignees); const findRealtimeAssignees = () => wrapper.findComponent(SidebarAssigneesRealtime); const findEditableItem = () => wrapper.findComponent(SidebarEditableItem); - const findDropdown = () => wrapper.findComponent(MultiSelectDropdown); const findInviteMembersLink = () => wrapper.findComponent(SidebarInviteMembers); - const findSearchField = () => wrapper.findComponent(GlSearchBoxByType); - - const findParticipantsLoading = () => wrapper.find('[data-testid="loading-participants"]'); - const findSelectedParticipants = () => wrapper.findAll('[data-testid="selected-participant"]'); - const findUnselectedParticipants = () => - wrapper.findAll('[data-testid="unselected-participant"]'); - const findCurrentUser = () => wrapper.findAll('[data-testid="current-user"]'); - const findUnassignLink = () => wrapper.find('[data-testid="unassign"]'); - const findEmptySearchResults = () => wrapper.find('[data-testid="empty-results"]'); + const findUserSelect = () => wrapper.findComponent(UserSelect); const expandDropdown = () => wrapper.vm.$refs.toggle.expand(); const createComponent = ({ - search = '', issuableQueryHandler = jest.fn().mockResolvedValue(issuableQueryResponse), - searchQueryHandler = jest.fn().mockResolvedValue(searchQueryResponse), updateIssueAssigneesMutationHandler = updateIssueAssigneesMutationSuccess, props = {}, provide = {}, } = {}) => { fakeApollo = createMockApollo([ - [getIssueParticipantsQuery, issuableQueryHandler], - [searchUsersQuery, searchQueryHandler], + [getIssueAssigneesQuery, issuableQueryHandler], [updateIssueAssigneesMutation, updateIssueAssigneesMutationHandler], ]); wrapper = shallowMount(SidebarAssigneesWidget, { @@ -82,15 +63,11 @@ describe('Sidebar assignees widget', () => { apolloProvider: fakeApollo, propsData: { iid: '1', + issuableId: 0, fullPath: '/mygroup/myProject', + allowMultipleAssignees: true, ...props, }, - data() { - return { - search, - selected: [], - }; - }, provide: { canUpdate: true, rootPath: '/', @@ -98,7 +75,7 @@ describe('Sidebar assignees widget', () => { }, stubs: { SidebarEditableItem, - MultiSelectDropdown, + UserSelect, GlSearchBoxByType, GlDropdown, }, @@ -148,19 +125,6 @@ describe('Sidebar assignees widget', () => { expect(findEditableItem().props('title')).toBe('Assignee'); }); - - describe('when expanded', () => { - it('renders a loading spinner if participants are loading', () => { - createComponent({ - props: { - initialAssignees, - }, - }); - expandDropdown(); - - expect(findParticipantsLoading().exists()).toBe(true); - }); - }); }); describe('without passed initial assignees', () => { @@ -198,7 +162,7 @@ describe('Sidebar assignees widget', () => { findAssignees().vm.$emit('assign-self'); expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ - assigneeUsernames: 'root', + assigneeUsernames: ['root'], fullPath: '/mygroup/myProject', iid: '1', }); @@ -220,7 +184,7 @@ describe('Sidebar assignees widget', () => { findAssignees().vm.$emit('assign-self'); expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ - assigneeUsernames: 'root', + assigneeUsernames: ['root'], fullPath: '/mygroup/myProject', iid: '1', }); @@ -245,16 +209,20 @@ describe('Sidebar assignees widget', () => { ]); }); - it('renders current user if they are not in participants or assignees', async () => { - gon.current_username = 'random'; - gon.current_user_fullname = 'Mr Random'; - gon.current_user_avatar_url = '/random'; - + it('does not trigger mutation or fire event when editing and exiting without making changes', async () => { createComponent(); + await waitForPromises(); - expandDropdown(); - expect(findCurrentUser().exists()).toBe(true); + findEditableItem().vm.$emit('open'); + + await waitForPromises(); + + findEditableItem().vm.$emit('close'); + + expect(findEditableItem().props('isDirty')).toBe(false); + expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledTimes(0); + expect(wrapper.emitted('assignees-updated')).toBe(undefined); }); describe('when expanded', () => { @@ -264,27 +232,18 @@ describe('Sidebar assignees widget', () => { expandDropdown(); }); - it('collapses the widget on multiselect dropdown toggle event', async () => { - findDropdown().vm.$emit('toggle'); + it('collapses the widget on user select toggle event', async () => { + findUserSelect().vm.$emit('toggle'); await nextTick(); - expect(findDropdown().isVisible()).toBe(false); + expect(findUserSelect().isVisible()).toBe(false); }); - it('renders participants list with correct amount of selected and unselected', async () => { - expect(findSelectedParticipants()).toHaveLength(1); - expect(findUnselectedParticipants()).toHaveLength(2); - }); - - it('does not render current user if they are in participants', () => { - expect(findCurrentUser().exists()).toBe(false); - }); - - it('unassigns all participants when clicking on `Unassign`', () => { - findUnassignLink().vm.$emit('click'); + it('calls an update mutation with correct variables on User Select input event', () => { + findUserSelect().vm.$emit('input', [{ username: 'root' }]); findEditableItem().vm.$emit('close'); expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ - assigneeUsernames: [], + assigneeUsernames: ['root'], fullPath: '/mygroup/myProject', iid: '1', }); @@ -293,68 +252,38 @@ describe('Sidebar assignees widget', () => { describe('when multiselect is disabled', () => { beforeEach(async () => { - createComponent({ props: { multipleAssignees: false } }); + createComponent({ props: { allowMultipleAssignees: false } }); await waitForPromises(); expandDropdown(); }); - it('adds a single assignee when clicking on unselected user', async () => { - findUnselectedParticipants().at(0).vm.$emit('click'); + it('closes a dropdown after User Select input event', async () => { + findUserSelect().vm.$emit('input', [{ username: 'root' }]); expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ assigneeUsernames: ['root'], fullPath: '/mygroup/myProject', iid: '1', }); - }); - it('removes an assignee when clicking on selected user', () => { - findSelectedParticipants().at(0).vm.$emit('click', new Event('click')); + await waitForPromises(); - expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ - assigneeUsernames: [], - fullPath: '/mygroup/myProject', - iid: '1', - }); + expect(findUserSelect().isVisible()).toBe(false); }); }); describe('when multiselect is enabled', () => { beforeEach(async () => { - createComponent({ props: { multipleAssignees: true } }); + createComponent({ props: { allowMultipleAssignees: true } }); await waitForPromises(); expandDropdown(); }); - it('adds a few assignees after clicking on unselected users and closing a dropdown', () => { - findUnselectedParticipants().at(0).vm.$emit('click'); - findUnselectedParticipants().at(1).vm.$emit('click'); - findEditableItem().vm.$emit('close'); - - expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ - assigneeUsernames: ['francina.skiles', 'root', 'johndoe'], - fullPath: '/mygroup/myProject', - iid: '1', - }); - }); - - it('removes an assignee when clicking on selected user and then closing dropdown', () => { - findSelectedParticipants().at(0).vm.$emit('click', new Event('click')); - - findEditableItem().vm.$emit('close'); - - expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ - assigneeUsernames: [], - fullPath: '/mygroup/myProject', - iid: '1', - }); - }); - it('does not call a mutation when clicking on participants until dropdown is closed', () => { - findUnselectedParticipants().at(0).vm.$emit('click'); - findSelectedParticipants().at(0).vm.$emit('click', new Event('click')); + findUserSelect().vm.$emit('input', [{ username: 'root' }]); expect(updateIssueAssigneesMutationSuccess).not.toHaveBeenCalled(); + expect(findUserSelect().isVisible()).toBe(true); }); }); @@ -363,7 +292,7 @@ describe('Sidebar assignees widget', () => { await waitForPromises(); expandDropdown(); - findUnassignLink().vm.$emit('click'); + findUserSelect().vm.$emit('input', []); findEditableItem().vm.$emit('close'); await waitForPromises(); @@ -372,95 +301,6 @@ describe('Sidebar assignees widget', () => { message: 'An error occurred while updating assignees.', }); }); - - describe('when searching', () => { - it('does not show loading spinner when debounce timer is still running', async () => { - createComponent({ search: 'roo' }); - await waitForPromises(); - expandDropdown(); - - expect(findParticipantsLoading().exists()).toBe(false); - }); - - it('shows loading spinner when searching for users', async () => { - createComponent({ search: 'roo' }); - await waitForPromises(); - expandDropdown(); - jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); - await nextTick(); - - expect(findParticipantsLoading().exists()).toBe(true); - }); - - it('renders a list of found users and external participants matching search term', async () => { - const responseCopy = cloneDeep(issuableQueryResponse); - responseCopy.data.workspace.issuable.participants.nodes.push({ - id: 'gid://gitlab/User/5', - avatarUrl: '/someavatar', - name: 'Roodie', - username: 'roodie', - webUrl: '/roodie', - status: null, - }); - - const issuableQueryHandler = jest.fn().mockResolvedValue(responseCopy); - - createComponent({ issuableQueryHandler }); - await waitForPromises(); - expandDropdown(); - - findSearchField().vm.$emit('input', 'roo'); - await nextTick(); - - jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); - await nextTick(); - await waitForPromises(); - - expect(findUnselectedParticipants()).toHaveLength(3); - }); - - it('renders a list of found users only if no external participants match search term', async () => { - createComponent({ search: 'roo' }); - await waitForPromises(); - expandDropdown(); - jest.advanceTimersByTime(250); - await nextTick(); - await waitForPromises(); - - expect(findUnselectedParticipants()).toHaveLength(2); - }); - - it('shows a message about no matches if search returned an empty list', async () => { - const responseCopy = cloneDeep(searchQueryResponse); - responseCopy.data.workspace.users.nodes = []; - - createComponent({ - search: 'roo', - searchQueryHandler: jest.fn().mockResolvedValue(responseCopy), - }); - await waitForPromises(); - expandDropdown(); - jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); - await nextTick(); - await waitForPromises(); - - expect(findUnselectedParticipants()).toHaveLength(0); - expect(findEmptySearchResults().exists()).toBe(true); - }); - - it('shows an error if search query was rejected', async () => { - createComponent({ search: 'roo', searchQueryHandler: mockError }); - await waitForPromises(); - expandDropdown(); - jest.advanceTimersByTime(250); - await nextTick(); - await waitForPromises(); - - expect(createFlash).toHaveBeenCalledWith({ - message: 'An error occurred while searching users.', - }); - }); - }); }); describe('when user is not signed in', () => { @@ -469,11 +309,6 @@ describe('Sidebar assignees widget', () => { createComponent(); }); - it('does not show current user in the dropdown', () => { - expandDropdown(); - expect(findCurrentUser().exists()).toBe(false); - }); - it('passes signedIn prop as false to IssuableAssignees', () => { expect(findAssignees().props('signedIn')).toBe(false); }); @@ -507,17 +342,17 @@ describe('Sidebar assignees widget', () => { expect(findEditableItem().props('isDirty')).toBe(false); }); - it('passes truthy `isDirty` prop if selected users list was changed', async () => { + it('passes truthy `isDirty` prop after User Select component emitted an input event', async () => { expandDropdown(); expect(findEditableItem().props('isDirty')).toBe(false); - findUnselectedParticipants().at(0).vm.$emit('click'); + findUserSelect().vm.$emit('input', []); await nextTick(); expect(findEditableItem().props('isDirty')).toBe(true); }); it('passes falsy `isDirty` prop after dropdown is closed', async () => { expandDropdown(); - findUnselectedParticipants().at(0).vm.$emit('click'); + findUserSelect().vm.$emit('input', []); findEditableItem().vm.$emit('close'); await waitForPromises(); expect(findEditableItem().props('isDirty')).toBe(false); @@ -530,7 +365,7 @@ describe('Sidebar assignees widget', () => { expect(findInviteMembersLink().exists()).toBe(false); }); - it('does not render invite members link if `directlyInviteMembers` and `indirectlyInviteMembers` were not passed', async () => { + it('does not render invite members link if `directlyInviteMembers` was not passed', async () => { createComponent(); await waitForPromises(); expect(findInviteMembersLink().exists()).toBe(false); @@ -545,14 +380,4 @@ describe('Sidebar assignees widget', () => { await waitForPromises(); expect(findInviteMembersLink().exists()).toBe(true); }); - - it('renders invite members link if `indirectlyInviteMembers` is true', async () => { - createComponent({ - provide: { - indirectlyInviteMembers: true, - }, - }); - await waitForPromises(); - expect(findInviteMembersLink().exists()).toBe(true); - }); }); diff --git a/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js index 06f7da3d1ab..cfbe7227915 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js @@ -1,25 +1,14 @@ import { shallowMount } from '@vue/test-utils'; -import InviteMemberModal from '~/invite_member/components/invite_member_modal.vue'; -import InviteMemberTrigger from '~/invite_member/components/invite_member_trigger.vue'; import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; import SidebarInviteMembers from '~/sidebar/components/assignees/sidebar_invite_members.vue'; -const testProjectMembersPath = 'test-path'; - describe('Sidebar invite members component', () => { let wrapper; const findDirectInviteLink = () => wrapper.findComponent(InviteMembersTrigger); - const findIndirectInviteLink = () => wrapper.findComponent(InviteMemberTrigger); - const findInviteModal = () => wrapper.findComponent(InviteMemberModal); - const createComponent = ({ directlyInviteMembers = false } = {}) => { - wrapper = shallowMount(SidebarInviteMembers, { - provide: { - directlyInviteMembers, - projectMembersPath: testProjectMembersPath, - }, - }); + const createComponent = () => { + wrapper = shallowMount(SidebarInviteMembers); }; afterEach(() => { @@ -28,32 +17,11 @@ describe('Sidebar invite members component', () => { describe('when directly inviting members', () => { beforeEach(() => { - createComponent({ directlyInviteMembers: true }); + createComponent(); }); it('renders a direct link to project members path', () => { expect(findDirectInviteLink().exists()).toBe(true); }); - - it('does not render invite members trigger and modal components', () => { - expect(findIndirectInviteLink().exists()).toBe(false); - expect(findInviteModal().exists()).toBe(false); - }); - }); - - describe('when indirectly inviting members', () => { - beforeEach(() => { - createComponent(); - }); - - it('does not render a direct link to project members path', () => { - expect(findDirectInviteLink().exists()).toBe(false); - }); - - it('does not render invite members trigger and modal components', () => { - expect(findIndirectInviteLink().exists()).toBe(true); - expect(findInviteModal().exists()).toBe(true); - expect(findInviteModal().props('membersPath')).toBe(testProjectMembersPath); - }); }); }); diff --git a/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js b/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js new file mode 100644 index 00000000000..91cbcc6cc27 --- /dev/null +++ b/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js @@ -0,0 +1,183 @@ +import { GlDatepicker } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; +import SidebarFormattedDate from '~/sidebar/components/date/sidebar_formatted_date.vue'; +import SidebarInheritDate from '~/sidebar/components/date/sidebar_inherit_date.vue'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import epicStartDateQuery from '~/sidebar/queries/epic_start_date.query.graphql'; +import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql'; +import { issuableDueDateResponse, issuableStartDateResponse } from '../../mock_data'; + +jest.mock('~/flash'); + +Vue.use(VueApollo); + +describe('Sidebar date Widget', () => { + let wrapper; + let fakeApollo; + const date = '2021-04-15'; + + const findEditableItem = () => wrapper.findComponent(SidebarEditableItem); + const findPopoverIcon = () => wrapper.find('[data-testid="inherit-date-popover"]'); + const findDatePicker = () => wrapper.find(GlDatepicker); + + const createComponent = ({ + dueDateQueryHandler = jest.fn().mockResolvedValue(issuableDueDateResponse()), + startDateQueryHandler = jest.fn().mockResolvedValue(issuableStartDateResponse()), + canInherit = false, + dateType = undefined, + issuableType = 'issue', + } = {}) => { + fakeApollo = createMockApollo([ + [issueDueDateQuery, dueDateQueryHandler], + [epicStartDateQuery, startDateQueryHandler], + ]); + + wrapper = shallowMount(SidebarDateWidget, { + apolloProvider: fakeApollo, + provide: { + canUpdate: true, + }, + propsData: { + fullPath: 'group/project', + iid: '1', + issuableType, + canInherit, + dateType, + }, + stubs: { + SidebarEditableItem, + GlDatepicker, + }, + }); + }; + + 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('dateType is due date by default', () => { + createComponent(); + + expect(wrapper.text()).toContain('Due date'); + }); + + it('does not display icon popover by default', () => { + createComponent(); + + expect(findPopoverIcon().exists()).toBe(false); + }); + + it('does not render GlDatePicker', () => { + createComponent(); + + expect(findDatePicker().exists()).toBe(false); + }); + + describe('when issuable has no due date', () => { + beforeEach(async () => { + createComponent({ + dueDateQueryHandler: jest.fn().mockResolvedValue(issuableDueDateResponse(null)), + }); + await waitForPromises(); + }); + + it('passes a `loading` prop as false to editable item', () => { + expect(findEditableItem().props('loading')).toBe(false); + }); + + it('emits `dueDateUpdated` event with a `null` payload', () => { + expect(wrapper.emitted('dueDateUpdated')).toEqual([[null]]); + }); + }); + + describe('when issue has due date', () => { + beforeEach(async () => { + createComponent({ + dueDateQueryHandler: jest.fn().mockResolvedValue(issuableDueDateResponse(date)), + }); + await waitForPromises(); + }); + + it('passes a `loading` prop as false to editable item', () => { + expect(findEditableItem().props('loading')).toBe(false); + }); + + it('emits `dueDateUpdated` event with the date payload', () => { + expect(wrapper.emitted('dueDateUpdated')).toEqual([[date]]); + }); + + it('uses a correct prop to set the initial date for GlDatePicker', () => { + expect(findDatePicker().props()).toMatchObject({ + value: null, + autocomplete: 'off', + defaultDate: expect.any(Object), + }); + }); + + it('renders GlDatePicker', async () => { + expect(findDatePicker().exists()).toBe(true); + }); + }); + + it.each` + canInherit | component | componentName | expected + ${true} | ${SidebarFormattedDate} | ${'SidebarFormattedDate'} | ${false} + ${true} | ${SidebarInheritDate} | ${'SidebarInheritDate'} | ${true} + ${false} | ${SidebarFormattedDate} | ${'SidebarFormattedDate'} | ${true} + ${false} | ${SidebarInheritDate} | ${'SidebarInheritDate'} | ${false} + `( + 'when canInherit is $canInherit, $componentName display is $expected', + ({ canInherit, component, expected }) => { + createComponent({ canInherit }); + + expect(wrapper.find(component).exists()).toBe(expected); + }, + ); + + it('displays a flash message when query is rejected', async () => { + createComponent({ + dueDateQueryHandler: jest.fn().mockRejectedValue('Houston, we have a problem'), + }); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalled(); + }); + + it.each` + dateType | text | event | mockedResponse | issuableType | queryHandler + ${'dueDate'} | ${'Due date'} | ${'dueDateUpdated'} | ${issuableDueDateResponse} | ${'issue'} | ${'dueDateQueryHandler'} + ${'startDate'} | ${'Start date'} | ${'startDateUpdated'} | ${issuableStartDateResponse} | ${'epic'} | ${'startDateQueryHandler'} + `( + 'when dateType is $dateType, component renders $text and emits $event', + async ({ dateType, text, event, mockedResponse, issuableType, queryHandler }) => { + createComponent({ + dateType, + issuableType, + [queryHandler]: jest.fn().mockResolvedValue(mockedResponse(date)), + }); + await waitForPromises(); + + expect(wrapper.text()).toContain(text); + expect(wrapper.emitted(event)).toEqual([[date]]); + }, + ); + + it('displays icon popover when issuable can inherit date', () => { + createComponent({ canInherit: true }); + + expect(findPopoverIcon().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/sidebar/components/date/sidebar_formatted_date_spec.js b/spec/frontend/sidebar/components/date/sidebar_formatted_date_spec.js new file mode 100644 index 00000000000..1eda4ea977f --- /dev/null +++ b/spec/frontend/sidebar/components/date/sidebar_formatted_date_spec.js @@ -0,0 +1,62 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import SidebarFormattedDate from '~/sidebar/components/date/sidebar_formatted_date.vue'; + +describe('SidebarFormattedDate', () => { + let wrapper; + const findFormattedDate = () => wrapper.find("[data-testid='sidebar-date-value']"); + const findRemoveButton = () => wrapper.find(GlButton); + + const createComponent = ({ hasDate = true } = {}) => { + wrapper = shallowMount(SidebarFormattedDate, { + provide: { + canUpdate: true, + }, + propsData: { + formattedDate: 'Apr 15, 2021', + hasDate, + issuableType: 'issue', + resetText: 'remove', + isLoading: false, + canDelete: true, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays formatted date', () => { + expect(findFormattedDate().text()).toBe('Apr 15, 2021'); + }); + + describe('when issue has due date', () => { + it('displays remove button', () => { + expect(findRemoveButton().exists()).toBe(true); + expect(findRemoveButton().children).toEqual(wrapper.props.resetText); + }); + + it('emits reset-date event on click on remove button', () => { + findRemoveButton().vm.$emit('click'); + + expect(wrapper.emitted('reset-date')).toEqual([[undefined]]); + }); + }); + + describe('when issuable has no due date', () => { + beforeEach(() => { + createComponent({ + hasDate: false, + }); + }); + + it('does not display remove button', () => { + expect(findRemoveButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js b/spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js new file mode 100644 index 00000000000..4d38eba8035 --- /dev/null +++ b/spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js @@ -0,0 +1,53 @@ +import { GlFormRadio } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import SidebarFormattedDate from '~/sidebar/components/date/sidebar_formatted_date.vue'; +import SidebarInheritDate from '~/sidebar/components/date/sidebar_inherit_date.vue'; + +describe('SidebarInheritDate', () => { + let wrapper; + const findFixedFormattedDate = () => wrapper.findAll(SidebarFormattedDate).at(0); + const findInheritFormattedDate = () => wrapper.findAll(SidebarFormattedDate).at(1); + const findFixedRadio = () => wrapper.findAll(GlFormRadio).at(0); + const findInheritRadio = () => wrapper.findAll(GlFormRadio).at(1); + + const createComponent = () => { + wrapper = shallowMount(SidebarInheritDate, { + provide: { + canUpdate: true, + }, + propsData: { + issuable: { + dueDate: '2021-04-15', + dueDateIsFixed: true, + dueDateFixed: '2021-04-15', + dueDateFromMilestones: '2021-05-15', + }, + isLoading: false, + dateType: 'dueDate', + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays formatted fixed and inherited dates with radio buttons', () => { + expect(wrapper.findAll(SidebarFormattedDate)).toHaveLength(2); + expect(wrapper.findAll(GlFormRadio)).toHaveLength(2); + expect(findFixedFormattedDate().props('formattedDate')).toBe('Apr 15, 2021'); + expect(findInheritFormattedDate().props('formattedDate')).toBe('May 15, 2021'); + expect(findFixedRadio().text()).toBe('Fixed:'); + expect(findInheritRadio().text()).toBe('Inherited:'); + }); + + it('emits set-date event on click on radio button', () => { + findFixedRadio().vm.$emit('input', true); + + expect(wrapper.emitted('set-date')).toEqual([[true]]); + }); +}); diff --git a/spec/frontend/sidebar/components/due_date/sidebar_due_date_widget_spec.js b/spec/frontend/sidebar/components/due_date/sidebar_due_date_widget_spec.js deleted file mode 100644 index f58ceb0f1be..00000000000 --- a/spec/frontend/sidebar/components/due_date/sidebar_due_date_widget_spec.js +++ /dev/null @@ -1,106 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; -import SidebarDueDateWidget from '~/sidebar/components/due_date/sidebar_due_date_widget.vue'; -import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql'; -import { issueDueDateResponse } from '../../mock_data'; - -jest.mock('~/flash'); - -Vue.use(VueApollo); - -describe('Sidebar Due date Widget', () => { - let wrapper; - let fakeApollo; - const date = '2021-04-15'; - - const findEditableItem = () => wrapper.findComponent(SidebarEditableItem); - const findFormattedDueDate = () => wrapper.find("[data-testid='sidebar-duedate-value']"); - - const createComponent = ({ - dueDateQueryHandler = jest.fn().mockResolvedValue(issueDueDateResponse()), - } = {}) => { - fakeApollo = createMockApollo([[issueDueDateQuery, dueDateQueryHandler]]); - - wrapper = shallowMount(SidebarDueDateWidget, { - 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); - }); - - describe('when issue has no due date', () => { - beforeEach(async () => { - createComponent({ - dueDateQueryHandler: jest.fn().mockResolvedValue(issueDueDateResponse(null)), - }); - await waitForPromises(); - }); - - it('passes a `loading` prop as false to editable item', () => { - expect(findEditableItem().props('loading')).toBe(false); - }); - - it('dueDate is null by default', () => { - expect(findFormattedDueDate().text()).toBe('None'); - }); - - it('emits `dueDateUpdated` event with a `null` payload', () => { - expect(wrapper.emitted('dueDateUpdated')).toEqual([[null]]); - }); - }); - - describe('when issue has due date', () => { - beforeEach(async () => { - createComponent({ - dueDateQueryHandler: jest.fn().mockResolvedValue(issueDueDateResponse(date)), - }); - await waitForPromises(); - }); - - it('passes a `loading` prop as false to editable item', () => { - expect(findEditableItem().props('loading')).toBe(false); - }); - - it('has dueDate', () => { - expect(findFormattedDueDate().text()).toBe('Apr 15, 2021'); - }); - - it('emits `dueDateUpdated` event with the date payload', () => { - expect(wrapper.emitted('dueDateUpdated')).toEqual([[date]]); - }); - }); - - it('displays a flash message when query is rejected', async () => { - createComponent({ - dueDateQueryHandler: jest.fn().mockRejectedValue('Houston, we have a problem'), - }); - await waitForPromises(); - - expect(createFlash).toHaveBeenCalled(); - }); -}); diff --git a/spec/frontend/sidebar/components/participants/sidebar_participants_widget_spec.js b/spec/frontend/sidebar/components/participants/sidebar_participants_widget_spec.js new file mode 100644 index 00000000000..57b9a10b23e --- /dev/null +++ b/spec/frontend/sidebar/components/participants/sidebar_participants_widget_spec.js @@ -0,0 +1,89 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import Participants from '~/sidebar/components/participants/participants.vue'; +import SidebarParticipantsWidget from '~/sidebar/components/participants/sidebar_participants_widget.vue'; +import epicParticipantsQuery from '~/sidebar/queries/epic_participants.query.graphql'; +import { epicParticipantsResponse } from '../../mock_data'; + +Vue.use(VueApollo); + +describe('Sidebar Participants Widget', () => { + let wrapper; + let fakeApollo; + + const findParticipants = () => wrapper.findComponent(Participants); + + const createComponent = ({ + participantsQueryHandler = jest.fn().mockResolvedValue(epicParticipantsResponse()), + } = {}) => { + fakeApollo = createMockApollo([[epicParticipantsQuery, participantsQueryHandler]]); + + wrapper = shallowMount(SidebarParticipantsWidget, { + apolloProvider: fakeApollo, + propsData: { + fullPath: 'group', + iid: '1', + issuableType: 'epic', + }, + stubs: { + Participants, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + it('passes a `loading` prop as true to child component when query is loading', () => { + createComponent(); + + expect(findParticipants().props('loading')).toBe(true); + }); + + describe('when participants are loaded', () => { + beforeEach(() => { + createComponent({ + participantsQueryHandler: jest.fn().mockResolvedValue(epicParticipantsResponse()), + }); + return waitForPromises(); + }); + + it('passes a `loading` prop as false to editable item', () => { + expect(findParticipants().props('loading')).toBe(false); + }); + + it('passes participants to child component', () => { + expect(findParticipants().props('participants')).toEqual( + epicParticipantsResponse().data.workspace.issuable.participants.nodes, + ); + }); + }); + + describe('when error occurs', () => { + it('emits error event with correct parameters', async () => { + const mockError = new Error('mayday'); + + createComponent({ + participantsQueryHandler: jest.fn().mockRejectedValue(mockError), + }); + + await waitForPromises(); + + const [ + [ + { + message, + error: { networkError }, + }, + ], + ] = wrapper.emitted('fetch-error'); + expect(message).toBe(wrapper.vm.$options.i18n.fetchingError); + expect(networkError).toEqual(mockError); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js b/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js new file mode 100644 index 00000000000..549ab99c6af --- /dev/null +++ b/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js @@ -0,0 +1,131 @@ +import { GlIcon, GlToggle } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import SidebarSubscriptionWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; +import issueSubscribedQuery from '~/sidebar/queries/issue_subscribed.query.graphql'; +import { issueSubscriptionsResponse } from '../../mock_data'; + +jest.mock('~/flash'); + +Vue.use(VueApollo); + +describe('Sidebar Subscriptions Widget', () => { + let wrapper; + let fakeApollo; + + const findEditableItem = () => wrapper.findComponent(SidebarEditableItem); + const findToggle = () => wrapper.findComponent(GlToggle); + const findIcon = () => wrapper.findComponent(GlIcon); + + const createComponent = ({ + subscriptionsQueryHandler = jest.fn().mockResolvedValue(issueSubscriptionsResponse()), + } = {}) => { + fakeApollo = createMockApollo([[issueSubscribedQuery, subscriptionsQueryHandler]]); + + wrapper = shallowMount(SidebarSubscriptionWidget, { + apolloProvider: fakeApollo, + provide: { + canUpdate: true, + }, + propsData: { + fullPath: 'group/project', + iid: '1', + 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); + }); + + describe('when user is not subscribed to the issue', () => { + beforeEach(() => { + createComponent(); + return waitForPromises(); + }); + + it('passes a `loading` prop as false to editable item', () => { + expect(findEditableItem().props('loading')).toBe(false); + }); + + it('toggle is unchecked', () => { + expect(findToggle().props('value')).toBe(false); + }); + + it('emits `subscribedUpdated` event with a `false` payload', () => { + expect(wrapper.emitted('subscribedUpdated')).toEqual([[false]]); + }); + }); + + describe('when user is subscribed to the issue', () => { + beforeEach(() => { + createComponent({ + subscriptionsQueryHandler: jest.fn().mockResolvedValue(issueSubscriptionsResponse(true)), + }); + return waitForPromises(); + }); + + it('passes a `loading` prop as false to editable item', () => { + expect(findEditableItem().props('loading')).toBe(false); + }); + + it('toggle is checked', () => { + expect(findToggle().props('value')).toBe(true); + }); + + it('emits `subscribedUpdated` event with a `true` payload', () => { + expect(wrapper.emitted('subscribedUpdated')).toEqual([[true]]); + }); + }); + + describe('when emails are disabled', () => { + it('toggle is disabled and off when user is subscribed', async () => { + createComponent({ + subscriptionsQueryHandler: jest + .fn() + .mockResolvedValue(issueSubscriptionsResponse(true, true)), + }); + await waitForPromises(); + + expect(findIcon().props('name')).toBe('notifications-off'); + expect(findToggle().props('disabled')).toBe(true); + }); + + it('toggle is disabled and off when user is not subscribed', async () => { + createComponent({ + subscriptionsQueryHandler: jest + .fn() + .mockResolvedValue(issueSubscriptionsResponse(false, true)), + }); + await waitForPromises(); + + expect(findIcon().props('name')).toBe('notifications-off'); + expect(findToggle().props('disabled')).toBe(true); + }); + }); + + it('displays a flash message when query is rejected', async () => { + createComponent({ + subscriptionsQueryHandler: jest.fn().mockRejectedValue('Houston, we have a problem'), + }); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/sidebar/components/time_tracking/mock_data.js b/spec/frontend/sidebar/components/time_tracking/mock_data.js new file mode 100644 index 00000000000..862bcbe861e --- /dev/null +++ b/spec/frontend/sidebar/components/time_tracking/mock_data.js @@ -0,0 +1,102 @@ +export const getIssueTimelogsQueryResponse = { + data: { + issuable: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/148', + title: + 'Est perferendis dicta expedita ipsum adipisci laudantium omnis consequatur consequatur et.', + timelogs: { + nodes: [ + { + __typename: 'Timelog', + timeSpent: 14400, + user: { + name: 'John Doe18', + __typename: 'UserCore', + }, + spentAt: '2020-05-01T00:00:00Z', + note: { + body: 'I paired with @root on this last week.', + __typename: 'Note', + }, + }, + { + __typename: 'Timelog', + timeSpent: 1800, + user: { + name: 'Administrator', + __typename: 'UserCore', + }, + spentAt: '2021-05-07T13:19:01Z', + note: null, + }, + { + __typename: 'Timelog', + timeSpent: 14400, + user: { + name: 'Administrator', + __typename: 'UserCore', + }, + spentAt: '2021-05-01T00:00:00Z', + note: { + body: 'I did some work on this last week.', + __typename: 'Note', + }, + }, + ], + __typename: 'TimelogConnection', + }, + }, + }, +}; + +export const getMrTimelogsQueryResponse = { + data: { + issuable: { + __typename: 'MergeRequest', + id: 'gid://gitlab/MergeRequest/29', + title: 'Esse amet perspiciatis voluptas et sed praesentium debitis repellat.', + timelogs: { + nodes: [ + { + __typename: 'Timelog', + timeSpent: 1800, + user: { + name: 'Administrator', + __typename: 'UserCore', + }, + spentAt: '2021-05-07T14:44:55Z', + note: { + body: 'Thirty minutes!', + __typename: 'Note', + }, + }, + { + __typename: 'Timelog', + timeSpent: 3600, + user: { + name: 'Administrator', + __typename: 'UserCore', + }, + spentAt: '2021-05-07T14:44:39Z', + note: null, + }, + { + __typename: 'Timelog', + timeSpent: 300, + user: { + name: 'Administrator', + __typename: 'UserCore', + }, + spentAt: '2021-03-10T00:00:00Z', + note: { + body: 'A note with some time', + __typename: 'Note', + }, + }, + ], + __typename: 'TimelogConnection', + }, + }, + }, +}; diff --git a/spec/frontend/sidebar/components/time_tracking/report_spec.js b/spec/frontend/sidebar/components/time_tracking/report_spec.js new file mode 100644 index 00000000000..0aa5aa2f691 --- /dev/null +++ b/spec/frontend/sidebar/components/time_tracking/report_spec.js @@ -0,0 +1,125 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { getAllByRole, getByRole } from '@testing-library/dom'; +import { shallowMount, createLocalVue, mount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import Report from '~/sidebar/components/time_tracking/report.vue'; +import getIssueTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql'; +import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql'; +import { getIssueTimelogsQueryResponse, getMrTimelogsQueryResponse } from './mock_data'; + +jest.mock('~/flash'); + +describe('Issuable Time Tracking Report', () => { + const localVue = createLocalVue(); + localVue.use(VueApollo); + let wrapper; + let fakeApollo; + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const successIssueQueryHandler = jest.fn().mockResolvedValue(getIssueTimelogsQueryResponse); + const successMrQueryHandler = jest.fn().mockResolvedValue(getMrTimelogsQueryResponse); + + const mountComponent = ({ + queryHandler = successIssueQueryHandler, + issuableType = 'issue', + mountFunction = shallowMount, + limitToHours = false, + } = {}) => { + fakeApollo = createMockApollo([ + [getIssueTimelogsQuery, queryHandler], + [getMrTimelogsQuery, queryHandler], + ]); + wrapper = mountFunction(Report, { + provide: { + issuableId: 1, + issuableType, + }, + propsData: { limitToHours }, + localVue, + apolloProvider: fakeApollo, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + it('should render loading spinner', () => { + mountComponent(); + + expect(findLoadingIcon()).toExist(); + }); + + it('should render error message on reject', async () => { + mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') }); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalled(); + }); + + describe('for issue', () => { + beforeEach(() => { + mountComponent({ mountFunction: mount }); + }); + + it('calls correct query', () => { + expect(successIssueQueryHandler).toHaveBeenCalled(); + }); + + it('renders correct results', async () => { + await waitForPromises(); + + expect(getAllByRole(wrapper.element, 'row', { name: /John Doe18/i })).toHaveLength(1); + expect(getAllByRole(wrapper.element, 'row', { name: /Administrator/i })).toHaveLength(2); + }); + }); + + describe('for merge request', () => { + beforeEach(() => { + mountComponent({ + queryHandler: successMrQueryHandler, + issuableType: 'merge_request', + mountFunction: mount, + }); + }); + + it('calls correct query', () => { + expect(successMrQueryHandler).toHaveBeenCalled(); + }); + + it('renders correct results', async () => { + await waitForPromises(); + + expect(getAllByRole(wrapper.element, 'row', { name: /Administrator/i })).toHaveLength(3); + }); + }); + + describe('observes `limit display of time tracking units to hours` setting', () => { + describe('when false', () => { + beforeEach(() => { + mountComponent({ limitToHours: false, mountFunction: mount }); + }); + + it('renders correct results', async () => { + await waitForPromises(); + + expect(getByRole(wrapper.element, 'columnheader', { name: /1d 30m/i })).not.toBeNull(); + }); + }); + + describe('when true', () => { + beforeEach(() => { + mountComponent({ limitToHours: true, mountFunction: mount }); + }); + + it('renders correct results', async () => { + await waitForPromises(); + + expect(getByRole(wrapper.element, 'columnheader', { name: /8h 30m/i })).not.toBeNull(); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js index 4d03aedf1be..f26cdcb8b20 100644 --- a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js @@ -10,6 +10,7 @@ describe('Issuable Time Tracker', () => { const findComparisonMeter = () => findByTestId('compareMeter').attributes('title'); const findCollapsedState = () => findByTestId('collapsedState'); const findTimeRemainingProgress = () => findByTestId('timeRemainingProgress'); + const findReportLink = () => findByTestId('reportLink'); const defaultProps = { timeEstimate: 10_000, // 2h 46m @@ -192,6 +193,33 @@ describe('Issuable Time Tracker', () => { }); }); + describe('Time tracking report', () => { + describe('When no time spent', () => { + beforeEach(() => { + wrapper = mountComponent({ + props: { + timeSpent: 0, + timeSpentHumanReadable: '', + }, + }); + }); + + it('link should not appear', () => { + expect(findReportLink().exists()).toBe(false); + }); + }); + + describe('When time spent', () => { + beforeEach(() => { + wrapper = mountComponent(); + }); + + it('link should appear', () => { + expect(findReportLink().exists()).toBe(true); + }); + }); + }); + describe('Help pane', () => { const findHelpButton = () => findByTestId('helpButton'); const findCloseHelpButton = () => findByTestId('closeHelpButton'); diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index 2a4858a6320..b052038661a 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -233,7 +233,7 @@ export const issueConfidentialityResponse = (confidential = false) => ({ }, }); -export const issueDueDateResponse = (dueDate = null) => ({ +export const issuableDueDateResponse = (dueDate = null) => ({ data: { workspace: { __typename: 'Project', @@ -246,59 +246,82 @@ export const issueDueDateResponse = (dueDate = null) => ({ }, }); -export const issueReferenceResponse = (reference) => ({ +export const issuableStartDateResponse = (startDate = null) => ({ data: { workspace: { - __typename: 'Project', + __typename: 'Group', issuable: { - __typename: 'Issue', - id: 'gid://gitlab/Issue/4', - reference, + __typename: 'Epic', + id: 'gid://gitlab/Epic/4', + startDate, + startDateIsFixed: true, + startDateFixed: startDate, + startDateFromMilestones: null, }, }, }, }); -export const issuableQueryResponse = { +export const epicParticipantsResponse = () => ({ data: { workspace: { - __typename: 'Project', + __typename: 'Group', issuable: { - __typename: 'Issue', - id: 'gid://gitlab/Issue/1', - iid: '1', + __typename: 'Epic', + id: 'gid://gitlab/Epic/4', participants: { nodes: [ { - id: 'gid://gitlab/User/1', - avatarUrl: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', - name: 'Administrator', - username: 'root', - webUrl: '/root', - status: null, - }, - { id: 'gid://gitlab/User/2', avatarUrl: 'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon', name: 'Jacki Kub', username: 'francina.skiles', webUrl: '/franc', - status: { - availability: 'BUSY', - }, - }, - { - id: 'gid://gitlab/User/3', - avatarUrl: '/avatar', - name: 'John Doe', - username: 'johndoe', - webUrl: '/john', status: null, }, ], }, + }, + }, + }, +}); + +export const issueReferenceResponse = (reference) => ({ + data: { + workspace: { + __typename: 'Project', + issuable: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/4', + reference, + }, + }, + }, +}); + +export const issueSubscriptionsResponse = (subscribed = false, emailsDisabled = false) => ({ + data: { + workspace: { + __typename: 'Project', + issuable: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/4', + subscribed, + emailsDisabled, + }, + }, + }, +}); + +export const issuableQueryResponse = { + data: { + workspace: { + __typename: 'Project', + issuable: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/1', + iid: '1', assignees: { nodes: [ { @@ -370,32 +393,121 @@ export const updateIssueAssigneesMutationResponse = { ], __typename: 'UserConnection', }, - participants: { - nodes: [ - { - __typename: 'User', - id: 'gid://gitlab/User/1', + __typename: 'Issue', + }, + }, + }, +}; + +export const subscriptionNullResponse = { + data: { + issuableAssigneesUpdated: null, + }, +}; + +const mockUser1 = { + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: '/root', + status: null, +}; + +const mockUser2 = { + id: 'gid://gitlab/User/4', + avatarUrl: '/avatar2', + name: 'rookie', + username: 'rookie', + webUrl: 'rookie', + status: null, +}; + +export const searchResponse = { + data: { + workspace: { + __typename: 'Project', + users: { + nodes: [ + { + user: mockUser1, + }, + { + user: mockUser2, + }, + ], + }, + }, + }, +}; + +export const projectMembersResponse = { + data: { + workspace: { + __typename: 'Project', + users: { + nodes: [ + // Remove nulls https://gitlab.com/gitlab-org/gitlab/-/issues/329750 + null, + null, + // Remove duplicated entry https://gitlab.com/gitlab-org/gitlab/-/issues/327822 + mockUser1, + mockUser1, + mockUser2, + { + user: { + id: 'gid://gitlab/User/2', avatarUrl: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', - name: 'Administrator', - username: 'root', - webUrl: '/root', - status: null, + 'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon', + name: 'Jacki Kub', + username: 'francina.skiles', + webUrl: '/franc', + status: { + availability: 'BUSY', + }, }, + }, + ], + }, + }, + }, +}; + +export const participantsQueryResponse = { + data: { + workspace: { + __typename: 'Project', + issuable: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/1', + iid: '1', + participants: { + nodes: [ + // Remove duplicated entry https://gitlab.com/gitlab-org/gitlab/-/issues/327822 + mockUser1, + mockUser1, { - __typename: 'User', id: 'gid://gitlab/User/2', avatarUrl: 'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon', name: 'Jacki Kub', username: 'francina.skiles', webUrl: '/franc', + status: { + availability: 'BUSY', + }, + }, + { + id: 'gid://gitlab/User/3', + avatarUrl: '/avatar', + name: 'John Doe', + username: 'rollie', + webUrl: '/john', status: null, }, ], - __typename: 'UserConnection', }, - __typename: 'Issue', }, }, }, diff --git a/spec/frontend/sidebar/sidebar_assignees_spec.js b/spec/frontend/sidebar/sidebar_assignees_spec.js index e737b57e33d..dc121dcb897 100644 --- a/spec/frontend/sidebar/sidebar_assignees_spec.js +++ b/spec/frontend/sidebar/sidebar_assignees_spec.js @@ -17,6 +17,7 @@ describe('sidebar assignees', () => { wrapper = shallowMount(SidebarAssignees, { propsData: { issuableIid: '1', + issuableId: 1, mediator, field: '', projectPath: 'projectPath', diff --git a/spec/frontend/sidebar/sidebar_subscriptions_spec.js b/spec/frontend/sidebar/sidebar_subscriptions_spec.js deleted file mode 100644 index d900fde7e70..00000000000 --- a/spec/frontend/sidebar/sidebar_subscriptions_spec.js +++ /dev/null @@ -1,36 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import SidebarSubscriptions from '~/sidebar/components/subscriptions/sidebar_subscriptions.vue'; -import SidebarService from '~/sidebar/services/sidebar_service'; -import SidebarMediator from '~/sidebar/sidebar_mediator'; -import SidebarStore from '~/sidebar/stores/sidebar_store'; -import Mock from './mock_data'; - -describe('Sidebar Subscriptions', () => { - let wrapper; - let mediator; - - beforeEach(() => { - mediator = new SidebarMediator(Mock.mediator); - wrapper = shallowMount(SidebarSubscriptions, { - propsData: { - mediator, - }, - }); - }); - - afterEach(() => { - wrapper.destroy(); - SidebarService.singleton = null; - SidebarStore.singleton = null; - SidebarMediator.singleton = null; - }); - - it('calls the mediator toggleSubscription on event', () => { - const spy = jest.spyOn(mediator, 'toggleSubscription').mockReturnValue(Promise.resolve()); - - wrapper.vm.onToggleSubscription(); - - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); - }); -}); diff --git a/spec/frontend/static_site_editor/mock_data.js b/spec/frontend/static_site_editor/mock_data.js index 8bc65c6ce31..8d64e1799b8 100644 --- a/spec/frontend/static_site_editor/mock_data.js +++ b/spec/frontend/static_site_editor/mock_data.js @@ -55,7 +55,7 @@ export const mergeRequestTemplates = [ export const submitChangesError = 'Could not save changes'; export const commitBranchResponse = { - web_url: '/tree/root-master-patch-88195', + web_url: '/tree/root-main-patch-88195', }; export const commitMultipleResponse = { short_id: 'ed899a2f4b5', @@ -84,8 +84,8 @@ export const mounts = [ }, ]; -export const branch = 'master'; +export const branch = 'main'; -export const baseUrl = '/user1/project1/-/sse/master%2Ftest.md'; +export const baseUrl = '/user1/project1/-/sse/main%2Ftest.md'; export const imageRoot = 'source/images/'; diff --git a/spec/frontend/static_site_editor/pages/home_spec.js b/spec/frontend/static_site_editor/pages/home_spec.js index 0936ba3011c..eb056469603 100644 --- a/spec/frontend/static_site_editor/pages/home_spec.js +++ b/spec/frontend/static_site_editor/pages/home_spec.js @@ -275,6 +275,7 @@ describe('static_site_editor/pages/home', () => { formattedMarkdown, project, sourcePath, + targetBranch: branch, username, images, mergeRequestMeta, diff --git a/spec/frontend/static_site_editor/services/generate_branch_name_spec.js b/spec/frontend/static_site_editor/services/generate_branch_name_spec.js index 0624fc3b7b4..7e437506a16 100644 --- a/spec/frontend/static_site_editor/services/generate_branch_name_spec.js +++ b/spec/frontend/static_site_editor/services/generate_branch_name_spec.js @@ -1,7 +1,7 @@ -import { DEFAULT_TARGET_BRANCH, BRANCH_SUFFIX_COUNT } from '~/static_site_editor/constants'; +import { BRANCH_SUFFIX_COUNT } from '~/static_site_editor/constants'; import generateBranchName from '~/static_site_editor/services/generate_branch_name'; -import { username } from '../mock_data'; +import { username, branch as targetBranch } from '../mock_data'; describe('generateBranchName', () => { const timestamp = 12345678901234; @@ -11,11 +11,11 @@ describe('generateBranchName', () => { }); it('generates a name that includes the username and target branch', () => { - expect(generateBranchName(username)).toMatch(`${username}-${DEFAULT_TARGET_BRANCH}`); + expect(generateBranchName(username, targetBranch)).toMatch(`${username}-${targetBranch}`); }); it(`adds the first ${BRANCH_SUFFIX_COUNT} numbers of the current timestamp`, () => { - expect(generateBranchName(username)).toMatch( + expect(generateBranchName(username, targetBranch)).toMatch( timestamp.toString().substring(BRANCH_SUFFIX_COUNT), ); }); diff --git a/spec/frontend/static_site_editor/services/renderers/render_image_spec.js b/spec/frontend/static_site_editor/services/renderers/render_image_spec.js index e9e40835982..d3298aa0b26 100644 --- a/spec/frontend/static_site_editor/services/renderers/render_image_spec.js +++ b/spec/frontend/static_site_editor/services/renderers/render_image_spec.js @@ -47,11 +47,11 @@ describe('rich_content_editor/renderers/render_image', () => { it.each` destination | isAbsolute | src ${'http://test.host/absolute/path/to/image.png'} | ${true} | ${'http://test.host/absolute/path/to/image.png'} - ${'/relative/path/to/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/default/source/relative/path/to/image.png'} - ${'/target/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/source/with/target/image.png'} - ${'relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/relative/to/current/image.png'} - ${'./relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/./relative/to/current/image.png'} - ${'../relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/../relative/to/current/image.png'} + ${'/relative/path/to/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/main/default/source/relative/path/to/image.png'} + ${'/target/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/main/source/with/target/image.png'} + ${'relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/main/relative/to/current/image.png'} + ${'./relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/main/./relative/to/current/image.png'} + ${'../relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/main/../relative/to/current/image.png'} `('returns an image with the correct attributes', ({ destination, isAbsolute, src }) => { node.destination = destination; diff --git a/spec/frontend/static_site_editor/services/submit_content_changes_spec.js b/spec/frontend/static_site_editor/services/submit_content_changes_spec.js index d4cbc5d235e..d9bceb76a37 100644 --- a/spec/frontend/static_site_editor/services/submit_content_changes_spec.js +++ b/spec/frontend/static_site_editor/services/submit_content_changes_spec.js @@ -3,7 +3,6 @@ import Api from '~/api'; import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils'; import { - DEFAULT_TARGET_BRANCH, SUBMIT_CHANGES_BRANCH_ERROR, SUBMIT_CHANGES_COMMIT_ERROR, SUBMIT_CHANGES_MERGE_REQUEST_ERROR, @@ -25,6 +24,7 @@ import { createMergeRequestResponse, mergeRequestMeta, sourcePath, + branch as targetBranch, sourceContentYAML as content, trackingCategory, images, @@ -33,7 +33,7 @@ import { jest.mock('~/static_site_editor/services/generate_branch_name'); describe('submitContentChanges', () => { - const branch = 'branch-name'; + const sourceBranch = 'branch-name'; let trackingSpy; let origPage; @@ -41,6 +41,7 @@ describe('submitContentChanges', () => { username, projectId, sourcePath, + targetBranch, content, images, mergeRequestMeta, @@ -54,7 +55,7 @@ describe('submitContentChanges', () => { .spyOn(Api, 'createProjectMergeRequest') .mockResolvedValue({ data: createMergeRequestResponse }); - generateBranchName.mockReturnValue(branch); + generateBranchName.mockReturnValue(sourceBranch); origPage = document.body.dataset.page; document.body.dataset.page = trackingCategory; @@ -69,8 +70,8 @@ describe('submitContentChanges', () => { it('creates a branch named after the username and target branch', () => { return submitContentChanges(buildPayload()).then(() => { expect(Api.createBranch).toHaveBeenCalledWith(projectId, { - ref: DEFAULT_TARGET_BRANCH, - branch, + ref: targetBranch, + branch: sourceBranch, }); }); }); @@ -86,7 +87,7 @@ describe('submitContentChanges', () => { describe('committing markdown formatting changes', () => { const formattedMarkdown = `formatted ${content}`; const commitPayload = { - branch, + branch: sourceBranch, commit_message: `${DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE}\n\n${DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION}`, actions: [ { @@ -116,7 +117,7 @@ describe('submitContentChanges', () => { it('commits the content changes to the branch when creating branch succeeds', () => { return submitContentChanges(buildPayload()).then(() => { expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, { - branch, + branch: sourceBranch, commit_message: mergeRequestMeta.title, actions: [ { @@ -140,7 +141,7 @@ describe('submitContentChanges', () => { const payload = buildPayload({ content: contentWithoutImages }); return submitContentChanges(payload).then(() => { expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, { - branch, + branch: sourceBranch, commit_message: mergeRequestMeta.title, actions: [ { @@ -169,8 +170,8 @@ describe('submitContentChanges', () => { convertObjectPropsToSnakeCase({ title, description, - targetBranch: DEFAULT_TARGET_BRANCH, - sourceBranch: branch, + targetBranch, + sourceBranch, }), ); }); @@ -194,7 +195,7 @@ describe('submitContentChanges', () => { }); it('returns the branch name', () => { - expect(result).toMatchObject({ branch: { label: branch } }); + expect(result).toMatchObject({ branch: { label: sourceBranch } }); }); it('returns commit short id and web url', () => { diff --git a/spec/frontend/task_list_spec.js b/spec/frontend/task_list_spec.js index b6ac3167fea..2d7a735bd11 100644 --- a/spec/frontend/task_list_spec.js +++ b/spec/frontend/task_list_spec.js @@ -16,7 +16,20 @@ describe('TaskList', () => { beforeEach(() => { setFixtures(` <div class="task-list"> - <div class="js-task-list-container"></div> + <div class="js-task-list-container"> + <ul data-sourcepos="5:1-5:11" class="task-list" dir="auto"> + <li data-sourcepos="5:1-5:11" class="task-list-item enabled"> + <input type="checkbox" class="task-list-item-checkbox" checked=""> markdown task + </li> + </ul> + + <ul class="task-list" dir="auto"> + <li class="task-list-item enabled"> + <input type="checkbox" class="task-list-item-checkbox"> hand-coded checkbox + </li> + </ul> + <textarea class="hidden js-task-list-field"></textarea> + </div> </div> `); @@ -59,32 +72,47 @@ describe('TaskList', () => { describe('disableTaskListItems', () => { it('should call taskList method with disable param', () => { - jest.spyOn($.prototype, 'taskList').mockImplementation(() => {}); + taskList.disableTaskListItems(); - taskList.disableTaskListItems({ currentTarget }); - - expect(currentTarget.taskList).toHaveBeenCalledWith('disable'); + expect(document.querySelectorAll('.task-list-item input:disabled').length).toEqual(2); }); }); describe('enableTaskListItems', () => { - it('should call taskList method with enable param', () => { - jest.spyOn($.prototype, 'taskList').mockImplementation(() => {}); + it('should enable markdown tasks and disable non-markdown tasks', () => { + taskList.disableTaskListItems(); + taskList.enableTaskListItems(); + + expect(document.querySelectorAll('.task-list-item input:enabled').length).toEqual(1); + expect(document.querySelectorAll('.task-list-item input:disabled').length).toEqual(1); + }); + }); + + describe('enable', () => { + it('should enable task list items and on document event', () => { + jest.spyOn($.prototype, 'on').mockImplementation(() => {}); + + taskList.enable(); - taskList.enableTaskListItems({ currentTarget }); + expect(document.querySelectorAll('.task-list-item input:enabled').length).toEqual(1); + expect(document.querySelectorAll('.task-list-item input:disabled').length).toEqual(1); - expect(currentTarget.taskList).toHaveBeenCalledWith('enable'); + expect($(document).on).toHaveBeenCalledWith( + 'tasklist:changed', + taskList.taskListContainerSelector, + taskList.updateHandler, + ); }); }); describe('disable', () => { it('should disable task list items and off document event', () => { - jest.spyOn(taskList, 'disableTaskListItems').mockImplementation(() => {}); jest.spyOn($.prototype, 'off').mockImplementation(() => {}); taskList.disable(); - expect(taskList.disableTaskListItems).toHaveBeenCalled(); + expect(document.querySelectorAll('.task-list-item input:disabled').length).toEqual(2); + expect($(document).off).toHaveBeenCalledWith( 'tasklist:changed', taskList.taskListContainerSelector, diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js index 2c7bcaa98b0..dd4c8198b72 100644 --- a/spec/frontend/tracking_spec.js +++ b/spec/frontend/tracking_spec.js @@ -155,6 +155,32 @@ describe('Tracking', () => { }); }); + describe('.enableFormTracking', () => { + it('tells snowplow to enable form tracking', () => { + const config = { forms: { whitelist: [''] }, fields: { whitelist: [''] } }; + Tracking.enableFormTracking(config, ['_passed_context_']); + + expect(snowplowSpy).toHaveBeenCalledWith('enableFormTracking', config, [ + { data: { source: 'gitlab-javascript' }, schema: undefined }, + '_passed_context_', + ]); + }); + + it('throws an error if no whitelist rules are provided', () => { + const expectedError = new Error( + 'Unable to enable form event tracking without whitelist rules.', + ); + + expect(() => Tracking.enableFormTracking()).toThrow(expectedError); + expect(() => Tracking.enableFormTracking({ fields: { whitelist: [] } })).toThrow( + expectedError, + ); + expect(() => Tracking.enableFormTracking({ fields: { whitelist: [1] } })).not.toThrow( + expectedError, + ); + }); + }); + describe('.flushPendingEvents', () => { it('flushes any pending events', () => { Tracking.initialized = false; diff --git a/spec/frontend/users_select/index_spec.js b/spec/frontend/users_select/index_spec.js index 5b07087b76c..99caaf61c54 100644 --- a/spec/frontend/users_select/index_spec.js +++ b/spec/frontend/users_select/index_spec.js @@ -1,145 +1,33 @@ -import { waitFor } from '@testing-library/dom'; -import MockAdapter from 'axios-mock-adapter'; -import { cloneDeep } from 'lodash'; -import { getJSONFixture } from 'helpers/fixtures'; -import axios from '~/lib/utils/axios_utils'; -import UsersSelect from '~/users_select'; - -// TODO: generate this from a fixture that guarantees the same output in CE and EE [(see issue)][1]. -// Hardcoding this HTML temproarily fixes a FOSS ~"master::broken" [(see issue)][2]. -// [1]: https://gitlab.com/gitlab-org/gitlab/-/issues/327809 -// [2]: https://gitlab.com/gitlab-org/gitlab/-/issues/327805 -const getUserSearchHTML = () => ` -<div class="js-sidebar-assignee-data selectbox hide-collapsed"> -<input type="hidden" name="merge_request[assignee_ids][]" value="0"> -<div class="dropdown js-sidebar-assignee-dropdown"> -<button class="dropdown-menu-toggle js-user-search js-author-search js-multiselect js-save-user-data js-invite-members-track" type="button" data-first-user="frontend-fixtures" data-current-user="true" data-iid="1" data-issuable-type="merge_request" data-project-id="1" data-author-id="1" data-field-name="merge_request[assignee_ids][]" data-issue-update="http://test.host/frontend-fixtures/merge-requests-project/-/merge_requests/1.json" data-ability-name="merge_request" data-null-user="true" data-display="static" data-multi-select="true" data-dropdown-title="Select assignee(s)" data-dropdown-header="Assignee(s)" data-track-event="show_invite_members" data-toggle="dropdown"><span class="dropdown-toggle-text ">Select assignee(s)</span><svg class="s16 dropdown-menu-toggle-icon gl-top-3" data-testid="chevron-down-icon"><use xlink:href="http://test.host/assets/icons-16c30bec0d8a57f0a33e6f6215c6aff7a6ec5e4a7e6b7de733a6b648541a336a.svg#chevron-down"></use></svg></button><div class="dropdown-menu dropdown-select dropdown-menu-user dropdown-menu-selectable dropdown-menu-author dropdown-extended-height"> -<div class="dropdown-title gl-display-flex"> -<span class="gl-ml-auto">Assign to</span><button class="dropdown-title-button dropdown-menu-close gl-ml-auto" aria-label="Close" type="button"><svg class="s16 dropdown-menu-close-icon" data-testid="close-icon"><use xlink:href="http://test.host/assets/icons-16c30bec0d8a57f0a33e6f6215c6aff7a6ec5e4a7e6b7de733a6b648541a336a.svg#close"></use></svg></button> -</div> -<div class="dropdown-input"> -<input type="search" id="" data-qa-selector="dropdown_input_field" class="dropdown-input-field" placeholder="Search users" autocomplete="off"><svg class="s16 dropdown-input-search" data-testid="search-icon"><use xlink:href="http://test.host/assets/icons-16c30bec0d8a57f0a33e6f6215c6aff7a6ec5e4a7e6b7de733a6b648541a336a.svg#search"></use></svg><svg class="s16 dropdown-input-clear js-dropdown-input-clear" data-testid="close-icon"><use xlink:href="http://test.host/assets/icons-16c30bec0d8a57f0a33e6f6215c6aff7a6ec5e4a7e6b7de733a6b648541a336a.svg#close"></use></svg> -</div> -<div data-qa-selector="dropdown_list_content" class="dropdown-content "></div> -<div class="dropdown-footer"> -<ul class="dropdown-footer-list"> -<li> -<div class="js-invite-members-trigger" data-display-text="Invite Members" data-event="click_invite_members" data-label="edit_assignee" data-trigger-element="anchor"></div> -</li> -</ul> -</div> -<div class="dropdown-loading"><div class="gl-spinner-container"><span class="gl-spinner gl-spinner-orange gl-spinner-md gl-mt-7" aria-label="Loading"></span></div></div> -</div> -</div> -</div> -`; - -const USER_SEARCH_HTML = getUserSearchHTML(); -const AUTOCOMPLETE_USERS = getJSONFixture('autocomplete/users.json'); +import { + createInputsModelExpectation, + createUnassignedExpectation, + createAssignedExpectation, + createTestContext, + findDropdownItemsModel, + findDropdownItem, + findAssigneesInputsModel, + getUsersFixtureAt, + setAssignees, + toggleDropdown, + waitForDropdownItems, +} from './test_helper'; describe('~/users_select/index', () => { - let subject; - let mock; - - const createSubject = (currentUser = null) => { - if (subject) { - throw new Error('test subject is already created'); - } - - subject = new UsersSelect(currentUser); - }; - - // finders ------------------------------------------------------------------- - const findAssigneesInputs = () => - document.querySelectorAll('input[name="merge_request[assignee_ids][]'); - const findAssigneesInputsModel = () => - Array.from(findAssigneesInputs()).map((input) => ({ - value: input.value, - dataset: { ...input.dataset }, - })); - const findUserSearchButton = () => document.querySelector('.js-user-search'); - const findDropdownItem = ({ id }) => document.querySelector(`li[data-user-id="${id}"] a`); - const findDropdownItemsModel = () => - Array.from(document.querySelectorAll('.dropdown-content li')).map((el) => { - if (el.classList.contains('divider')) { - return { - type: 'divider', - }; - } else if (el.classList.contains('dropdown-header')) { - return { - type: 'dropdown-header', - text: el.textContent, - }; - } - - return { - type: 'user', - userId: el.dataset.userId, - }; - }); - - // arrange/act helpers ------------------------------------------------------- - const setAssignees = (...users) => { - findAssigneesInputs().forEach((x) => x.remove()); - - const container = document.querySelector('.js-sidebar-assignee-data'); - - container.prepend( - ...users.map((user) => { - const input = document.createElement('input'); - input.name = 'merge_request[assignee_ids][]'; - input.value = user.id.toString(); - input.setAttribute('data-avatar-url', user.avatar_url); - input.setAttribute('data-name', user.name); - input.setAttribute('data-username', user.username); - input.setAttribute('data-can-merge', user.can_merge); - return input; - }), - ); - }; - const toggleDropdown = () => findUserSearchButton().click(); - const waitForDropdownItems = () => - waitFor(() => expect(findDropdownItem(AUTOCOMPLETE_USERS[0])).not.toBeNull()); - - // assertion helpers --------------------------------------------------------- - const createUnassignedExpectation = () => { - return [ - { type: 'user', userId: '0' }, - { type: 'divider' }, - ...AUTOCOMPLETE_USERS.map((x) => ({ type: 'user', userId: x.id.toString() })), - ]; - }; - const createAssignedExpectation = (...selectedUsers) => { - const selectedIds = new Set(selectedUsers.map((x) => x.id)); - const unselectedUsers = AUTOCOMPLETE_USERS.filter((x) => !selectedIds.has(x.id)); - - return [ - { type: 'user', userId: '0' }, - { type: 'divider' }, - { type: 'dropdown-header', text: 'Assignee(s)' }, - ...selectedUsers.map((x) => ({ type: 'user', userId: x.id.toString() })), - { type: 'divider' }, - ...unselectedUsers.map((x) => ({ type: 'user', userId: x.id.toString() })), - ]; - }; + const context = createTestContext({ + fixturePath: 'merge_requests/merge_request_with_single_assignee_feature.html', + }); beforeEach(() => { - const rootEl = document.createElement('div'); - rootEl.innerHTML = USER_SEARCH_HTML; - document.body.appendChild(rootEl); - - mock = new MockAdapter(axios); - mock.onGet('/-/autocomplete/users.json').reply(200, cloneDeep(AUTOCOMPLETE_USERS)); + context.setup(); }); afterEach(() => { - document.body.innerHTML = ''; - subject = null; + context.teardown(); }); describe('when opened', () => { beforeEach(async () => { - createSubject(); + context.createSubject(); toggleDropdown(); await waitForDropdownItems(); @@ -150,8 +38,12 @@ describe('~/users_select/index', () => { }); describe('when users are selected', () => { - const selectedUsers = [AUTOCOMPLETE_USERS[2], AUTOCOMPLETE_USERS[4]]; - const expectation = createAssignedExpectation(...selectedUsers); + const selectedUsers = [getUsersFixtureAt(2), getUsersFixtureAt(4)]; + const lastSelected = selectedUsers[selectedUsers.length - 1]; + const expectation = createAssignedExpectation({ + header: 'Assignee', + assigned: [lastSelected], + }); beforeEach(() => { selectedUsers.forEach((user) => { @@ -163,42 +55,22 @@ describe('~/users_select/index', () => { expect(findDropdownItemsModel()).toEqual(expectation); }); - it('shows assignee even after close and open', () => { - toggleDropdown(); - toggleDropdown(); - - expect(findDropdownItemsModel()).toEqual(expectation); - }); - it('updates field', () => { - expect(findAssigneesInputsModel()).toEqual( - selectedUsers.map((user) => ({ - value: user.id.toString(), - dataset: { - approved: user.approved.toString(), - avatar_url: user.avatar_url, - can_merge: user.can_merge.toString(), - can_update_merge_request: user.can_update_merge_request.toString(), - id: user.id.toString(), - name: user.name, - show_status: user.show_status.toString(), - state: user.state, - username: user.username, - web_url: user.web_url, - }, - })), - ); + expect(findAssigneesInputsModel()).toEqual(createInputsModelExpectation([lastSelected])); }); }); }); describe('with preselected user and opened', () => { - const expectation = createAssignedExpectation(AUTOCOMPLETE_USERS[0]); + const expectation = createAssignedExpectation({ + header: 'Assignee', + assigned: [getUsersFixtureAt(0)], + }); beforeEach(async () => { - setAssignees(AUTOCOMPLETE_USERS[0]); + setAssignees(getUsersFixtureAt(0)); - createSubject(); + context.createSubject(); toggleDropdown(); await waitForDropdownItems(); diff --git a/spec/frontend/users_select/test_helper.js b/spec/frontend/users_select/test_helper.js new file mode 100644 index 00000000000..c5adbe9bb09 --- /dev/null +++ b/spec/frontend/users_select/test_helper.js @@ -0,0 +1,152 @@ +import MockAdapter from 'axios-mock-adapter'; +import { memoize, cloneDeep } from 'lodash'; +import { getFixture, getJSONFixture } from 'helpers/fixtures'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import UsersSelect from '~/users_select'; + +// fixtures ------------------------------------------------------------------- +const getUserSearchHTML = memoize((fixturePath) => { + const html = getFixture(fixturePath); + const parser = new DOMParser(); + + const el = parser.parseFromString(html, 'text/html').querySelector('.assignee'); + + return el.outerHTML; +}); + +const getUsersFixture = memoize(() => getJSONFixture('autocomplete/users.json')); + +export const getUsersFixtureAt = (idx) => getUsersFixture()[idx]; + +// test context --------------------------------------------------------------- +export const createTestContext = ({ fixturePath }) => { + let mock = null; + let subject = null; + + const setup = () => { + const rootEl = document.createElement('div'); + rootEl.innerHTML = getUserSearchHTML(fixturePath); + document.body.appendChild(rootEl); + + mock = new MockAdapter(axios); + mock.onGet('/-/autocomplete/users.json').reply(200, cloneDeep(getUsersFixture())); + }; + + const teardown = () => { + mock.restore(); + document.body.innerHTML = ''; + subject = null; + }; + + const createSubject = () => { + if (subject) { + throw new Error('test subject is already created'); + } + + subject = new UsersSelect(null); + }; + + return { + setup, + teardown, + createSubject, + }; +}; + +// finders ------------------------------------------------------------------- +export const findAssigneesInputs = () => + document.querySelectorAll('input[name="merge_request[assignee_ids][]'); +export const findAssigneesInputsModel = () => + Array.from(findAssigneesInputs()).map((input) => ({ + value: input.value, + dataset: { ...input.dataset }, + })); +export const findUserSearchButton = () => document.querySelector('.js-user-search'); +export const findDropdownItem = ({ id }) => document.querySelector(`li[data-user-id="${id}"] a`); +export const findDropdownItemsModel = () => + Array.from(document.querySelectorAll('.dropdown-content li')).map((el) => { + if (el.classList.contains('divider')) { + return { + type: 'divider', + }; + } else if (el.classList.contains('dropdown-header')) { + return { + type: 'dropdown-header', + text: el.textContent, + }; + } + + return { + type: 'user', + userId: el.dataset.userId, + }; + }); + +// arrange/act helpers ------------------------------------------------------- +export const setAssignees = (...users) => { + findAssigneesInputs().forEach((x) => x.remove()); + + const container = document.querySelector('.js-sidebar-assignee-data'); + + container.prepend( + ...users.map((user) => { + const input = document.createElement('input'); + input.name = 'merge_request[assignee_ids][]'; + input.value = user.id.toString(); + input.setAttribute('data-avatar-url', user.avatar_url); + input.setAttribute('data-name', user.name); + input.setAttribute('data-username', user.username); + input.setAttribute('data-can-merge', user.can_merge); + return input; + }), + ); +}; +export const toggleDropdown = () => findUserSearchButton().click(); +export const waitForDropdownItems = async () => { + await axios.waitForAll(); + await waitForPromises(); +}; + +// assertion helpers --------------------------------------------------------- +export const createUnassignedExpectation = () => { + return [ + { type: 'user', userId: '0' }, + { type: 'divider' }, + ...getUsersFixture().map((x) => ({ + type: 'user', + userId: x.id.toString(), + })), + ]; +}; + +export const createAssignedExpectation = ({ header, assigned }) => { + const assignedIds = new Set(assigned.map((x) => x.id)); + const unassignedIds = getUsersFixture().filter((x) => !assignedIds.has(x.id)); + + return [ + { type: 'user', userId: '0' }, + { type: 'divider' }, + { type: 'dropdown-header', text: header }, + ...assigned.map((x) => ({ type: 'user', userId: x.id.toString() })), + { type: 'divider' }, + ...unassignedIds.map((x) => ({ type: 'user', userId: x.id.toString() })), + ]; +}; + +export const createInputsModelExpectation = (users) => + users.map((user) => ({ + value: user.id.toString(), + dataset: { + approved: user.approved.toString(), + avatar_url: user.avatar_url, + can_merge: user.can_merge.toString(), + can_update_merge_request: user.can_update_merge_request.toString(), + id: user.id.toString(), + name: user.name, + show_status: user.show_status.toString(), + state: user.state, + username: user.username, + web_url: user.web_url, + }, + })); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js index eadf07e54fb..115f21d8b35 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js @@ -34,7 +34,7 @@ describe('MRWidgetHeader', () => { divergedCommitsCount: 12, sourceBranch: 'mr-widget-refactor', sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', - targetBranch: 'master', + targetBranch: 'main', statusPath: 'abc', }, }); @@ -48,7 +48,7 @@ describe('MRWidgetHeader', () => { divergedCommitsCount: 0, sourceBranch: 'mr-widget-refactor', sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', - targetBranch: 'master', + targetBranch: 'main', statusPath: 'abc', }, }); @@ -64,14 +64,14 @@ describe('MRWidgetHeader', () => { divergedCommitsCount: 1, sourceBranch: 'mr-widget-refactor', sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', - targetBranch: 'master', - targetBranchPath: '/foo/bar/master', + targetBranch: 'main', + targetBranchPath: '/foo/bar/main', statusPath: 'abc', }, }); expect(wrapper.vm.commitsBehindText).toBe( - 'The source branch is <a href="/foo/bar/master">1 commit behind</a> the target branch', + 'The source branch is <a href="/foo/bar/main">1 commit behind</a> the target branch', ); }); @@ -81,14 +81,14 @@ describe('MRWidgetHeader', () => { divergedCommitsCount: 2, sourceBranch: 'mr-widget-refactor', sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', - targetBranch: 'master', - targetBranchPath: '/foo/bar/master', + targetBranch: 'main', + targetBranchPath: '/foo/bar/main', statusPath: 'abc', }, }); expect(wrapper.vm.commitsBehindText).toBe( - 'The source branch is <a href="/foo/bar/master">2 commits behind</a> the target branch', + 'The source branch is <a href="/foo/bar/main">2 commits behind</a> the target branch', ); }); }); @@ -105,7 +105,7 @@ describe('MRWidgetHeader', () => { sourceBranchRemoved: false, targetBranchPath: 'foo/bar/commits-path', targetBranchTreePath: 'foo/bar/tree/path', - targetBranch: 'master', + targetBranch: 'main', isOpen: true, emailPatchesPath: '/mr/email-patches', plainDiffPath: '/mr/plainDiffPath', @@ -125,7 +125,7 @@ describe('MRWidgetHeader', () => { }); it('renders target branch', () => { - expect(wrapper.find('.js-target-branch').text().trim()).toBe('master'); + expect(wrapper.find('.js-target-branch').text().trim()).toBe('main'); }); }); @@ -138,7 +138,7 @@ describe('MRWidgetHeader', () => { sourceBranchRemoved: false, targetBranchPath: 'foo/bar/commits-path', targetBranchTreePath: 'foo/bar/tree/path', - targetBranch: 'master', + targetBranch: 'main', isOpen: true, canPushToSourceBranch: true, emailPatchesPath: '/mr/email-patches', @@ -227,7 +227,7 @@ describe('MRWidgetHeader', () => { sourceBranchRemoved: false, targetBranchPath: 'foo/bar/commits-path', targetBranchTreePath: 'foo/bar/tree/path', - targetBranch: 'master', + targetBranch: 'main', isOpen: false, emailPatchesPath: '/mr/email-patches', plainDiffPath: '/mr/plainDiffPath', @@ -257,7 +257,7 @@ describe('MRWidgetHeader', () => { sourceBranchRemoved: false, targetBranchPath: 'foo/bar/commits-path', targetBranchTreePath: 'foo/bar/tree/path', - targetBranch: 'master', + targetBranch: 'main', isOpen: true, emailPatchesPath: '/mr/email-patches', plainDiffPath: '/mr/plainDiffPath', @@ -281,7 +281,7 @@ describe('MRWidgetHeader', () => { sourceBranchRemoved: false, targetBranchPath: 'foo/bar/commits-path', targetBranchTreePath: 'foo/bar/tree/path', - targetBranch: 'master', + targetBranch: 'main', isOpen: true, emailPatchesPath: '/mr/email-patches', plainDiffPath: '/mr/plainDiffPath', 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 e5862df5dda..ac20487c55f 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,7 +16,6 @@ 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" @@ -40,13 +39,14 @@ exports[`MRWidgetAutoMergeEnabled when graphql is disabled template should have <a class="btn btn-sm btn-default js-cancel-auto-merge" + data-qa-selector="cancel_auto_merge_button" data-testid="cancelAutomaticMergeButton" href="#" role="button" > <!----> - Cancel automatic merge + Cancel </a> </h4> @@ -108,7 +108,6 @@ 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" @@ -132,13 +131,14 @@ exports[`MRWidgetAutoMergeEnabled when graphql is enabled template should have c <a class="btn btn-sm btn-default js-cancel-auto-merge" + data-qa-selector="cancel_auto_merge_button" data-testid="cancelAutomaticMergeButton" href="#" role="button" > <!----> - Cancel automatic merge + Cancel </a> </h4> diff --git a/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_ready_to_merge_spec.js.snap b/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_ready_to_merge_spec.js.snap new file mode 100644 index 00000000000..cef1dff3335 --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_ready_to_merge_spec.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReadyToMerge with a mismatched SHA warns the user to refresh to review 1`] = `"<gl-sprintf-stub message=\\"New changes were added. %{linkStart}Reload the page to review them%{linkEnd}\\"></gl-sprintf-stub>"`; diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js index 1af96717b56..0110a76e722 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js @@ -192,15 +192,13 @@ describe('MRWidgetAutoMergeEnabled', () => { }); describe('cancelButtonText', () => { - it('should return "Cancel automatic merge" if MWPS is selected', () => { + it('should return "Cancel" if MWPS is selected', () => { factory({ ...defaultMrProps(), autoMergeStrategy: MWPS_MERGE_STRATEGY, }); - expect(wrapper.findByTestId('cancelAutomaticMergeButton').text()).toBe( - 'Cancel automatic merge', - ); + expect(wrapper.findByTestId('cancelAutomaticMergeButton').text()).toBe('Cancel'); }); }); }); @@ -329,7 +327,7 @@ describe('MRWidgetAutoMergeEnabled', () => { expect(statusText).toBe('to be merged automatically when the pipeline succeeds'); }); - it('should render the cancel button as "Cancel automatic merge" if MWPS is selected', () => { + it('should render the cancel button as "Cancel" if MWPS is selected', () => { factory({ ...defaultMrProps(), autoMergeStrategy: MWPS_MERGE_STRATEGY, @@ -337,7 +335,7 @@ describe('MRWidgetAutoMergeEnabled', () => { const cancelButtonText = trimText(wrapper.find('.js-cancel-auto-merge').text()); - expect(cancelButtonText).toBe('Cancel automatic merge'); + expect(cancelButtonText).toBe('Cancel'); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js index e4123b2ca83..b31a75f30d3 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js @@ -8,7 +8,7 @@ describe('Commits header component', () => { wrapper = shallowMount(CommitsHeader, { propsData: { isSquashEnabled: false, - targetBranch: 'master', + targetBranch: 'main', commitsCount: 5, isFastForwardEnabled: false, ...props, @@ -94,7 +94,7 @@ describe('Commits header component', () => { it('has correct target branch displayed', () => { createComponent(); - expect(findTargetBranchMessage().text()).toBe('master'); + expect(findTargetBranchMessage().text()).toBe('main'); }); it('does has merge commit part of the message', () => { diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_merging_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_merging_spec.js index b16fb5171e7..b6c16958993 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_merging_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_merging_spec.js @@ -4,6 +4,7 @@ import MrWidgetMerging from '~/vue_merge_request_widget/components/states/mr_wid describe('MRWidgetMerging', () => { let wrapper; + const GlEmoji = { template: '<img />' }; beforeEach(() => { wrapper = shallowMount(MrWidgetMerging, { propsData: { @@ -12,6 +13,9 @@ describe('MRWidgetMerging', () => { targetBranch: 'branch', }, }, + stubs: { + GlEmoji, + }, }); }); @@ -27,7 +31,7 @@ describe('MRWidgetMerging', () => { .trim() .replace(/\s\s+/g, ' ') .replace(/[\r\n]+/g, ' '), - ).toContain('This merge request is in the process of being merged'); + ).toContain('Merging!'); }); it('renders branch information', () => { diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js index 983e4a35078..85a42946325 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -1,13 +1,13 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; -import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import simplePoll from '~/lib/utils/simple_poll'; import CommitEdit from '~/vue_merge_request_widget/components/states/commit_edit.vue'; import CommitMessageDropdown from '~/vue_merge_request_widget/components/states/commit_message_dropdown.vue'; import CommitsHeader from '~/vue_merge_request_widget/components/states/commits_header.vue'; import ReadyToMerge from '~/vue_merge_request_widget/components/states/ready_to_merge.vue'; import SquashBeforeMerge from '~/vue_merge_request_widget/components/states/squash_before_merge.vue'; -import { MWPS_MERGE_STRATEGY, MTWPS_MERGE_STRATEGY } from '~/vue_merge_request_widget/constants'; +import { MWPS_MERGE_STRATEGY } from '~/vue_merge_request_widget/constants'; import eventHub from '~/vue_merge_request_widget/event_hub'; jest.mock('~/lib/utils/simple_poll', () => @@ -42,7 +42,7 @@ const createTestMr = (customConfig) => { commitMessageWithDescription, shouldRemoveSourceBranch: true, canRemoveSourceBranch: false, - targetBranch: 'master', + targetBranch: 'main', preferredAutoMergeStrategy: MWPS_MERGE_STRATEGY, availableAutoMergeStrategies: [MWPS_MERGE_STRATEGY], mergeImmediatelyDocsPath: 'path/to/merge/immediately/docs', @@ -58,11 +58,9 @@ const createTestService = () => ({ poll: jest.fn().mockResolvedValue(), }); +let wrapper; const createComponent = (customConfig = {}) => { - const Component = Vue.extend(ReadyToMerge); - - return new Component({ - el: document.createElement('div'), + wrapper = shallowMount(ReadyToMerge, { propsData: { mr: createTestMr(customConfig), service: createTestService(), @@ -71,277 +69,207 @@ const createComponent = (customConfig = {}) => { }; describe('ReadyToMerge', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - afterEach(() => { - vm.$destroy(); - }); - - describe('props', () => { - it('should have props', () => { - const { mr, service } = ReadyToMerge.props; - - expect(mr.type instanceof Object).toBeTruthy(); - expect(mr.required).toBeTruthy(); - - expect(service.type instanceof Object).toBeTruthy(); - expect(service.required).toBeTruthy(); - }); - }); - - describe('data', () => { - it('should have default data', () => { - expect(vm.mergeWhenBuildSucceeds).toBeFalsy(); - expect(vm.useCommitMessageWithDescription).toBeFalsy(); - expect(vm.showCommitMessageEditor).toBeFalsy(); - expect(vm.isMakingRequest).toBeFalsy(); - expect(vm.isMergingImmediately).toBeFalsy(); - expect(vm.commitMessage).toBe(vm.mr.commitMessage); - }); + wrapper.destroy(); }); describe('computed', () => { describe('isAutoMergeAvailable', () => { it('should return true when at least one merge strategy is available', () => { - vm.mr.availableAutoMergeStrategies = [MWPS_MERGE_STRATEGY]; + createComponent(); - expect(vm.isAutoMergeAvailable).toBe(true); + expect(wrapper.vm.isAutoMergeAvailable).toBe(true); }); it('should return false when no merge strategies are available', () => { - vm.mr.availableAutoMergeStrategies = []; + createComponent({ mr: { availableAutoMergeStrategies: [] } }); - expect(vm.isAutoMergeAvailable).toBe(false); + expect(wrapper.vm.isAutoMergeAvailable).toBe(false); }); }); describe('status', () => { it('defaults to success', () => { - Vue.set(vm.mr, 'pipeline', true); - Vue.set(vm.mr, 'availableAutoMergeStrategies', []); + createComponent({ mr: { pipeline: true, availableAutoMergeStrategies: [] } }); - expect(vm.status).toEqual('success'); + expect(wrapper.vm.status).toEqual('success'); }); it('returns failed when MR has CI but also has an unknown status', () => { - Vue.set(vm.mr, 'hasCI', true); + createComponent({ mr: { hasCI: true } }); - expect(vm.status).toEqual('failed'); + expect(wrapper.vm.status).toEqual('failed'); }); it('returns default when MR has no pipeline', () => { - Vue.set(vm.mr, 'availableAutoMergeStrategies', []); + createComponent({ mr: { availableAutoMergeStrategies: [] } }); - expect(vm.status).toEqual('success'); + expect(wrapper.vm.status).toEqual('success'); }); it('returns pending when pipeline is active', () => { - Vue.set(vm.mr, 'pipeline', {}); - Vue.set(vm.mr, 'isPipelineActive', true); + createComponent({ mr: { pipeline: {}, isPipelineActive: true } }); - expect(vm.status).toEqual('pending'); + expect(wrapper.vm.status).toEqual('pending'); }); it('returns failed when pipeline is failed', () => { - Vue.set(vm.mr, 'pipeline', {}); - Vue.set(vm.mr, 'isPipelineFailed', true); - Vue.set(vm.mr, 'availableAutoMergeStrategies', []); + createComponent({ + mr: { pipeline: {}, isPipelineFailed: true, availableAutoMergeStrategies: [] }, + }); - expect(vm.status).toEqual('failed'); + expect(wrapper.vm.status).toEqual('failed'); }); }); describe('mergeButtonVariant', () => { it('defaults to success class', () => { - Vue.set(vm.mr, 'availableAutoMergeStrategies', []); + createComponent({ + mr: { availableAutoMergeStrategies: [] }, + }); - expect(vm.mergeButtonVariant).toEqual('success'); + expect(wrapper.vm.mergeButtonVariant).toEqual('success'); }); it('returns success class for success status', () => { - Vue.set(vm.mr, 'availableAutoMergeStrategies', []); - Vue.set(vm.mr, 'pipeline', true); + createComponent({ + mr: { availableAutoMergeStrategies: [], pipeline: true }, + }); - expect(vm.mergeButtonVariant).toEqual('success'); + expect(wrapper.vm.mergeButtonVariant).toEqual('success'); }); it('returns info class for pending status', () => { - Vue.set(vm.mr, 'availableAutoMergeStrategies', [MTWPS_MERGE_STRATEGY]); + createComponent(); - expect(vm.mergeButtonVariant).toEqual('info'); + expect(wrapper.vm.mergeButtonVariant).toEqual('info'); }); it('returns danger class for failed status', () => { - vm.mr.hasCI = true; + createComponent({ mr: { hasCI: true } }); - expect(vm.mergeButtonVariant).toEqual('danger'); + expect(wrapper.vm.mergeButtonVariant).toEqual('danger'); }); }); describe('status icon', () => { it('defaults to tick icon', () => { - expect(vm.iconClass).toEqual('success'); + createComponent(); + + expect(wrapper.vm.iconClass).toEqual('success'); }); it('shows tick for success status', () => { - vm.mr.pipeline = true; + createComponent({ mr: { pipeline: true } }); - expect(vm.iconClass).toEqual('success'); + expect(wrapper.vm.iconClass).toEqual('success'); }); it('shows tick for pending status', () => { - vm.mr.pipeline = {}; - vm.mr.isPipelineActive = true; + createComponent({ mr: { pipeline: {}, isPipelineActive: true } }); - expect(vm.iconClass).toEqual('success'); - }); - - it('shows warning icon for failed status', () => { - vm.mr.hasCI = true; - - expect(vm.iconClass).toEqual('warning'); - }); - - it('shows warning icon for merge not allowed', () => { - vm.mr.hasCI = true; - - expect(vm.iconClass).toEqual('warning'); + expect(wrapper.vm.iconClass).toEqual('success'); }); }); describe('mergeButtonText', () => { it('should return "Merge" when no auto merge strategies are available', () => { - Vue.set(vm.mr, 'availableAutoMergeStrategies', []); + createComponent({ mr: { availableAutoMergeStrategies: [] } }); - expect(vm.mergeButtonText).toEqual('Merge'); + expect(wrapper.vm.mergeButtonText).toEqual('Merge'); }); - it('should return "Merge in progress"', () => { - Vue.set(vm, 'isMergingImmediately', true); + it('should return "Merge in progress"', async () => { + createComponent(); + + wrapper.setData({ isMergingImmediately: true }); + + await Vue.nextTick(); - expect(vm.mergeButtonText).toEqual('Merge in progress'); + expect(wrapper.vm.mergeButtonText).toEqual('Merge in progress'); }); it('should return "Merge when pipeline succeeds" when the MWPS auto merge strategy is available', () => { - Vue.set(vm, 'isMergingImmediately', false); - Vue.set(vm.mr, 'preferredAutoMergeStrategy', MWPS_MERGE_STRATEGY); + createComponent({ + mr: { isMergingImmediately: false, preferredAutoMergeStrategy: MWPS_MERGE_STRATEGY }, + }); - expect(vm.mergeButtonText).toEqual('Merge when pipeline succeeds'); + expect(wrapper.vm.mergeButtonText).toEqual('Merge when pipeline succeeds'); }); }); describe('autoMergeText', () => { it('should return Merge when pipeline succeeds', () => { - Vue.set(vm.mr, 'preferredAutoMergeStrategy', MWPS_MERGE_STRATEGY); + createComponent({ mr: { preferredAutoMergeStrategy: MWPS_MERGE_STRATEGY } }); - expect(vm.autoMergeText).toEqual('Merge when pipeline succeeds'); + expect(wrapper.vm.autoMergeText).toEqual('Merge when pipeline succeeds'); }); }); describe('shouldShowMergeImmediatelyDropdown', () => { it('should return false if no pipeline is active', () => { - Vue.set(vm.mr, 'isPipelineActive', false); - Vue.set(vm.mr, 'onlyAllowMergeIfPipelineSucceeds', false); + createComponent({ + mr: { isPipelineActive: false, onlyAllowMergeIfPipelineSucceeds: false }, + }); - expect(vm.shouldShowMergeImmediatelyDropdown).toBe(false); + expect(wrapper.vm.shouldShowMergeImmediatelyDropdown).toBe(false); }); it('should return false if "Pipelines must succeed" is enabled for the current project', () => { - Vue.set(vm.mr, 'isPipelineActive', true); - Vue.set(vm.mr, 'onlyAllowMergeIfPipelineSucceeds', true); + createComponent({ mr: { isPipelineActive: true, onlyAllowMergeIfPipelineSucceeds: true } }); - expect(vm.shouldShowMergeImmediatelyDropdown).toBe(false); - }); - - it('should return true if the MR\'s pipeline is active and "Pipelines must succeed" is not enabled for the current project', () => { - Vue.set(vm.mr, 'isPipelineActive', true); - Vue.set(vm.mr, 'onlyAllowMergeIfPipelineSucceeds', false); - - expect(vm.shouldShowMergeImmediatelyDropdown).toBe(true); + expect(wrapper.vm.shouldShowMergeImmediatelyDropdown).toBe(false); }); }); describe('isMergeButtonDisabled', () => { it('should return false with initial data', () => { - Vue.set(vm.mr, 'isMergeAllowed', true); + createComponent({ mr: { isMergeAllowed: true } }); - expect(vm.isMergeButtonDisabled).toBe(false); + expect(wrapper.vm.isMergeButtonDisabled).toBe(false); }); it('should return true when there is no commit message', () => { - Vue.set(vm.mr, 'isMergeAllowed', true); - Vue.set(vm, 'commitMessage', ''); + createComponent({ mr: { isMergeAllowed: true, commitMessage: '' } }); - expect(vm.isMergeButtonDisabled).toBe(true); + expect(wrapper.vm.isMergeButtonDisabled).toBe(true); }); it('should return true if merge is not allowed', () => { - Vue.set(vm.mr, 'isMergeAllowed', false); - Vue.set(vm.mr, 'availableAutoMergeStrategies', []); - Vue.set(vm.mr, 'onlyAllowMergeIfPipelineSucceeds', true); + createComponent({ + mr: { + isMergeAllowed: false, + availableAutoMergeStrategies: [], + onlyAllowMergeIfPipelineSucceeds: true, + }, + }); - expect(vm.isMergeButtonDisabled).toBe(true); + expect(wrapper.vm.isMergeButtonDisabled).toBe(true); }); - it('should return true when the vm instance is making request', () => { - Vue.set(vm.mr, 'isMergeAllowed', true); - Vue.set(vm, 'isMakingRequest', true); + it('should return true when the vm instance is making request', async () => { + createComponent({ mr: { isMergeAllowed: true } }); - expect(vm.isMergeButtonDisabled).toBe(true); - }); - }); + wrapper.setData({ isMakingRequest: true }); - describe('isMergeImmediatelyDangerous', () => { - it('should always return false in CE', () => { - expect(vm.isMergeImmediatelyDangerous).toBe(false); + await Vue.nextTick(); + + expect(wrapper.vm.isMergeButtonDisabled).toBe(true); }); }); }); describe('methods', () => { - describe('shouldShowMergeControls', () => { - it('should return false when an external pipeline is running and required to succeed', () => { - Vue.set(vm.mr, 'isMergeAllowed', false); - Vue.set(vm.mr, 'availableAutoMergeStrategies', []); - - expect(vm.shouldShowMergeControls).toBe(false); - }); - - it('should return true when the build succeeded or build not required to succeed', () => { - Vue.set(vm.mr, 'isMergeAllowed', true); - Vue.set(vm.mr, 'availableAutoMergeStrategies', []); - - expect(vm.shouldShowMergeControls).toBe(true); - }); - - it('should return true when showing the MWPS button and a pipeline is running that needs to be successful', () => { - Vue.set(vm.mr, 'isMergeAllowed', false); - Vue.set(vm.mr, 'availableAutoMergeStrategies', [MWPS_MERGE_STRATEGY]); - - expect(vm.shouldShowMergeControls).toBe(true); - }); - - it('should return true when showing the MWPS button but not required for the pipeline to succeed', () => { - Vue.set(vm.mr, 'isMergeAllowed', true); - Vue.set(vm.mr, 'availableAutoMergeStrategies', [MWPS_MERGE_STRATEGY]); - - expect(vm.shouldShowMergeControls).toBe(true); - }); - }); - describe('updateMergeCommitMessage', () => { it('should revert flag and change commitMessage', () => { - expect(vm.commitMessage).toEqual(commitMessage); - vm.updateMergeCommitMessage(true); + createComponent(); + + wrapper.vm.updateMergeCommitMessage(true); - expect(vm.commitMessage).toEqual(commitMessageWithDescription); - vm.updateMergeCommitMessage(false); + expect(wrapper.vm.commitMessage).toEqual(commitMessageWithDescription); + wrapper.vm.updateMergeCommitMessage(false); - expect(vm.commitMessage).toEqual(commitMessage); + expect(wrapper.vm.commitMessage).toEqual(commitMessage); }); }); @@ -356,23 +284,26 @@ describe('ReadyToMerge', () => { }); it('should handle merge when pipeline succeeds', (done) => { + createComponent(); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); jest - .spyOn(vm.service, 'merge') + .spyOn(wrapper.vm.service, 'merge') .mockReturnValue(returnPromise('merge_when_pipeline_succeeds')); - vm.removeSourceBranch = false; - vm.handleMergeButtonClick(true); + wrapper.setData({ removeSourceBranch: false }); + + wrapper.vm.handleMergeButtonClick(true); setImmediate(() => { - expect(vm.isMakingRequest).toBeTruthy(); + expect(wrapper.vm.isMakingRequest).toBeTruthy(); expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); - const params = vm.service.merge.mock.calls[0][0]; + const params = wrapper.vm.service.merge.mock.calls[0][0]; expect(params).toEqual( expect.objectContaining({ - sha: vm.mr.sha, - commit_message: vm.mr.commitMessage, + sha: wrapper.vm.mr.sha, + commit_message: wrapper.vm.mr.commitMessage, should_remove_source_branch: false, auto_merge_strategy: 'merge_when_pipeline_succeeds', }), @@ -382,15 +313,17 @@ describe('ReadyToMerge', () => { }); it('should handle merge failed', (done) => { + createComponent(); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - jest.spyOn(vm.service, 'merge').mockReturnValue(returnPromise('failed')); - vm.handleMergeButtonClick(false, true); + jest.spyOn(wrapper.vm.service, 'merge').mockReturnValue(returnPromise('failed')); + wrapper.vm.handleMergeButtonClick(false, true); setImmediate(() => { - expect(vm.isMakingRequest).toBeTruthy(); + expect(wrapper.vm.isMakingRequest).toBeTruthy(); expect(eventHub.$emit).toHaveBeenCalledWith('FailedToMerge', undefined); - const params = vm.service.merge.mock.calls[0][0]; + const params = wrapper.vm.service.merge.mock.calls[0][0]; expect(params.should_remove_source_branch).toBeTruthy(); expect(params.auto_merge_strategy).toBeUndefined(); @@ -399,15 +332,17 @@ describe('ReadyToMerge', () => { }); it('should handle merge action accepted case', (done) => { - jest.spyOn(vm.service, 'merge').mockReturnValue(returnPromise('success')); - jest.spyOn(vm, 'initiateMergePolling').mockImplementation(() => {}); - vm.handleMergeButtonClick(); + createComponent(); + + jest.spyOn(wrapper.vm.service, 'merge').mockReturnValue(returnPromise('success')); + jest.spyOn(wrapper.vm, 'initiateMergePolling').mockImplementation(() => {}); + wrapper.vm.handleMergeButtonClick(); setImmediate(() => { - expect(vm.isMakingRequest).toBeTruthy(); - expect(vm.initiateMergePolling).toHaveBeenCalled(); + expect(wrapper.vm.isMakingRequest).toBeTruthy(); + expect(wrapper.vm.initiateMergePolling).toHaveBeenCalled(); - const params = vm.service.merge.mock.calls[0][0]; + const params = wrapper.vm.service.merge.mock.calls[0][0]; expect(params.should_remove_source_branch).toBeTruthy(); expect(params.auto_merge_strategy).toBeUndefined(); @@ -418,128 +353,31 @@ describe('ReadyToMerge', () => { describe('initiateMergePolling', () => { it('should call simplePoll', () => { - vm.initiateMergePolling(); + createComponent(); + + wrapper.vm.initiateMergePolling(); expect(simplePoll).toHaveBeenCalledWith(expect.any(Function), { timeout: 0 }); }); it('should call handleMergePolling', () => { - jest.spyOn(vm, 'handleMergePolling').mockImplementation(() => {}); - - vm.initiateMergePolling(); - - expect(vm.handleMergePolling).toHaveBeenCalled(); - }); - }); - - describe('handleMergePolling', () => { - const returnPromise = (state) => - new Promise((resolve) => { - resolve({ - data: { - state, - source_branch_exists: true, - }, - }); - }); - - beforeEach(() => { - loadFixtures('merge_requests/merge_request_of_current_user.html'); - }); - - it('should call start and stop polling when MR merged', (done) => { - jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - jest.spyOn(vm.service, 'poll').mockReturnValue(returnPromise('merged')); - jest.spyOn(vm, 'initiateRemoveSourceBranchPolling').mockImplementation(() => {}); - - let cpc = false; // continuePollingCalled - let spc = false; // stopPollingCalled - - vm.handleMergePolling( - () => { - cpc = true; - }, - () => { - spc = true; - }, - ); - setImmediate(() => { - expect(vm.service.poll).toHaveBeenCalled(); - expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); - expect(eventHub.$emit).toHaveBeenCalledWith('FetchActionsContent'); - expect(vm.initiateRemoveSourceBranchPolling).toHaveBeenCalled(); - expect(refreshUserMergeRequestCounts).toHaveBeenCalled(); - expect(cpc).toBeFalsy(); - expect(spc).toBeTruthy(); + createComponent(); - done(); - }); - }); - - it('updates status box', (done) => { - jest.spyOn(vm.service, 'poll').mockReturnValue(returnPromise('merged')); - jest.spyOn(vm, 'initiateRemoveSourceBranchPolling').mockImplementation(() => {}); - - vm.handleMergePolling( - () => {}, - () => {}, - ); - - setImmediate(() => { - const statusBox = document.querySelector('.status-box'); - - expect(statusBox.classList.contains('status-box-mr-merged')).toBeTruthy(); - expect(statusBox.textContent).toContain('Merged'); - - done(); - }); - }); - - it('updates merge request count badge', (done) => { - jest.spyOn(vm.service, 'poll').mockReturnValue(returnPromise('merged')); - jest.spyOn(vm, 'initiateRemoveSourceBranchPolling').mockImplementation(() => {}); - - vm.handleMergePolling( - () => {}, - () => {}, - ); + jest.spyOn(wrapper.vm, 'handleMergePolling').mockImplementation(() => {}); - setImmediate(() => { - expect(document.querySelector('.js-merge-counter').textContent).toBe('0'); - - done(); - }); - }); - - it('should continue polling until MR is merged', (done) => { - jest.spyOn(vm.service, 'poll').mockReturnValue(returnPromise('some_other_state')); - jest.spyOn(vm, 'initiateRemoveSourceBranchPolling').mockImplementation(() => {}); - - let cpc = false; // continuePollingCalled - let spc = false; // stopPollingCalled - - vm.handleMergePolling( - () => { - cpc = true; - }, - () => { - spc = true; - }, - ); - setImmediate(() => { - expect(cpc).toBeTruthy(); - expect(spc).toBeFalsy(); + wrapper.vm.initiateMergePolling(); - done(); - }); + expect(wrapper.vm.handleMergePolling).toHaveBeenCalled(); }); }); describe('initiateRemoveSourceBranchPolling', () => { it('should emit event and call simplePoll', () => { + createComponent(); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - vm.initiateRemoveSourceBranchPolling(); + wrapper.vm.initiateRemoveSourceBranchPolling(); expect(eventHub.$emit).toHaveBeenCalledWith('SetBranchRemoveFlag', [true]); expect(simplePoll).toHaveBeenCalled(); @@ -557,13 +395,15 @@ describe('ReadyToMerge', () => { }); it('should call start and stop polling when MR merged', (done) => { + createComponent(); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - jest.spyOn(vm.service, 'poll').mockReturnValue(returnPromise(false)); + jest.spyOn(wrapper.vm.service, 'poll').mockReturnValue(returnPromise(false)); let cpc = false; // continuePollingCalled let spc = false; // stopPollingCalled - vm.handleRemoveBranchPolling( + wrapper.vm.handleRemoveBranchPolling( () => { cpc = true; }, @@ -572,7 +412,7 @@ describe('ReadyToMerge', () => { }, ); setImmediate(() => { - expect(vm.service.poll).toHaveBeenCalled(); + expect(wrapper.vm.service.poll).toHaveBeenCalled(); const args = eventHub.$emit.mock.calls[0]; @@ -590,12 +430,14 @@ describe('ReadyToMerge', () => { }); it('should continue polling until MR is merged', (done) => { - jest.spyOn(vm.service, 'poll').mockReturnValue(returnPromise(true)); + createComponent(); + + jest.spyOn(wrapper.vm.service, 'poll').mockReturnValue(returnPromise(true)); let cpc = false; // continuePollingCalled let spc = false; // stopPollingCalled - vm.handleRemoveBranchPolling( + wrapper.vm.handleRemoveBranchPolling( () => { cpc = true; }, @@ -616,49 +458,26 @@ describe('ReadyToMerge', () => { describe('Remove source branch checkbox', () => { describe('when user can merge but cannot delete branch', () => { it('should be disabled in the rendered output', () => { - const checkboxElement = vm.$el.querySelector('#remove-source-branch-input'); + createComponent(); - expect(checkboxElement).toBeNull(); + expect(wrapper.find('#remove-source-branch-input').exists()).toBe(false); }); }); describe('when user can merge and can delete branch', () => { beforeEach(() => { - vm = createComponent({ + createComponent({ mr: { canRemoveSourceBranch: true }, }); }); it('isRemoveSourceBranchButtonDisabled should be false', () => { - expect(vm.isRemoveSourceBranchButtonDisabled).toBe(false); - }); - - it('removed source branch should be enabled in rendered output', () => { - const checkboxElement = vm.$el.querySelector('#remove-source-branch-input'); - - expect(checkboxElement).not.toBeNull(); + expect(wrapper.find('#remove-source-branch-input').props('disabled')).toBe(undefined); }); }); }); describe('render children components', () => { - let wrapper; - const localVue = createLocalVue(); - - const createLocalComponent = (customConfig = {}) => { - wrapper = shallowMount(localVue.extend(ReadyToMerge), { - localVue, - propsData: { - mr: createTestMr(customConfig), - service: createTestService(), - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - const findCheckboxElement = () => wrapper.find(SquashBeforeMerge); const findCommitsHeaderElement = () => wrapper.find(CommitsHeader); const findCommitEditElements = () => wrapper.findAll(CommitEdit); @@ -667,7 +486,7 @@ describe('ReadyToMerge', () => { describe('squash checkbox', () => { it('should be rendered when squash before merge is enabled and there is more than 1 commit', () => { - createLocalComponent({ + createComponent({ mr: { commitsCount: 2, enableSquashBeforeMerge: true }, }); @@ -675,13 +494,13 @@ describe('ReadyToMerge', () => { }); it('should not be rendered when squash before merge is disabled', () => { - createLocalComponent({ mr: { commitsCount: 2, enableSquashBeforeMerge: false } }); + createComponent({ mr: { commitsCount: 2, enableSquashBeforeMerge: false } }); expect(findCheckboxElement().exists()).toBeFalsy(); }); it('should not be rendered when there is only 1 commit', () => { - createLocalComponent({ mr: { commitsCount: 1, enableSquashBeforeMerge: true } }); + createComponent({ mr: { commitsCount: 1, enableSquashBeforeMerge: true } }); expect(findCheckboxElement().exists()).toBeFalsy(); }); @@ -695,7 +514,7 @@ describe('ReadyToMerge', () => { `( 'is $state when squashIsReadonly returns $expectation ', ({ squashState, prop, expectation }) => { - createLocalComponent({ + createComponent({ mr: { commitsCount: 2, enableSquashBeforeMerge: true, [squashState]: expectation }, }); @@ -704,7 +523,7 @@ describe('ReadyToMerge', () => { ); it('is not rendered for "Do not allow" option', () => { - createLocalComponent({ + createComponent({ mr: { commitsCount: 2, enableSquashBeforeMerge: true, @@ -720,14 +539,14 @@ describe('ReadyToMerge', () => { describe('commits count collapsible header', () => { it('should be rendered when fast-forward is disabled', () => { - createLocalComponent(); + createComponent(); expect(findCommitsHeaderElement().exists()).toBeTruthy(); }); describe('when fast-forward is enabled', () => { it('should be rendered if squash and squash before are enabled and there is more than 1 commit', () => { - createLocalComponent({ + createComponent({ mr: { ffOnlyEnabled: true, enableSquashBeforeMerge: true, @@ -740,7 +559,7 @@ describe('ReadyToMerge', () => { }); it('should not be rendered if squash before merge is disabled', () => { - createLocalComponent({ + createComponent({ mr: { ffOnlyEnabled: true, enableSquashBeforeMerge: false, @@ -753,7 +572,7 @@ describe('ReadyToMerge', () => { }); it('should not be rendered if squash is disabled', () => { - createLocalComponent({ + createComponent({ mr: { ffOnlyEnabled: true, squash: false, @@ -766,7 +585,7 @@ describe('ReadyToMerge', () => { }); it('should not be rendered if commits count is 1', () => { - createLocalComponent({ + createComponent({ mr: { ffOnlyEnabled: true, squash: true, @@ -783,7 +602,7 @@ describe('ReadyToMerge', () => { describe('commits edit components', () => { describe('when fast-forward merge is enabled', () => { it('should not be rendered if squash is disabled', () => { - createLocalComponent({ + createComponent({ mr: { ffOnlyEnabled: true, squash: false, @@ -796,7 +615,7 @@ describe('ReadyToMerge', () => { }); it('should not be rendered if squash before merge is disabled', () => { - createLocalComponent({ + createComponent({ mr: { ffOnlyEnabled: true, squash: true, @@ -809,7 +628,7 @@ describe('ReadyToMerge', () => { }); it('should not be rendered if there is only one commit', () => { - createLocalComponent({ + createComponent({ mr: { ffOnlyEnabled: true, squash: true, @@ -822,7 +641,7 @@ describe('ReadyToMerge', () => { }); it('should have one edit component if squash is enabled and there is more than 1 commit', () => { - createLocalComponent({ + createComponent({ mr: { ffOnlyEnabled: true, squashIsSelected: true, @@ -837,13 +656,13 @@ describe('ReadyToMerge', () => { }); it('should have one edit component when squash is disabled', () => { - createLocalComponent(); + createComponent(); expect(findCommitEditElements().length).toBe(1); }); it('should have two edit components when squash is enabled and there is more than 1 commit', () => { - createLocalComponent({ + createComponent({ mr: { commitsCount: 2, squashIsSelected: true, @@ -855,7 +674,7 @@ describe('ReadyToMerge', () => { }); it('should have one edit components when squash is enabled and there is 1 commit only', () => { - createLocalComponent({ + createComponent({ mr: { commitsCount: 1, squash: true, @@ -867,13 +686,13 @@ describe('ReadyToMerge', () => { }); it('should have correct edit merge commit label', () => { - createLocalComponent(); + createComponent(); expect(findFirstCommitEditLabel()).toBe('Merge commit message'); }); it('should have correct edit squash commit label', () => { - createLocalComponent({ + createComponent({ mr: { commitsCount: 2, squashIsSelected: true, @@ -887,13 +706,13 @@ describe('ReadyToMerge', () => { describe('commits dropdown', () => { it('should not be rendered if squash is disabled', () => { - createLocalComponent(); + createComponent(); expect(findCommitDropdownElement().exists()).toBeFalsy(); }); it('should be rendered if squash is enabled and there is more than 1 commit', () => { - createLocalComponent({ + createComponent({ mr: { enableSquashBeforeMerge: true, squashIsSelected: true, commitsCount: 2 }, }); @@ -902,83 +721,38 @@ describe('ReadyToMerge', () => { }); }); - describe('Merge controls', () => { - describe('when allowed to merge', () => { - beforeEach(() => { - vm = createComponent({ - mr: { isMergeAllowed: true, canRemoveSourceBranch: true }, - }); - }); - - it('shows remove source branch checkbox', () => { - expect(vm.$el.querySelector('.js-remove-source-branch-checkbox')).not.toBeNull(); - }); - - it('shows modify commit message button', () => { - expect(vm.$el.querySelector('.js-modify-commit-message-button')).toBeDefined(); - }); - - it('does not show message about needing to resolve items', () => { - expect(vm.$el.querySelector('.js-resolve-mr-widget-items-message')).toBeNull(); - }); - }); - - describe('when not allowed to merge', () => { - beforeEach(() => { - vm = createComponent({ - mr: { isMergeAllowed: false }, - }); - }); - - it('does not show remove source branch checkbox', () => { - expect(vm.$el.querySelector('.js-remove-source-branch-checkbox')).toBeNull(); - }); - - it('shows message to resolve all items before being allowed to merge', () => { - expect(vm.$el.querySelector('.js-resolve-mr-widget-items-message')).toBeDefined(); - }); - }); - }); - describe('Merge request project settings', () => { describe('when the merge commit merge method is enabled', () => { beforeEach(() => { - vm = createComponent({ + createComponent({ mr: { ffOnlyEnabled: false }, }); }); it('should not show fast forward message', () => { - expect(vm.$el.querySelector('.mr-fast-forward-message')).toBeNull(); - }); - - it('should show "Modify commit message" button', () => { - expect(vm.$el.querySelector('.js-modify-commit-message-button')).toBeDefined(); + expect(wrapper.find('.mr-fast-forward-message').exists()).toBe(false); }); }); describe('when the fast-forward merge method is enabled', () => { beforeEach(() => { - vm = createComponent({ + createComponent({ mr: { ffOnlyEnabled: true }, }); }); it('should show fast forward message', () => { - expect(vm.$el.querySelector('.mr-fast-forward-message')).toBeDefined(); - }); - - it('should not show "Modify commit message" button', () => { - expect(vm.$el.querySelector('.js-modify-commit-message-button')).toBeNull(); + expect(wrapper.find('.mr-fast-forward-message').exists()).toBe(true); }); }); }); describe('with a mismatched SHA', () => { - const findMismatchShaBlock = () => vm.$el.querySelector('.js-sha-mismatch'); + const findMismatchShaBlock = () => wrapper.find('.js-sha-mismatch'); + const findMismatchShaTextBlock = () => findMismatchShaBlock().find(GlSprintf); beforeEach(() => { - vm = createComponent({ + createComponent({ mr: { isSHAMismatch: true, mergeRequestDiffsPath: '/merge_requests/1/diffs', @@ -987,17 +761,11 @@ describe('ReadyToMerge', () => { }); it('displays a warning message', () => { - expect(findMismatchShaBlock()).toExist(); + expect(findMismatchShaBlock().exists()).toBe(true); }); it('warns the user to refresh to review', () => { - expect(findMismatchShaBlock().textContent.trim()).toBe( - 'New changes were added. Reload the page to review them', - ); - }); - - it('displays link to the diffs tab', () => { - expect(findMismatchShaBlock().querySelector('a').href).toContain(vm.mr.mergeRequestDiffsPath); + expect(findMismatchShaTextBlock().element.outerHTML).toMatchSnapshot(); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js index 6c0d69ea109..c6bfca4516f 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js @@ -42,9 +42,7 @@ describe('UnresolvedDiscussions', () => { }); it('should have correct elements', () => { - expect(wrapper.element.innerText).toContain( - `Before this can be merged, one or more threads must be resolved.`, - ); + expect(wrapper.element.innerText).toContain(`Merge blocked: all threads must be resolved.`); expect(wrapper.element.innerText).toContain('Jump to first unresolved thread'); expect(wrapper.element.innerText).toContain('Resolve all threads in new issue'); @@ -56,9 +54,7 @@ describe('UnresolvedDiscussions', () => { describe('without threads path', () => { it('should not show create issue link if user cannot create issue', () => { - expect(wrapper.element.innerText).toContain( - `Before this can be merged, one or more threads must be resolved.`, - ); + expect(wrapper.element.innerText).toContain(`Merge blocked: all threads must be resolved.`); expect(wrapper.element.innerText).toContain('Jump to first unresolved thread'); expect(wrapper.element.innerText).not.toContain('Resolve all threads in new issue'); diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_mock_data.js b/spec/frontend/vue_mr_widget/deployment/deployment_mock_data.js index ff29022b75d..2083dc88681 100644 --- a/spec/frontend/vue_mr_widget/deployment/deployment_mock_data.js +++ b/spec/frontend/vue_mr_widget/deployment/deployment_mock_data.js @@ -45,15 +45,15 @@ const deploymentMockData = { changes: [ { path: 'index.html', - external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/index.html', + external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/index.html', }, { path: 'imgs/gallery.html', - external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/imgs/gallery.html', + external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/imgs/gallery.html', }, { path: 'about/', - external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/about/', + external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/about/', }, ], }; diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_view_button_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_view_button_spec.js index a5d91468ef2..eb6e3711e2e 100644 --- a/spec/frontend/vue_mr_widget/deployment/deployment_view_button_spec.js +++ b/spec/frontend/vue_mr_widget/deployment/deployment_view_button_spec.js @@ -1,4 +1,5 @@ -import { mount } from '@vue/test-utils'; +import { GlDropdown, GlLink } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import DeploymentViewButton from '~/vue_merge_request_widget/components/deployment/deployment_view_button.vue'; import ReviewAppLink from '~/vue_merge_request_widget/components/review_app_link.vue'; import { deploymentMockData } from './deployment_mock_data'; @@ -11,14 +12,14 @@ const appButtonText = { describe('Deployment View App button', () => { let wrapper; - const factory = (options = {}) => { - wrapper = mount(DeploymentViewButton, { + const createComponent = (options = {}) => { + wrapper = mountExtended(DeploymentViewButton, { ...options, }); }; beforeEach(() => { - factory({ + createComponent({ propsData: { deployment: deploymentMockData, appButtonText, @@ -30,15 +31,21 @@ describe('Deployment View App button', () => { wrapper.destroy(); }); + const findReviewAppLink = () => wrapper.findComponent(ReviewAppLink); + const findMrWigdetDeploymentDropdown = () => wrapper.findComponent(GlDropdown); + const findMrWigdetDeploymentDropdownIcon = () => + wrapper.findByTestId('mr-wigdet-deployment-dropdown-icon'); + const findDeployUrlMenuItems = () => wrapper.findAllComponents(GlLink); + describe('text', () => { it('renders text as passed', () => { - expect(wrapper.find(ReviewAppLink).text()).toContain(appButtonText.text); + expect(findReviewAppLink().props().display.text).toBe(appButtonText.text); }); }); describe('without changes', () => { beforeEach(() => { - factory({ + createComponent({ propsData: { deployment: { ...deploymentMockData, changes: null }, appButtonText, @@ -47,13 +54,13 @@ describe('Deployment View App button', () => { }); it('renders the link to the review app without dropdown', () => { - expect(wrapper.find('.js-mr-wigdet-deployment-dropdown').exists()).toBe(false); + expect(findMrWigdetDeploymentDropdown().exists()).toBe(false); }); }); describe('with a single change', () => { beforeEach(() => { - factory({ + createComponent({ propsData: { deployment: { ...deploymentMockData, changes: [deploymentMockData.changes[0]] }, appButtonText, @@ -62,21 +69,20 @@ describe('Deployment View App button', () => { }); it('renders the link to the review app without dropdown', () => { - expect(wrapper.find('.js-mr-wigdet-deployment-dropdown').exists()).toBe(false); + expect(findMrWigdetDeploymentDropdown().exists()).toBe(false); + expect(findMrWigdetDeploymentDropdownIcon().exists()).toBe(false); }); it('renders the link to the review app linked to to the first change', () => { const expectedUrl = deploymentMockData.changes[0].external_url; - const deployUrl = wrapper.find('.js-deploy-url'); - expect(deployUrl.attributes().href).not.toBeNull(); - expect(deployUrl.attributes().href).toEqual(expectedUrl); + expect(findReviewAppLink().attributes('href')).toBe(expectedUrl); }); }); describe('with multiple changes', () => { beforeEach(() => { - factory({ + createComponent({ propsData: { deployment: deploymentMockData, appButtonText, @@ -85,18 +91,18 @@ describe('Deployment View App button', () => { }); it('renders the link to the review app with dropdown', () => { - expect(wrapper.find('.js-mr-wigdet-deployment-dropdown').exists()).toBe(true); + expect(findMrWigdetDeploymentDropdown().exists()).toBe(true); + expect(findMrWigdetDeploymentDropdownIcon().exists()).toBe(true); }); it('renders all the links to the review apps', () => { - const allUrls = wrapper.findAll('.js-deploy-url-menu-item').wrappers; + const allUrls = findDeployUrlMenuItems().wrappers; const expectedUrls = deploymentMockData.changes.map((change) => change.external_url); expectedUrls.forEach((expectedUrl, idx) => { const deployUrl = allUrls[idx]; - expect(deployUrl.attributes().href).not.toBeNull(); - expect(deployUrl.attributes().href).toEqual(expectedUrl); + expect(deployUrl.attributes('href')).toBe(expectedUrl); }); }); }); diff --git a/spec/frontend/vue_mr_widget/mock_data.js b/spec/frontend/vue_mr_widget/mock_data.js index aa2345abccf..8e36a9225d6 100644 --- a/spec/frontend/vue_mr_widget/mock_data.js +++ b/spec/frontend/vue_mr_widget/mock_data.js @@ -48,7 +48,7 @@ export default { source_branch_link: 'daaaa', source_project_id: 19, source_project_full_path: '/group1/project1', - target_branch: 'master', + target_branch: 'main', target_project_id: 19, target_project_full_path: '/group2/project2', merge_request_add_ci_config_path: '/group2/project2/new/pipeline', @@ -83,7 +83,7 @@ export default { diff_head_sha: '104096c51715e12e7ae41f9333e9fa35b73f385d', diff_head_commit_short_id: '104096c5', default_merge_commit_message: - "Merge branch 'daaaa' into 'master'\n\nUpdate README.md\n\nSee merge request !22", + "Merge branch 'daaaa' into 'main'\n\nUpdate README.md\n\nSee merge request !22", pipeline: { id: 172, user: { @@ -173,8 +173,8 @@ export default { title: 'Update README.md', source_branch: 'feature-1', source_branch_path: '/root/detached-merge-request-pipelines/branches/feature-1', - target_branch: 'master', - target_branch_path: '/root/detached-merge-request-pipelines/branches/master', + target_branch: 'main', + target_branch_path: '/root/detached-merge-request-pipelines/branches/main', }, commit: { id: '104096c51715e12e7ae41f9333e9fa35b73f385d', @@ -243,7 +243,7 @@ export default { head_path: 'blob_path', }, codequality_help_path: 'code_quality.html', - target_branch_path: '/root/acets-app/branches/master', + target_branch_path: '/root/acets-app/branches/main', source_branch_path: '/root/acets-app/branches/daaaa', conflict_resolution_ui_path: '/root/acets-app/-/merge_requests/22/conflicts', remove_wip_path: '/root/acets-app/-/merge_requests/22/remove_wip', @@ -264,7 +264,7 @@ export default { ci_environments_status_url: '/root/acets-app/-/merge_requests/22/ci_environments_status', project_archived: false, default_merge_commit_message_with_description: - "Merge branch 'daaaa' into 'master'\n\nUpdate README.md\n\nSee merge request !22", + "Merge branch 'daaaa' into 'main'\n\nUpdate README.md\n\nSee merge request !22", default_squash_commit_message: 'Test squash commit message', diverged_commits_count: 0, only_allow_merge_if_pipeline_succeeds: false, diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js index c4962b608e1..446cd2a1e2f 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; -import { securityReportDownloadPathsQueryResponse } from 'jest/vue_shared/security_reports/mock_data'; +import { securityReportMergeRequestDownloadPathsQueryResponse } from 'jest/vue_shared/security_reports/mock_data'; import axios from '~/lib/utils/axios_utils'; import { setFaviconOverlay } from '~/lib/utils/favicon'; import notify from '~/lib/utils/notify'; @@ -12,7 +12,7 @@ import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/consta import eventHub from '~/vue_merge_request_widget/event_hub'; import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue'; import { stateKey } from '~/vue_merge_request_widget/stores/state_maps'; -import securityReportDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_download_paths.query.graphql'; +import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql'; import { faviconDataUrl, overlayDataUrl } from '../lib/utils/mock_data'; import mockData from './mock_data'; @@ -559,15 +559,15 @@ describe('MrWidgetOptions', () => { const changes = [ { path: 'index.html', - external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/index.html', + external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/index.html', }, { path: 'imgs/gallery.html', - external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/imgs/gallery.html', + external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/imgs/gallery.html', }, { path: 'about/', - external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/about/', + external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/about/', }, ]; const deploymentMockData = { @@ -688,22 +688,22 @@ describe('MrWidgetOptions', () => { scheduled_actions: [], }, ref: { - name: 'master', - path: '/root/ci-web-terminal/commits/master', + name: 'main', + path: '/root/ci-web-terminal/commits/main', tag: false, branch: true, }, commit: { id: 'aa1939133d373c94879becb79d91828a892ee319', short_id: 'aa193913', - title: "Merge branch 'master-test' into 'master'", + title: "Merge branch 'main-test' into 'main'", created_at: '2018-10-22T11:41:33.000Z', parent_ids: [ '4622f4dd792468993003caf2e3be978798cbe096', '76598df914cdfe87132d0c3c40f80db9fa9396a4', ], message: - "Merge branch 'master-test' into 'master'\n\nUpdate .gitlab-ci.yml\n\nSee merge request root/ci-web-terminal!1", + "Merge branch 'main-test' into 'main'\n\nUpdate .gitlab-ci.yml\n\nSee merge request root/ci-web-terminal!1", author_name: 'Administrator', author_email: 'admin@example.com', authored_date: '2018-10-22T11:41:33.000Z', @@ -751,17 +751,16 @@ describe('MrWidgetOptions', () => { changes: [ { path: 'index.html', - external_url: - 'http://root-master-patch-91341.volatile-watch.surge.sh/index.html', + external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/index.html', }, { path: 'imgs/gallery.html', external_url: - 'http://root-master-patch-91341.volatile-watch.surge.sh/imgs/gallery.html', + 'http://root-main-patch-91341.volatile-watch.surge.sh/imgs/gallery.html', }, { path: 'about/', - external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/about/', + external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/about/', }, ], status: 'success', @@ -831,8 +830,8 @@ describe('MrWidgetOptions', () => { return createComponent(mrData, { apolloProvider: createMockApollo([ [ - securityReportDownloadPathsQuery, - async () => ({ data: securityReportDownloadPathsQueryResponse }), + securityReportMergeRequestDownloadPathsQuery, + async () => ({ data: securityReportMergeRequestDownloadPathsQueryResponse }), ], ]), }); diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_managment_sidebar_assignees_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js index 28646994ed1..db9b0930c06 100644 --- a/spec/frontend/vue_shared/alert_details/sidebar/alert_managment_sidebar_assignees_spec.js +++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js @@ -1,7 +1,7 @@ import { GlDropdownItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SidebarAssignee from '~/vue_shared/alert_details/components/sidebar/sidebar_assignee.vue'; import SidebarAssignees from '~/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue'; import AlertSetAssignees from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql'; @@ -13,6 +13,29 @@ describe('Alert Details Sidebar Assignees', () => { let wrapper; let mock; + const mockPath = '/-/autocomplete/users.json'; + const mockUsers = [ + { + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 1, + name: 'User 1', + username: 'root', + }, + { + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 2, + name: 'User 2', + username: 'not-root', + }, + ]; + + const findAssigned = () => wrapper.findByTestId('assigned-users'); + const findDropdown = () => wrapper.findComponent(GlDropdownItem); + const findSidebarIcon = () => wrapper.findByTestId('assignees-icon'); + const findUnassigned = () => wrapper.findByTestId('unassigned-users'); + function mountComponent({ data, users = [], @@ -21,7 +44,7 @@ describe('Alert Details Sidebar Assignees', () => { loading = false, stubs = {}, } = {}) { - wrapper = shallowMount(SidebarAssignees, { + wrapper = shallowMountExtended(SidebarAssignees, { data() { return { users, @@ -56,10 +79,7 @@ describe('Alert Details Sidebar Assignees', () => { mock.restore(); }); - const findAssigned = () => wrapper.find('[data-testid="assigned-users"]'); - const findUnassigned = () => wrapper.find('[data-testid="unassigned-users"]'); - - describe('updating the alert status', () => { + describe('sidebar expanded', () => { const mockUpdatedMutationResult = { data: { alertSetAssignees: { @@ -73,30 +93,13 @@ describe('Alert Details Sidebar Assignees', () => { beforeEach(() => { mock = new MockAdapter(axios); - const path = '/-/autocomplete/users.json'; - const users = [ - { - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - id: 1, - name: 'User 1', - username: 'root', - }, - { - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - id: 2, - name: 'User 2', - username: 'not-root', - }, - ]; - mock.onGet(path).replyOnce(200, users); + mock.onGet(mockPath).replyOnce(200, mockUsers); mountComponent({ data: { alert: mockAlert }, sidebarCollapsed: false, loading: false, - users, + users: mockUsers, stubs: { SidebarAssignee, }, @@ -106,7 +109,11 @@ describe('Alert Details Sidebar Assignees', () => { it('renders a unassigned option', async () => { wrapper.setData({ isDropdownSearching: false }); await wrapper.vm.$nextTick(); - expect(wrapper.find(GlDropdownItem).text()).toBe('Unassigned'); + expect(findDropdown().text()).toBe('Unassigned'); + }); + + it('does not display the collapsed sidebar icon', () => { + expect(findSidebarIcon().exists()).toBe(false); }); it('calls `$apollo.mutate` with `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', async () => { @@ -170,4 +177,28 @@ describe('Alert Details Sidebar Assignees', () => { expect(findAssigned().find('.dropdown-menu-user-username').text()).toBe('@root'); }); }); + + describe('sidebar collapsed', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + + mock.onGet(mockPath).replyOnce(200, mockUsers); + + mountComponent({ + data: { alert: mockAlert }, + loading: false, + users: mockUsers, + stubs: { + SidebarAssignee, + }, + }); + }); + it('does not display the status dropdown', () => { + expect(findDropdown().exists()).toBe(false); + }); + + it('does display the collapsed sidebar icon', () => { + expect(findSidebarIcon().exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js index 0014957517f..d5be5b623b8 100644 --- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js +++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js @@ -1,5 +1,5 @@ import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import updateAlertStatusMutation from '~/graphql_shared/mutations/alert_status_update.mutation.graphql'; import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue'; import AlertSidebarStatus from '~/vue_shared/alert_details/components/sidebar/sidebar_status.vue'; @@ -10,12 +10,13 @@ const mockAlert = mockAlerts[0]; describe('Alert Details Sidebar Status', () => { let wrapper; - const findStatusDropdown = () => wrapper.find(GlDropdown); - const findStatusDropdownItem = () => wrapper.find(GlDropdownItem); - const findStatusLoadingIcon = () => wrapper.find(GlLoadingIcon); - const findStatusDropdownHeader = () => wrapper.find('[data-testid="dropdown-header"]'); + const findStatusDropdown = () => wrapper.findComponent(GlDropdown); + const findStatusDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findStatusLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findStatusDropdownHeader = () => wrapper.findByTestId('dropdown-header'); const findAlertStatus = () => wrapper.findComponent(AlertStatus); - const findStatus = () => wrapper.find('[data-testid="status"]'); + const findStatus = () => wrapper.findByTestId('status'); + const findSidebarIcon = () => wrapper.findByTestId('status-icon'); function mountComponent({ data, @@ -24,7 +25,7 @@ describe('Alert Details Sidebar Status', () => { stubs = {}, provide = {}, } = {}) { - wrapper = mount(AlertSidebarStatus, { + wrapper = mountExtended(AlertSidebarStatus, { propsData: { alert: { ...mockAlert }, ...data, @@ -52,7 +53,7 @@ describe('Alert Details Sidebar Status', () => { } }); - describe('Alert Sidebar Dropdown Status', () => { + describe('sidebar expanded', () => { beforeEach(() => { mountComponent({ data: { alert: mockAlert }, @@ -69,6 +70,10 @@ describe('Alert Details Sidebar Status', () => { expect(findStatusDropdownHeader().exists()).toBe(true); }); + it('does not display the collapsed sidebar icon', () => { + expect(findSidebarIcon().exists()).toBe(false); + }); + describe('updating the alert status', () => { const mockUpdatedMutationResult = { data: { @@ -109,22 +114,47 @@ describe('Alert Details Sidebar Status', () => { expect(findStatusLoadingIcon().exists()).toBe(false); expect(findStatus().text()).toBe('Triggered'); }); + + it('renders default translated statuses', () => { + mountComponent({ sidebarCollapsed: false }); + expect(findAlertStatus().props('statuses')).toBe(PAGE_CONFIG.OPERATIONS.STATUSES); + expect(findStatus().text()).toBe('Triggered'); + }); + + it('emits "alert-update" when the status has been updated', () => { + mountComponent({ sidebarCollapsed: false }); + expect(wrapper.emitted('alert-update')).toBeUndefined(); + findAlertStatus().vm.$emit('handle-updating'); + expect(wrapper.emitted('alert-update')).toEqual([[]]); + }); + + it('renders translated statuses', () => { + const status = 'TEST'; + const statuses = { [status]: 'Test' }; + mountComponent({ + data: { alert: { ...mockAlert, status } }, + provide: { statuses }, + sidebarCollapsed: false, + }); + expect(findAlertStatus().props('statuses')).toBe(statuses); + expect(findStatus().text()).toBe(statuses.TEST); + }); }); }); - describe('Statuses', () => { - it('renders default translated statuses', () => { - mountComponent({}); - expect(findAlertStatus().props('statuses')).toBe(PAGE_CONFIG.OPERATIONS.STATUSES); - expect(findStatus().text()).toBe('Triggered'); + describe('sidebar collapsed', () => { + beforeEach(() => { + mountComponent({ + data: { alert: mockAlert }, + loading: false, + }); + }); + it('does not display the status dropdown', () => { + expect(findStatusDropdown().exists()).toBe(false); }); - it('renders translated statuses', () => { - const status = 'TEST'; - const statuses = { [status]: 'Test' }; - mountComponent({ data: { alert: { ...mockAlert, status } }, provide: { statuses } }); - expect(findAlertStatus().props('statuses')).toBe(statuses); - expect(findStatus().text()).toBe(statuses.TEST); + it('does display the collapsed sidebar icon', () => { + expect(findSidebarIcon().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/vue_shared/components/alerts_deprecation_warning_spec.js b/spec/frontend/vue_shared/components/alerts_deprecation_warning_spec.js new file mode 100644 index 00000000000..b73f4d6a396 --- /dev/null +++ b/spec/frontend/vue_shared/components/alerts_deprecation_warning_spec.js @@ -0,0 +1,48 @@ +import { GlAlert, GlLink } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import AlertDeprecationWarning from '~/vue_shared/components/alerts_deprecation_warning.vue'; + +describe('AlertDetails', () => { + let wrapper; + + function mountComponent(hasManagedPrometheus = false) { + wrapper = mount(AlertDeprecationWarning, { + provide: { + hasManagedPrometheus, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findAlert = () => wrapper.findComponent(GlAlert); + const findLink = () => wrapper.findComponent(GlLink); + + describe('Alert details', () => { + describe('with no manual prometheus', () => { + beforeEach(() => { + mountComponent(); + }); + + it('renders nothing', () => { + expect(findAlert().exists()).toBe(false); + }); + }); + + describe('with manual prometheus', () => { + beforeEach(() => { + mountComponent(true); + }); + + it('renders a deprecation notice', () => { + expect(findAlert().text()).toContain('GitLab-managed Prometheus is deprecated'); + expect(findLink().attributes('href')).toContain( + 'operations/metrics/alerts.html#managed-prometheus-instances', + ); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/commit_spec.js b/spec/frontend/vue_shared/components/commit_spec.js index 66ceebed489..6a31742141b 100644 --- a/spec/frontend/vue_shared/components/commit_spec.js +++ b/spec/frontend/vue_shared/components/commit_spec.js @@ -32,8 +32,8 @@ describe('Commit component', () => { createComponent({ tag: false, commitRef: { - name: 'master', - ref_url: 'http://localhost/namespace2/gitlabhq/tree/master', + name: 'main', + ref_url: 'http://localhost/namespace2/gitlabhq/tree/main', }, commitUrl: 'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067', @@ -55,8 +55,8 @@ describe('Commit component', () => { props = { tag: true, commitRef: { - name: 'master', - ref_url: 'http://localhost/namespace2/gitlabhq/tree/master', + name: 'main', + ref_url: 'http://localhost/namespace2/gitlabhq/tree/main', }, commitUrl: 'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067', @@ -122,8 +122,8 @@ describe('Commit component', () => { props = { tag: false, commitRef: { - name: 'master', - ref_url: 'http://localhost/namespace2/gitlabhq/tree/master', + name: 'main', + ref_url: 'http://localhost/namespace2/gitlabhq/tree/main', }, commitUrl: 'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067', @@ -145,8 +145,8 @@ describe('Commit component', () => { props = { tag: false, commitRef: { - name: 'master', - ref_url: 'http://localhost/namespace2/gitlabhq/tree/master', + name: 'main', + ref_url: 'http://localhost/namespace2/gitlabhq/tree/main', }, commitUrl: 'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067', @@ -158,7 +158,7 @@ describe('Commit component', () => { createComponent(props); const refEl = wrapper.find('.ref-name'); - expect(refEl.text()).toContain('master'); + expect(refEl.text()).toContain('main'); expect(refEl.attributes('href')).toBe(props.commitRef.ref_url); @@ -173,8 +173,8 @@ describe('Commit component', () => { props = { tag: false, commitRef: { - name: 'master', - ref_url: 'http://localhost/namespace2/gitlabhq/tree/master', + name: 'main', + ref_url: 'http://localhost/namespace2/gitlabhq/tree/main', }, commitUrl: 'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067', @@ -206,8 +206,8 @@ describe('Commit component', () => { props = { tag: false, commitRef: { - name: 'master', - ref_url: 'http://localhost/namespace2/gitlabhq/tree/master', + name: 'main', + ref_url: 'http://localhost/namespace2/gitlabhq/tree/main', }, commitUrl: 'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067', @@ -232,8 +232,8 @@ describe('Commit component', () => { it('should render path as href attribute', () => { props = { commitRef: { - name: 'master', - path: 'http://localhost/namespace2/gitlabhq/tree/master', + name: 'main', + path: 'http://localhost/namespace2/gitlabhq/tree/main', }, }; diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js index 9e96c154546..b2ed79cd75a 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js @@ -1,3 +1,6 @@ +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; + +import AccessorUtilities from '~/lib/utils/accessor'; import { stripQuotes, uniqueTokens, @@ -5,6 +8,8 @@ import { processFilters, filterToQueryObject, urlQueryToFilter, + getRecentlyUsedTokenValues, + setTokenValueToRecentlyUsed, } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; import { @@ -14,6 +19,12 @@ import { tokenValuePlain, } from './mock_data'; +const mockStorageKey = 'recent-tokens'; + +function setLocalStorageAvailability(isAvailable) { + jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(isAvailable); +} + describe('Filtered Search Utils', () => { describe('stripQuotes', () => { it.each` @@ -249,3 +260,79 @@ describe('urlQueryToFilter', () => { expect(res).toEqual(result); }); }); + +describe('getRecentlyUsedTokenValues', () => { + useLocalStorageSpy(); + + beforeEach(() => { + localStorage.removeItem(mockStorageKey); + }); + + it('returns array containing recently used token values from provided recentTokenValuesStorageKey', () => { + setLocalStorageAvailability(true); + + const mockExpectedArray = [{ foo: 'bar' }]; + localStorage.setItem(mockStorageKey, JSON.stringify(mockExpectedArray)); + + expect(getRecentlyUsedTokenValues(mockStorageKey)).toEqual(mockExpectedArray); + }); + + it('returns empty array when provided recentTokenValuesStorageKey does not have anything in localStorage', () => { + setLocalStorageAvailability(true); + + expect(getRecentlyUsedTokenValues(mockStorageKey)).toEqual([]); + }); + + it('returns empty array when when access to localStorage is not available', () => { + setLocalStorageAvailability(false); + + expect(getRecentlyUsedTokenValues(mockStorageKey)).toEqual([]); + }); +}); + +describe('setTokenValueToRecentlyUsed', () => { + const mockTokenValue1 = { foo: 'bar' }; + const mockTokenValue2 = { bar: 'baz' }; + useLocalStorageSpy(); + + beforeEach(() => { + localStorage.removeItem(mockStorageKey); + }); + + it('adds provided tokenValue to localStorage for recentTokenValuesStorageKey', () => { + setLocalStorageAvailability(true); + + setTokenValueToRecentlyUsed(mockStorageKey, mockTokenValue1); + + expect(JSON.parse(localStorage.getItem(mockStorageKey))).toEqual([mockTokenValue1]); + }); + + it('adds provided tokenValue to localStorage at the top of existing values (i.e. Stack order)', () => { + setLocalStorageAvailability(true); + + setTokenValueToRecentlyUsed(mockStorageKey, mockTokenValue1); + setTokenValueToRecentlyUsed(mockStorageKey, mockTokenValue2); + + expect(JSON.parse(localStorage.getItem(mockStorageKey))).toEqual([ + mockTokenValue2, + mockTokenValue1, + ]); + }); + + it('ensures that provided tokenValue is not added twice', () => { + setLocalStorageAvailability(true); + + setTokenValueToRecentlyUsed(mockStorageKey, mockTokenValue1); + setTokenValueToRecentlyUsed(mockStorageKey, mockTokenValue1); + + expect(JSON.parse(localStorage.getItem(mockStorageKey))).toEqual([mockTokenValue1]); + }); + + it('does not add any value when acess to localStorage is not available', () => { + setLocalStorageAvailability(false); + + setTokenValueToRecentlyUsed(mockStorageKey, mockTokenValue1); + + expect(JSON.parse(localStorage.getItem(mockStorageKey))).toBeNull(); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js index c24528ba4d2..23e4deab9c1 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -1,12 +1,15 @@ import { GlFilteredSearchToken } from '@gitlab/ui'; import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; import Api from '~/api'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue'; import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'; import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue'; +import IterationToken from '~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; +import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue'; export const mockAuthor1 = { id: 1, @@ -37,7 +40,7 @@ export const mockAuthor3 = { export const mockAuthors = [mockAuthor1, mockAuthor2, mockAuthor3]; -export const mockBranches = [{ name: 'Master' }, { name: 'v1.x' }, { name: 'my-Branch' }]; +export const mockBranches = [{ name: 'Main' }, { name: 'v1.x' }, { name: 'my-Branch' }]; export const mockRegularMilestone = { id: 1, @@ -82,7 +85,7 @@ export const mockBranchToken = { title: 'Source Branch', unique: true, token: BranchToken, - operators: [{ value: '=', description: 'is', default: 'true' }], + operators: OPERATOR_IS_ONLY, fetchBranches: Api.branches.bind(Api), }; @@ -93,11 +96,20 @@ export const mockAuthorToken = { unique: false, symbol: '@', token: AuthorToken, - operators: [{ value: '=', description: 'is', default: 'true' }], + operators: OPERATOR_IS_ONLY, fetchPath: 'gitlab-org/gitlab-test', fetchAuthors: Api.projectUsers.bind(Api), }; +export const mockIterationToken = { + type: 'iteration', + icon: 'iteration', + title: 'Iteration', + unique: true, + token: IterationToken, + fetchIterations: () => Promise.resolve(), +}; + export const mockLabelToken = { type: 'label_name', icon: 'labels', @@ -105,7 +117,7 @@ export const mockLabelToken = { unique: false, symbol: '~', token: LabelToken, - operators: [{ value: '=', description: 'is', default: 'true' }], + operators: OPERATOR_IS_ONLY, fetchLabels: () => Promise.resolve(mockLabels), }; @@ -116,7 +128,7 @@ export const mockMilestoneToken = { unique: true, symbol: '%', token: MilestoneToken, - operators: [{ value: '=', description: 'is', default: 'true' }], + operators: OPERATOR_IS_ONLY, fetchMilestones: () => Promise.resolve({ data: mockMilestones }), }; @@ -127,9 +139,9 @@ export const mockEpicToken = { unique: true, symbol: '&', token: EpicToken, - operators: [{ value: '=', description: 'is', default: 'true' }], + operators: OPERATOR_IS_ONLY, + idProperty: 'iid', fetchEpics: () => Promise.resolve({ data: mockEpics }), - fetchSingleEpic: () => Promise.resolve({ data: mockEpics[0] }), }; export const mockReactionEmojiToken = { @@ -138,7 +150,7 @@ export const mockReactionEmojiToken = { title: 'My-Reaction', unique: true, token: EmojiToken, - operators: [{ value: '=', description: 'is', default: 'true' }], + operators: OPERATOR_IS_ONLY, fetchEmojis: () => Promise.resolve(mockEmojis), }; @@ -148,13 +160,21 @@ export const mockMembershipToken = { title: 'Membership', token: GlFilteredSearchToken, unique: true, - operators: [{ value: '=', description: 'is' }], + operators: OPERATOR_IS_ONLY, options: [ { value: 'exclude', title: 'Direct' }, { value: 'only', title: 'Inherited' }, ], }; +export const mockWeightToken = { + type: 'weight', + icon: 'weight', + title: 'Weight', + unique: true, + token: WeightToken, +}; + export const mockMembershipTokenOptionsWithoutTitles = { ...mockMembershipToken, options: [{ value: 'exclude' }, { value: 'only' }], diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js index 765e576914c..3b50927dcc6 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js @@ -11,8 +11,8 @@ import { deprecatedCreateFlash as createFlash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { - DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY, + DEFAULT_NONE_ANY, } from '~/vue_shared/components/filtered_search_bar/constants'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; @@ -159,7 +159,7 @@ describe('AuthorToken', () => { }); it('renders provided defaultAuthors as suggestions', async () => { - const defaultAuthors = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; + const defaultAuthors = DEFAULT_NONE_ANY; wrapper = createComponent({ active: true, config: { ...mockAuthorToken, defaultAuthors }, diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js new file mode 100644 index 00000000000..0db47f1f189 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js @@ -0,0 +1,228 @@ +import { GlFilteredSearchToken } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { + mockRegularLabel, + mockLabels, +} from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; + +import { DEFAULT_LABELS } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + getRecentlyUsedTokenValues, + setTokenValueToRecentlyUsed, +} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; + +import { mockLabelToken } from '../mock_data'; + +jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils'); + +const mockStorageKey = 'recent-tokens-label_name'; + +const defaultStubs = { + Portal: true, + GlFilteredSearchToken: { + template: ` + <div> + <slot name="view-token"></slot> + <slot name="view"></slot> + </div> + `, + }, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, +}; + +const defaultSlots = { + 'view-token': ` + <div class="js-view-token">${mockRegularLabel.title}</div> + `, + view: ` + <div class="js-view">${mockRegularLabel.title}</div> + `, +}; + +const mockProps = { + tokenConfig: mockLabelToken, + tokenValue: { data: '' }, + tokenActive: false, + tokensListLoading: false, + tokenValues: [], + fnActiveTokenValue: jest.fn(), + defaultTokenValues: DEFAULT_LABELS, + recentTokenValuesStorageKey: mockStorageKey, + fnCurrentTokenValue: jest.fn(), +}; + +function createComponent({ + props = { ...mockProps }, + stubs = defaultStubs, + slots = defaultSlots, +} = {}) { + return mount(BaseToken, { + propsData: { + ...props, + }, + provide: { + portalName: 'fake target', + alignSuggestions: jest.fn(), + suggestionsListClass: 'custom-class', + }, + stubs, + slots, + }); +} + +describe('BaseToken', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent({ + props: { + ...mockProps, + tokenValue: { data: `"${mockRegularLabel.title}"` }, + tokenValues: mockLabels, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('data', () => { + it('calls `getRecentlyUsedTokenValues` to populate `recentTokenValues` when `recentTokenValuesStorageKey` is defined', () => { + expect(getRecentlyUsedTokenValues).toHaveBeenCalledWith(mockStorageKey); + }); + }); + + describe('computed', () => { + describe('currentTokenValue', () => { + it('calls `fnCurrentTokenValue` when it is provided', () => { + // We're disabling lint to trigger computed prop execution for this test. + // eslint-disable-next-line no-unused-vars + const { currentTokenValue } = wrapper.vm; + + expect(wrapper.vm.fnCurrentTokenValue).toHaveBeenCalledWith(`"${mockRegularLabel.title}"`); + }); + }); + + describe('activeTokenValue', () => { + it('calls `fnActiveTokenValue` when it is provided', async () => { + wrapper.setProps({ + fnCurrentTokenValue: undefined, + }); + + await wrapper.vm.$nextTick(); + + // We're disabling lint to trigger computed prop execution for this test. + // eslint-disable-next-line no-unused-vars + const { activeTokenValue } = wrapper.vm; + + expect(wrapper.vm.fnActiveTokenValue).toHaveBeenCalledWith( + mockLabels, + `"${mockRegularLabel.title.toLowerCase()}"`, + ); + }); + }); + }); + + describe('watch', () => { + describe('tokenActive', () => { + let wrapperWithTokenActive; + + beforeEach(() => { + wrapperWithTokenActive = createComponent({ + props: { + ...mockProps, + tokenActive: true, + tokenValue: { data: `"${mockRegularLabel.title}"` }, + }, + }); + }); + + afterEach(() => { + wrapperWithTokenActive.destroy(); + }); + + it('emits `fetch-token-values` event on the component when value of this prop is changed to false and `tokenValues` array is empty', async () => { + wrapperWithTokenActive.setProps({ + tokenActive: false, + }); + + await wrapperWithTokenActive.vm.$nextTick(); + + expect(wrapperWithTokenActive.emitted('fetch-token-values')).toBeTruthy(); + expect(wrapperWithTokenActive.emitted('fetch-token-values')).toEqual([ + [`"${mockRegularLabel.title}"`], + ]); + }); + }); + }); + + describe('methods', () => { + describe('handleTokenValueSelected', () => { + it('calls `setTokenValueToRecentlyUsed` when `recentTokenValuesStorageKey` is defined', () => { + const mockTokenValue = { + id: 1, + title: 'Foo', + }; + + wrapper.vm.handleTokenValueSelected(mockTokenValue); + + expect(setTokenValueToRecentlyUsed).toHaveBeenCalledWith(mockStorageKey, mockTokenValue); + }); + }); + }); + + describe('template', () => { + it('renders gl-filtered-search-token component', () => { + const wrapperWithNoStubs = createComponent({ + stubs: {}, + }); + const glFilteredSearchToken = wrapperWithNoStubs.find(GlFilteredSearchToken); + + expect(glFilteredSearchToken.exists()).toBe(true); + expect(glFilteredSearchToken.props('config')).toBe(mockLabelToken); + + wrapperWithNoStubs.destroy(); + }); + + it('renders `view-token` slot when present', () => { + expect(wrapper.find('.js-view-token').exists()).toBe(true); + }); + + it('renders `view` slot when present', () => { + expect(wrapper.find('.js-view').exists()).toBe(true); + }); + + describe('events', () => { + let wrapperWithNoStubs; + + beforeEach(() => { + wrapperWithNoStubs = createComponent({ + stubs: { Portal: true }, + }); + }); + + afterEach(() => { + wrapperWithNoStubs.destroy(); + }); + + it('emits `fetch-token-values` event on component after a delay when component emits `input` event', async () => { + jest.useFakeTimers(); + + wrapperWithNoStubs.find(GlFilteredSearchToken).vm.$emit('input', { data: 'foo' }); + await wrapperWithNoStubs.vm.$nextTick(); + + jest.runAllTimers(); + + expect(wrapperWithNoStubs.emitted('fetch-token-values')).toBeTruthy(); + expect(wrapperWithNoStubs.emitted('fetch-token-values')[1]).toEqual(['foo']); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js index a20bc4986fc..331c9c2c14d 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js @@ -10,10 +10,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import { - DEFAULT_LABEL_NONE, - DEFAULT_LABEL_ANY, -} from '~/vue_shared/components/filtered_search_bar/constants'; +import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue'; import { mockBranches, mockBranchToken } from '../mock_data'; @@ -77,7 +74,7 @@ describe('BranchToken', () => { describe('currentValue', () => { it('returns lowercase string for `value.data`', () => { - expect(wrapper.vm.currentValue).toBe('master'); + expect(wrapper.vm.currentValue).toBe('main'); }); }); @@ -137,7 +134,7 @@ describe('BranchToken', () => { }); describe('template', () => { - const defaultBranches = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; + const defaultBranches = DEFAULT_NONE_ANY; async function showSuggestions() { const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); const suggestionsSegment = tokenSegments.at(2); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js index 231f2f01428..fb48aea8e4f 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js @@ -13,6 +13,7 @@ import axios from '~/lib/utils/axios_utils'; import { DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY, + DEFAULT_NONE_ANY, } from '~/vue_shared/components/filtered_search_bar/constants'; import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'; @@ -137,7 +138,7 @@ describe('EmojiToken', () => { }); describe('template', () => { - const defaultEmojis = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; + const defaultEmojis = DEFAULT_NONE_ANY; beforeEach(async () => { wrapper = createComponent({ diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js index 0c3f9e1363f..addc058f658 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js @@ -68,21 +68,6 @@ describe('EpicToken', () => { await wrapper.vm.$nextTick(); }); - describe('currentValue', () => { - it.each` - data | id - ${`${mockEpics[0].title}::&${mockEpics[0].iid}`} | ${mockEpics[0].iid} - ${mockEpics[0].iid} | ${mockEpics[0].iid} - ${'foobar'} | ${'foobar'} - `('$data returns $id', async ({ data, id }) => { - wrapper.setProps({ value: { data } }); - - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.currentValue).toBe(id); - }); - }); - describe('activeEpic', () => { it('returns object for currently present `value.data`', async () => { wrapper.setProps({ @@ -140,20 +125,6 @@ describe('EpicToken', () => { expect(wrapper.vm.loading).toBe(false); }); }); - - describe('fetchSingleEpic', () => { - it('calls `config.fetchSingleEpic` with provided iid param', async () => { - jest.spyOn(wrapper.vm.config, 'fetchSingleEpic'); - - wrapper.vm.fetchSingleEpic(1); - - expect(wrapper.vm.config.fetchSingleEpic).toHaveBeenCalledWith(1); - - await waitForPromises(); - - expect(wrapper.vm.epics).toEqual([mockEpics[0]]); - }); - }); }); describe('template', () => { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js new file mode 100644 index 00000000000..ca5dc984ae0 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js @@ -0,0 +1,78 @@ +import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import createFlash from '~/flash'; +import IterationToken from '~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue'; +import { mockIterationToken } from '../mock_data'; + +jest.mock('~/flash'); + +describe('IterationToken', () => { + const title = 'gitlab-org: #1'; + let wrapper; + + const createComponent = ({ config = mockIterationToken, value = { data: '' } } = {}) => + mount(IterationToken, { + propsData: { + config, + value, + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: 'custom-class', + }, + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders iteration value', async () => { + wrapper = createComponent({ value: { data: title } }); + + await wrapper.vm.$nextTick(); + + const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment); + + expect(tokenSegments).toHaveLength(3); // `Iteration` `=` `gitlab-org: #1` + expect(tokenSegments.at(2).text()).toBe(title); + }); + + it('fetches initial values', () => { + const fetchIterationsSpy = jest.fn().mockResolvedValue(); + + wrapper = createComponent({ + config: { ...mockIterationToken, fetchIterations: fetchIterationsSpy }, + value: { data: title }, + }); + + expect(fetchIterationsSpy).toHaveBeenCalledWith(title); + }); + + it('fetches iterations on user input', () => { + const search = 'hello'; + const fetchIterationsSpy = jest.fn().mockResolvedValue(); + + wrapper = createComponent({ + config: { ...mockIterationToken, fetchIterations: fetchIterationsSpy }, + }); + + wrapper.findComponent(GlFilteredSearchToken).vm.$emit('input', { data: search }); + + expect(fetchIterationsSpy).toHaveBeenCalledWith(search); + }); + + it('renders error message when request fails', async () => { + const fetchIterationsSpy = jest.fn().mockRejectedValue(); + + wrapper = createComponent({ + config: { ...mockIterationToken, fetchIterations: fetchIterationsSpy }, + }); + + await wrapper.vm.$nextTick(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching iterations.', + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js index 8528c062426..57514a0c499 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js @@ -16,8 +16,7 @@ import axios from '~/lib/utils/axios_utils'; import { DEFAULT_LABELS, - DEFAULT_LABEL_NONE, - DEFAULT_LABEL_ANY, + DEFAULT_NONE_ANY, } from '~/vue_shared/components/filtered_search_bar/constants'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; @@ -176,7 +175,7 @@ describe('LabelToken', () => { }); describe('template', () => { - const defaultLabels = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; + const defaultLabels = DEFAULT_NONE_ANY; beforeEach(async () => { wrapper = createComponent({ value: { data: `"${mockRegularLabel.title}"` } }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js new file mode 100644 index 00000000000..9a72be636cd --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js @@ -0,0 +1,37 @@ +import { GlFilteredSearchTokenSegment } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue'; +import { mockWeightToken } from '../mock_data'; + +jest.mock('~/flash'); + +describe('WeightToken', () => { + const weight = '3'; + let wrapper; + + const createComponent = ({ config = mockWeightToken, value = { data: '' } } = {}) => + mount(WeightToken, { + propsData: { + config, + value, + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: 'custom-class', + }, + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders weight value', () => { + wrapper = createComponent({ value: { data: weight } }); + + const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment); + + expect(tokenSegments).toHaveLength(3); // `Weight` `=` `3` + expect(tokenSegments.at(2).text()).toBe(weight); + }); +}); diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js index 99bf0d84d0c..8738924f717 100644 --- a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js +++ b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js @@ -132,6 +132,35 @@ describe('RelatedIssuableItem', () => { it('renders due date component with correct due date', () => { expect(wrapper.find(IssueDueDate).props('date')).toBe(props.dueDate); }); + + it('does not render red icon for overdue issue that is closed', async () => { + mountComponent({ + props: { + ...props, + closedAt: '2018-12-01T00:00:00.00Z', + }, + }); + await wrapper.vm.$nextTick(); + + expect(wrapper.find(IssueDueDate).props('closed')).toBe(true); + }); + + it('should not contain the `.text-danger` css class for overdue issue that is closed', async () => { + mountComponent({ + props: { + ...props, + closedAt: '2018-12-01T00:00:00.00Z', + }, + }); + await wrapper.vm.$nextTick(); + + expect(wrapper.find(IssueDueDate).find('.board-card-info-icon').classes('text-danger')).toBe( + false, + ); + expect(wrapper.find(IssueDueDate).find('.board-card-info-text').classes('text-danger')).toBe( + false, + ); + }); }); describe('token assignees', () => { diff --git a/spec/frontend/vue_shared/components/keep_alive_slots_spec.js b/spec/frontend/vue_shared/components/keep_alive_slots_spec.js new file mode 100644 index 00000000000..10c6cbe6d94 --- /dev/null +++ b/spec/frontend/vue_shared/components/keep_alive_slots_spec.js @@ -0,0 +1,122 @@ +import { nextTick } from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue'; + +const SLOT_1 = { + slotKey: 'slot-1', + title: 'Hello 1', +}; +const SLOT_2 = { + slotKey: 'slot-2', + title: 'Hello 2', +}; + +describe('~/vue_shared/components/keep_alive_slots.vue', () => { + let wrapper; + + const createSlotContent = ({ slotKey, title }) => ` + <div data-testid="slot-child" data-slot-id="${slotKey}"> + <h1>${title}</h1> + <input type="text" /> + </div> + `; + const createComponent = (props = {}) => { + wrapper = mountExtended(KeepAliveSlots, { + propsData: props, + slots: { + [SLOT_1.slotKey]: createSlotContent(SLOT_1), + [SLOT_2.slotKey]: createSlotContent(SLOT_2), + }, + }); + }; + + const findRenderedSlots = () => + wrapper.findAllByTestId('slot-child').wrappers.map((x) => ({ + title: x.find('h1').text(), + inputValue: x.find('input').element.value, + isVisible: x.isVisible(), + })); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('doesnt show anything', () => { + expect(findRenderedSlots()).toEqual([]); + }); + + describe('when slotKey is changed', () => { + beforeEach(async () => { + wrapper.setProps({ slotKey: SLOT_1.slotKey }); + await nextTick(); + }); + + it('shows slot', () => { + expect(findRenderedSlots()).toEqual([ + { + title: SLOT_1.title, + isVisible: true, + inputValue: '', + }, + ]); + }); + + it('hides everything when slotKey cannot be found', async () => { + wrapper.setProps({ slotKey: '' }); + await nextTick(); + + expect(findRenderedSlots()).toEqual([ + { + title: SLOT_1.title, + isVisible: false, + inputValue: '', + }, + ]); + }); + + describe('when user intreracts then slotKey changes again', () => { + beforeEach(async () => { + wrapper.find('input').setValue('TEST'); + wrapper.setProps({ slotKey: SLOT_2.slotKey }); + await nextTick(); + }); + + it('keeps first slot alive but hidden', () => { + expect(findRenderedSlots()).toEqual([ + { + title: SLOT_1.title, + isVisible: false, + inputValue: 'TEST', + }, + { + title: SLOT_2.title, + isVisible: true, + inputValue: '', + }, + ]); + }); + }); + }); + }); + + describe('initialized with slotKey', () => { + beforeEach(() => { + createComponent({ slotKey: SLOT_2.slotKey }); + }); + + it('shows slot', () => { + expect(findRenderedSlots()).toEqual([ + { + title: SLOT_2.title, + isVisible: true, + inputValue: '', + }, + ]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap b/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap index c454166e30b..3b49536799c 100644 --- a/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap +++ b/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap @@ -6,7 +6,7 @@ exports[`Suggestion Diff component matches snapshot 1`] = ` > <suggestion-diff-header-stub batchsuggestionscount="1" - class="qa-suggestion-diff-header js-suggestion-diff-header" + class="js-suggestion-diff-header" defaultcommitmessage="Apply suggestion" helppagepath="path_to_docs" isapplyingbatch="true" diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js index 077c2174571..fec6abc9639 100644 --- a/spec/frontend/vue_shared/components/markdown/header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/header_spec.js @@ -48,6 +48,7 @@ describe('Markdown field header component', () => { 'Add a bullet list', 'Add a numbered list', 'Add a task list', + 'Add a collapsible section', 'Add a table', 'Go full screen', ]; @@ -133,6 +134,14 @@ describe('Markdown field header component', () => { ); }); + it('renders collapsible section template', () => { + const detailsBlockButton = findToolbarButtonByProp('icon', 'details-block'); + + expect(detailsBlockButton.props('tag')).toEqual( + '<details><summary>Click to expand</summary>\n{text}\n</details>', + ); + }); + it('does not render suggestion button if `canSuggest` is set to false', () => { createWrapper({ canSuggest: false, diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js index 74e9cbcbb53..acf97713885 100644 --- a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js +++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js @@ -1,6 +1,7 @@ import { GlAlert, GlBadge, GlPagination, GlTabs, GlTab } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import Tracking from '~/tracking'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import PageWrapper from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue'; @@ -291,7 +292,7 @@ describe('AlertManagementEmptyState', () => { unique: true, symbol: '@', token: AuthorToken, - operators: [{ value: '=', description: 'is', default: 'true' }], + operators: OPERATOR_IS_ONLY, fetchPath: '/link', fetchAuthors: expect.any(Function), }, @@ -302,7 +303,7 @@ describe('AlertManagementEmptyState', () => { unique: true, symbol: '@', token: AuthorToken, - operators: [{ value: '=', description: 'is', default: 'true' }], + operators: OPERATOR_IS_ONLY, fetchPath: '/link', fetchAuthors: expect.any(Function), }, diff --git a/spec/frontend/vue_shared/components/registry/list_item_spec.js b/spec/frontend/vue_shared/components/registry/list_item_spec.js index 33c9c808dc3..ca4bf0b0652 100644 --- a/spec/frontend/vue_shared/components/registry/list_item_spec.js +++ b/spec/frontend/vue_shared/components/registry/list_item_spec.js @@ -101,16 +101,16 @@ describe('list item', () => { }); describe('disabled prop', () => { - it('when true applies disabled-content class', () => { + it('when true applies gl-opacity-5 class', () => { mountComponent({ disabled: true }); - expect(wrapper.classes('disabled-content')).toBe(true); + expect(wrapper.classes('gl-opacity-5')).toBe(true); }); - it('when false does not apply disabled-content class', () => { + it('when false does not apply gl-opacity-5 class', () => { mountComponent({ disabled: false }); - expect(wrapper.classes('disabled-content')).toBe(false); + expect(wrapper.classes('gl-opacity-5')).toBe(false); }); }); diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js index 4033c943b82..32ef2d27ba7 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js +++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js @@ -1,4 +1,5 @@ -import { GlAlert, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui'; +import { GlAlert, GlButton, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; @@ -18,6 +19,24 @@ import { const localVue = createLocalVue(); localVue.use(VueApollo); +let resizeCallback; +const MockResizeObserver = { + bind(el, { value }) { + resizeCallback = value; + }, + mockResize(size) { + bp.getBreakpointSize.mockReturnValue(size); + resizeCallback(); + }, + unbind() { + resizeCallback = null; + }, +}; + +localVue.directive('gl-resize-observer', MockResizeObserver); + +jest.mock('@gitlab/ui/dist/utils'); + describe('RunnerInstructionsModal component', () => { let wrapper; let fakeApollo; @@ -27,7 +46,8 @@ describe('RunnerInstructionsModal component', () => { const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAlert = () => wrapper.findComponent(GlAlert); - const findPlatformButtons = () => wrapper.findAllByTestId('platform-button'); + const findPlatformButtonGroup = () => wrapper.findByTestId('platform-buttons'); + const findPlatformButtons = () => findPlatformButtonGroup().findAllComponents(GlButton); const findArchitectureDropdownItems = () => wrapper.findAllByTestId('architecture-dropdown-item'); const findBinaryInstructions = () => wrapper.findByTestId('binary-instructions'); const findRegisterCommand = () => wrapper.findByTestId('register-command'); @@ -141,6 +161,22 @@ describe('RunnerInstructionsModal component', () => { }); }); + describe('when the modal resizes', () => { + it('to an xs viewport', async () => { + MockResizeObserver.mockResize('xs'); + await nextTick(); + + expect(findPlatformButtonGroup().attributes('vertical')).toBeTruthy(); + }); + + it('to a non-xs viewport', async () => { + MockResizeObserver.mockResize('sm'); + await nextTick(); + + expect(findPlatformButtonGroup().props('vertical')).toBeFalsy(); + }); + }); + describe('when apollo is loading', () => { it('should show a skeleton loader', async () => { createComponent(); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js index 1175d183c6c..88557917cb5 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js @@ -1,8 +1,8 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; import DropdownContents from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue'; - import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; import { mockConfig } from './mock_data'; @@ -50,13 +50,20 @@ describe('DropdownContent', () => { describe('template', () => { it('renders component container element with class `labels-select-dropdown-contents` and no styles', () => { expect(wrapper.attributes('class')).toContain('labels-select-dropdown-contents'); - expect(wrapper.attributes('style')).toBe(undefined); + expect(wrapper.attributes('style')).toBeUndefined(); }); - it('renders component container element with styles when `renderOnTop` is true', () => { - wrapper = createComponent(mockConfig, { renderOnTop: true }); + describe('when `renderOnTop` is true', () => { + it.each` + variant | expected + ${DropdownVariant.Sidebar} | ${'bottom: 3rem'} + ${DropdownVariant.Standalone} | ${'bottom: 2rem'} + ${DropdownVariant.Embedded} | ${'bottom: 2rem'} + `('renders upward for $variant variant', ({ variant, expected }) => { + wrapper = createComponent({ ...mockConfig, variant }, { renderOnTop: true }); - expect(wrapper.attributes('style')).toContain('bottom: 100%'); + expect(wrapper.attributes('style')).toContain(expected); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js index 4cf36df2502..3f00eab17b7 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js @@ -3,6 +3,7 @@ import Vuex from 'vuex'; import { isInViewport } from '~/lib/utils/common_utils'; import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue'; +import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; import DropdownButton from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue'; import DropdownContents from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue'; import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue'; @@ -190,40 +191,33 @@ describe('LabelsSelectRoot', () => { }); describe('sets content direction based on viewport', () => { - it('does not set direction when `state.variant` is not "embedded"', async () => { - createComponent(); - - wrapper.vm.$store.dispatch('toggleDropdownContents'); - wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state); - await wrapper.vm.$nextTick; - - expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false); - }); - - describe('when `state.variant` is "embedded"', () => { - beforeEach(() => { - createComponent({ ...mockConfig, variant: 'embedded' }); - wrapper.vm.$store.dispatch('toggleDropdownContents'); - }); + describe.each(Object.values(DropdownVariant))( + 'when labels variant is "%s"', + ({ variant }) => { + beforeEach(() => { + createComponent({ ...mockConfig, variant }); + wrapper.vm.$store.dispatch('toggleDropdownContents'); + }); - it('set direction when out of viewport', () => { - isInViewport.mockImplementation(() => false); - wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state); + it('set direction when out of viewport', () => { + isInViewport.mockImplementation(() => false); + wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(true); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(true); + }); }); - }); - it('does not set direction when inside of viewport', () => { - isInViewport.mockImplementation(() => true); - wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state); + it('does not set direction when inside of viewport', () => { + isInViewport.mockImplementation(() => true); + wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false); + }); }); - }); - }); + }, + ); }); }); diff --git a/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js b/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js index 691e19473c1..28c5acc8110 100644 --- a/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js +++ b/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js @@ -1,28 +1,36 @@ import { shallowMount } from '@vue/test-utils'; +import timezoneMock from 'timezone-mock'; import { formatDate, getTimeago } from '~/lib/utils/datetime_utility'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; describe('Time ago with tooltip component', () => { let vm; - const buildVm = (propsData = {}, scopedSlots = {}) => { + const timestamp = '2017-05-08T14:57:39.781Z'; + const timeAgoTimestamp = getTimeago().format(timestamp); + + const defaultProps = { + time: timestamp, + }; + + const buildVm = (props = {}, scopedSlots = {}) => { vm = shallowMount(TimeAgoTooltip, { - propsData, + propsData: { + ...defaultProps, + ...props, + }, scopedSlots, }); }; - const timestamp = '2017-05-08T14:57:39.781Z'; - const timeAgoTimestamp = getTimeago().format(timestamp); afterEach(() => { vm.destroy(); + timezoneMock.unregister(); }); it('should render timeago with a bootstrap tooltip', () => { - buildVm({ - time: timestamp, - }); + buildVm(); expect(vm.attributes('title')).toEqual(formatDate(timestamp)); expect(vm.text()).toEqual(timeAgoTimestamp); @@ -30,7 +38,6 @@ describe('Time ago with tooltip component', () => { it('should render provided html class', () => { buildVm({ - time: timestamp, cssClass: 'foo', }); @@ -38,14 +45,58 @@ describe('Time ago with tooltip component', () => { }); it('should render with the datetime attribute', () => { - buildVm({ time: timestamp }); + buildVm(); expect(vm.attributes('datetime')).toEqual(timestamp); }); it('should render provided scope content with the correct timeAgo string', () => { - buildVm({ time: timestamp }, { default: `<span>The time is {{ props.timeAgo }}</span>` }); + buildVm(null, { default: `<span>The time is {{ props.timeAgo }}</span>` }); expect(vm.text()).toEqual(`The time is ${timeAgoTimestamp}`); }); + + describe('number based timestamps', () => { + // Store a date object before we mock the TZ + const date = new Date(); + + describe('with default TZ', () => { + beforeEach(() => { + buildVm({ time: date.getTime() }); + }); + + it('handled correctly', () => { + expect(vm.text()).toEqual(getTimeago().format(date.getTime())); + }); + }); + + describe.each` + timezone | offset + ${'US/Pacific'} | ${420} + ${'US/Eastern'} | ${240} + ${'Brazil/East'} | ${180} + ${'UTC'} | ${-0} + ${'Europe/London'} | ${-60} + `('with different client vs server TZ', ({ timezone, offset }) => { + let tzDate; + + beforeEach(() => { + timezoneMock.register(timezone); + // Date object with mocked TZ + tzDate = new Date(); + buildVm({ time: date.getTime() }); + }); + + it('the date object should have correct timezones', () => { + expect(tzDate.getTimezoneOffset()).toBe(offset); + }); + + it('timeago should handled the date correctly', () => { + // getTime() should always handle the TZ, which allows for us to validate the date objects represent + // the same date and time regardless of the TZ. + expect(vm.text()).toEqual(getTimeago().format(date.getTime())); + expect(vm.text()).toEqual(getTimeago().format(tzDate.getTime())); + }); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/user_select_spec.js b/spec/frontend/vue_shared/components/user_select_spec.js new file mode 100644 index 00000000000..5a609568220 --- /dev/null +++ b/spec/frontend/vue_shared/components/user_select_spec.js @@ -0,0 +1,311 @@ +import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { cloneDeep } from 'lodash'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphql'; +import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants'; +import getIssueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; +import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; +import { + searchResponse, + projectMembersResponse, + participantsQueryResponse, +} from '../../sidebar/mock_data'; + +const assignee = { + id: 'gid://gitlab/User/4', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Developer', + username: 'dev', + webUrl: '/dev', + status: null, +}; + +const mockError = jest.fn().mockRejectedValue('Error!'); + +const waitForSearch = async () => { + jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); + await nextTick(); + await waitForPromises(); +}; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('User select dropdown', () => { + let wrapper; + let fakeApollo; + + const findSearchField = () => wrapper.findComponent(GlSearchBoxByType); + const findParticipantsLoading = () => wrapper.find('[data-testid="loading-participants"]'); + const findSelectedParticipants = () => wrapper.findAll('[data-testid="selected-participant"]'); + const findUnselectedParticipants = () => + wrapper.findAll('[data-testid="unselected-participant"]'); + const findCurrentUser = () => wrapper.findAll('[data-testid="current-user"]'); + const findUnassignLink = () => wrapper.find('[data-testid="unassign"]'); + const findEmptySearchResults = () => wrapper.find('[data-testid="empty-results"]'); + + const createComponent = ({ + props = {}, + searchQueryHandler = jest.fn().mockResolvedValue(projectMembersResponse), + participantsQueryHandler = jest.fn().mockResolvedValue(participantsQueryResponse), + } = {}) => { + fakeApollo = createMockApollo([ + [searchUsersQuery, searchQueryHandler], + [getIssueParticipantsQuery, participantsQueryHandler], + ]); + wrapper = shallowMount(UserSelect, { + localVue, + apolloProvider: fakeApollo, + propsData: { + headerText: 'test', + text: 'test-text', + fullPath: '/project', + iid: '1', + value: [], + currentUser: { + username: 'random', + name: 'Mr. Random', + }, + allowMultipleAssignees: false, + ...props, + }, + stubs: { + GlDropdown, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + it('renders a loading spinner if participants are loading', () => { + createComponent(); + + expect(findParticipantsLoading().exists()).toBe(true); + }); + + it('emits an `error` event if participants query was rejected', async () => { + createComponent({ participantsQueryHandler: mockError }); + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[], []]); + }); + + it('emits an `error` event if search query was rejected', async () => { + createComponent({ searchQueryHandler: mockError }); + await waitForSearch(); + + expect(wrapper.emitted('error')).toEqual([[], []]); + }); + + it('renders current user if they are not in participants or assignees', async () => { + createComponent(); + await waitForPromises(); + + expect(findCurrentUser().exists()).toBe(true); + }); + + it('displays correct amount of selected users', async () => { + createComponent({ + props: { + value: [assignee], + }, + }); + await waitForPromises(); + + expect(findSelectedParticipants()).toHaveLength(1); + }); + + describe('when search is empty', () => { + it('renders a merged list of participants and project members', async () => { + createComponent(); + await waitForPromises(); + expect(findUnselectedParticipants()).toHaveLength(3); + }); + + it('renders `Unassigned` link with the checkmark when there are no selected users', async () => { + createComponent(); + await waitForPromises(); + expect(findUnassignLink().props('isChecked')).toBe(true); + }); + + it('renders `Unassigned` link without the checkmark when there are selected users', async () => { + createComponent({ + props: { + value: [assignee], + }, + }); + await waitForPromises(); + expect(findUnassignLink().props('isChecked')).toBe(false); + }); + + it('emits an input event with empty array after clicking on `Unassigned`', async () => { + createComponent({ + props: { + value: [assignee], + }, + }); + await waitForPromises(); + findUnassignLink().vm.$emit('click'); + + expect(wrapper.emitted('input')).toEqual([[[]]]); + }); + + it('emits an empty array after unselecting the only selected assignee', async () => { + createComponent({ + props: { + value: [assignee], + }, + }); + await waitForPromises(); + + findSelectedParticipants().at(0).vm.$emit('click', new Event('click')); + expect(wrapper.emitted('input')).toEqual([[[]]]); + }); + + it('allows only one user to be selected if `allowMultipleAssignees` is false', async () => { + createComponent({ + props: { + value: [assignee], + }, + }); + await waitForPromises(); + + findUnselectedParticipants().at(0).vm.$emit('click'); + expect(wrapper.emitted('input')).toEqual([ + [ + [ + { + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 'gid://gitlab/User/1', + name: 'Administrator', + status: null, + username: 'root', + webUrl: '/root', + }, + ], + ], + ]); + }); + + it('adds user to selected if `allowMultipleAssignees` is true', async () => { + createComponent({ + props: { + value: [assignee], + allowMultipleAssignees: true, + }, + }); + await waitForPromises(); + + findUnselectedParticipants().at(0).vm.$emit('click'); + expect(wrapper.emitted('input')[0][0]).toHaveLength(2); + }); + }); + + describe('when searching', () => { + it('does not show loading spinner when debounce timer is still running', async () => { + createComponent(); + await waitForPromises(); + findSearchField().vm.$emit('input', 'roo'); + + expect(findParticipantsLoading().exists()).toBe(false); + }); + + it('shows loading spinner when searching for users', async () => { + createComponent(); + await waitForPromises(); + findSearchField().vm.$emit('input', 'roo'); + jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); + await nextTick(); + + expect(findParticipantsLoading().exists()).toBe(true); + }); + + it('renders a list of found users and external participants matching search term', async () => { + createComponent({ searchQueryHandler: jest.fn().mockResolvedValue(searchResponse) }); + await waitForPromises(); + + findSearchField().vm.$emit('input', 'ro'); + await waitForSearch(); + + expect(findUnselectedParticipants()).toHaveLength(3); + }); + + it('renders a list of found users only if no external participants match search term', async () => { + createComponent({ searchQueryHandler: jest.fn().mockResolvedValue(searchResponse) }); + await waitForPromises(); + + findSearchField().vm.$emit('input', 'roo'); + await waitForSearch(); + + expect(findUnselectedParticipants()).toHaveLength(2); + }); + + it('shows a message about no matches if search returned an empty list', async () => { + const responseCopy = cloneDeep(searchResponse); + responseCopy.data.workspace.users.nodes = []; + + createComponent({ + searchQueryHandler: jest.fn().mockResolvedValue(responseCopy), + }); + await waitForPromises(); + findSearchField().vm.$emit('input', 'tango'); + await waitForSearch(); + + expect(findUnselectedParticipants()).toHaveLength(0); + expect(findEmptySearchResults().exists()).toBe(true); + }); + }); + + // TODO Remove this test after the following issue is resolved in the backend + // https://gitlab.com/gitlab-org/gitlab/-/issues/329750 + describe('temporary error suppression', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(); + }); + + const nullError = { message: 'Cannot return null for non-nullable field GroupMember.user' }; + + it.each` + mockErrors + ${[nullError]} + ${[nullError, nullError]} + `('does not emit errors', async ({ mockErrors }) => { + createComponent({ + searchQueryHandler: jest.fn().mockResolvedValue({ + errors: mockErrors, + }), + }); + await waitForSearch(); + + expect(wrapper.emitted()).toEqual({}); + // eslint-disable-next-line no-console + expect(console.error).toHaveBeenCalled(); + }); + + it.each` + mockErrors + ${[{ message: 'serious error' }]} + ${[nullError, { message: 'serious error' }]} + `('emits error when non-null related errors are included', async ({ mockErrors }) => { + createComponent({ + searchQueryHandler: jest.fn().mockResolvedValue({ + errors: mockErrors, + }), + }); + await waitForSearch(); + + expect(wrapper.emitted('error')).toEqual([[]]); + // eslint-disable-next-line no-console + expect(console.error).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/vuex_module_provider_spec.js b/spec/frontend/vue_shared/components/vuex_module_provider_spec.js new file mode 100644 index 00000000000..ebd396bd87c --- /dev/null +++ b/spec/frontend/vue_shared/components/vuex_module_provider_spec.js @@ -0,0 +1,47 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue'; + +const TestComponent = Vue.extend({ + inject: ['vuexModule'], + template: `<div data-testid="vuexModule">{{ vuexModule }}</div> `, +}); + +const TEST_VUEX_MODULE = 'testVuexModule'; + +describe('~/vue_shared/components/vuex_module_provider', () => { + let wrapper; + + const findProvidedVuexModule = () => wrapper.find('[data-testid="vuexModule"]').text(); + + const createComponent = (extraParams = {}) => { + wrapper = mount(VuexModuleProvider, { + propsData: { + vuexModule: TEST_VUEX_MODULE, + }, + slots: { + default: TestComponent, + }, + ...extraParams, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('provides "vuexModule" set from prop', () => { + createComponent(); + expect(findProvidedVuexModule()).toBe(TEST_VUEX_MODULE); + }); + + it('does not blow up when used with vue-apollo', () => { + // See https://github.com/vuejs/vue-apollo/pull/1153 for details + const localVue = createLocalVue(); + localVue.use(VueApollo); + + createComponent({ localVue }); + expect(findProvidedVuexModule()).toBe(TEST_VUEX_MODULE); + }); +}); diff --git a/spec/frontend/vue_shared/directives/validation_spec.js b/spec/frontend/vue_shared/directives/validation_spec.js index 2764a71d204..51ee73cabde 100644 --- a/spec/frontend/vue_shared/directives/validation_spec.js +++ b/spec/frontend/vue_shared/directives/validation_spec.js @@ -1,15 +1,21 @@ import { shallowMount } from '@vue/test-utils'; -import validation from '~/vue_shared/directives/validation'; +import validation, { initForm } from '~/vue_shared/directives/validation'; describe('validation directive', () => { let wrapper; - const createComponent = ({ inputAttributes, showValidation } = {}) => { + const createComponentFactory = ({ inputAttributes, template, data }) => { const defaultInputAttributes = { type: 'text', required: true, }; + const defaultTemplate = ` + <form> + <input v-validation:[showValidation] name="exampleField" v-bind="attributes" /> + </form> + `; + const component = { directives: { validation: validation(), @@ -17,27 +23,52 @@ describe('validation directive', () => { data() { return { attributes: inputAttributes || defaultInputAttributes, - showValidation, - form: { - state: null, - fields: { - exampleField: { - state: null, - feedback: '', - }, + ...data, + }; + }, + template: template || defaultTemplate, + }; + + wrapper = shallowMount(component, { attachTo: document.body }); + }; + + const createComponent = ({ inputAttributes, showValidation, template } = {}) => + createComponentFactory({ + inputAttributes, + data: { + showValidation, + form: { + state: null, + fields: { + exampleField: { + state: null, + feedback: '', }, }, - }; + }, + }, + template, + }); + + const createComponentWithInitForm = ({ inputAttributes } = {}) => + createComponentFactory({ + inputAttributes, + data: { + form: initForm({ + fields: { + exampleField: { + state: null, + value: 'lorem', + }, + }, + }), }, template: ` <form> - <input v-validation:[showValidation] name="exampleField" v-bind="attributes" /> + <input v-validation:[form.showValidation] name="exampleField" v-bind="attributes" /> </form> `, - }; - - wrapper = shallowMount(component, { attachTo: document.body }); - }; + }); afterEach(() => { wrapper.destroy(); @@ -48,6 +79,12 @@ describe('validation directive', () => { const findForm = () => wrapper.find('form'); const findInput = () => wrapper.find('input'); + const setValueAndTriggerValidation = (value) => { + const input = findInput(); + input.setValue(value); + input.trigger('blur'); + }; + describe.each([true, false])( 'with fields untouched and "showValidation" set to "%s"', (showValidation) => { @@ -78,12 +115,6 @@ describe('validation directive', () => { `( 'with input-attributes set to $inputAttributes', ({ inputAttributes, validValue, invalidValue }) => { - const setValueAndTriggerValidation = (value) => { - const input = findInput(); - input.setValue(value); - input.trigger('blur'); - }; - beforeEach(() => { createComponent({ inputAttributes }); }); @@ -129,4 +160,130 @@ describe('validation directive', () => { }); }, ); + + describe('with group elements', () => { + const template = ` + <form> + <div v-validation:[showValidation]> + <input name="exampleField" v-bind="attributes" /> + </div> + </form> + `; + beforeEach(() => { + createComponent({ + template, + inputAttributes: { + required: true, + }, + }); + }); + + describe('with invalid value', () => { + beforeEach(() => { + setValueAndTriggerValidation(''); + }); + + it('should set correct field state', () => { + expect(getFormData().fields.exampleField).toEqual({ + state: false, + feedback: expect.any(String), + }); + }); + + it('should set correct feedback', () => { + expect(getFormData().fields.exampleField.feedback).toBe('Please fill out this field.'); + }); + }); + + describe('with valid value', () => { + beforeEach(() => { + setValueAndTriggerValidation('hello'); + }); + + it('set the correct state', () => { + expect(getFormData().fields.exampleField).toEqual({ + state: true, + feedback: '', + }); + }); + }); + }); + + describe('component using initForm', () => { + it('sets the form fields correctly', () => { + createComponentWithInitForm(); + + expect(getFormData().state).toBe(false); + expect(getFormData().showValidation).toBe(false); + + expect(getFormData().fields.exampleField).toMatchObject({ + value: 'lorem', + state: null, + required: true, + feedback: expect.any(String), + }); + }); + }); +}); + +describe('initForm', () => { + const MOCK_FORM = { + fields: { + name: { + value: 'lorem', + }, + description: { + value: 'ipsum', + required: false, + skipValidation: true, + }, + }, + }; + + const EXPECTED_FIELDS = { + name: { value: 'lorem', required: true, state: null, feedback: null }, + description: { value: 'ipsum', required: false, state: true, feedback: null }, + }; + + it('returns form object', () => { + expect(initForm(MOCK_FORM)).toMatchObject({ + state: false, + showValidation: false, + fields: EXPECTED_FIELDS, + }); + }); + + it('returns form object with additional parameters', () => { + const customFormObject = { + foo: { + bar: 'lorem', + }, + }; + + const form = { + ...MOCK_FORM, + ...customFormObject, + }; + + expect(initForm(form)).toMatchObject({ + state: false, + showValidation: false, + fields: EXPECTED_FIELDS, + ...customFormObject, + }); + }); + + it('can override existing state and showValidation values', () => { + const form = { + ...MOCK_FORM, + state: true, + showValidation: true, + }; + + expect(initForm(form)).toMatchObject({ + state: true, + showValidation: true, + fields: EXPECTED_FIELDS, + }); + }); }); diff --git a/spec/frontend/projects/experiment_new_project_creation/components/legacy_container_spec.js b/spec/frontend/vue_shared/new_namespace/components/legacy_container_spec.js index 6fc36d6362c..52f36aa0e77 100644 --- a/spec/frontend/projects/experiment_new_project_creation/components/legacy_container_spec.js +++ b/spec/frontend/vue_shared/new_namespace/components/legacy_container_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import LegacyContainer from '~/projects/experiment_new_project_creation/components/legacy_container.vue'; +import LegacyContainer from '~/vue_shared/new_namespace/components/legacy_container.vue'; describe('Legacy container component', () => { let wrapper; diff --git a/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js b/spec/frontend/vue_shared/new_namespace/components/welcome_spec.js index 9fd1230806e..602213fca83 100644 --- a/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js +++ b/spec/frontend/vue_shared/new_namespace/components/welcome_spec.js @@ -3,8 +3,7 @@ import { nextTick } from 'vue'; import { mockTracking } from 'helpers/tracking_helper'; import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; import { getExperimentData } from '~/experimentation/utils'; -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'; +import WelcomePage from '~/vue_shared/new_namespace/components/welcome.vue'; jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn() })); @@ -12,8 +11,18 @@ describe('Welcome page', () => { let wrapper; let trackingSpy; - const createComponent = (propsData) => { - wrapper = shallowMount(WelcomePage, { propsData }); + const DEFAULT_PROPS = { + title: 'Create new something', + }; + + const createComponent = ({ propsData, slots }) => { + wrapper = shallowMount(WelcomePage, { + slots, + propsData: { + ...DEFAULT_PROPS, + ...propsData, + }, + }); }; beforeEach(() => { @@ -29,7 +38,7 @@ describe('Welcome page', () => { }); it('tracks link clicks', async () => { - createComponent({ panels: [{ name: 'test', href: '#' }] }); + createComponent({ propsData: { experiment: 'foo', panels: [{ name: 'test', href: '#' }] } }); const link = wrapper.find('a'); link.trigger('click'); await nextTick(); @@ -38,11 +47,11 @@ describe('Welcome page', () => { }); }); - it('adds new_repo experiment data if in experiment', async () => { + it('adds experiment data if in experiment', async () => { const mockExperimentData = 'data'; getExperimentData.mockReturnValue(mockExperimentData); - createComponent({ panels: [{ name: 'test', href: '#' }] }); + createComponent({ propsData: { experiment: 'foo', panels: [{ name: 'test', href: '#' }] } }); const link = wrapper.find('a'); link.trigger('click'); await nextTick(); @@ -57,12 +66,13 @@ describe('Welcome page', () => { }); }); - it('renders new project push tip popover', () => { - createComponent({ panels: [{ name: 'test', href: '#' }] }); - - const popover = wrapper.findComponent(NewProjectPushTipPopover); + it('renders footer slot if provided', () => { + const DUMMY = 'Test message'; + createComponent({ + slots: { footer: DUMMY }, + propsData: { panels: [{ name: 'test', href: '#' }] }, + }); - expect(popover.exists()).toBe(true); - expect(popover.props().target()).toBe(wrapper.find({ ref: 'clipTip' }).element); + expect(wrapper.text()).toContain(DUMMY); }); }); diff --git a/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js b/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js new file mode 100644 index 00000000000..30937921900 --- /dev/null +++ b/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js @@ -0,0 +1,114 @@ +import { GlBreadcrumb } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import LegacyContainer from '~/vue_shared/new_namespace/components/legacy_container.vue'; +import WelcomePage from '~/vue_shared/new_namespace/components/welcome.vue'; +import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'; + +describe('Experimental new project creation app', () => { + let wrapper; + + const findWelcomePage = () => wrapper.findComponent(WelcomePage); + const findLegacyContainer = () => wrapper.findComponent(LegacyContainer); + const findBreadcrumb = () => wrapper.findComponent(GlBreadcrumb); + + const DEFAULT_PROPS = { + title: 'Create something', + initialBreadcrumb: 'Something', + panels: [ + { name: 'panel1', selector: '#some-selector1' }, + { name: 'panel2', selector: '#some-selector2' }, + ], + persistenceKey: 'DEMO-PERSISTENCE-KEY', + }; + + const createComponent = ({ slots, propsData } = {}) => { + wrapper = shallowMount(NewNamespacePage, { + slots, + propsData: { + ...DEFAULT_PROPS, + ...propsData, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + window.location.hash = ''; + }); + + it('passes experiment to welcome component if provided', () => { + const EXPERIMENT = 'foo'; + createComponent({ propsData: { experiment: EXPERIMENT } }); + + expect(findWelcomePage().props().experiment).toBe(EXPERIMENT); + }); + + describe('with empty hash', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders welcome page', () => { + expect(findWelcomePage().exists()).toBe(true); + }); + + it('does not render breadcrumbs', () => { + expect(findBreadcrumb().exists()).toBe(false); + }); + }); + + it('renders first container if jumpToLastPersistedPanel passed', () => { + createComponent({ propsData: { jumpToLastPersistedPanel: true } }); + expect(findWelcomePage().exists()).toBe(false); + expect(findLegacyContainer().exists()).toBe(true); + }); + + describe('when hash is not empty on load', () => { + beforeEach(() => { + window.location.hash = `#${DEFAULT_PROPS.panels[1].name}`; + createComponent(); + }); + + it('renders relevant container', () => { + expect(findWelcomePage().exists()).toBe(false); + + const container = findLegacyContainer(); + + expect(container.exists()).toBe(true); + expect(container.props().selector).toBe(DEFAULT_PROPS.panels[1].selector); + }); + + it('renders breadcrumbs', () => { + const breadcrumb = findBreadcrumb(); + expect(breadcrumb.exists()).toBe(true); + expect(breadcrumb.props().items[0].text).toBe(DEFAULT_PROPS.initialBreadcrumb); + }); + }); + + it('renders extra description if provided', () => { + window.location.hash = `#${DEFAULT_PROPS.panels[1].name}`; + const EXTRA_DESCRIPTION = 'Some extra description'; + createComponent({ + slots: { + 'extra-description': EXTRA_DESCRIPTION, + }, + }); + + expect(wrapper.text()).toContain(EXTRA_DESCRIPTION); + }); + + it('renders relevant container when hash changes', async () => { + createComponent(); + expect(findWelcomePage().exists()).toBe(true); + + window.location.hash = `#${DEFAULT_PROPS.panels[0].name}`; + const ev = document.createEvent('HTMLEvents'); + ev.initEvent('hashchange', false, false); + window.dispatchEvent(ev); + + await nextTick(); + expect(findWelcomePage().exists()).toBe(false); + expect(findLegacyContainer().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/vue_shared/security_reports/components/apollo_mocks.js b/spec/frontend/vue_shared/security_reports/components/apollo_mocks.js new file mode 100644 index 00000000000..066f9a57bc6 --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/components/apollo_mocks.js @@ -0,0 +1,12 @@ +export const buildConfigureSecurityFeatureMockFactory = (mutationType) => ({ + successPath = 'testSuccessPath', + errors = [], +} = {}) => ({ + data: { + [mutationType]: { + successPath, + errors, + __typename: `${mutationType}Payload`, + }, + }, +}); diff --git a/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js new file mode 100644 index 00000000000..517eee6a729 --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js @@ -0,0 +1,184 @@ +import { GlButton } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { featureToMutationMap } from 'ee_else_ce/security_configuration/components/constants'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { humanize } from '~/lib/utils/text_utility'; +import { redirectTo } from '~/lib/utils/url_utility'; +import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; +import { buildConfigureSecurityFeatureMockFactory } from './apollo_mocks'; + +jest.mock('~/lib/utils/url_utility'); + +Vue.use(VueApollo); + +const projectPath = 'namespace/project'; + +describe('ManageViaMr component', () => { + let wrapper; + + const findButton = () => wrapper.findComponent(GlButton); + + function createMockApolloProvider(mutation, handler) { + const requestHandlers = [[mutation, handler]]; + + return createMockApollo(requestHandlers); + } + + function createComponent({ + featureName = 'SAST', + featureType = 'sast', + isFeatureConfigured = false, + variant = undefined, + category = undefined, + ...options + } = {}) { + wrapper = extendedWrapper( + mount(ManageViaMr, { + provide: { + projectPath, + }, + propsData: { + feature: { + name: featureName, + type: featureType, + configured: isFeatureConfigured, + }, + variant, + category, + }, + ...options, + }), + ); + } + + afterEach(() => { + wrapper.destroy(); + }); + + // This component supports different report types/mutations depending on + // whether it's in a CE or EE context. This makes sure we are only testing + // the ones available in the current test context. + const supportedReportTypes = Object.entries(featureToMutationMap).map( + ([featureType, { getMutationPayload, mutationId }]) => { + const { mutation, variables: mutationVariables } = getMutationPayload(projectPath); + return [humanize(featureType), featureType, mutation, mutationId, mutationVariables]; + }, + ); + + describe.each(supportedReportTypes)( + '%s', + (featureName, featureType, mutation, mutationId, mutationVariables) => { + const buildConfigureSecurityFeatureMock = buildConfigureSecurityFeatureMockFactory( + mutationId, + ); + const successHandler = jest.fn(async () => buildConfigureSecurityFeatureMock()); + const noSuccessPathHandler = async () => + buildConfigureSecurityFeatureMock({ + successPath: '', + }); + const errorHandler = async () => + buildConfigureSecurityFeatureMock({ + errors: ['foo'], + }); + const pendingHandler = () => new Promise(() => {}); + + describe('when feature is configured', () => { + beforeEach(() => { + const apolloProvider = createMockApolloProvider(mutation, successHandler); + createComponent({ apolloProvider, featureName, featureType, isFeatureConfigured: true }); + }); + + it('it does not render a button', () => { + expect(findButton().exists()).toBe(false); + }); + }); + + describe('when feature is not configured', () => { + beforeEach(() => { + const apolloProvider = createMockApolloProvider(mutation, successHandler); + createComponent({ apolloProvider, featureName, featureType, isFeatureConfigured: false }); + }); + + it('it does render a button', () => { + expect(findButton().exists()).toBe(true); + }); + + it('clicking on the button triggers the configure mutation', () => { + findButton().trigger('click'); + + expect(successHandler).toHaveBeenCalledTimes(1); + expect(successHandler).toHaveBeenCalledWith(mutationVariables); + }); + }); + + describe('given a pending response', () => { + beforeEach(() => { + const apolloProvider = createMockApolloProvider(mutation, pendingHandler); + createComponent({ apolloProvider, featureName, featureType }); + }); + + it('renders spinner correctly', async () => { + const button = findButton(); + expect(button.props('loading')).toBe(false); + await button.trigger('click'); + expect(button.props('loading')).toBe(true); + }); + }); + + describe('given a successful response', () => { + beforeEach(() => { + const apolloProvider = createMockApolloProvider(mutation, successHandler); + createComponent({ apolloProvider, featureName, featureType }); + }); + + it('should call redirect helper with correct value', async () => { + await wrapper.trigger('click'); + await waitForPromises(); + expect(redirectTo).toHaveBeenCalledTimes(1); + expect(redirectTo).toHaveBeenCalledWith('testSuccessPath'); + // This is done for UX reasons. If the loading prop is set to false + // on success, then there's a period where the button is clickable + // again. Instead, we want the button to display a loading indicator + // for the remainder of the lifetime of the page (i.e., until the + // browser can start painting the new page it's been redirected to). + expect(findButton().props().loading).toBe(true); + }); + }); + + describe.each` + handler | message + ${noSuccessPathHandler} | ${`${featureName} merge request creation mutation failed`} + ${errorHandler} | ${'foo'} + `('given an error response', ({ handler, message }) => { + beforeEach(() => { + const apolloProvider = createMockApolloProvider(mutation, handler); + createComponent({ apolloProvider, featureName, featureType }); + }); + + it('should catch and emit error', async () => { + await wrapper.trigger('click'); + await waitForPromises(); + expect(wrapper.emitted('error')).toEqual([[message]]); + expect(findButton().props('loading')).toBe(false); + }); + }); + }, + ); + + describe('button props', () => { + it('passes the variant and category props to the GlButton', () => { + const variant = 'danger'; + const category = 'tertiary'; + createComponent({ variant, category }); + + expect(wrapper.findComponent(GlButton).props()).toMatchObject({ + variant, + category, + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/security_reports/mock_data.js b/spec/frontend/vue_shared/security_reports/mock_data.js index 7918f70d702..bd9ce3b7314 100644 --- a/spec/frontend/vue_shared/security_reports/mock_data.js +++ b/spec/frontend/vue_shared/security_reports/mock_data.js @@ -322,7 +322,7 @@ export const secretScanningDiffSuccessMock = { head_report_created_at: '2020-01-10T10:00:00.000Z', }; -export const securityReportDownloadPathsQueryNoArtifactsResponse = { +export const securityReportMergeRequestDownloadPathsQueryNoArtifactsResponse = { project: { mergeRequest: { headPipeline: { @@ -339,7 +339,7 @@ export const securityReportDownloadPathsQueryNoArtifactsResponse = { }, }; -export const securityReportDownloadPathsQueryResponse = { +export const securityReportMergeRequestDownloadPathsQueryResponse = { project: { mergeRequest: { headPipeline: { @@ -447,8 +447,114 @@ export const securityReportDownloadPathsQueryResponse = { }, }; +export const securityReportPipelineDownloadPathsQueryResponse = { + project: { + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/176', + jobs: { + nodes: [ + { + name: 'secret_detection', + artifacts: { + nodes: [ + { + downloadPath: + '/gitlab-org/secrets-detection-test/-/jobs/1399/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + { + downloadPath: + '/gitlab-org/secrets-detection-test/-/jobs/1399/artifacts/download?file_type=secret_detection', + fileType: 'SECRET_DETECTION', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + __typename: 'CiJob', + }, + { + name: 'bandit-sast', + artifacts: { + nodes: [ + { + downloadPath: + '/gitlab-org/secrets-detection-test/-/jobs/1400/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + { + downloadPath: + '/gitlab-org/secrets-detection-test/-/jobs/1400/artifacts/download?file_type=sast', + fileType: 'SAST', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + __typename: 'CiJob', + }, + { + name: 'eslint-sast', + artifacts: { + nodes: [ + { + downloadPath: + '/gitlab-org/secrets-detection-test/-/jobs/1401/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + { + downloadPath: + '/gitlab-org/secrets-detection-test/-/jobs/1401/artifacts/download?file_type=sast', + fileType: 'SAST', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + __typename: 'CiJob', + }, + { + name: 'all_artifacts', + artifacts: { + nodes: [ + { + downloadPath: + '/gitlab-org/secrets-detection-test/-/jobs/1402/artifacts/download?file_type=archive', + fileType: 'ARCHIVE', + __typename: 'CiJobArtifact', + }, + { + downloadPath: + '/gitlab-org/secrets-detection-test/-/jobs/1402/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + { + downloadPath: + '/gitlab-org/secrets-detection-test/-/jobs/1402/artifacts/download?file_type=metadata', + fileType: 'METADATA', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + __typename: 'CiJob', + }, + ], + __typename: 'CiJobConnection', + }, + __typename: 'Pipeline', + }, + __typename: 'MergeRequest', + }, + __typename: 'Project', +}; + /** - * These correspond to SAST jobs in the securityReportDownloadPathsQueryResponse above. + * These correspond to SAST jobs in the securityReportMergeRequestDownloadPathsQueryResponse above. */ export const sastArtifacts = [ { @@ -464,7 +570,7 @@ export const sastArtifacts = [ ]; /** - * These correspond to Secret Detection jobs in the securityReportDownloadPathsQueryResponse above. + * These correspond to Secret Detection jobs in the securityReportMergeRequestDownloadPathsQueryResponse above. */ export const secretDetectionArtifacts = [ { @@ -481,7 +587,7 @@ export const expectedDownloadDropdownProps = { }; /** - * These correspond to any jobs with zip archives in the securityReportDownloadPathsQueryResponse above. + * These correspond to any jobs with zip archives in the securityReportMergeRequestDownloadPathsQueryResponse above. */ export const archiveArtifacts = [ { @@ -492,7 +598,7 @@ export const archiveArtifacts = [ ]; /** - * These correspond to any jobs with trace data in the securityReportDownloadPathsQueryResponse above. + * These correspond to any jobs with trace data in the securityReportMergeRequestDownloadPathsQueryResponse above. */ export const traceArtifacts = [ { @@ -518,7 +624,7 @@ export const traceArtifacts = [ ]; /** - * These correspond to any jobs with metadata data in the securityReportDownloadPathsQueryResponse above. + * These correspond to any jobs with metadata data in the securityReportMergeRequestDownloadPathsQueryResponse above. */ export const metadataArtifacts = [ { diff --git a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js index 0b4816a951e..038d7754776 100644 --- a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js +++ b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js @@ -9,8 +9,8 @@ import { trimText } from 'helpers/text_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { expectedDownloadDropdownProps, - securityReportDownloadPathsQueryNoArtifactsResponse, - securityReportDownloadPathsQueryResponse, + securityReportMergeRequestDownloadPathsQueryNoArtifactsResponse, + securityReportMergeRequestDownloadPathsQueryResponse, sastDiffSuccessMock, secretScanningDiffSuccessMock, } from 'jest/vue_shared/security_reports/mock_data'; @@ -22,7 +22,7 @@ import { REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION, } from '~/vue_shared/security_reports/constants'; -import securityReportDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_download_paths.query.graphql'; +import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql'; import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_app.vue'; jest.mock('~/flash'); @@ -59,12 +59,13 @@ describe('Security reports app', () => { }; const pendingHandler = () => new Promise(() => {}); - const successHandler = () => Promise.resolve({ data: securityReportDownloadPathsQueryResponse }); + const successHandler = () => + Promise.resolve({ data: securityReportMergeRequestDownloadPathsQueryResponse }); const successEmptyHandler = () => - Promise.resolve({ data: securityReportDownloadPathsQueryNoArtifactsResponse }); + Promise.resolve({ data: securityReportMergeRequestDownloadPathsQueryNoArtifactsResponse }); const failureHandler = () => Promise.resolve({ errors: [{ message: 'some error' }] }); const createMockApolloProvider = (handler) => { - const requestHandlers = [[securityReportDownloadPathsQuery, handler]]; + const requestHandlers = [[securityReportMergeRequestDownloadPathsQuery, handler]]; return createMockApollo(requestHandlers); }; diff --git a/spec/frontend/vue_shared/security_reports/utils_spec.js b/spec/frontend/vue_shared/security_reports/utils_spec.js index aa9e54fa10c..b7129ece698 100644 --- a/spec/frontend/vue_shared/security_reports/utils_spec.js +++ b/spec/frontend/vue_shared/security_reports/utils_spec.js @@ -3,9 +3,13 @@ import { REPORT_TYPE_SECRET_DETECTION, REPORT_FILE_TYPES, } from '~/vue_shared/security_reports/constants'; -import { extractSecurityReportArtifacts } from '~/vue_shared/security_reports/utils'; import { - securityReportDownloadPathsQueryResponse, + extractSecurityReportArtifactsFromMergeRequest, + extractSecurityReportArtifactsFromPipeline, +} from '~/vue_shared/security_reports/utils'; +import { + securityReportMergeRequestDownloadPathsQueryResponse, + securityReportPipelineDownloadPathsQueryResponse, sastArtifacts, secretDetectionArtifacts, archiveArtifacts, @@ -13,7 +17,18 @@ import { metadataArtifacts, } from './mock_data'; -describe('extractSecurityReportArtifacts', () => { +describe.each([ + [ + 'extractSecurityReportArtifactsFromMergeRequest', + extractSecurityReportArtifactsFromMergeRequest, + securityReportMergeRequestDownloadPathsQueryResponse, + ], + [ + 'extractSecurityReportArtifactsFromPipelines', + extractSecurityReportArtifactsFromPipeline, + securityReportPipelineDownloadPathsQueryResponse, + ], +])('%s', (funcName, extractFunc, response) => { it.each` reportTypes | expectedArtifacts ${[]} | ${[]} @@ -27,9 +42,7 @@ describe('extractSecurityReportArtifacts', () => { `( 'returns the expected artifacts given report types $reportTypes', ({ reportTypes, expectedArtifacts }) => { - expect( - extractSecurityReportArtifacts(reportTypes, securityReportDownloadPathsQueryResponse), - ).toEqual(expectedArtifacts); + expect(extractFunc(reportTypes, response)).toEqual(expectedArtifacts); }, ); }); diff --git a/spec/frontend/whats_new/components/app_spec.js b/spec/frontend/whats_new/components/app_spec.js index 45c4682208b..12034346aba 100644 --- a/spec/frontend/whats_new/components/app_spec.js +++ b/spec/frontend/whats_new/components/app_spec.js @@ -82,6 +82,7 @@ describe('App', () => { }); const getDrawer = () => wrapper.find(GlDrawer); + const getBackdrop = () => wrapper.find('.whats-new-modal-backdrop'); it('contains a drawer', () => { expect(getDrawer().exists()).toBe(true); @@ -100,6 +101,11 @@ describe('App', () => { expect(actions.closeDrawer).toHaveBeenCalled(); }); + it('dispatches closeDrawer when clicking the backdrop', () => { + getBackdrop().trigger('click'); + expect(actions.closeDrawer).toHaveBeenCalled(); + }); + it.each([true, false])('passes open property', async (openState) => { wrapper.vm.$store.state.open = openState; @@ -149,7 +155,10 @@ describe('App', () => { wrapper.vm.$store.state.pageInfo = { nextPage: 840 }; emitBottomReached(); - expect(actions.fetchItems).toHaveBeenCalledWith(expect.anything(), { page: 840 }); + expect(actions.fetchItems).toHaveBeenCalledWith(expect.anything(), { + page: 840, + versionDigest: 'version-digest', + }); }); it('when nextPage does not exist it does not call fetchItems', () => { diff --git a/spec/frontend/whats_new/components/feature_spec.js b/spec/frontend/whats_new/components/feature_spec.js new file mode 100644 index 00000000000..9e9cb59c0d6 --- /dev/null +++ b/spec/frontend/whats_new/components/feature_spec.js @@ -0,0 +1,46 @@ +import { shallowMount } from '@vue/test-utils'; +import Feature from '~/whats_new/components/feature.vue'; + +describe("What's new single feature", () => { + /** @type {import("@vue/test-utils").Wrapper} */ + let wrapper; + + const exampleFeature = { + title: 'Compliance pipeline configurations', + body: + '<p>We are thrilled to announce that it is now possible to define enforceable pipelines that will run for any project assigned a corresponding compliance framework.</p>', + stage: 'Manage', + 'self-managed': true, + 'gitlab-com': true, + packages: ['Ultimate'], + url: 'https://docs.gitlab.com/ee/user/project/settings/#compliance-pipeline-configuration', + image_url: 'https://img.youtube.com/vi/upLJ_equomw/hqdefault.jpg', + published_at: '2021-04-22T00:00:00.000Z', + release: '13.11', + }; + + const findReleaseDate = () => wrapper.find('[data-testid="release-date"]'); + + const createWrapper = ({ feature } = {}) => { + wrapper = shallowMount(Feature, { + propsData: { feature }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders the date', () => { + createWrapper({ feature: exampleFeature }); + expect(findReleaseDate().text()).toBe('April 22, 2021'); + }); + + describe('when the published_at is null', () => { + it("doesn't render the date", () => { + createWrapper({ feature: { ...exampleFeature, published_at: null } }); + expect(findReleaseDate().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/whats_new/store/actions_spec.js b/spec/frontend/whats_new/store/actions_spec.js index 39ad526cf14..c9614c7330b 100644 --- a/spec/frontend/whats_new/store/actions_spec.js +++ b/spec/frontend/whats_new/store/actions_spec.js @@ -44,16 +44,33 @@ describe('whats new actions', () => { axiosMock.restore(); }); + it("doesn't require arguments", () => { + axiosMock.reset(); + + axiosMock + .onGet('/-/whats_new', { params: { page: undefined, v: undefined } }) + .replyOnce(200, [{ title: 'GitLab Stories' }]); + + testAction( + actions.fetchItems, + {}, + {}, + expect.arrayContaining([ + { type: types.ADD_FEATURES, payload: [{ title: 'GitLab Stories' }] }, + ]), + ); + }); + it('passes arguments', () => { axiosMock.reset(); axiosMock - .onGet('/-/whats_new', { params: { page: 8 } }) + .onGet('/-/whats_new', { params: { page: 8, v: 42 } }) .replyOnce(200, [{ title: 'GitLab Stories' }]); testAction( actions.fetchItems, - { page: 8 }, + { page: 8, versionDigest: 42 }, {}, expect.arrayContaining([ { type: types.ADD_FEATURES, payload: [{ title: 'GitLab Stories' }] }, |