diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-08-31 15:13:01 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-08-31 15:13:01 +0300 |
commit | 3034c7e6aa99d21c3d9fa1df01f60fdd3f32d914 (patch) | |
tree | 424f5a291abf1a93ff9870667ecb301b899972fc /spec | |
parent | 6170bdc060501ecf6f817a530b3dc9f2e39ad4c3 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
31 files changed, 1742 insertions, 195 deletions
diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb index 14b198dbefe..0b8e6a6b6a2 100644 --- a/spec/controllers/search_controller_spec.rb +++ b/spec/controllers/search_controller_spec.rb @@ -270,6 +270,17 @@ RSpec.describe SearchController do get(:show, params: { search: 'foo@bar.com', scope: 'users' }) end end + + it 'increments the custom search sli apdex' do + expect(Gitlab::Metrics::GlobalSearchSlis).to receive(:record_apdex).with( + elapsed: a_kind_of(Numeric), + search_scope: 'issues', + search_type: 'basic', + search_level: 'global' + ) + + get :show, params: { scope: 'issues', search: 'hello world' } + end end describe 'GET #count', :aggregate_failures do diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb index 91971406bd6..e50674228f6 100644 --- a/spec/features/admin/admin_runners_spec.rb +++ b/spec/features/admin/admin_runners_spec.rb @@ -81,7 +81,7 @@ RSpec.describe "Admin Runners" do visit admin_runners_path within_runner_row(runner.id) do - expect(find("[data-label='Jobs']")).to have_content '2' + expect(find("[data-testid='job-count']")).to have_content '2' end end diff --git a/spec/features/projects/fork_spec.rb b/spec/features/projects/fork_spec.rb index fb27f0961b6..b8c127f0078 100644 --- a/spec/features/projects/fork_spec.rb +++ b/spec/features/projects/fork_spec.rb @@ -126,7 +126,10 @@ RSpec.describe 'Project fork' do let(:user) { create(:group_member, :maintainer, user: create(:user), group: group ).user } def submit_form - select(group.name) + find('[data-testid="select_namespace_dropdown"]').click + find('[data-testid="select_namespace_dropdown_search_field"]').fill_in(with: group.name) + click_button group.name + click_button 'Fork project' end diff --git a/spec/frontend/boards/components/board_blocked_icon_spec.js b/spec/frontend/boards/components/board_blocked_icon_spec.js index cf4ba07da16..ffdc0a7cecc 100644 --- a/spec/frontend/boards/components/board_blocked_icon_spec.js +++ b/spec/frontend/boards/components/board_blocked_icon_spec.js @@ -10,13 +10,17 @@ import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants'; import { truncate } from '~/lib/utils/text_utility'; import { mockIssue, + mockEpic, mockBlockingIssue1, mockBlockingIssue2, + mockBlockingEpic1, mockBlockingIssuablesResponse1, mockBlockingIssuablesResponse2, mockBlockingIssuablesResponse3, mockBlockedIssue1, mockBlockedIssue2, + mockBlockedEpic1, + mockBlockingEpicIssuablesResponse1, } from '../mock_data'; describe('BoardBlockedIcon', () => { @@ -51,9 +55,11 @@ describe('BoardBlockedIcon', () => { const createWrapperWithApollo = ({ item = mockBlockedIssue1, blockingIssuablesSpy = jest.fn().mockResolvedValue(mockBlockingIssuablesResponse1), + issuableItem = mockIssue, + issuableType = issuableTypes.issue, } = {}) => { mockApollo = createMockApollo([ - [blockingIssuablesQueries[issuableTypes.issue].query, blockingIssuablesSpy], + [blockingIssuablesQueries[issuableType].query, blockingIssuablesSpy], ]); Vue.use(VueApollo); @@ -62,27 +68,34 @@ describe('BoardBlockedIcon', () => { apolloProvider: mockApollo, propsData: { item: { - ...mockIssue, + ...issuableItem, ...item, }, uniqueId: 'uniqueId', - issuableType: issuableTypes.issue, + issuableType, }, attachTo: document.body, }), ); }; - const createWrapper = ({ item = {}, queries = {}, data = {}, loading = false } = {}) => { + const createWrapper = ({ + item = {}, + queries = {}, + data = {}, + loading = false, + mockIssuable = mockIssue, + issuableType = issuableTypes.issue, + } = {}) => { wrapper = extendedWrapper( shallowMount(BoardBlockedIcon, { propsData: { item: { - ...mockIssue, + ...mockIssuable, ...item, }, uniqueId: 'uniqueid', - issuableType: issuableTypes.issue, + issuableType, }, data() { return { @@ -105,11 +118,24 @@ describe('BoardBlockedIcon', () => { ); }; - it('should render blocked icon', () => { - createWrapper(); + it.each` + mockIssuable | issuableType | expectedIcon + ${mockIssue} | ${issuableTypes.issue} | ${'issue-block'} + ${mockEpic} | ${issuableTypes.epic} | ${'entity-blocked'} + `( + 'should render blocked icon for $issuableType', + ({ mockIssuable, issuableType, expectedIcon }) => { + createWrapper({ + mockIssuable, + issuableType, + }); - expect(findGlIcon().exists()).toBe(true); - }); + expect(findGlIcon().exists()).toBe(true); + const icon = findGlIcon(); + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe(expectedIcon); + }, + ); it('should display a loading spinner while loading', () => { createWrapper({ loading: true }); @@ -124,17 +150,29 @@ describe('BoardBlockedIcon', () => { }); describe('on mouseenter on blocked icon', () => { - it('should query for blocking issuables and render the result', async () => { - createWrapperWithApollo(); + it.each` + item | issuableType | mockBlockingIssuable | issuableItem | blockingIssuablesSpy + ${mockBlockedIssue1} | ${issuableTypes.issue} | ${mockBlockingIssue1} | ${mockIssue} | ${jest.fn().mockResolvedValue(mockBlockingIssuablesResponse1)} + ${mockBlockedEpic1} | ${issuableTypes.epic} | ${mockBlockingEpic1} | ${mockEpic} | ${jest.fn().mockResolvedValue(mockBlockingEpicIssuablesResponse1)} + `( + 'should query for blocking issuables and render the result for $issuableType', + async ({ item, issuableType, issuableItem, mockBlockingIssuable, blockingIssuablesSpy }) => { + createWrapperWithApollo({ + item, + issuableType, + issuableItem, + blockingIssuablesSpy, + }); - expect(findGlPopover().text()).not.toContain(mockBlockingIssue1.title); + expect(findGlPopover().text()).not.toContain(mockBlockingIssuable.title); - await mouseenter(); + await mouseenter(); - expect(findGlPopover().exists()).toBe(true); - expect(findIssuableTitle().text()).toContain(mockBlockingIssue1.title); - expect(wrapper.vm.skip).toBe(true); - }); + expect(findGlPopover().exists()).toBe(true); + expect(findIssuableTitle().text()).toContain(mockBlockingIssuable.title); + expect(wrapper.vm.skip).toBe(true); + }, + ); it('should emit "blocking-issuables-error" event on query error', async () => { const mockError = new Error('mayday'); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 0e739f03f31..dc1f3246be0 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -266,6 +266,7 @@ export const rawIssue = { }; export const mockIssueFullPath = 'gitlab-org/test-subgroup/gitlab-test'; +export const mockEpicFullPath = 'gitlab-org/test-subgroup'; export const mockIssue = { id: 'gid://gitlab/Issue/436', @@ -291,6 +292,47 @@ export const mockIssue = { type: 'ISSUE', }; +export const mockEpic = { + id: 'gid://gitlab/Epic/26', + iid: '1', + group: { + id: 'gid://gitlab/Group/33', + fullPath: 'twitter', + __typename: 'Group', + }, + title: 'Eum animi debitis occaecati ad non odio repellat voluptatem similique.', + state: 'opened', + reference: '&1', + referencePath: `${mockEpicFullPath}&1`, + webPath: `/groups/${mockEpicFullPath}/-/epics/1`, + webUrl: `${mockEpicFullPath}/-/epics/1`, + createdAt: '2022-01-18T05:15:15Z', + closedAt: null, + __typename: 'Epic', + relativePosition: null, + confidential: false, + subscribed: true, + blocked: true, + blockedByCount: 1, + labels: { + nodes: [], + __typename: 'LabelConnection', + }, + hasIssues: true, + descendantCounts: { + closedEpics: 0, + closedIssues: 0, + openedEpics: 0, + openedIssues: 2, + __typename: 'EpicDescendantCount', + }, + descendantWeightSum: { + closedIssues: 0, + openedIssues: 0, + __typename: 'EpicDescendantWeights', + }, +}; + export const mockActiveIssue = { ...mockIssue, id: 'gid://gitlab/Issue/436', @@ -523,6 +565,15 @@ export const mockBlockingIssue1 = { __typename: 'Issue', }; +export const mockBlockingEpic1 = { + id: 'gid://gitlab/Epic/29', + iid: '4', + title: 'Sint nihil exercitationem aspernatur unde molestiae rem accusantium.', + reference: 'twitter&4', + webUrl: 'http://gdk.test:3000/groups/gitlab-org/test-subgroup/-/epics/4', + __typename: 'Epic', +}; + export const mockBlockingIssue2 = { id: 'gid://gitlab/Issue/524', iid: '5', @@ -564,6 +615,23 @@ export const mockBlockingIssuablesResponse1 = { }, }; +export const mockBlockingEpicIssuablesResponse1 = { + data: { + group: { + __typename: 'Group', + id: 'gid://gitlab/Group/33', + issuable: { + __typename: 'Epic', + id: 'gid://gitlab/Epic/26', + blockingIssuables: { + __typename: 'EpicConnection', + nodes: [mockBlockingEpic1], + }, + }, + }, + }, +}; + export const mockBlockingIssuablesResponse2 = { data: { issuable: { @@ -601,6 +669,12 @@ export const mockBlockedIssue2 = { webUrl: 'http://gdk.test:3000/gitlab-org/my-project-1/-/issues/0', }; +export const mockBlockedEpic1 = { + id: '26', + blockedByCount: 1, + webUrl: 'http://gdk.test:3000/gitlab-org/test-subgroup/-/epics/1', +}; + export const mockMoveIssueParams = { itemId: 1, fromListId: 'gid://gitlab/List/1', diff --git a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js index 2a0fde45384..f676f2db08e 100644 --- a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js @@ -4,11 +4,14 @@ import { mount, shallowMount } from '@vue/test-utils'; import axios from 'axios'; import AxiosMockAdapter from 'axios-mock-adapter'; import { kebabCase } from 'lodash'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import createFlash from '~/flash'; -import httpStatus from '~/lib/utils/http_status'; import * as urlUtility from '~/lib/utils/url_utility'; import ForkForm from '~/pages/projects/forks/new/components/fork_form.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import searchQuery from '~/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql'; +import ProjectNamespace from '~/pages/projects/forks/new/components/project_namespace.vue'; jest.mock('~/flash'); jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); @@ -16,6 +19,7 @@ jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); describe('ForkForm component', () => { let wrapper; let axiosMock; + let mockQueryResponse; const PROJECT_VISIBILITY_TYPE = { private: @@ -24,26 +28,11 @@ describe('ForkForm component', () => { public: 'Public The project can be accessed without any authentication.', }; - const GON_GITLAB_URL = 'https://gitlab.com'; const GON_API_VERSION = 'v7'; - const MOCK_NAMESPACES_RESPONSE = [ - { - name: 'one', - full_name: 'one-group/one', - id: 1, - }, - { - name: 'two', - full_name: 'two-group/two', - id: 2, - }, - ]; - const DEFAULT_PROVIDE = { newGroupPath: 'some/groups/path', visibilityHelpPath: 'some/visibility/help/path', - endpoint: '/some/project-full-path/-/forks/new.json', projectFullPath: '/some/project-full-path', projectId: '10', projectName: 'Project Name', @@ -53,12 +42,44 @@ describe('ForkForm component', () => { restrictedVisibilityLevels: [], }; - const mockGetRequest = (data = {}, statusCode = httpStatus.OK) => { - axiosMock.onGet(DEFAULT_PROVIDE.endpoint).replyOnce(statusCode, data); - }; + Vue.use(VueApollo); const createComponentFactory = (mountFn) => (provide = {}, data = {}) => { + const queryResponse = { + project: { + id: 'gid://gitlab/Project/1', + forkTargets: { + nodes: [ + { + id: 'gid://gitlab/Group/21', + fullPath: 'flightjs', + name: 'Flight JS', + visibility: 'public', + }, + { + id: 'gid://gitlab/Namespace/4', + fullPath: 'root', + name: 'Administrator', + visibility: 'public', + }, + ], + }, + }, + }; + + mockQueryResponse = jest.fn().mockResolvedValue({ data: queryResponse }); + const requestHandlers = [[searchQuery, mockQueryResponse]]; + const apolloProvider = createMockApollo(requestHandlers); + + apolloProvider.clients.defaultClient.cache.writeQuery({ + query: searchQuery, + data: { + ...queryResponse, + }, + }); + wrapper = mountFn(ForkForm, { + apolloProvider, provide: { ...DEFAULT_PROVIDE, ...provide, @@ -83,7 +104,6 @@ describe('ForkForm component', () => { beforeEach(() => { axiosMock = new AxiosMockAdapter(axios); window.gon = { - gitlab_url: GON_GITLAB_URL, api_version: GON_API_VERSION, }; }); @@ -93,12 +113,11 @@ describe('ForkForm component', () => { axiosMock.restore(); }); - const findFormSelectOptions = () => wrapper.find('select[name="namespace"]').findAll('option'); const findPrivateRadio = () => wrapper.find('[data-testid="radio-private"]'); const findInternalRadio = () => wrapper.find('[data-testid="radio-internal"]'); const findPublicRadio = () => wrapper.find('[data-testid="radio-public"]'); const findForkNameInput = () => wrapper.find('[data-testid="fork-name-input"]'); - const findForkUrlInput = () => wrapper.find('[data-testid="fork-url-input"]'); + const findForkUrlInput = () => wrapper.findComponent(ProjectNamespace); const findForkSlugInput = () => wrapper.find('[data-testid="fork-slug-input"]'); const findForkDescriptionTextarea = () => wrapper.find('[data-testid="fork-description-textarea"]'); @@ -106,7 +125,6 @@ describe('ForkForm component', () => { wrapper.find('[data-testid="fork-visibility-radio-group"]'); it('will go to projectFullPath when click cancel button', () => { - mockGetRequest(); createComponent(); const { projectFullPath } = DEFAULT_PROVIDE; @@ -115,8 +133,13 @@ describe('ForkForm component', () => { expect(cancelButton.attributes('href')).toBe(projectFullPath); }); + const selectedMockNamespace = { name: 'two', full_name: 'two-group/two', id: 2 }; + + const fillForm = () => { + findForkUrlInput().vm.$emit('select', selectedMockNamespace); + }; + it('has input with csrf token', () => { - mockGetRequest(); createComponent(); expect(wrapper.find('input[name="authenticity_token"]').attributes('value')).toBe( @@ -125,7 +148,6 @@ describe('ForkForm component', () => { }); it('pre-populate form from project props', () => { - mockGetRequest(); createComponent(); expect(findForkNameInput().attributes('value')).toBe(DEFAULT_PROVIDE.projectName); @@ -135,75 +157,19 @@ describe('ForkForm component', () => { ); }); - it('sets project URL prepend text with gon.gitlab_url', () => { - mockGetRequest(); - createComponent(); - - expect(wrapper.find(GlFormInputGroup).text()).toContain(`${GON_GITLAB_URL}/`); - }); - it('will have required attribute for required fields', () => { - mockGetRequest(); createComponent(); expect(findForkNameInput().attributes('required')).not.toBeUndefined(); - expect(findForkUrlInput().attributes('required')).not.toBeUndefined(); expect(findForkSlugInput().attributes('required')).not.toBeUndefined(); expect(findVisibilityRadioGroup().attributes('required')).not.toBeUndefined(); expect(findForkDescriptionTextarea().attributes('required')).toBeUndefined(); }); - describe('forks namespaces', () => { - beforeEach(() => { - mockGetRequest({ namespaces: MOCK_NAMESPACES_RESPONSE }); - createFullComponent(); - }); - - it('make GET request from endpoint', async () => { - await axios.waitForAll(); - - expect(axiosMock.history.get[0].url).toBe(DEFAULT_PROVIDE.endpoint); - }); - - it('generate default option', async () => { - await axios.waitForAll(); - - const optionsArray = findForkUrlInput().findAll('option'); - - expect(optionsArray.at(0).text()).toBe('Select a namespace'); - }); - - it('populate project url namespace options', async () => { - await axios.waitForAll(); - - const optionsArray = findForkUrlInput().findAll('option'); - - expect(optionsArray).toHaveLength(MOCK_NAMESPACES_RESPONSE.length + 1); - expect(optionsArray.at(1).text()).toBe(MOCK_NAMESPACES_RESPONSE[0].full_name); - expect(optionsArray.at(2).text()).toBe(MOCK_NAMESPACES_RESPONSE[1].full_name); - }); - - it('set namespaces in alphabetical order', async () => { - const namespace = { - name: 'three', - full_name: 'aaa/three', - id: 3, - }; - mockGetRequest({ - namespaces: [...MOCK_NAMESPACES_RESPONSE, namespace], - }); - createComponent(); - await axios.waitForAll(); - - expect(wrapper.vm.namespaces).toEqual([namespace, ...MOCK_NAMESPACES_RESPONSE]); - }); - }); - describe('project slug', () => { const projectPath = 'some other project slug'; beforeEach(() => { - mockGetRequest(); createComponent({ projectPath, }); @@ -232,7 +198,6 @@ describe('ForkForm component', () => { describe('visibility level', () => { it('displays the correct description', () => { - mockGetRequest(); createComponent(); const formRadios = wrapper.findAll(GlFormRadio); @@ -243,7 +208,6 @@ describe('ForkForm component', () => { }); it('displays all 3 visibility levels', () => { - mockGetRequest(); createComponent(); expect(wrapper.findAll(GlFormRadio)).toHaveLength(3); @@ -262,16 +226,12 @@ describe('ForkForm component', () => { }, ]; - beforeEach(() => { - mockGetRequest(); - }); - it('resets the visibility to default "private"', async () => { createFullComponent({ projectVisibility: 'public' }, { namespaces }); expect(wrapper.vm.form.fields.visibility.value).toBe('public'); - await findFormSelectOptions().at(1).setSelected(); + fillForm(); await nextTick(); expect(getByRole(wrapper.element, 'radio', { name: /private/i }).checked).toBe(true); @@ -280,8 +240,7 @@ describe('ForkForm component', () => { it('sets the visibility to be null when restrictedVisibilityLevels is set', async () => { createFullComponent({ restrictedVisibilityLevels: [10] }, { namespaces }); - await findFormSelectOptions().at(1).setSelected(); - + fillForm(); await nextTick(); const container = getByRole(wrapper.element, 'radiogroup', { name: /visibility/i }); @@ -315,8 +274,7 @@ describe('ForkForm component', () => { ${'public'} | ${[0, 20]} ${'public'} | ${[10, 20]} ${'public'} | ${[0, 10, 20]} - `('checks the correct radio button', async ({ project, restrictedVisibilityLevels }) => { - mockGetRequest(); + `('checks the correct radio button', ({ project, restrictedVisibilityLevels }) => { createFullComponent({ projectVisibility: project, restrictedVisibilityLevels, @@ -357,7 +315,7 @@ describe('ForkForm component', () => { ${'public'} | ${'public'} | ${undefined} | ${'true'} | ${'true'} | ${[0, 10, 20]} `( 'sets appropriate radio button disabled state', - async ({ + ({ project, namespace, privateIsDisabled, @@ -365,7 +323,6 @@ describe('ForkForm component', () => { publicIsDisabled, restrictedVisibilityLevels, }) => { - mockGetRequest(); createComponent( { projectVisibility: project, @@ -387,11 +344,9 @@ describe('ForkForm component', () => { const setupComponent = (fields = {}) => { jest.spyOn(urlUtility, 'redirectTo').mockImplementation(); - mockGetRequest(); createFullComponent( {}, { - namespaces: MOCK_NAMESPACES_RESPONSE, form: { state: true, ...fields, @@ -400,17 +355,13 @@ describe('ForkForm component', () => { ); }; - const selectedMockNamespaceIndex = 1; - const namespaceId = MOCK_NAMESPACES_RESPONSE[selectedMockNamespaceIndex].id; - - const fillForm = async () => { - const namespaceOptions = findForkUrlInput().findAll('option'); - - await namespaceOptions.at(selectedMockNamespaceIndex + 1).setSelected(); - }; + beforeEach(() => { + setupComponent(); + }); const submitForm = async () => { - await fillForm(); + fillForm(); + await nextTick(); const form = wrapper.find(GlForm); await form.trigger('submit'); @@ -418,7 +369,7 @@ describe('ForkForm component', () => { }; describe('with invalid form', () => { - it('does not make POST request', async () => { + it('does not make POST request', () => { jest.spyOn(axios, 'post'); setupComponent(); @@ -471,7 +422,7 @@ describe('ForkForm component', () => { description: projectDescription, id: projectId, name: projectName, - namespace_id: namespaceId, + namespace_id: selectedMockNamespace.id, path: projectPath, visibility: projectVisibility, }; diff --git a/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js b/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js new file mode 100644 index 00000000000..1a88aebae32 --- /dev/null +++ b/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js @@ -0,0 +1,177 @@ +import { + GlButton, + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlSearchBoxByType, + GlTruncate, +} from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import createFlash from '~/flash'; +import searchQuery from '~/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql'; +import ProjectNamespace from '~/pages/projects/forks/new/components/project_namespace.vue'; + +jest.mock('~/flash'); + +describe('ProjectNamespace component', () => { + let wrapper; + let originalGon; + + const data = { + project: { + __typename: 'Project', + id: 'gid://gitlab/Project/1', + forkTargets: { + nodes: [ + { + id: 'gid://gitlab/Group/21', + fullPath: 'flightjs', + name: 'Flight JS', + visibility: 'public', + }, + { + id: 'gid://gitlab/Namespace/4', + fullPath: 'root', + name: 'Administrator', + visibility: 'public', + }, + ], + }, + }, + }; + + const mockQueryResponse = jest.fn().mockResolvedValue({ data }); + + const emptyQueryResponse = { + project: { + __typename: 'Project', + id: 'gid://gitlab/Project/1', + forkTargets: { + nodes: [], + }, + }, + }; + + const mockQueryError = jest.fn().mockRejectedValue(new Error('Network error')); + + Vue.use(VueApollo); + + const gitlabUrl = 'https://gitlab.com'; + + const defaultProvide = { + projectFullPath: 'gitlab-org/project', + }; + + const mountComponent = ({ + provide = defaultProvide, + queryHandler = mockQueryResponse, + mountFn = shallowMount, + } = {}) => { + const requestHandlers = [[searchQuery, queryHandler]]; + const apolloProvider = createMockApollo(requestHandlers); + + wrapper = mountFn(ProjectNamespace, { + apolloProvider, + provide, + }); + }; + + const findButtonLabel = () => wrapper.findComponent(GlButton); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownText = () => wrapper.findComponent(GlTruncate); + const findInput = () => wrapper.findComponent(GlSearchBoxByType); + + const clickDropdownItem = async () => { + wrapper.findComponent(GlDropdownItem).vm.$emit('click'); + await nextTick(); + }; + + const showDropdown = () => { + findDropdown().vm.$emit('shown'); + }; + + beforeAll(() => { + originalGon = window.gon; + window.gon = { gitlab_url: gitlabUrl }; + }); + + afterAll(() => { + window.gon = originalGon; + wrapper.destroy(); + }); + + describe('Initial state', () => { + beforeEach(() => { + mountComponent({ mountFn: mount }); + jest.runOnlyPendingTimers(); + }); + + it('renders the root url as a label', () => { + expect(findButtonLabel().text()).toBe(`${gitlabUrl}/`); + expect(findButtonLabel().props('label')).toBe(true); + }); + + it('renders placeholder text', () => { + expect(findDropdownText().props('text')).toBe('Select a namespace'); + }); + }); + + describe('After user interactions', () => { + beforeEach(async () => { + mountComponent({ mountFn: mount }); + jest.runOnlyPendingTimers(); + await nextTick(); + showDropdown(); + }); + + it('focuses on the input when the dropdown is opened', () => { + const spy = jest.spyOn(findInput().vm, 'focusInput'); + showDropdown(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('displays fetched namespaces', () => { + const listItems = wrapper.findAll('li'); + expect(listItems).toHaveLength(3); + expect(listItems.at(0).findComponent(GlDropdownSectionHeader).text()).toBe('Namespaces'); + expect(listItems.at(1).text()).toBe(data.project.forkTargets.nodes[0].fullPath); + expect(listItems.at(2).text()).toBe(data.project.forkTargets.nodes[1].fullPath); + }); + + it('sets the selected namespace', async () => { + const { fullPath } = data.project.forkTargets.nodes[0]; + await clickDropdownItem(); + expect(findDropdownText().props('text')).toBe(fullPath); + }); + }); + + describe('With empty query response', () => { + beforeEach(() => { + mountComponent({ queryHandler: emptyQueryResponse, mountFn: mount }); + jest.runOnlyPendingTimers(); + }); + + it('renders `No matches found`', () => { + expect(wrapper.find('li').text()).toBe('No matches found'); + }); + }); + + describe('With error while fetching data', () => { + beforeEach(async () => { + mountComponent({ queryHandler: mockQueryError }); + jest.runOnlyPendingTimers(); + await nextTick(); + }); + + it('creates a flash message and captures the error', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: 'Something went wrong while loading data. Please refresh the page to try again.', + captureError: true, + error: expect.any(Error), + }); + }); + }); +}); diff --git a/spec/frontend/runner/components/cells/runner_stacked_summary_cell_spec.js b/spec/frontend/runner/components/cells/runner_stacked_summary_cell_spec.js new file mode 100644 index 00000000000..65550524baa --- /dev/null +++ b/spec/frontend/runner/components/cells/runner_stacked_summary_cell_spec.js @@ -0,0 +1,164 @@ +import { __ } from '~/locale'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import RunnerStackedSummaryCell from '~/runner/components/cells/runner_stacked_summary_cell.vue'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import RunnerTags from '~/runner/components/runner_tags.vue'; +import RunnerSummaryField from '~/runner/components/cells/runner_summary_field.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + +import { INSTANCE_TYPE, PROJECT_TYPE } from '~/runner/constants'; + +import { allRunnersData } from '../../mock_data'; + +const mockRunner = allRunnersData.data.runners.nodes[0]; + +describe('RunnerTypeCell', () => { + let wrapper; + + const findLockIcon = () => wrapper.findByTestId('lock-icon'); + const findRunnerTags = () => wrapper.findComponent(RunnerTags); + const findRunnerSummaryField = (icon) => + wrapper.findAllComponents(RunnerSummaryField).filter((w) => w.props('icon') === icon) + .wrappers[0]; + + const createComponent = (runner, options) => { + wrapper = mountExtended(RunnerStackedSummaryCell, { + propsData: { + runner: { + ...mockRunner, + ...runner, + }, + }, + stubs: { + RunnerSummaryField, + }, + ...options, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays the runner name as id and short token', () => { + expect(wrapper.text()).toContain( + `#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`, + ); + }); + + it('Does not display the locked icon', () => { + expect(findLockIcon().exists()).toBe(false); + }); + + it('Displays the locked icon for locked runners', () => { + createComponent({ + runnerType: PROJECT_TYPE, + locked: true, + }); + + expect(findLockIcon().exists()).toBe(true); + }); + + it('Displays the runner type', () => { + createComponent({ + runnerType: INSTANCE_TYPE, + locked: true, + }); + + expect(wrapper.text()).toContain('shared'); + }); + + it('Displays the runner version', () => { + expect(wrapper.text()).toContain(mockRunner.version); + }); + + it('Displays the runner description', () => { + expect(wrapper.text()).toContain(mockRunner.description); + }); + + it('Displays last contact', () => { + createComponent({ + contactedAt: '2022-01-02', + }); + + expect(findRunnerSummaryField('clock').find(TimeAgo).props('time')).toBe('2022-01-02'); + }); + + it('Displays empty last contact', () => { + createComponent({ + contactedAt: null, + }); + + expect(findRunnerSummaryField('clock').find(TimeAgo).exists()).toBe(false); + expect(findRunnerSummaryField('clock').text()).toContain(__('Never')); + }); + + it('Displays ip address', () => { + createComponent({ + ipAddress: '127.0.0.1', + }); + + expect(findRunnerSummaryField('disk').text()).toContain('127.0.0.1'); + }); + + it('Displays no ip address', () => { + createComponent({ + ipAddress: null, + }); + + expect(findRunnerSummaryField('disk')).toBeUndefined(); + }); + + it('Displays job count', () => { + expect(findRunnerSummaryField('pipeline').text()).toContain(`${mockRunner.jobCount}`); + }); + + it('Formats large job counts ', () => { + createComponent({ + jobCount: 1000, + }); + + expect(findRunnerSummaryField('pipeline').text()).toContain('1,000'); + }); + + it('Formats large job counts with a plus symbol', () => { + createComponent({ + jobCount: 1001, + }); + + expect(findRunnerSummaryField('pipeline').text()).toContain('1,000+'); + }); + + it('Displays created at', () => { + expect(findRunnerSummaryField('calendar').find(TimeAgo).props('time')).toBe( + mockRunner.createdAt, + ); + }); + + it('Displays tag list', () => { + createComponent({ + tagList: ['shell', 'linux'], + }); + + expect(findRunnerTags().props('tagList')).toEqual(['shell', 'linux']); + }); + + it('Displays a custom slot', () => { + const slotContent = 'My custom runner name'; + + createComponent( + {}, + { + slots: { + 'runner-name': slotContent, + }, + }, + ); + + expect(wrapper.text()).toContain(slotContent); + }); +}); diff --git a/spec/frontend/runner/components/cells/runner_summary_field_spec.js b/spec/frontend/runner/components/cells/runner_summary_field_spec.js new file mode 100644 index 00000000000..b49addf112f --- /dev/null +++ b/spec/frontend/runner/components/cells/runner_summary_field_spec.js @@ -0,0 +1,49 @@ +import { GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerSummaryField from '~/runner/components/cells/runner_summary_field.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +describe('RunnerSummaryField', () => { + let wrapper; + + const findIcon = () => wrapper.findComponent(GlIcon); + const getTooltipValue = () => getBinding(wrapper.element, 'gl-tooltip').value; + + const createComponent = ({ props, ...options } = {}) => { + wrapper = shallowMount(RunnerSummaryField, { + propsData: { + icon: '', + tooltip: '', + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, + ...options, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('shows content in slot', () => { + createComponent({ + slots: { default: 'content' }, + }); + + expect(wrapper.text()).toBe('content'); + }); + + it('shows icon', () => { + createComponent({ props: { icon: 'git' } }); + + expect(findIcon().props('name')).toBe('git'); + }); + + it('shows tooltip', () => { + createComponent({ props: { tooltip: 'tooltip' } }); + + expect(getTooltipValue()).toBe('tooltip'); + }); +}); diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index 95673b3932b..c3c7616f338 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -22,7 +22,10 @@ describe('RunnerList', () => { const findCell = ({ row = 0, fieldKey }) => extendedWrapper(findRows().at(row).find(`[data-testid="td-${fieldKey}"]`)); - const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMountExtended) => { + const createComponent = ( + { props = {}, provide = {}, ...options } = {}, + mountFn = shallowMountExtended, + ) => { wrapper = mountFn(RunnerList, { propsData: { runners: mockRunners, @@ -32,6 +35,7 @@ describe('RunnerList', () => { provide: { onlineContactTimeoutSecs, staleTimeoutSecs, + ...provide, }, ...options, }); @@ -221,4 +225,60 @@ describe('RunnerList', () => { expect(findSkeletonLoader().exists()).toBe(false); }); }); + + describe.each` + glFeatures + ${{ runnerListStackedLayoutAdmin: true }} + ${{ runnerListStackedLayout: true }} + `('When glFeatures = $glFeatures', ({ glFeatures }) => { + beforeEach(() => { + createComponent( + { + stubs: { + RunnerStatusPopover: { + template: '<div/>', + }, + }, + provide: { + glFeatures, + }, + }, + mountExtended, + ); + }); + + it('Displays stacked list headers', () => { + const headerLabels = findHeaders().wrappers.map((w) => w.text()); + + expect(headerLabels).toEqual([ + 'Status', + 'Runner', + '', // actions has no label + ]); + }); + + it('Displays stacked details of a runner', () => { + const { id, description, version, shortSha } = mockRunners[0]; + const numericId = getIdFromGraphQLId(id); + + // Badges + expect(findCell({ fieldKey: 'status' }).text()).toMatchInterpolatedText('never contacted'); + + // Runner summary + const summary = findCell({ fieldKey: 'summary' }).text(); + + expect(summary).toContain(`#${numericId} (${shortSha})`); + expect(summary).toContain('specific'); + + expect(summary).toContain(version); + expect(summary).toContain(description); + + expect(summary).toContain('Last contact'); + expect(summary).toContain('0'); // job count + expect(summary).toContain('Created'); + + // Actions + expect(findCell({ fieldKey: 'actions' }).exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/set_status_modal/set_status_form_spec.js b/spec/frontend/set_status_modal/set_status_form_spec.js new file mode 100644 index 00000000000..8e1623eedf5 --- /dev/null +++ b/spec/frontend/set_status_modal/set_status_form_spec.js @@ -0,0 +1,167 @@ +import $ from 'jquery'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import SetStatusForm from '~/set_status_modal/set_status_form.vue'; +import EmojiPicker from '~/emoji/components/picker.vue'; +import { timeRanges } from '~/vue_shared/constants'; +import { sprintf } from '~/locale'; +import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; + +describe('SetStatusForm', () => { + let wrapper; + + const defaultPropsData = { + defaultEmoji: 'speech_balloon', + emoji: 'thumbsup', + message: 'Foo bar', + availability: false, + }; + + const createComponent = async ({ propsData = {} } = {}) => { + wrapper = mountExtended(SetStatusForm, { + propsData: { + ...defaultPropsData, + ...propsData, + }, + }); + + await waitForPromises(); + }; + + const findMessageInput = () => + wrapper.findByPlaceholderText(SetStatusForm.i18n.statusMessagePlaceholder); + const findSelectedEmoji = (emoji) => + wrapper.findByTestId('selected-emoji').find(`gl-emoji[data-name="${emoji}"]`); + + it('sets up emoji autocomplete for the message input', async () => { + const gfmAutoCompleteSetupSpy = jest.spyOn(GfmAutoComplete.prototype, 'setup'); + + await createComponent(); + + expect(gfmAutoCompleteSetupSpy).toHaveBeenCalledWith($(findMessageInput().element), { + emojis: true, + }); + }); + + describe('when emoji is set', () => { + it('displays emoji', async () => { + await createComponent(); + + expect(findSelectedEmoji(defaultPropsData.emoji).exists()).toBe(true); + }); + }); + + describe('when emoji is not set and message is changed', () => { + it('displays default emoji', async () => { + await createComponent({ + propsData: { + emoji: '', + }, + }); + + await findMessageInput().trigger('keyup'); + + expect(findSelectedEmoji(defaultPropsData.defaultEmoji).exists()).toBe(true); + }); + }); + + describe('when message is set', () => { + it('displays filled in message input', async () => { + await createComponent(); + + expect(findMessageInput().element.value).toBe(defaultPropsData.message); + }); + }); + + describe('when clear status after is set', () => { + it('displays value in dropdown toggle button', async () => { + const clearStatusAfter = timeRanges[0]; + + await createComponent({ + propsData: { + clearStatusAfter, + }, + }); + + expect(wrapper.findByRole('button', { name: clearStatusAfter.label }).exists()).toBe(true); + }); + }); + + describe('when emoji is changed', () => { + beforeEach(async () => { + await createComponent(); + + wrapper.findComponent(EmojiPicker).vm.$emit('click', defaultPropsData.emoji); + }); + + it('emits `emoji-click` event', () => { + expect(wrapper.emitted('emoji-click')).toEqual([[defaultPropsData.emoji]]); + }); + }); + + describe('when message is changed', () => { + it('emits `message-input` event', async () => { + await createComponent(); + + const newMessage = 'Foo bar baz'; + + await findMessageInput().setValue(newMessage); + + expect(wrapper.emitted('message-input')).toEqual([[newMessage]]); + }); + }); + + describe('when availability checkbox is changed', () => { + it('emits `availability-input` event', async () => { + await createComponent(); + + await wrapper + .findByLabelText( + `${SetStatusForm.i18n.availabilityCheckboxLabel} ${SetStatusForm.i18n.availabilityCheckboxHelpText}`, + ) + .setChecked(); + + expect(wrapper.emitted('availability-input')).toEqual([[true]]); + }); + }); + + describe('when `Clear status after` dropdown is changed', () => { + it('emits `clear-status-after-click`', async () => { + await wrapper.findByTestId('thirtyMinutes').trigger('click'); + + expect(wrapper.emitted('clear-status-after-click')).toEqual([[timeRanges[0]]]); + }); + }); + + describe('when clear status button is clicked', () => { + beforeEach(async () => { + await createComponent(); + + await wrapper + .findByRole('button', { name: SetStatusForm.i18n.clearStatusButtonLabel }) + .trigger('click'); + }); + + it('clears emoji and message', () => { + expect(wrapper.emitted('emoji-click')).toEqual([['']]); + expect(wrapper.emitted('message-input')).toEqual([['']]); + expect(wrapper.findByTestId('no-emoji-placeholder').exists()).toBe(true); + }); + }); + + describe('when `currentClearStatusAfter` prop is set', () => { + it('displays clear status message', async () => { + const date = '2022-08-25 21:14:48 UTC'; + + await createComponent({ + propsData: { + currentClearStatusAfter: date, + }, + }); + + expect( + wrapper.findByText(sprintf(SetStatusForm.i18n.clearStatusAfterMessage, { date })).exists(), + ).toBe(true); + }); + }); +}); diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js index e3b5478290a..4191c44bb99 100644 --- a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js +++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js @@ -9,6 +9,7 @@ import stubChildren from 'helpers/stub_children'; import SetStatusModalWrapper, { AVAILABILITY_STATUS, } from '~/set_status_modal/set_status_modal_wrapper.vue'; +import SetStatusForm from '~/set_status_modal/set_status_form.vue'; jest.mock('~/flash'); @@ -42,6 +43,7 @@ describe('SetStatusModalWrapper', () => { ...stubChildren(SetStatusModalWrapper), GlFormInput: false, GlFormInputGroup: false, + SetStatusForm: false, EmojiPicker: EmojiPickerStub, }, mocks: { @@ -118,10 +120,10 @@ describe('SetStatusModalWrapper', () => { }); }); - it('sets emojiTag when clicking in emoji picker', async () => { + it('passes emoji to `SetStatusForm`', async () => { await getEmojiPicker().vm.$emit('click', 'thumbsup'); - expect(wrapper.vm.emojiTag).toContain('data-name="thumbsup"'); + expect(wrapper.findComponent(SetStatusForm).props('emoji')).toBe('thumbsup'); }); }); @@ -194,7 +196,7 @@ describe('SetStatusModalWrapper', () => { findAvailabilityCheckbox().vm.$emit('input', true); // set the currentClearStatusAfter to 30 minutes - wrapper.find('[data-testid="thirtyMinutes"]').vm.$emit('click'); + wrapper.find('[data-testid="thirtyMinutes"]').trigger('click'); findModal().vm.$emit('primary'); await nextTick(); diff --git a/spec/frontend/surveys/merge_request_performance/app_spec.js b/spec/frontend/surveys/merge_request_performance/app_spec.js index 1d84685bf88..af91d8aeb6b 100644 --- a/spec/frontend/surveys/merge_request_performance/app_spec.js +++ b/spec/frontend/surveys/merge_request_performance/app_spec.js @@ -6,6 +6,17 @@ import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisse import MergeRequestExperienceSurveyApp from '~/surveys/merge_request_experience/app.vue'; import SatisfactionRate from '~/surveys/components/satisfaction_rate.vue'; +const createRenderTrackedArguments = () => [ + undefined, + 'survey:mr_experience', + { + label: 'render', + extra: { + accountAge: 0, + }, + }, +]; + describe('MergeRequestExperienceSurveyApp', () => { let trackingSpy; let wrapper; @@ -24,6 +35,7 @@ describe('MergeRequestExperienceSurveyApp', () => { dismiss, shouldShowCallout, }); + trackingSpy = mockTracking(undefined, undefined, jest.spyOn); wrapper = shallowMountExtended(MergeRequestExperienceSurveyApp, { propsData: { accountAge: 0, @@ -33,9 +45,12 @@ describe('MergeRequestExperienceSurveyApp', () => { GlSprintf, }, }); - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }; + beforeEach(() => { + localStorage.clear(); + }); + describe('when user callout is visible', () => { beforeEach(() => { createWrapper(); @@ -47,6 +62,16 @@ describe('MergeRequestExperienceSurveyApp', () => { expect(wrapper.emitted().close).toBe(undefined); }); + it('tracks render once', async () => { + expect(trackingSpy).toHaveBeenCalledWith(...createRenderTrackedArguments()); + }); + + it("doesn't track subsequent renders", async () => { + createWrapper(); + expect(trackingSpy).toHaveBeenCalledWith(...createRenderTrackedArguments()); + expect(trackingSpy).toHaveBeenCalledTimes(1); + }); + describe('when close button clicked', () => { beforeEach(() => { findCloseButton().vm.$emit('click'); @@ -68,6 +93,15 @@ describe('MergeRequestExperienceSurveyApp', () => { }, }); }); + + it('tracks subsequent renders', async () => { + createWrapper(); + expect(trackingSpy.mock.calls).toEqual([ + createRenderTrackedArguments(), + expect.anything(), + createRenderTrackedArguments(), + ]); + }); }); it('applies correct feature name for user callout', () => { @@ -148,6 +182,10 @@ describe('MergeRequestExperienceSurveyApp', () => { it('emits close event', async () => { expect(wrapper.emitted()).toMatchObject({ close: [[]] }); }); + + it("doesn't track anything", async () => { + expect(trackingSpy).toHaveBeenCalledTimes(0); + }); }); describe('when Escape key is pressed', () => { diff --git a/spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js b/spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js index cb53dc1fb61..063425454d7 100644 --- a/spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js @@ -1,10 +1,10 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import AddedCommentMessage from '~/vue_merge_request_widget/components/added_commit_message.vue'; let wrapper; function factory(propsData) { - wrapper = shallowMount(AddedCommentMessage, { + wrapper = mount(AddedCommentMessage, { propsData: { isFastForwardEnabled: false, targetBranch: 'main', @@ -23,4 +23,13 @@ describe('Widget added commit message', () => { expect(wrapper.element.outerHTML).toContain('The changes were not merged'); }); + + it('renders merge commit as a link', () => { + factory({ state: 'merged', mergeCommitPath: 'https://test.host/merge-commit-link' }); + + expect(wrapper.find('[data-testid="merge-commit-sha"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="merge-commit-sha"]').attributes('href')).toBe( + 'https://test.host/merge-commit-link', + ); + }); }); diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb index ca336c3ecaa..75ac2ca87ab 100644 --- a/spec/lib/gitlab/ci/config/entry/job_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb @@ -605,8 +605,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do let(:deps) do double('deps', 'default_entry' => default, - 'workflow_entry' => workflow, - 'variables_value' => nil) + 'workflow_entry' => workflow) end context 'when job config overrides default config' do diff --git a/spec/lib/gitlab/ci/config/entry/legacy_variables_spec.rb b/spec/lib/gitlab/ci/config/entry/legacy_variables_spec.rb new file mode 100644 index 00000000000..252a2b4eacb --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/legacy_variables_spec.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Config::Entry::LegacyVariables do + let(:config) { {} } + let(:metadata) { {} } + + subject(:entry) { described_class.new(config, **metadata) } + + before do + entry.compose! + end + + shared_examples 'valid config' do + describe '#value' do + it 'returns hash with key value strings' do + expect(entry.value).to eq result + end + end + + describe '#errors' do + it 'does not append errors' do + expect(entry.errors).to be_empty + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + shared_examples 'invalid config' do |error_message| + describe '#valid?' do + it 'is not valid' do + expect(entry).not_to be_valid + end + end + + describe '#errors' do + it 'saves errors' do + expect(entry.errors) + .to include(error_message) + end + end + end + + context 'when entry config value has key-value pairs' do + let(:config) do + { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' } + end + + let(:result) do + { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' } + end + + it_behaves_like 'valid config' + + describe '#value_with_data' do + it 'returns variable with data' do + expect(entry.value_with_data).to eq( + 'VARIABLE_1' => { value: 'value 1', description: nil }, + 'VARIABLE_2' => { value: 'value 2', description: nil } + ) + end + end + end + + context 'with numeric keys and values in the config' do + let(:config) { { 10 => 20 } } + let(:result) do + { '10' => '20' } + end + + it_behaves_like 'valid config' + end + + context 'when key is an array' do + let(:config) { { ['VAR1'] => 'val1' } } + let(:result) do + { 'VAR1' => 'val1' } + end + + it_behaves_like 'invalid config', /should be a hash of key value pairs/ + end + + context 'when value is a symbol' do + let(:config) { { 'VAR1' => :val1 } } + let(:result) do + { 'VAR1' => 'val1' } + end + + it_behaves_like 'valid config' + end + + context 'when value is a boolean' do + let(:config) { { 'VAR1' => true } } + let(:result) do + { 'VAR1' => 'val1' } + end + + it_behaves_like 'invalid config', /should be a hash of key value pairs/ + end + + context 'when entry config value has key-value pair and hash' do + let(:config) do + { 'VARIABLE_1' => { value: 'value 1', description: 'variable 1' }, + 'VARIABLE_2' => 'value 2' } + end + + it_behaves_like 'invalid config', /should be a hash of key value pairs/ + + context 'when metadata has use_value_data: true' do + let(:metadata) { { use_value_data: true } } + + let(:result) do + { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' } + end + + it_behaves_like 'valid config' + + describe '#value_with_data' do + it 'returns variable with data' do + expect(entry.value_with_data).to eq( + 'VARIABLE_1' => { value: 'value 1', description: 'variable 1' }, + 'VARIABLE_2' => { value: 'value 2', description: nil } + ) + end + end + end + end + + context 'when entry value is an array' do + let(:config) { [:VAR, 'test'] } + + it_behaves_like 'invalid config', /should be a hash of key value pairs/ + end + + context 'when metadata has use_value_data: true' do + let(:metadata) { { use_value_data: true } } + + context 'when entry value has hash with other key-pairs' do + let(:config) do + { 'VARIABLE_1' => { value: 'value 1', hello: 'variable 1' }, + 'VARIABLE_2' => 'value 2' } + end + + it_behaves_like 'invalid config', /should be a hash of key value pairs, value can be a hash/ + end + + context 'when entry config value has hash with nil description' do + let(:config) do + { 'VARIABLE_1' => { value: 'value 1', description: nil } } + end + + it_behaves_like 'invalid config', /should be a hash of key value pairs, value can be a hash/ + end + + context 'when entry config value has hash without description' do + let(:config) do + { 'VARIABLE_1' => { value: 'value 1' } } + end + + let(:result) do + { 'VARIABLE_1' => 'value 1' } + end + + it_behaves_like 'valid config' + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/processable_spec.rb b/spec/lib/gitlab/ci/config/entry/processable_spec.rb index 714b0a3b6aa..5f42a8c49a7 100644 --- a/spec/lib/gitlab/ci/config/entry/processable_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/processable_spec.rb @@ -197,6 +197,34 @@ RSpec.describe Gitlab::Ci::Config::Entry::Processable do end end end + + context 'when a variable has an invalid data attribute' do + let(:config) do + { + script: 'echo', + variables: { 'VAR1' => 'val 1', 'VAR2' => { value: 'val 2', description: 'hello var 2' } } + } + end + + it 'reports error about variable' do + expect(entry.errors) + .to include 'variables:var2 config must be a string' + end + + context 'when the FF ci_variables_refactoring_to_variable is disabled' do + let(:entry_without_ff) { node_class.new(config, name: :rspec) } + + before do + stub_feature_flags(ci_variables_refactoring_to_variable: false) + entry_without_ff.compose! + end + + it 'reports error about variable' do + expect(entry_without_ff.errors) + .to include /config should be a hash of key value pairs/ + end + end + end end end @@ -212,13 +240,11 @@ RSpec.describe Gitlab::Ci::Config::Entry::Processable do let(:unspecified) { double('unspecified', 'specified?' => false) } let(:default) { double('default', '[]' => unspecified) } let(:workflow) { double('workflow', 'has_rules?' => false) } - let(:variables) {} let(:deps) do double('deps', default_entry: default, - workflow_entry: workflow, - variables_value: variables) + workflow_entry: workflow) end context 'with workflow rules' do diff --git a/spec/lib/gitlab/ci/config/entry/root_spec.rb b/spec/lib/gitlab/ci/config/entry/root_spec.rb index 55ad119ea21..5efc65f2117 100644 --- a/spec/lib/gitlab/ci/config/entry/root_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/root_spec.rb @@ -350,6 +350,33 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do end end end + + context 'when a variable has an invalid data key' do + let(:hash) do + { variables: { VAR1: { invalid: 'hello' } }, rspec: { script: 'hello' } } + end + + describe '#errors' do + it 'reports errors about the invalid variable' do + expect(root.errors) + .to include /var1 config uses invalid data keys: invalid/ + end + + context 'when the FF ci_variables_refactoring_to_variable is disabled' do + let(:root_without_ff) { described_class.new(hash, user: user, project: project) } + + before do + stub_feature_flags(ci_variables_refactoring_to_variable: false) + root_without_ff.compose! + end + + it 'reports errors about the invalid variable' do + expect(root_without_ff.errors) + .to include /variables config should be a hash of key value pairs, value can be a hash/ + end + end + end + end end context 'when value is not a hash' do diff --git a/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb b/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb index c85fe366da6..303d825c591 100644 --- a/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true -require 'fast_spec_helper' +require 'spec_helper' require 'gitlab_chronic_duration' -require_dependency 'active_model' RSpec.describe Gitlab::Ci::Config::Entry::Rules::Rule do let(:factory) do @@ -363,7 +362,20 @@ RSpec.describe Gitlab::Ci::Config::Entry::Rules::Rule do it { is_expected.not_to be_valid } it 'returns an error about invalid variables:' do - expect(subject.errors).to include(/variables config should be a hash of key value pairs/) + expect(subject.errors).to include(/variables config should be a hash/) + end + + context 'when the FF ci_variables_refactoring_to_variable is disabled' do + let(:entry_without_ff) { factory.create! } + + before do + stub_feature_flags(ci_variables_refactoring_to_variable: false) + entry_without_ff.compose! + end + + it 'returns an error about invalid variables:' do + expect(subject.errors).to include(/variables config should be a hash/) + end end end end diff --git a/spec/lib/gitlab/ci/config/entry/variable_spec.rb b/spec/lib/gitlab/ci/config/entry/variable_spec.rb new file mode 100644 index 00000000000..ebc4e8f9984 --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/variable_spec.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Config::Entry::Variable do + let(:config) { {} } + let(:metadata) { {} } + + subject(:entry) do + described_class.new(config, **metadata).tap do |entry| + entry.key = 'VAR1' # composable_hash requires key to be set + end + end + + before do + entry.compose! + end + + describe 'SimpleVariable' do + context 'when config is a string' do + let(:config) { 'value' } + + describe '#valid?' do + it { is_expected.to be_valid } + end + + describe '#value' do + subject(:value) { entry.value } + + it { is_expected.to eq('value') } + end + end + + context 'when config is an integer' do + let(:config) { 1 } + + describe '#valid?' do + it { is_expected.to be_valid } + end + + describe '#value' do + subject(:value) { entry.value } + + it { is_expected.to eq('1') } + end + end + + context 'when config is an array' do + let(:config) { [] } + + describe '#valid?' do + it { is_expected.not_to be_valid } + end + + describe '#errors' do + subject(:errors) { entry.errors } + + it { is_expected.to include 'variable definition must be either a string or a hash' } + end + end + end + + describe 'ComplexVariable' do + context 'when config is a hash with description' do + let(:config) { { value: 'value', description: 'description' } } + + context 'when metadata allowed_value_data is not provided' do + describe '#valid?' do + it { is_expected.not_to be_valid } + end + + describe '#errors' do + subject(:errors) { entry.errors } + + it { is_expected.to include 'var1 config must be a string' } + end + end + + context 'when metadata allowed_value_data is (value, description)' do + let(:metadata) { { allowed_value_data: %i[value description] } } + + describe '#valid?' do + it { is_expected.to be_valid } + end + + describe '#value' do + subject(:value) { entry.value } + + it { is_expected.to eq('value') } + end + + describe '#value_with_data' do + subject(:value_with_data) { entry.value_with_data } + + it { is_expected.to eq(value: 'value', description: 'description') } + end + + context 'when config value is a symbol' do + let(:config) { { value: :value, description: 'description' } } + + describe '#value' do + subject(:value) { entry.value } + + it { is_expected.to eq('value') } + end + + describe '#value_with_data' do + subject(:value_with_data) { entry.value_with_data } + + it { is_expected.to eq(value: 'value', description: 'description') } + end + end + + context 'when config value is an integer' do + let(:config) { { value: 123, description: 'description' } } + + describe '#value' do + subject(:value) { entry.value } + + it { is_expected.to eq('123') } + end + + describe '#value_with_data' do + subject(:value_with_data) { entry.value_with_data } + + it { is_expected.to eq(value: '123', description: 'description') } + end + end + + context 'when config value is an array' do + let(:config) { { value: ['value'], description: 'description' } } + + describe '#valid?' do + it { is_expected.not_to be_valid } + end + + describe '#errors' do + subject(:errors) { entry.errors } + + it { is_expected.to include 'var1 config value must be an alphanumeric string' } + end + end + + context 'when config description is a symbol' do + let(:config) { { value: 'value', description: :description } } + + describe '#value' do + subject(:value) { entry.value } + + it { is_expected.to eq('value') } + end + + describe '#value_with_data' do + subject(:value_with_data) { entry.value_with_data } + + it { is_expected.to eq(value: 'value', description: :description) } + end + end + end + + context 'when metadata allowed_value_data is (value, xyz)' do + let(:metadata) { { allowed_value_data: %i[value xyz] } } + + describe '#valid?' do + it { is_expected.not_to be_valid } + end + + describe '#errors' do + subject(:errors) { entry.errors } + + it { is_expected.to include 'var1 config uses invalid data keys: description' } + end + end + end + + context 'when config is a hash without description' do + let(:config) { { value: 'value' } } + + context 'when metadata allowed_value_data is not provided' do + describe '#valid?' do + it { is_expected.not_to be_valid } + end + + describe '#errors' do + subject(:errors) { entry.errors } + + it { is_expected.to include 'var1 config must be a string' } + end + end + + context 'when metadata allowed_value_data is (value, description)' do + let(:metadata) { { allowed_value_data: %i[value description] } } + + describe '#valid?' do + it { is_expected.to be_valid } + end + + describe '#value' do + subject(:value) { entry.value } + + it { is_expected.to eq('value') } + end + + describe '#value_with_data' do + subject(:value_with_data) { entry.value_with_data } + + it { is_expected.to eq(value: 'value', description: nil) } + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/variables_spec.rb b/spec/lib/gitlab/ci/config/entry/variables_spec.rb index 78d37e228df..055975e4311 100644 --- a/spec/lib/gitlab/ci/config/entry/variables_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/variables_spec.rb @@ -3,41 +3,46 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Config::Entry::Variables do + let(:config) { {} } let(:metadata) { {} } - subject { described_class.new(config, **metadata) } + subject(:entry) { described_class.new(config, **metadata) } + + before do + entry.compose! + end shared_examples 'valid config' do describe '#value' do it 'returns hash with key value strings' do - expect(subject.value).to eq result + expect(entry.value).to eq result end end describe '#errors' do it 'does not append errors' do - expect(subject.errors).to be_empty + expect(entry.errors).to be_empty end end describe '#valid?' do it 'is valid' do - expect(subject).to be_valid + expect(entry).to be_valid end end end - shared_examples 'invalid config' do + shared_examples 'invalid config' do |error_message| describe '#valid?' do it 'is not valid' do - expect(subject).not_to be_valid + expect(entry).not_to be_valid end end describe '#errors' do it 'saves errors' do - expect(subject.errors) - .to include /should be a hash of key value pairs/ + expect(entry.errors) + .to include(error_message) end end end @@ -52,6 +57,15 @@ RSpec.describe Gitlab::Ci::Config::Entry::Variables do end it_behaves_like 'valid config' + + describe '#value_with_data' do + it 'returns variable with data' do + expect(entry.value_with_data).to eq( + 'VARIABLE_1' => { value: 'value 1', description: nil }, + 'VARIABLE_2' => { value: 'value 2', description: nil } + ) + end + end end context 'with numeric keys and values in the config' do @@ -63,33 +77,63 @@ RSpec.describe Gitlab::Ci::Config::Entry::Variables do it_behaves_like 'valid config' end + context 'when key is an array' do + let(:config) { { ['VAR1'] => 'val1' } } + + it_behaves_like 'invalid config', /must be an alphanumeric string/ + end + + context 'when value is a symbol' do + let(:config) { { 'VAR1' => :val1 } } + let(:result) do + { 'VAR1' => 'val1' } + end + + it_behaves_like 'valid config' + end + + context 'when value is a boolean' do + let(:config) { { 'VAR1' => true } } + + it_behaves_like 'invalid config', /must be either a string or a hash/ + end + context 'when entry config value has key-value pair and hash' do let(:config) do { 'VARIABLE_1' => { value: 'value 1', description: 'variable 1' }, 'VARIABLE_2' => 'value 2' } end - let(:result) do - { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' } - end + it_behaves_like 'invalid config', /variable_1 config must be a string/ - it_behaves_like 'invalid config' + context 'when metadata has allowed_value_data' do + let(:metadata) { { allowed_value_data: %i[value description] } } - context 'when metadata has use_value_data' do - let(:metadata) { { use_value_data: true } } + let(:result) do + { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' } + end it_behaves_like 'valid config' + + describe '#value_with_data' do + it 'returns variable with data' do + expect(entry.value_with_data).to eq( + 'VARIABLE_1' => { value: 'value 1', description: 'variable 1' }, + 'VARIABLE_2' => { value: 'value 2', description: nil } + ) + end + end end end context 'when entry value is an array' do let(:config) { [:VAR, 'test'] } - it_behaves_like 'invalid config' + it_behaves_like 'invalid config', /variables config should be a hash/ end - context 'when metadata has use_value_data' do - let(:metadata) { { use_value_data: true } } + context 'when metadata has allowed_value_data' do + let(:metadata) { { allowed_value_data: %i[value description] } } context 'when entry value has hash with other key-pairs' do let(:config) do @@ -97,7 +141,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Variables do 'VARIABLE_2' => 'value 2' } end - it_behaves_like 'invalid config' + it_behaves_like 'invalid config', /variable_1 config uses invalid data keys: hello/ end context 'when entry config value has hash with nil description' do @@ -105,7 +149,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Variables do { 'VARIABLE_1' => { value: 'value 1', description: nil } } end - it_behaves_like 'invalid config' + it_behaves_like 'invalid config', /variable_1 config description must be an alphanumeric string/ end context 'when entry config value has hash without description' do diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index eafa8f8fb25..bffdd370179 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -1033,24 +1033,26 @@ module Gitlab end end - describe 'Variables' do - subject { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)).execute } + # Change this to a `describe` block when removing the FF ci_variables_refactoring_to_variable + shared_examples 'Variables' do + subject(:execute) { described_class.new(config).execute } - let(:build) { subject.builds.first } + let(:build) { execute.builds.first } let(:job_variables) { build[:job_variables] } let(:root_variables_inheritance) { build[:root_variables_inheritance] } context 'when global variables are defined' do - let(:variables) do - { 'VAR1' => 'value1', 'VAR2' => 'value2' } - end - let(:config) do - { - variables: variables, - before_script: ['pwd'], - rspec: { script: 'rspec' } - } + <<~YAML + variables: + VAR1: value1 + VAR2: value2 + + before_script: [pwd] + + rspec: + script: rspec + YAML end it 'returns global variables' do @@ -1060,16 +1062,17 @@ module Gitlab end context 'when job variables are defined' do - let(:config) do - { - before_script: ['pwd'], - rspec: { script: 'rspec', variables: variables } - } - end - context 'when syntax is correct' do - let(:variables) do - { 'VAR1' => 'value1', 'VAR2' => 'value2' } + let(:config) do + <<~YAML + before_script: [pwd] + + rspec: + script: rspec + variables: + VAR1: value1 + VAR2: value2 + YAML end it 'returns job variables' do @@ -1083,16 +1086,28 @@ module Gitlab context 'when syntax is incorrect' do context 'when variables defined but invalid' do - let(:variables) do - %w(VAR1 value1 VAR2 value2) + let(:config) do + <<~YAML + before_script: [pwd] + + rspec: + script: rspec + variables: [VAR1 value1 VAR2 value2] + YAML end - it_behaves_like 'returns errors', /jobs:rspec:variables config should be a hash of key value pairs/ + it_behaves_like 'returns errors', /jobs:rspec:variables config should be a hash/ end context 'when variables key defined but value not specified' do - let(:variables) do - nil + let(:config) do + <<~YAML + before_script: [pwd] + + rspec: + script: rspec + variables: null + YAML end it 'returns empty array' do @@ -1109,10 +1124,12 @@ module Gitlab context 'when job variables are not defined' do let(:config) do - { - before_script: ['pwd'], - rspec: { script: 'rspec' } - } + <<~YAML + before_script: ['pwd'] + + rspec: + script: rspec + YAML end it 'returns empty array' do @@ -1120,6 +1137,42 @@ module Gitlab expect(root_variables_inheritance).to eq(true) end end + + context 'when variables have different type of values' do + let(:config) do + <<~YAML + before_script: [pwd] + + rspec: + variables: + VAR1: value1 + VAR2: :value2 + VAR3: 123 + script: rspec + YAML + end + + it 'returns job variables' do + expect(job_variables).to contain_exactly( + { key: 'VAR1', value: 'value1', public: true }, + { key: 'VAR2', value: 'value2', public: true }, + { key: 'VAR3', value: '123', public: true } + ) + expect(root_variables_inheritance).to eq(true) + end + end + end + + context 'when ci_variables_refactoring_to_variable is enabled' do + it_behaves_like 'Variables' + end + + context 'when ci_variables_refactoring_to_variable is disabled' do + before do + stub_feature_flags(ci_variables_refactoring_to_variable: false) + end + + it_behaves_like 'Variables' end context 'when using `extends`' do @@ -2705,13 +2758,13 @@ module Gitlab context 'returns errors if variables is not a map' do let(:config) { YAML.dump({ variables: "test", rspec: { script: "test" } }) } - it_behaves_like 'returns errors', 'variables config should be a hash of key value pairs, value can be a hash' + it_behaves_like 'returns errors', 'variables config should be a hash' end context 'returns errors if variables is not a map of key-value strings' do let(:config) { YAML.dump({ variables: { test: false }, rspec: { script: "test" } }) } - it_behaves_like 'returns errors', 'variables config should be a hash of key value pairs, value can be a hash' + it_behaves_like 'returns errors', 'variable definition must be either a string or a hash' end context 'returns errors if job when is not on_success, on_failure or always' do diff --git a/spec/lib/gitlab/config/entry/composable_hash_spec.rb b/spec/lib/gitlab/config/entry/composable_hash_spec.rb index f64b39231a3..331c9efc741 100644 --- a/spec/lib/gitlab/config/entry/composable_hash_spec.rb +++ b/spec/lib/gitlab/config/entry/composable_hash_spec.rb @@ -6,7 +6,8 @@ RSpec.describe Gitlab::Config::Entry::ComposableHash, :aggregate_failures do let(:valid_config) do { DATABASE_SECRET: 'passw0rd', - API_TOKEN: 'passw0rd2' + API_TOKEN: 'passw0rd2', + ACCEPT_PASSWORD: false } end @@ -55,6 +56,12 @@ RSpec.describe Gitlab::Config::Entry::ComposableHash, :aggregate_failures do expect(entry[:API_TOKEN].metadata).to eq(name: :API_TOKEN) expect(entry[:API_TOKEN].parent.class).to eq(Gitlab::Config::Entry::ComposableHash) expect(entry[:API_TOKEN].value).to eq('passw0rd2') + expect(entry[:ACCEPT_PASSWORD]).to be_a(Gitlab::Config::Entry::Node) + expect(entry[:ACCEPT_PASSWORD].description).to eq('ACCEPT_PASSWORD node definition') + expect(entry[:ACCEPT_PASSWORD].key).to eq(:ACCEPT_PASSWORD) + expect(entry[:ACCEPT_PASSWORD].metadata).to eq(name: :ACCEPT_PASSWORD) + expect(entry[:ACCEPT_PASSWORD].parent.class).to eq(Gitlab::Config::Entry::ComposableHash) + expect(entry[:ACCEPT_PASSWORD].value).to eq(false) end describe '#descendants' do diff --git a/spec/lib/gitlab/metrics/global_search_slis_spec.rb b/spec/lib/gitlab/metrics/global_search_slis_spec.rb new file mode 100644 index 00000000000..23f79abc27e --- /dev/null +++ b/spec/lib/gitlab/metrics/global_search_slis_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Metrics::GlobalSearchSlis do + using RSpec::Parameterized::TableSyntax + + before do + stub_feature_flags(global_search_custom_slis: feature_flag_enabled) + end + + describe '#initialize_slis!' do + context 'when global_search_custom_slis feature flag is enabled' do + let(:feature_flag_enabled) { true } + + it 'initializes Apdex SLI for global_search' do + expect(Gitlab::Metrics::Sli::Apdex).to receive(:initialize_sli).with( + :global_search, + a_kind_of(Array) + ) + + described_class.initialize_slis! + end + end + + context 'when global_search_custom_slis feature flag is disabled' do + let(:feature_flag_enabled) { false } + + it 'does not initialzie the Apdex SLI for global_search' do + expect(Gitlab::Metrics::Sli::Apdex).not_to receive(:initialize_sli) + + described_class.initialize_slis! + end + end + end + + describe '#record_apdex' do + context 'when global_search_custom_slis feature flag is enabled' do + let(:feature_flag_enabled) { true } + + where(:search_type, :code_search, :duration_target) do + 'basic' | false | 7.031 + 'basic' | true | 21.903 + 'advanced' | false | 4.865 + 'advanced' | true | 13.546 + end + + with_them do + before do + allow(::Gitlab::ApplicationContext).to receive(:current_context_attribute).with(:caller_id).and_return('end') + end + + let(:search_scope) { code_search ? 'blobs' : 'issues' } + + it 'increments the global_search SLI as a success if the elapsed time is within the target' do + duration = duration_target - 0.1 + + expect(Gitlab::Metrics::Sli::Apdex[:global_search]).to receive(:increment).with( + labels: { + search_type: search_type, + search_level: 'global', + search_scope: search_scope, + endpoint_id: 'end' + }, + success: true + ) + + described_class.record_apdex( + elapsed: duration, + search_type: search_type, + search_level: 'global', + search_scope: search_scope + ) + end + + it 'increments the global_search SLI as a failure if the elapsed time is not within the target' do + duration = duration_target + 0.1 + + expect(Gitlab::Metrics::Sli::Apdex[:global_search]).to receive(:increment).with( + labels: { + search_type: search_type, + search_level: 'global', + search_scope: search_scope, + endpoint_id: 'end' + }, + success: false + ) + + described_class.record_apdex( + elapsed: duration, + search_type: search_type, + search_level: 'global', + search_scope: search_scope + ) + end + end + end + + context 'when global_search_custom_slis feature flag is disabled' do + let(:feature_flag_enabled) { false } + + it 'does not call increment on the apdex SLI' do + expect(Gitlab::Metrics::Sli::Apdex[:global_search]).not_to receive(:increment) + + described_class.record_apdex( + elapsed: 1, + search_type: 'basic', + search_level: 'global', + search_scope: 'issues' + ) + end + end + end +end diff --git a/spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb b/spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb index 7d96adf95e8..8d4629bf48b 100644 --- a/spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb +++ b/spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb @@ -150,6 +150,29 @@ RSpec.describe Gitlab::WebIde::Config::Entry::Terminal do } ) end + + context 'when the FF ci_variables_refactoring_to_variable is disabled' do + let(:entry_without_ff) { described_class.new(config, with_image_ports: true) } + + before do + stub_feature_flags(ci_variables_refactoring_to_variable: false) + entry_without_ff.compose! + end + + it 'returns correct value' do + expect(entry_without_ff.value) + .to eq( + tag_list: ['webide'], + job_variables: [{ key: 'KEY', value: 'value', public: true }], + options: { + image: { name: "image:1.0" }, + services: [{ name: "mysql" }], + before_script: %w[ls pwd], + script: ['sleep 100'] + } + ) + end + end end end end diff --git a/spec/requests/admin/hook_logs_controller_spec.rb b/spec/requests/admin/hook_logs_controller_spec.rb new file mode 100644 index 00000000000..f8d3381c052 --- /dev/null +++ b/spec/requests/admin/hook_logs_controller_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Admin::HookLogsController, :enable_admin_mode do + let_it_be(:user) { create(:admin) } + let_it_be_with_refind(:web_hook) { create(:system_hook) } + let_it_be_with_refind(:web_hook_log) { create(:web_hook_log, web_hook: web_hook) } + + it_behaves_like WebHooks::HookLogActions do + let!(:show_path) { admin_hook_hook_log_path(web_hook, web_hook_log) } + let!(:retry_path) { retry_admin_hook_hook_log_path(web_hook, web_hook_log) } + let(:edit_hook_path) { edit_admin_hook_path(web_hook) } + end +end diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb index 6034d26f1d2..bc410f657b9 100644 --- a/spec/requests/api/search_spec.rb +++ b/spec/requests/api/search_spec.rb @@ -351,6 +351,17 @@ RSpec.describe API::Search do end end + it 'increments the custom search sli apdex' do + expect(Gitlab::Metrics::GlobalSearchSlis).to receive(:record_apdex).with( + elapsed: a_kind_of(Numeric), + search_scope: 'issues', + search_type: 'basic', + search_level: 'global' + ) + + get api(endpoint, user), params: { scope: 'issues', search: 'john doe' } + end + it 'sets global search information for logging' do expect(Gitlab::Instrumentation::GlobalSearchApi).to receive(:set_information).with( type: 'basic', diff --git a/spec/requests/projects/hook_logs_controller_spec.rb b/spec/requests/projects/hook_logs_controller_spec.rb new file mode 100644 index 00000000000..8b3ec307e53 --- /dev/null +++ b/spec/requests/projects/hook_logs_controller_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::HookLogsController do + let_it_be(:user) { create(:user) } + let_it_be_with_refind(:web_hook) { create(:project_hook) } + let_it_be_with_refind(:web_hook_log) { create(:web_hook_log, web_hook: web_hook) } + + let(:project) { web_hook.project } + + it_behaves_like WebHooks::HookLogActions do + let(:edit_hook_path) { edit_project_hook_url(project, web_hook) } + + before do + project.add_owner(user) + end + end +end diff --git a/spec/requests/projects/settings/integration_hook_logs_controller_spec.rb b/spec/requests/projects/settings/integration_hook_logs_controller_spec.rb new file mode 100644 index 00000000000..77daff901a1 --- /dev/null +++ b/spec/requests/projects/settings/integration_hook_logs_controller_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::Settings::IntegrationHookLogsController do + let_it_be(:user) { create(:user) } + let_it_be(:integration) { create(:datadog_integration) } + let_it_be_with_refind(:web_hook) { integration.service_hook } + let_it_be_with_refind(:web_hook_log) { create(:web_hook_log, web_hook: web_hook) } + + let(:project) { integration.project } + + it_behaves_like WebHooks::HookLogActions do + let(:edit_hook_path) { edit_project_settings_integration_url(project, integration) } + + before do + project.add_owner(user) + end + end +end diff --git a/spec/support/shared_examples/controllers/concerns/web_hooks/integrations_hook_log_actions_shared_examples.rb b/spec/support/shared_examples/controllers/concerns/web_hooks/integrations_hook_log_actions_shared_examples.rb new file mode 100644 index 00000000000..62c9c3508a8 --- /dev/null +++ b/spec/support/shared_examples/controllers/concerns/web_hooks/integrations_hook_log_actions_shared_examples.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +RSpec.shared_examples WebHooks::HookLogActions do + let!(:show_path) { web_hook_log.present.details_path } + let!(:retry_path) { web_hook_log.present.retry_path } + + before do + sign_in(user) + end + + describe 'GET #show' do + it 'renders a 200 if the hook exists' do + get show_path + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template('hook_logs/show') + end + + it 'renders a 404 if the hook does not exist' do + web_hook.destroy! + get show_path + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + describe 'POST #retry' do + it 'executes the hook and redirects to the service form' do + stub_request(:post, web_hook.url) + + expect_next_found_instance_of(web_hook.class) do |hook| + expect(hook).to receive(:execute).and_call_original + end + + post retry_path + + expect(response).to redirect_to(edit_hook_path) + end + + it 'renders a 404 if the hook does not exist' do + web_hook.destroy! + post retry_path + + expect(response).to have_gitlab_http_status(:not_found) + end + end +end diff --git a/spec/tooling/danger/project_helper_spec.rb b/spec/tooling/danger/project_helper_spec.rb index 2f52c0fd36c..4cc5df385a5 100644 --- a/spec/tooling/danger/project_helper_spec.rb +++ b/spec/tooling/danger/project_helper_spec.rb @@ -31,6 +31,8 @@ RSpec.describe Tooling::Danger::ProjectHelper do end where(:path, :expected_categories) do + 'glfm_specification/example_snapshots/prosemirror_json.yml' | [:frontend] + 'glfm_specification/input/glfm_anything.yml' | [:frontend, :backend] 'usage_data.rb' | [:database, :backend, :product_intelligence] 'doc/foo.md' | [:docs] 'CONTRIBUTING.md' | [:docs] |