diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-05-19 18:44:42 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-05-19 18:44:42 +0300 |
commit | 4555e1b21c365ed8303ffb7a3325d773c9b8bf31 (patch) | |
tree | 5423a1c7516cffe36384133ade12572cf709398d /spec/frontend/vue_shared | |
parent | e570267f2f6b326480d284e0164a6464ba4081bc (diff) |
Add latest changes from gitlab-org/gitlab@13-12-stable-eev13.12.0-rc42
Diffstat (limited to 'spec/frontend/vue_shared')
35 files changed, 2065 insertions, 203 deletions
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/vue_shared/new_namespace/components/legacy_container_spec.js b/spec/frontend/vue_shared/new_namespace/components/legacy_container_spec.js new file mode 100644 index 00000000000..52f36aa0e77 --- /dev/null +++ b/spec/frontend/vue_shared/new_namespace/components/legacy_container_spec.js @@ -0,0 +1,63 @@ +import { shallowMount } from '@vue/test-utils'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import LegacyContainer from '~/vue_shared/new_namespace/components/legacy_container.vue'; + +describe('Legacy container component', () => { + let wrapper; + let dummy; + + const createComponent = (propsData) => { + wrapper = shallowMount(LegacyContainer, { propsData }); + }; + + afterEach(() => { + wrapper.destroy(); + resetHTMLFixture(); + wrapper = null; + }); + + describe('when selector targets real node', () => { + beforeEach(() => { + setHTMLFixture('<div class="dummy-target"></div>'); + dummy = document.querySelector('.dummy-target'); + createComponent({ selector: '.dummy-target' }); + }); + + describe('when mounted', () => { + it('moves node inside component', () => { + expect(dummy.parentNode).toBe(wrapper.element); + }); + + it('sets active class', () => { + expect(dummy.classList.contains('active')).toBe(true); + }); + }); + + describe('when unmounted', () => { + beforeEach(() => { + wrapper.destroy(); + }); + + it('moves node back', () => { + expect(dummy.parentNode).toBe(document.body); + }); + + it('removes active class', () => { + expect(dummy.classList.contains('active')).toBe(false); + }); + }); + }); + + describe('when selector targets template node', () => { + beforeEach(() => { + setHTMLFixture('<template class="dummy-target">content</template>'); + dummy = document.querySelector('.dummy-target'); + createComponent({ selector: '.dummy-target' }); + }); + + it('copies node content when mounted', () => { + expect(dummy.innerHTML).toEqual(wrapper.element.innerHTML); + expect(dummy.parentNode).toBe(document.body); + }); + }); +}); diff --git a/spec/frontend/vue_shared/new_namespace/components/welcome_spec.js b/spec/frontend/vue_shared/new_namespace/components/welcome_spec.js new file mode 100644 index 00000000000..602213fca83 --- /dev/null +++ b/spec/frontend/vue_shared/new_namespace/components/welcome_spec.js @@ -0,0 +1,78 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { mockTracking } from 'helpers/tracking_helper'; +import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; +import { getExperimentData } from '~/experimentation/utils'; +import WelcomePage from '~/vue_shared/new_namespace/components/welcome.vue'; + +jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn() })); + +describe('Welcome page', () => { + let wrapper; + let trackingSpy; + + const DEFAULT_PROPS = { + title: 'Create new something', + }; + + const createComponent = ({ propsData, slots }) => { + wrapper = shallowMount(WelcomePage, { + slots, + propsData: { + ...DEFAULT_PROPS, + ...propsData, + }, + }); + }; + + beforeEach(() => { + trackingSpy = mockTracking('_category_', document, jest.spyOn); + trackingSpy.mockImplementation(() => {}); + getExperimentData.mockReturnValue(undefined); + }); + + afterEach(() => { + wrapper.destroy(); + window.location.hash = ''; + wrapper = null; + }); + + it('tracks link clicks', async () => { + createComponent({ propsData: { experiment: 'foo', panels: [{ name: 'test', href: '#' }] } }); + const link = wrapper.find('a'); + link.trigger('click'); + await nextTick(); + return wrapper.vm.$nextTick().then(() => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_tab', { label: 'test' }); + }); + }); + + it('adds experiment data if in experiment', async () => { + const mockExperimentData = 'data'; + getExperimentData.mockReturnValue(mockExperimentData); + + createComponent({ propsData: { experiment: 'foo', panels: [{ name: 'test', href: '#' }] } }); + const link = wrapper.find('a'); + link.trigger('click'); + await nextTick(); + return wrapper.vm.$nextTick().then(() => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_tab', { + label: 'test', + context: { + data: mockExperimentData, + schema: TRACKING_CONTEXT_SCHEMA, + }, + }); + }); + }); + + it('renders footer slot if provided', () => { + const DUMMY = 'Test message'; + createComponent({ + slots: { footer: DUMMY }, + propsData: { panels: [{ name: 'test', href: '#' }] }, + }); + + 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); }, ); }); |