diff options
Diffstat (limited to 'spec/frontend/ci')
36 files changed, 1140 insertions, 324 deletions
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js index 3f1eebbc6a5..c0fb133b9b1 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js @@ -1,11 +1,10 @@ import { shallowMount } from '@vue/test-utils'; +import { TYPENAME_GROUP } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import ciGroupVariables from '~/ci/ci_variable_list/components/ci_group_variables.vue'; import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; -import { GRAPHQL_GROUP_TYPE } from '~/ci/ci_variable_list/constants'; - const mockProvide = { glFeatures: { groupScopedCiVariables: false, @@ -36,7 +35,7 @@ describe('Ci Group Variable wrapper', () => { it('are passed down the correctly to ci_variable_shared', () => { expect(findCiShared().props()).toEqual({ - id: convertToGraphQLId(GRAPHQL_GROUP_TYPE, mockProvide.groupId), + id: convertToGraphQLId(TYPENAME_GROUP, mockProvide.groupId), areScopedVariablesAvailable: false, componentName: 'GroupVariables', entity: 'group', diff --git a/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js index 7230017c560..bd1e6b17d6b 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js @@ -1,11 +1,10 @@ import { shallowMount } from '@vue/test-utils'; +import { TYPENAME_PROJECT } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import ciProjectVariables from '~/ci/ci_variable_list/components/ci_project_variables.vue'; import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; -import { GRAPHQL_PROJECT_TYPE } from '~/ci/ci_variable_list/constants'; - const mockProvide = { projectFullPath: '/namespace/project', projectId: 1, @@ -32,7 +31,7 @@ describe('Ci Project Variable wrapper', () => { it('Passes down the correct props to ci_variable_shared', () => { expect(findCiShared().props()).toEqual({ - id: convertToGraphQLId(GRAPHQL_PROJECT_TYPE, mockProvide.projectId), + id: convertToGraphQLId(TYPENAME_PROJECT, mockProvide.projectId), areScopedVariablesAvailable: true, componentName: 'ProjectVariables', entity: 'project', diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js index 7838e4884d8..508af964ca3 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js @@ -21,6 +21,8 @@ describe('Ci variable modal', () => { let trackingSpy; const maskableRegex = '^[a-zA-Z0-9_+=/@:.~-]{8,}$'; + const maskableRawRegex = '^\\S{8,}$'; + const mockVariables = mockVariablesWithScopes(instanceString); const defaultProvide = { @@ -30,10 +32,13 @@ describe('Ci variable modal', () => { awsTipLearnLink: '/learn-link', containsVariableReferenceLink: '/reference', environmentScopeLink: '/help/environments', + glFeatures: { + ciRemoveCharacterLimitationRawMaskedVar: true, + }, isProtectedByDefault: false, maskedEnvironmentVariablesLink: '/variables-link', + maskableRawRegex, maskableRegex, - protectedEnvironmentVariablesLink: '/protected-link', }; const defaultProps = { @@ -424,6 +429,36 @@ describe('Ci variable modal', () => { describe('Validations', () => { const maskError = 'This variable can not be masked.'; + describe('when the variable is raw', () => { + const [variable] = mockVariables; + const validRawMaskedVariable = { + ...variable, + value: 'd$%^asdsadas', + masked: false, + raw: true, + }; + + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + props: { selectedVariable: validRawMaskedVariable }, + }); + }); + + it('should not show an error with symbols', async () => { + await findMaskedVariableCheckbox().trigger('click'); + + expect(findModal().text()).not.toContain(maskError); + }); + + it('should not show an error when length is less than 8', async () => { + await findValueField().vm.$emit('input', 'a'); + await findMaskedVariableCheckbox().trigger('click'); + + expect(findModal().text()).toContain(maskError); + }); + }); + describe('when the mask state is invalid', () => { beforeEach(async () => { const [variable] = mockVariables; diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js index 2d39bff8ce0..c977ae773db 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js @@ -6,6 +6,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; import { resolvers } from '~/ci/ci_variable_list/graphql/settings'; +import { TYPENAME_GROUP } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; @@ -227,7 +228,7 @@ describe('Ci Variable Shared Component', () => { variables: { endpoint: mockProvide.endpoint, fullPath: groupProps.fullPath, - id: convertToGraphQLId('Group', groupProps.id), + id: convertToGraphQLId(TYPENAME_GROUP, groupProps.id), variable: newVariable, }, }); diff --git a/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js b/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js index d7f0ce838d6..dc72694d26f 100644 --- a/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js @@ -11,11 +11,12 @@ describe('CI Editor Header', () => { let wrapper; let trackingSpy = null; - const createComponent = ({ showDrawer = false } = {}) => { + const createComponent = ({ showDrawer = false, showJobAssistantDrawer = false } = {}) => { wrapper = extendedWrapper( shallowMount(CiEditorHeader, { propsData: { showDrawer, + showJobAssistantDrawer, }, }), ); diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js index 6f28362e478..7bf955012c7 100644 --- a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js @@ -47,11 +47,6 @@ describe('Pipeline Status', () => { mockLinkedPipelinesQuery = jest.fn(); }); - afterEach(() => { - mockLinkedPipelinesQuery.mockReset(); - wrapper.destroy(); - }); - describe('when there are stages', () => { beforeEach(() => { createComponent(); @@ -74,9 +69,11 @@ describe('Pipeline Status', () => { describe('when querying upstream and downstream pipelines', () => { describe('when query succeeds', () => { - beforeEach(() => { + beforeEach(async () => { mockLinkedPipelinesQuery.mockResolvedValue(mockLinkedPipelines()); createComponentWithApollo(); + + await waitForPromises(); }); it('should call the query with the correct variables', () => { @@ -86,6 +83,10 @@ describe('Pipeline Status', () => { iid: mockProjectPipeline().pipeline.iid, }); }); + + it('renders only the latest downstream pipelines', () => { + expect(findPipelineMiniGraph().props('downstreamPipelines')).toHaveLength(1); + }); }); describe('when query fails', () => { diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js deleted file mode 100644 index 6f28362e478..00000000000 --- a/spec/frontend/ci/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js +++ /dev/null @@ -1,109 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import PipelineEditorMiniGraph from '~/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue'; -import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; -import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql'; -import { PIPELINE_FAILURE } from '~/ci/pipeline_editor/constants'; -import { mockLinkedPipelines, mockProjectFullPath, mockProjectPipeline } from '../../mock_data'; - -Vue.use(VueApollo); - -describe('Pipeline Status', () => { - let wrapper; - let mockApollo; - let mockLinkedPipelinesQuery; - - const createComponent = ({ hasStages = true, options } = {}) => { - wrapper = shallowMount(PipelineEditorMiniGraph, { - provide: { - dataMethod: 'graphql', - projectFullPath: mockProjectFullPath, - }, - propsData: { - pipeline: mockProjectPipeline({ hasStages }).pipeline, - }, - ...options, - }); - }; - - const createComponentWithApollo = (hasStages = true) => { - const handlers = [[getLinkedPipelinesQuery, mockLinkedPipelinesQuery]]; - mockApollo = createMockApollo(handlers); - - createComponent({ - hasStages, - options: { - apolloProvider: mockApollo, - }, - }); - }; - - const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph); - - beforeEach(() => { - mockLinkedPipelinesQuery = jest.fn(); - }); - - afterEach(() => { - mockLinkedPipelinesQuery.mockReset(); - wrapper.destroy(); - }); - - describe('when there are stages', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders pipeline mini graph', () => { - expect(findPipelineMiniGraph().exists()).toBe(true); - }); - }); - - describe('when there are no stages', () => { - beforeEach(() => { - createComponent({ hasStages: false }); - }); - - it('does not render pipeline mini graph', () => { - expect(findPipelineMiniGraph().exists()).toBe(false); - }); - }); - - describe('when querying upstream and downstream pipelines', () => { - describe('when query succeeds', () => { - beforeEach(() => { - mockLinkedPipelinesQuery.mockResolvedValue(mockLinkedPipelines()); - createComponentWithApollo(); - }); - - it('should call the query with the correct variables', () => { - expect(mockLinkedPipelinesQuery).toHaveBeenCalledTimes(1); - expect(mockLinkedPipelinesQuery).toHaveBeenCalledWith({ - fullPath: mockProjectFullPath, - iid: mockProjectPipeline().pipeline.iid, - }); - }); - }); - - describe('when query fails', () => { - beforeEach(async () => { - mockLinkedPipelinesQuery.mockRejectedValue(new Error()); - createComponentWithApollo(); - await waitForPromises(); - }); - - it('should emit an error event when query fails', async () => { - expect(wrapper.emitted('showError')).toHaveLength(1); - expect(wrapper.emitted('showError')[0]).toEqual([ - { - type: PIPELINE_FAILURE, - reasons: [wrapper.vm.$options.i18n.linkedPipelinesFetchError], - }, - ]); - }); - }); - }); -}); diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js new file mode 100644 index 00000000000..79200d92598 --- /dev/null +++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js @@ -0,0 +1,45 @@ +import { GlDrawer } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import JobAssistantDrawer from '~/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue'; +import waitForPromises from 'helpers/wait_for_promises'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; + +Vue.use(VueApollo); + +describe('Job assistant drawer', () => { + let wrapper; + + const findDrawer = () => wrapper.findComponent(GlDrawer); + + const findCancelButton = () => wrapper.findByTestId('cancel-button'); + + const createComponent = () => { + wrapper = mountExtended(JobAssistantDrawer, { + propsData: { + isVisible: true, + }, + }); + }; + + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('should emit close job assistant drawer event when closing the drawer', () => { + expect(wrapper.emitted('close-job-assistant-drawer')).toBeUndefined(); + + findDrawer().vm.$emit('close'); + + expect(wrapper.emitted('close-job-assistant-drawer')).toHaveLength(1); + }); + + it('should emit close job assistant drawer event when click cancel button', () => { + expect(wrapper.emitted('close-job-assistant-drawer')).toBeUndefined(); + + findCancelButton().trigger('click'); + + expect(wrapper.emitted('close-job-assistant-drawer')).toHaveLength(1); + }); +}); diff --git a/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js index 70310cbdb10..f40db50aab7 100644 --- a/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js @@ -56,6 +56,7 @@ describe('Pipeline editor tabs component', () => { currentTab: CREATE_TAB, isNewCiConfigFile: true, showDrawer: false, + showJobAssistantDrawer: false, ...props, }, data() { diff --git a/spec/frontend/ci/pipeline_editor/mock_data.js b/spec/frontend/ci/pipeline_editor/mock_data.js index 176dc24f169..541123d7efc 100644 --- a/spec/frontend/ci/pipeline_editor/mock_data.js +++ b/spec/frontend/ci/pipeline_editor/mock_data.js @@ -373,13 +373,64 @@ export const mockLinkedPipelines = ({ hasDownstream = true, hasUpstream = true } { id: 'gid://gitlab/Ci::Pipeline/612', path: '/root/job-log-sections/-/pipelines/612', - project: { name: 'job-log-sections', __typename: 'Project' }, + project: { + id: 'gid://gitlab/Project/21', + name: 'job-log-sections', + __typename: 'Project', + }, + detailedStatus: { + id: 'success-612-612', + group: 'success', + icon: 'status_success', + label: 'passed', + __typename: 'DetailedStatus', + }, + sourceJob: { + id: 'gid://gitlab/Ci::Bridge/532', + retried: false, + }, + __typename: 'Pipeline', + }, + { + id: 'gid://gitlab/Ci::Pipeline/611', + path: '/root/job-log-sections/-/pipelines/611', + project: { + id: 'gid://gitlab/Project/21', + name: 'job-log-sections', + __typename: 'Project', + }, detailedStatus: { + id: 'success-611-611', group: 'success', icon: 'status_success', label: 'passed', __typename: 'DetailedStatus', }, + sourceJob: { + id: 'gid://gitlab/Ci::Bridge/531', + retried: true, + }, + __typename: 'Pipeline', + }, + { + id: 'gid://gitlab/Ci::Pipeline/609', + path: '/root/job-log-sections/-/pipelines/609', + project: { + id: 'gid://gitlab/Project/21', + name: 'job-log-sections', + __typename: 'Project', + }, + detailedStatus: { + id: 'success-609-609', + group: 'success', + icon: 'status_success', + label: 'passed', + __typename: 'DetailedStatus', + }, + sourceJob: { + id: 'gid://gitlab/Ci::Bridge/530', + retried: true, + }, __typename: 'Pipeline', }, ], @@ -391,8 +442,13 @@ export const mockLinkedPipelines = ({ hasDownstream = true, hasUpstream = true } upstream = { id: 'gid://gitlab/Ci::Pipeline/610', path: '/root/trigger-downstream/-/pipelines/610', - project: { name: 'trigger-downstream', __typename: 'Project' }, + project: { + id: 'gid://gitlab/Project/21', + name: 'trigger-downstream', + __typename: 'Project', + }, detailedStatus: { + id: 'success-610-610', group: 'success', icon: 'status_success', label: 'passed', @@ -405,7 +461,9 @@ export const mockLinkedPipelines = ({ hasDownstream = true, hasUpstream = true } return { data: { project: { + id: 'gid://gitlab/Project/21', pipeline: { + id: 'gid://gitlab/Ci::Pipeline/790', path: '/root/ci-project/-/pipelines/790', downstream, upstream, diff --git a/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js index 2246d0bbf7e..a103acb33bc 100644 --- a/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js @@ -5,6 +5,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status'; import { objectToQuery, redirectTo } from '~/lib/utils/url_utility'; import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers'; import PipelineEditorTabs from '~/ci/pipeline_editor/components/pipeline_editor_tabs.vue'; @@ -343,7 +344,7 @@ describe('Pipeline editor app component', () => { describe('when the lint query returns a 500 error', () => { beforeEach(async () => { - mockCiConfigData.mockRejectedValueOnce(new Error(500)); + mockCiConfigData.mockRejectedValueOnce(new Error(HTTP_STATUS_INTERNAL_SERVER_ERROR)); await createComponentWithApollo({ stubs: { PipelineEditorHome, PipelineEditorHeader, ValidationSegment }, }); diff --git a/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js index 621e015e825..4f8f2112abe 100644 --- a/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js +++ b/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js @@ -6,6 +6,7 @@ import setWindowLocation from 'helpers/set_window_location_helper'; import CiEditorHeader from '~/ci/pipeline_editor/components/editor/ci_editor_header.vue'; import CommitSection from '~/ci/pipeline_editor/components/commit/commit_section.vue'; import PipelineEditorDrawer from '~/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue'; +import JobAssistantDrawer from '~/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue'; import PipelineEditorFileNav from '~/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; import PipelineEditorFileTree from '~/ci/pipeline_editor/components/file_tree/container.vue'; import BranchSwitcher from '~/ci/pipeline_editor/components/file_nav/branch_switcher.vue'; @@ -56,11 +57,13 @@ describe('Pipeline editor home wrapper', () => { const findFileNav = () => wrapper.findComponent(PipelineEditorFileNav); const findModal = () => wrapper.findComponent(GlModal); const findPipelineEditorDrawer = () => wrapper.findComponent(PipelineEditorDrawer); + const findJobAssistantDrawer = () => wrapper.findComponent(JobAssistantDrawer); const findPipelineEditorFileTree = () => wrapper.findComponent(PipelineEditorFileTree); const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorHeader); const findPipelineEditorTabs = () => wrapper.findComponent(PipelineEditorTabs); const findFileTreeBtn = () => wrapper.findByTestId('file-tree-toggle'); const findHelpBtn = () => wrapper.findByTestId('drawer-toggle'); + const findJobAssistantBtn = () => wrapper.findByTestId('job-assistant-drawer-toggle'); afterEach(() => { localStorage.clear(); @@ -261,6 +264,110 @@ describe('Pipeline editor home wrapper', () => { }); }); + describe('job assistant drawer', () => { + const clickHelpBtn = async () => { + findHelpBtn().vm.$emit('click'); + await nextTick(); + }; + const clickJobAssistantBtn = async () => { + findJobAssistantBtn().vm.$emit('click'); + await nextTick(); + }; + + const stubs = { + CiEditorHeader, + GlButton, + GlDrawer, + PipelineEditorTabs, + JobAssistantDrawer, + }; + + it('hides the job assistant drawer by default', () => { + createComponent({ + glFeatures: { + ciJobAssistantDrawer: true, + }, + }); + + expect(findJobAssistantDrawer().props('isVisible')).toBe(false); + }); + + it('toggles the job assistant drawer on button click', async () => { + createComponent({ + stubs, + glFeatures: { + ciJobAssistantDrawer: true, + }, + }); + + await clickJobAssistantBtn(); + + expect(findJobAssistantDrawer().props('isVisible')).toBe(true); + + await clickJobAssistantBtn(); + + expect(findJobAssistantDrawer().props('isVisible')).toBe(false); + }); + + it("closes the job assistant drawer through the drawer's close button", async () => { + createComponent({ + stubs, + glFeatures: { + ciJobAssistantDrawer: true, + }, + }); + + await clickJobAssistantBtn(); + + expect(findJobAssistantDrawer().props('isVisible')).toBe(true); + + findJobAssistantDrawer().findComponent(GlDrawer).vm.$emit('close'); + await nextTick(); + + expect(findJobAssistantDrawer().props('isVisible')).toBe(false); + }); + + it('covers helper drawer when opened last', async () => { + createComponent({ + stubs: { + ...stubs, + PipelineEditorDrawer, + }, + glFeatures: { + ciJobAssistantDrawer: true, + }, + }); + + await clickHelpBtn(); + await clickJobAssistantBtn(); + + const jobAssistantIndex = Number(findJobAssistantDrawer().props().zIndex); + const pipelineEditorDrawerIndex = Number(findPipelineEditorDrawer().props().zIndex); + + expect(jobAssistantIndex).toBeGreaterThan(pipelineEditorDrawerIndex); + }); + + it('covered by helper drawer when opened first', async () => { + createComponent({ + stubs: { + ...stubs, + PipelineEditorDrawer, + }, + glFeatures: { + ciJobAssistantDrawer: true, + }, + }); + + await clickJobAssistantBtn(); + await clickHelpBtn(); + + const jobAssistantIndex = Number(findJobAssistantDrawer().props().zIndex); + const pipelineEditorDrawerIndex = Number(findPipelineEditorDrawer().props().zIndex); + + expect(jobAssistantIndex).toBeLessThan(pipelineEditorDrawerIndex); + }); + }); + describe('file tree', () => { const toggleFileTree = async () => { findFileTreeBtn().vm.$emit('click'); diff --git a/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js index cd16045f92d..6f18899ebac 100644 --- a/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js +++ b/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js @@ -14,7 +14,9 @@ import { HTTP_STATUS_OK, } from '~/lib/utils/http_status'; import { redirectTo } from '~/lib/utils/url_utility'; -import PipelineNewForm from '~/ci/pipeline_new/components/pipeline_new_form.vue'; +import PipelineNewForm, { + POLLING_INTERVAL, +} from '~/ci/pipeline_new/components/pipeline_new_form.vue'; import ciConfigVariablesQuery from '~/ci/pipeline_new/graphql/queries/ci_config_variables.graphql'; import { resolvers } from '~/ci/pipeline_new/graphql/resolvers'; import RefsDropdown from '~/ci/pipeline_new/components/refs_dropdown.vue'; @@ -24,6 +26,7 @@ import { mockCiConfigVariablesResponseWithoutDesc, mockEmptyCiConfigVariablesResponse, mockError, + mockNoCachedCiConfigVariablesResponse, mockQueryParams, mockPostParams, mockProjectId, @@ -69,6 +72,10 @@ describe('Pipeline New Form', () => { const findCCAlert = () => wrapper.findComponent(CreditCardValidationRequiredAlert); const getFormPostParams = () => JSON.parse(mock.history.post[0].data); + const advanceToNextFetch = (milliseconds) => { + jest.advanceTimersByTime(milliseconds); + }; + const selectBranch = async (branch) => { // Select a branch in the dropdown findRefsDropdown().vm.$emit('input', { @@ -266,17 +273,98 @@ describe('Pipeline New Form', () => { }); }); - describe('when yml defines a variable', () => { - it('loading icon is shown when content is requested and hidden when received', async () => { - mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); - createComponentWithApollo({ props: mockQueryParams, method: mountExtended }); + describe('When there are no variables in the API cache', () => { + beforeEach(async () => { + mockCiConfigVariables.mockResolvedValue(mockNoCachedCiConfigVariablesResponse); + createComponentWithApollo({ method: mountExtended }); + await waitForPromises(); + }); + it('stops polling after CONFIG_VARIABLES_TIMEOUT ms have passed', async () => { + advanceToNextFetch(POLLING_INTERVAL); + await waitForPromises(); + + advanceToNextFetch(POLLING_INTERVAL); + await waitForPromises(); + + expect(mockCiConfigVariables).toHaveBeenCalledTimes(3); + + advanceToNextFetch(POLLING_INTERVAL); + await waitForPromises(); + + expect(mockCiConfigVariables).toHaveBeenCalledTimes(3); + }); + + it('shows loading icon while query polls for updated values', async () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(mockCiConfigVariables).toHaveBeenCalledTimes(1); + + advanceToNextFetch(POLLING_INTERVAL); + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(true); + expect(mockCiConfigVariables).toHaveBeenCalledTimes(2); + }); + + it('hides loading icon and stops polling after query fetches the updated values', async () => { expect(findLoadingIcon().exists()).toBe(true); + mockCiConfigVariables.mockResolvedValue(mockCiConfigVariablesResponse); + advanceToNextFetch(POLLING_INTERVAL); await waitForPromises(); expect(findLoadingIcon().exists()).toBe(false); + expect(mockCiConfigVariables).toHaveBeenCalledTimes(2); + + advanceToNextFetch(POLLING_INTERVAL); + await waitForPromises(); + + expect(mockCiConfigVariables).toHaveBeenCalledTimes(2); }); + }); + + const testBehaviorWhenCacheIsPopulated = (queryResponse) => { + beforeEach(async () => { + mockCiConfigVariables.mockResolvedValue(queryResponse); + createComponentWithApollo({ method: mountExtended }); + }); + + it('does not poll for new values', async () => { + await waitForPromises(); + + expect(mockCiConfigVariables).toHaveBeenCalledTimes(1); + + advanceToNextFetch(POLLING_INTERVAL); + await waitForPromises(); + + expect(mockCiConfigVariables).toHaveBeenCalledTimes(1); + }); + + it('loading icon is shown when content is requested and hidden when received', async () => { + expect(findLoadingIcon().exists()).toBe(true); + + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }; + + describe('When no variables are defined in the CI configuration and the cache is updated', () => { + testBehaviorWhenCacheIsPopulated(mockEmptyCiConfigVariablesResponse); + + it('displays an empty form', async () => { + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); + createComponentWithApollo({ method: mountExtended }); + await waitForPromises(); + + expect(findKeyInputs().at(0).element.value).toBe(''); + expect(findValueInputs().at(0).element.value).toBe(''); + expect(findVariableTypes().at(0).props('text')).toBe('Variable'); + }); + }); + + describe('When CI configuration has defined variables and they are stored in the cache', () => { + testBehaviorWhenCacheIsPopulated(mockCiConfigVariablesResponse); describe('with different predefined values', () => { beforeEach(async () => { diff --git a/spec/frontend/ci/pipeline_new/mock_data.js b/spec/frontend/ci/pipeline_new/mock_data.js index dfb643a0ba4..5b935c0c819 100644 --- a/spec/frontend/ci/pipeline_new/mock_data.js +++ b/spec/frontend/ci/pipeline_new/mock_data.js @@ -132,3 +132,4 @@ export const mockEmptyCiConfigVariablesResponse = mockCiConfigVariablesQueryResp export const mockCiConfigVariablesResponseWithoutDesc = mockCiConfigVariablesQueryResponse( mockYamlVariablesWithoutDesc, ); +export const mockNoCachedCiConfigVariablesResponse = mockCiConfigVariablesQueryResponse(null); diff --git a/spec/frontend/ci/reports/codequality_report/components/codequality_issue_body_spec.js b/spec/frontend/ci/reports/codequality_report/components/codequality_issue_body_spec.js index 5ca4b25da9b..90ca2a07266 100644 --- a/spec/frontend/ci/reports/codequality_report/components/codequality_issue_body_spec.js +++ b/spec/frontend/ci/reports/codequality_report/components/codequality_issue_body_spec.js @@ -38,20 +38,20 @@ describe('code quality issue body issue body', () => { describe('severity rating', () => { it.each` severity | iconClass | iconName - ${'INFO'} | ${'text-primary-400'} | ${'severity-info'} - ${'MINOR'} | ${'text-warning-200'} | ${'severity-low'} - ${'CRITICAL'} | ${'text-danger-600'} | ${'severity-high'} - ${'BLOCKER'} | ${'text-danger-800'} | ${'severity-critical'} - ${'UNKNOWN'} | ${'text-secondary-400'} | ${'severity-unknown'} - ${'INVALID'} | ${'text-secondary-400'} | ${'severity-unknown'} - ${'info'} | ${'text-primary-400'} | ${'severity-info'} - ${'minor'} | ${'text-warning-200'} | ${'severity-low'} - ${'major'} | ${'text-warning-400'} | ${'severity-medium'} - ${'critical'} | ${'text-danger-600'} | ${'severity-high'} - ${'blocker'} | ${'text-danger-800'} | ${'severity-critical'} - ${'unknown'} | ${'text-secondary-400'} | ${'severity-unknown'} - ${'invalid'} | ${'text-secondary-400'} | ${'severity-unknown'} - ${undefined} | ${'text-secondary-400'} | ${'severity-unknown'} + ${'INFO'} | ${'gl-text-blue-400'} | ${'severity-info'} + ${'MINOR'} | ${'gl-text-orange-200'} | ${'severity-low'} + ${'CRITICAL'} | ${'gl-text-red-600'} | ${'severity-high'} + ${'BLOCKER'} | ${'gl-text-red-800'} | ${'severity-critical'} + ${'UNKNOWN'} | ${'gl-text-gray-400'} | ${'severity-unknown'} + ${'INVALID'} | ${'gl-text-gray-400'} | ${'severity-unknown'} + ${'info'} | ${'gl-text-blue-400'} | ${'severity-info'} + ${'minor'} | ${'gl-text-orange-200'} | ${'severity-low'} + ${'major'} | ${'gl-text-orange-400'} | ${'severity-medium'} + ${'critical'} | ${'gl-text-red-600'} | ${'severity-high'} + ${'blocker'} | ${'gl-text-red-800'} | ${'severity-critical'} + ${'unknown'} | ${'gl-text-gray-400'} | ${'severity-unknown'} + ${'invalid'} | ${'gl-text-gray-400'} | ${'severity-unknown'} + ${undefined} | ${'gl-text-gray-400'} | ${'severity-unknown'} `( 'renders correct icon for "$severity" severity rating', ({ severity, iconClass, iconName }) => { diff --git a/spec/frontend/ci/reports/codequality_report/store/actions_spec.js b/spec/frontend/ci/reports/codequality_report/store/actions_spec.js index 88628210793..a606bce3d78 100644 --- a/spec/frontend/ci/reports/codequality_report/store/actions_spec.js +++ b/spec/frontend/ci/reports/codequality_report/store/actions_spec.js @@ -2,6 +2,11 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import { TEST_HOST } from 'spec/test_constants'; import axios from '~/lib/utils/axios_utils'; +import { + HTTP_STATUS_INTERNAL_SERVER_ERROR, + HTTP_STATUS_NO_CONTENT, + HTTP_STATUS_OK, +} from '~/lib/utils/http_status'; import createStore from '~/ci/reports/codequality_report/store'; import * as actions from '~/ci/reports/codequality_report/store/actions'; import * as types from '~/ci/reports/codequality_report/store/mutation_types'; @@ -55,7 +60,7 @@ describe('Codequality Reports actions', () => { describe('on success', () => { it('commits REQUEST_REPORTS and dispatches receiveReportsSuccess', () => { - mock.onGet(endpoint).reply(200, reportIssues); + mock.onGet(endpoint).reply(HTTP_STATUS_OK, reportIssues); return testAction( actions.fetchReports, @@ -74,7 +79,7 @@ describe('Codequality Reports actions', () => { describe('on error', () => { it('commits REQUEST_REPORTS and dispatches receiveReportsError', () => { - mock.onGet(endpoint).reply(500); + mock.onGet(endpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); return testAction( actions.fetchReports, @@ -89,7 +94,7 @@ describe('Codequality Reports actions', () => { describe('when base report is not found', () => { it('commits REQUEST_REPORTS and dispatches receiveReportsError', () => { const data = { status: STATUS_NOT_FOUND }; - mock.onGet(`${TEST_HOST}/codequality_reports.json`).reply(200, data); + mock.onGet(`${TEST_HOST}/codequality_reports.json`).reply(HTTP_STATUS_OK, data); return testAction( actions.fetchReports, @@ -105,9 +110,9 @@ describe('Codequality Reports actions', () => { it('continues polling until it receives data', () => { mock .onGet(endpoint) - .replyOnce(204, undefined, pollIntervalHeader) + .replyOnce(HTTP_STATUS_NO_CONTENT, undefined, pollIntervalHeader) .onGet(endpoint) - .reply(200, reportIssues); + .reply(HTTP_STATUS_OK, reportIssues); return Promise.all([ testAction( @@ -134,9 +139,9 @@ describe('Codequality Reports actions', () => { it('continues polling until it receives an error', () => { mock .onGet(endpoint) - .replyOnce(204, undefined, pollIntervalHeader) + .replyOnce(HTTP_STATUS_NO_CONTENT, undefined, pollIntervalHeader) .onGet(endpoint) - .reply(500); + .reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); return Promise.all([ testAction( diff --git a/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js b/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js new file mode 100644 index 00000000000..edf3d1706cc --- /dev/null +++ b/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js @@ -0,0 +1,80 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +import AdminNewRunnerApp from '~/ci/runner/admin_new_runner/admin_new_runner_app.vue'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue'; +import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue'; +import { DEFAULT_PLATFORM } from '~/ci/runner/constants'; + +const mockLegacyRegistrationToken = 'LEGACY_REGISTRATION_TOKEN'; + +Vue.use(VueApollo); + +describe('AdminNewRunnerApp', () => { + let wrapper; + + const findLegacyInstructionsLink = () => wrapper.findByTestId('legacy-instructions-link'); + const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal); + const findRunnerPlatformsRadioGroup = () => wrapper.findComponent(RunnerPlatformsRadioGroup); + const findRunnerFormFields = () => wrapper.findComponent(RunnerFormFields); + + const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => { + wrapper = mountFn(AdminNewRunnerApp, { + propsData: { + legacyRegistrationToken: mockLegacyRegistrationToken, + ...props, + }, + directives: { + GlModal: createMockDirective(), + }, + stubs: { + GlSprintf, + }, + ...options, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + describe('Shows legacy modal', () => { + it('passes legacy registration to modal', () => { + expect(findRunnerInstructionsModal().props('registrationToken')).toEqual( + mockLegacyRegistrationToken, + ); + }); + + it('opens a modal with the legacy instructions', () => { + const modalId = getBinding(findLegacyInstructionsLink().element, 'gl-modal').value; + + expect(findRunnerInstructionsModal().props('modalId')).toBe(modalId); + }); + }); + + describe('New runner form fields', () => { + describe('Platform', () => { + it('shows the platforms radio group', () => { + expect(findRunnerPlatformsRadioGroup().props('value')).toBe(DEFAULT_PLATFORM); + }); + }); + + describe('Runner', () => { + it('shows the runners fields', () => { + expect(findRunnerFormFields().props('value')).toEqual({ + accessLevel: 'NOT_PROTECTED', + paused: false, + description: '', + maintenanceNote: '', + maximumTimeout: ' ', + runUntagged: false, + tagList: '', + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js index e233268b756..ed4f43c12d8 100644 --- a/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js +++ b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js @@ -1,8 +1,6 @@ import Vue from 'vue'; -import { GlTab, GlTabs } from '@gitlab/ui'; import VueRouter from 'vue-router'; import VueApollo from 'vue-apollo'; -import setWindowLocation from 'helpers/set_window_location_helper'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -15,6 +13,7 @@ import RunnerDetails from '~/ci/runner/components/runner_details.vue'; import RunnerPauseButton from '~/ci/runner/components/runner_pause_button.vue'; import RunnerDeleteButton from '~/ci/runner/components/runner_delete_button.vue'; import RunnerEditButton from '~/ci/runner/components/runner_edit_button.vue'; +import RunnerDetailsTabs from '~/ci/runner/components/runner_details_tabs.vue'; import RunnersJobs from '~/ci/runner/components/runner_jobs.vue'; import runnerQuery from '~/ci/runner/graphql/show/runner.query.graphql'; @@ -42,14 +41,12 @@ describe('AdminRunnerShowApp', () => { let mockRunnerQuery; const findRunnerHeader = () => wrapper.findComponent(RunnerHeader); - const findTabs = () => wrapper.findComponent(GlTabs); - const findTabAt = (i) => wrapper.findAllComponents(GlTab).at(i); const findRunnerDetails = () => wrapper.findComponent(RunnerDetails); const findRunnerDeleteButton = () => wrapper.findComponent(RunnerDeleteButton); const findRunnerEditButton = () => wrapper.findComponent(RunnerEditButton); const findRunnerPauseButton = () => wrapper.findComponent(RunnerPauseButton); + const findRunnerDetailsTabs = () => wrapper.findComponent(RunnerDetailsTabs); const findRunnersJobs = () => wrapper.findComponent(RunnersJobs); - const findJobCountBadge = () => wrapper.findByTestId('job-count-badge'); const mockRunnerQueryResult = (runner = {}) => { mockRunnerQuery = jest.fn().mockResolvedValue({ @@ -89,16 +86,20 @@ describe('AdminRunnerShowApp', () => { expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId }); }); - it('displays the runner header', async () => { + it('displays the runner header', () => { expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`); }); it('displays the runner edit and pause buttons', async () => { - expect(findRunnerEditButton().exists()).toBe(true); + expect(findRunnerEditButton().attributes('href')).toBe(mockRunner.editAdminUrl); expect(findRunnerPauseButton().exists()).toBe(true); expect(findRunnerDeleteButton().exists()).toBe(true); }); + it('shows runner details', () => { + expect(findRunnerDetailsTabs().props('runner')).toEqual(mockRunner); + }); + it('shows basic runner details', async () => { const expected = `Description My Runner Last contact Never contacted @@ -118,20 +119,11 @@ describe('AdminRunnerShowApp', () => { expect(wrapper.text().replace(/\s+/g, ' ')).toContain(expected); }); - it.each(['#/', '#/unknown-tab'])('shows details when location hash is `%s`', async (hash) => { - setWindowLocation(hash); - - await createComponent({ mountFn: mountExtended }); - - expect(findTabs().props('value')).toBe(0); - expect(findRunnerDetails().exists()).toBe(true); - expect(findRunnersJobs().exists()).toBe(false); - }); - describe('when runner cannot be updated', () => { beforeEach(async () => { mockRunnerQueryResult({ userPermissions: { + ...mockRunner.userPermissions, updateRunner: false, }, }); @@ -145,12 +137,17 @@ describe('AdminRunnerShowApp', () => { expect(findRunnerEditButton().exists()).toBe(false); expect(findRunnerPauseButton().exists()).toBe(false); }); + + it('displays delete button', () => { + expect(findRunnerDeleteButton().exists()).toBe(true); + }); }); describe('when runner cannot be deleted', () => { beforeEach(async () => { mockRunnerQueryResult({ userPermissions: { + ...mockRunner.userPermissions, deleteRunner: false, }, }); @@ -160,9 +157,14 @@ describe('AdminRunnerShowApp', () => { }); }); - it('does not display the runner edit and pause buttons', () => { + it('does not display the delete button', () => { expect(findRunnerDeleteButton().exists()).toBe(false); }); + + it('displays edit and pause buttons', () => { + expect(findRunnerEditButton().exists()).toBe(true); + expect(findRunnerPauseButton().exists()).toBe(true); + }); }); describe('when runner is deleted', () => { @@ -240,74 +242,4 @@ describe('AdminRunnerShowApp', () => { expect(createAlert).toHaveBeenCalled(); }); }); - - describe('When showing jobs', () => { - const stubs = { - GlTab, - GlTabs, - }; - - it('without a runner, shows no jobs', () => { - mockRunnerQuery = jest.fn().mockResolvedValue({ - data: { - runner: null, - }, - }); - - createComponent({ stubs }); - - expect(findJobCountBadge().exists()).toBe(false); - expect(findRunnersJobs().exists()).toBe(false); - }); - - it('when URL hash links to jobs tab', async () => { - mockRunnerQueryResult(); - setWindowLocation('#/jobs'); - - await createComponent({ mountFn: mountExtended }); - - expect(findTabs().props('value')).toBe(1); - expect(findRunnerDetails().exists()).toBe(false); - expect(findRunnersJobs().exists()).toBe(true); - }); - - it('without a job count, shows no jobs count', async () => { - mockRunnerQueryResult({ jobCount: null }); - - await createComponent({ stubs }); - - expect(findJobCountBadge().exists()).toBe(false); - }); - - it('with a job count, shows jobs count', async () => { - const runner = { jobCount: 3 }; - mockRunnerQueryResult(runner); - - await createComponent({ stubs }); - - expect(findJobCountBadge().text()).toBe('3'); - }); - }); - - describe('When navigating to another tab', () => { - let routerPush; - - beforeEach(async () => { - mockRunnerQueryResult(); - - await createComponent({ mountFn: mountExtended }); - - routerPush = jest.spyOn(wrapper.vm.$router, 'push').mockImplementation(() => {}); - }); - - it('navigates to details', () => { - findTabAt(0).vm.$emit('click'); - expect(routerPush).toHaveBeenLastCalledWith({ name: 'details' }); - }); - - it('navigates to job', () => { - findTabAt(1).vm.$emit('click'); - expect(routerPush).toHaveBeenLastCalledWith({ name: 'jobs' }); - }); - }); }); diff --git a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js index 9084ecdb4cc..7fc240e520b 100644 --- a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js @@ -39,6 +39,7 @@ import { I18N_GROUP_TYPE, I18N_PROJECT_TYPE, INSTANCE_TYPE, + JOBS_ROUTE_PATH, PARAM_KEY_PAUSED, PARAM_KEY_STATUS, PARAM_KEY_TAG, @@ -56,6 +57,7 @@ import { allRunnersDataPaginated, onlineContactTimeoutSecs, staleTimeoutSecs, + newRunnerPath, emptyPageInfo, emptyStateSvgPath, emptyStateFilteredSvgPath, @@ -113,6 +115,7 @@ describe('AdminRunnersApp', () => { apolloProvider: createMockApollo(handlers, {}, cacheConfig), propsData: { registrationToken: mockRegistrationToken, + newRunnerPath, ...props, }, provide: { @@ -280,11 +283,14 @@ describe('AdminRunnersApp', () => { it('Shows job status and links to jobs', () => { const badge = wrapper - .find('tr [data-testid="td-summary"]') + .find('tr [data-testid="td-status"]') .findComponent(RunnerJobStatusBadge); expect(badge.props('jobStatus')).toBe(mockRunners[0].jobExecutionStatus); - expect(badge.attributes('href')).toBe(`http://localhost/admin/runners/${id}#/jobs`); + + const badgeHref = new URL(badge.attributes('href')); + expect(badgeHref.pathname).toBe(`/admin/runners/${id}`); + expect(badgeHref.hash).toBe(`#${JOBS_ROUTE_PATH}`); }); it('When runner is paused or unpaused, some data is refetched', async () => { @@ -443,7 +449,13 @@ describe('AdminRunnersApp', () => { }); it('shows an empty state', () => { - expect(findRunnerListEmptyState().props('isSearchFiltered')).toBe(false); + expect(findRunnerListEmptyState().props()).toEqual({ + newRunnerPath, + isSearchFiltered: false, + filteredSvgPath: emptyStateFilteredSvgPath, + registrationToken: mockRegistrationToken, + svgPath: emptyStateSvgPath, + }); }); describe('when a filter is selected by the user', () => { diff --git a/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js index 2fb824a8fa5..1ff60ff1a9d 100644 --- a/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js +++ b/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js @@ -10,6 +10,7 @@ import { INSTANCE_TYPE, STATUS_ONLINE, STATUS_OFFLINE, + JOB_STATUS_IDLE, } from '~/ci/runner/constants'; describe('RunnerStatusCell', () => { @@ -18,16 +19,18 @@ describe('RunnerStatusCell', () => { const findStatusBadge = () => wrapper.findComponent(RunnerStatusBadge); const findPausedBadge = () => wrapper.findComponent(RunnerPausedBadge); - const createComponent = ({ runner = {} } = {}) => { + const createComponent = ({ runner = {}, ...options } = {}) => { wrapper = mount(RunnerStatusCell, { propsData: { runner: { runnerType: INSTANCE_TYPE, active: true, status: STATUS_ONLINE, + jobExecutionStatus: JOB_STATUS_IDLE, ...runner, }, }, + ...options, }); }; @@ -74,4 +77,14 @@ describe('RunnerStatusCell', () => { expect(wrapper.text()).toBe(''); }); + + it('Displays "runner-job-status-badge" slot', () => { + createComponent({ + scopedSlots: { + 'runner-job-status-badge': ({ runner }) => `Job status ${runner.jobExecutionStatus}`, + }, + }); + + expect(wrapper.text()).toContain(`Job status ${JOB_STATUS_IDLE}`); + }); }); diff --git a/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js index 10280c77303..1711df42491 100644 --- a/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js +++ b/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js @@ -3,7 +3,6 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import RunnerSummaryCell from '~/ci/runner/components/cells/runner_summary_cell.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import RunnerTags from '~/ci/runner/components/runner_tags.vue'; -import RunnerJobStatusBadge from '~/ci/runner/components/runner_job_status_badge.vue'; import RunnerSummaryField from '~/ci/runner/components/cells/runner_summary_field.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -22,7 +21,6 @@ describe('RunnerTypeCell', () => { let wrapper; const findLockIcon = () => wrapper.findByTestId('lock-icon'); - const findRunnerJobStatusBadge = () => wrapper.findComponent(RunnerJobStatusBadge); const findRunnerTags = () => wrapper.findComponent(RunnerTags); const findRunnerSummaryField = (icon) => wrapper.findAllComponents(RunnerSummaryField).filter((w) => w.props('icon') === icon) @@ -95,10 +93,6 @@ describe('RunnerTypeCell', () => { expect(wrapper.text()).toContain(I18N_NO_DESCRIPTION); }); - it('Displays job execution status', () => { - expect(findRunnerJobStatusBadge().props('jobStatus')).toBe(mockRunner.jobExecutionStatus); - }); - it('Displays last contact', () => { createComponent({ contactedAt: '2022-01-02', @@ -166,14 +160,14 @@ describe('RunnerTypeCell', () => { expect(findRunnerTags().props('tagList')).toEqual(['shell', 'linux']); }); - it.each(['runner-name', 'runner-job-status-badge'])('Displays a custom "%s" slot', (slotName) => { + it('Displays a custom runner-name slot', () => { const slotContent = 'My custom runner name'; createComponent( {}, { slots: { - [slotName]: slotContent, + 'runner-name': slotContent, }, }, ); diff --git a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js index 0ecafdd7d83..0daaca9c4ff 100644 --- a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js +++ b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js @@ -1,8 +1,9 @@ import { GlModal, GlDropdown, GlDropdownItem, GlDropdownForm } from '@gitlab/ui'; import { mount, shallowMount, createWrapper } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; - import VueApollo from 'vue-apollo'; + +import { s__ } from '~/locale'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -84,9 +85,9 @@ describe('RegistrationDropdown', () => { it.each` type | text - ${INSTANCE_TYPE} | ${'Register an instance runner'} - ${GROUP_TYPE} | ${'Register a group runner'} - ${PROJECT_TYPE} | ${'Register a project runner'} + ${INSTANCE_TYPE} | ${s__('Runners|Register an instance runner')} + ${GROUP_TYPE} | ${s__('Runners|Register a group runner')} + ${PROJECT_TYPE} | ${s__('Runners|Register a project runner')} `('Dropdown text for type $type is "$text"', () => { createComponent({ props: { type: INSTANCE_TYPE } }, mount); diff --git a/spec/frontend/ci/runner/components/runner_bulk_delete_spec.js b/spec/frontend/ci/runner/components/runner_bulk_delete_spec.js index 64f5a0e3b57..0dc5a90fb83 100644 --- a/spec/frontend/ci/runner/components/runner_bulk_delete_spec.js +++ b/spec/frontend/ci/runner/components/runner_bulk_delete_spec.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { makeVar } from '@apollo/client/core'; import { GlModal, GlSprintf } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; import { createAlert } from '~/flash'; @@ -22,6 +23,7 @@ describe('RunnerBulkDelete', () => { let mockState; let mockCheckedRunnerIds; + const findBanner = () => wrapper.findByTestId('runner-bulk-delete-banner'); const findClearBtn = () => wrapper.findByText(s__('Runners|Clear selection')); const findDeleteBtn = () => wrapper.findByText(s__('Runners|Delete selected')); const findModal = () => wrapper.findComponent(GlModal); @@ -64,10 +66,11 @@ describe('RunnerBulkDelete', () => { beforeEach(() => { mockState = createLocalState(); + mockCheckedRunnerIds = makeVar([]); jest .spyOn(mockState.cacheConfig.typePolicies.Query.fields, 'checkedRunnerIds') - .mockImplementation(() => mockCheckedRunnerIds); + .mockImplementation(() => mockCheckedRunnerIds()); }); afterEach(() => { @@ -76,15 +79,13 @@ describe('RunnerBulkDelete', () => { describe('When no runners are checked', () => { beforeEach(async () => { - mockCheckedRunnerIds = []; - createComponent(); await waitForPromises(); }); it('shows no contents', () => { - expect(wrapper.html()).toBe(''); + expect(findBanner().exists()).toBe(false); }); }); @@ -94,7 +95,7 @@ describe('RunnerBulkDelete', () => { ${2} | ${[mockId1, mockId2]} | ${'2 runners'} `('When $count runner(s) are checked', ({ ids, text }) => { beforeEach(() => { - mockCheckedRunnerIds = ids; + mockCheckedRunnerIds(ids); createComponent(); @@ -102,7 +103,7 @@ describe('RunnerBulkDelete', () => { }); it(`shows "${text}"`, () => { - expect(wrapper.text()).toContain(text); + expect(findBanner().text()).toContain(text); }); it('clears selection', () => { @@ -133,7 +134,7 @@ describe('RunnerBulkDelete', () => { }; beforeEach(() => { - mockCheckedRunnerIds = [mockId1, mockId2]; + mockCheckedRunnerIds([mockId1, mockId2]); createComponent(); @@ -157,20 +158,23 @@ describe('RunnerBulkDelete', () => { it('mutation is called', () => { expect(bulkRunnerDeleteHandler).toHaveBeenCalledWith({ - input: { ids: mockCheckedRunnerIds }, + input: { ids: mockCheckedRunnerIds() }, }); }); }); describe('when deletion is successful', () => { + let deletedIds; + beforeEach(async () => { + deletedIds = mockCheckedRunnerIds(); bulkRunnerDeleteHandler.mockResolvedValue({ data: { - bulkRunnerDelete: { deletedIds: mockCheckedRunnerIds, errors: [] }, + bulkRunnerDelete: { deletedIds, errors: [] }, }, }); - confirmDeletion(); + mockCheckedRunnerIds([]); await waitForPromises(); }); @@ -182,12 +186,12 @@ describe('RunnerBulkDelete', () => { it('user interface is updated', () => { const { evict, gc } = apolloCache; - expect(evict).toHaveBeenCalledTimes(mockCheckedRunnerIds.length); + expect(evict).toHaveBeenCalledTimes(deletedIds.length); expect(evict).toHaveBeenCalledWith({ - id: expect.stringContaining(mockCheckedRunnerIds[0]), + id: expect.stringContaining(deletedIds[0]), }); expect(evict).toHaveBeenCalledWith({ - id: expect.stringContaining(mockCheckedRunnerIds[1]), + id: expect.stringContaining(deletedIds[1]), }); expect(gc).toHaveBeenCalledTimes(1); @@ -195,7 +199,7 @@ describe('RunnerBulkDelete', () => { it('emits deletion confirmation', () => { expect(wrapper.emitted('deleted')).toEqual([ - [{ message: expect.stringContaining(`${mockCheckedRunnerIds.length}`) }], + [{ message: expect.stringContaining(`${deletedIds.length}`) }], ]); }); diff --git a/spec/frontend/ci/runner/components/runner_details_tabs_spec.js b/spec/frontend/ci/runner/components/runner_details_tabs_spec.js new file mode 100644 index 00000000000..a59c5a21377 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_details_tabs_spec.js @@ -0,0 +1,127 @@ +import Vue from 'vue'; +import { GlTab, GlTabs } from '@gitlab/ui'; +import VueRouter from 'vue-router'; +import VueApollo from 'vue-apollo'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { JOBS_ROUTE_PATH, I18N_DETAILS, I18N_JOBS } from '~/ci/runner/constants'; + +import RunnerDetailsTabs from '~/ci/runner/components/runner_details_tabs.vue'; +import RunnerDetails from '~/ci/runner/components/runner_details.vue'; +import RunnerJobs from '~/ci/runner/components/runner_jobs.vue'; + +import { runnerData } from '../mock_data'; + +// Vue Test Utils `stubs` option does not stub components mounted +// in <router-view>. Use mocking instead: +jest.mock('~/ci/runner/components/runner_jobs.vue', () => { + const ActualRunnerJobs = jest.requireActual('~/ci/runner/components/runner_jobs.vue').default; + return { + props: ActualRunnerJobs.props, + render() {}, + }; +}); + +const mockRunner = runnerData.data.runner; + +Vue.use(VueApollo); +Vue.use(VueRouter); + +describe('RunnerDetailsTabs', () => { + let wrapper; + let routerPush; + + const findTabs = () => wrapper.findComponent(GlTabs); + const findRunnerDetails = () => wrapper.findComponent(RunnerDetails); + const findRunnerJobs = () => wrapper.findComponent(RunnerJobs); + const findJobCountBadge = () => wrapper.findByTestId('job-count-badge'); + + const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => { + wrapper = mountFn(RunnerDetailsTabs, { + propsData: { + runner: mockRunner, + ...props, + }, + ...options, + }); + + routerPush = jest.spyOn(wrapper.vm.$router, 'push').mockImplementation(() => {}); + + return waitForPromises(); + }; + + it('shows basic runner details', async () => { + await createComponent({ mountFn: mountExtended }); + + expect(findRunnerDetails().props('runner')).toBe(mockRunner); + expect(findRunnerJobs().exists()).toBe(false); + }); + + it('shows runner jobs', async () => { + setWindowLocation(`#${JOBS_ROUTE_PATH}`); + + await createComponent({ mountFn: mountExtended }); + + expect(findRunnerDetails().exists()).toBe(false); + expect(findRunnerJobs().props('runner')).toBe(mockRunner); + }); + + it.each` + jobCount | badgeText + ${null} | ${null} + ${1} | ${'1'} + ${1000} | ${'1,000'} + ${1001} | ${'1,000+'} + `('shows runner jobs count', async ({ jobCount, badgeText }) => { + await createComponent({ + stubs: { + GlTab, + }, + props: { + runner: { + ...mockRunner, + jobCount, + }, + }, + }); + + if (!badgeText) { + expect(findJobCountBadge().exists()).toBe(false); + } else { + expect(findJobCountBadge().text()).toBe(badgeText); + } + }); + + it.each(['#/', '#/unknown-tab'])('shows details when location hash is `%s`', async (hash) => { + setWindowLocation(hash); + + await createComponent({ mountFn: mountExtended }); + + expect(findTabs().props('value')).toBe(0); + expect(findRunnerDetails().exists()).toBe(true); + expect(findRunnerJobs().exists()).toBe(false); + }); + + describe.each` + location | tab | navigatedTo + ${'#/details'} | ${I18N_DETAILS} | ${[]} + ${'#/details'} | ${I18N_JOBS} | ${[[{ name: 'jobs' }]]} + ${'#/jobs'} | ${I18N_JOBS} | ${[]} + ${'#/jobs'} | ${I18N_DETAILS} | ${[[{ name: 'details' }]]} + `('When at $location', ({ location, tab, navigatedTo }) => { + beforeEach(async () => { + setWindowLocation(location); + + await createComponent({ + mountFn: mountExtended, + }); + }); + + it(`on click on ${tab}, navigates to ${JSON.stringify(navigatedTo)}`, () => { + wrapper.findByText(tab).trigger('click'); + + expect(routerPush.mock.calls).toEqual(navigatedTo); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_form_fields_spec.js b/spec/frontend/ci/runner/components/runner_form_fields_spec.js new file mode 100644 index 00000000000..5b429645d17 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_form_fields_spec.js @@ -0,0 +1,87 @@ +import { nextTick } from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue'; +import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED } from '~/ci/runner/constants'; + +const mockDescription = 'My description'; +const mockMaxTimeout = 60; +const mockTags = 'tag, tag2'; + +describe('RunnerFormFields', () => { + let wrapper; + + const findInput = (name) => wrapper.find(`input[name="${name}"]`); + + const createComponent = ({ runner } = {}) => { + wrapper = mountExtended(RunnerFormFields, { + propsData: { + value: runner, + }, + }); + }; + + it('updates runner fields', async () => { + createComponent(); + + expect(wrapper.emitted('input')).toBe(undefined); + + findInput('description').setValue(mockDescription); + findInput('max-timeout').setValue(mockMaxTimeout); + findInput('paused').setChecked(true); + findInput('protected').setChecked(true); + findInput('run-untagged').setChecked(true); + findInput('tags').setValue(mockTags); + + await nextTick(); + + expect(wrapper.emitted('input')[0][0]).toMatchObject({ + description: mockDescription, + maximumTimeout: mockMaxTimeout, + tagList: mockTags, + }); + }); + + it('checks checkbox fields', async () => { + createComponent({ + runner: { + paused: false, + accessLevel: ACCESS_LEVEL_NOT_PROTECTED, + runUntagged: false, + }, + }); + + findInput('paused').setChecked(true); + findInput('protected').setChecked(true); + findInput('run-untagged').setChecked(true); + + await nextTick(); + + expect(wrapper.emitted('input')[0][0]).toEqual({ + paused: true, + accessLevel: ACCESS_LEVEL_REF_PROTECTED, + runUntagged: true, + }); + }); + + it('unchecks checkbox fields', async () => { + createComponent({ + runner: { + paused: true, + accessLevel: ACCESS_LEVEL_REF_PROTECTED, + runUntagged: true, + }, + }); + + findInput('paused').setChecked(false); + findInput('protected').setChecked(false); + findInput('run-untagged').setChecked(false); + + await nextTick(); + + expect(wrapper.emitted('input')[0][0]).toEqual({ + paused: false, + accessLevel: ACCESS_LEVEL_NOT_PROTECTED, + runUntagged: false, + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_header_spec.js b/spec/frontend/ci/runner/components/runner_header_spec.js index a04011de1cd..abe3b47767e 100644 --- a/spec/frontend/ci/runner/components/runner_header_spec.js +++ b/spec/frontend/ci/runner/components/runner_header_spec.js @@ -6,7 +6,7 @@ import { GROUP_TYPE, STATUS_ONLINE, } from '~/ci/runner/constants'; -import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; +import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -71,7 +71,7 @@ describe('RunnerHeader', () => { it('displays the runner id', () => { createComponent({ runner: { - id: convertToGraphQLId(TYPE_CI_RUNNER, 99), + id: convertToGraphQLId(TYPENAME_CI_RUNNER, 99), }, }); @@ -99,7 +99,7 @@ describe('RunnerHeader', () => { it('does not display runner creation time if "createdAt" is missing', () => { createComponent({ runner: { - id: convertToGraphQLId(TYPE_CI_RUNNER, 99), + id: convertToGraphQLId(TYPENAME_CI_RUNNER, 99), createdAt: null, }, }); diff --git a/spec/frontend/ci/runner/components/runner_job_status_badge_spec.js b/spec/frontend/ci/runner/components/runner_job_status_badge_spec.js index 015bebf40e3..c4476d01386 100644 --- a/spec/frontend/ci/runner/components/runner_job_status_badge_spec.js +++ b/spec/frontend/ci/runner/components/runner_job_status_badge_spec.js @@ -23,16 +23,25 @@ describe('RunnerTypeBadge', () => { }; it.each` - jobStatus | classes | text - ${JOB_STATUS_RUNNING} | ${['gl-mr-3', 'gl-bg-transparent!', 'gl-text-blue-600!', 'gl-border', 'gl-border-blue-600!']} | ${I18N_JOB_STATUS_RUNNING} - ${JOB_STATUS_IDLE} | ${['gl-mr-3', 'gl-bg-transparent!', 'gl-text-gray-700!', 'gl-border', 'gl-border-gray-500!']} | ${I18N_JOB_STATUS_IDLE} + jobStatus | classes | text + ${JOB_STATUS_RUNNING} | ${['gl-text-blue-600!', 'gl-border-blue-600!']} | ${I18N_JOB_STATUS_RUNNING} + ${JOB_STATUS_IDLE} | ${['gl-text-gray-700!', 'gl-border-gray-500!']} | ${I18N_JOB_STATUS_IDLE} `( 'renders $jobStatus job status with "$text" text and styles', ({ jobStatus, classes, text }) => { createComponent({ props: { jobStatus } }); - expect(findBadge().props()).toMatchObject({ size: 'sm', variant: 'muted' }); - expect(findBadge().classes().sort()).toEqual(classes.sort()); + expect(findBadge().props()).toMatchObject({ size: 'md', variant: 'muted' }); + expect(findBadge().classes().sort()).toEqual( + [ + ...classes, + 'gl-border', + 'gl-display-inline-block', + 'gl-max-w-full', + 'gl-text-truncate', + 'gl-bg-transparent!', + ].sort(), + ); expect(findBadge().text()).toBe(text); }, ); diff --git a/spec/frontend/ci/runner/components/runner_jobs_table_spec.js b/spec/frontend/ci/runner/components/runner_jobs_table_spec.js index 8defe568df8..281aa1aeb77 100644 --- a/spec/frontend/ci/runner/components/runner_jobs_table_spec.js +++ b/spec/frontend/ci/runner/components/runner_jobs_table_spec.js @@ -72,7 +72,7 @@ describe('RunnerJobsTable', () => { }); it('Displays details of a job', () => { - const { id, detailedStatus, pipeline, shortSha, commitPath } = mockJobs[0]; + const { id, detailedStatus, project, shortSha, commitPath } = mockJobs[0]; expect(findCell({ field: 'status' }).text()).toMatchInterpolatedText(detailedStatus.text); @@ -81,10 +81,8 @@ describe('RunnerJobsTable', () => { detailedStatus.detailsPath, ); - expect(findCell({ field: 'project' }).text()).toBe(pipeline.project.name); - expect(findCell({ field: 'project' }).find('a').attributes('href')).toBe( - pipeline.project.webUrl, - ); + expect(findCell({ field: 'project' }).text()).toBe(project.name); + expect(findCell({ field: 'project' }).find('a').attributes('href')).toBe(project.webUrl); expect(findCell({ field: 'commit' }).text()).toBe(shortSha); expect(findCell({ field: 'commit' }).find('a').attributes('href')).toBe(commitPath); diff --git a/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js b/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js index d351f7b6908..6aea3ddf58c 100644 --- a/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js +++ b/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js @@ -4,10 +4,14 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import { + newRunnerPath, + emptyStateSvgPath, + emptyStateFilteredSvgPath, +} from 'jest/ci/runner/mock_data'; + import RunnerListEmptyState from '~/ci/runner/components/runner_list_empty_state.vue'; -const mockSvgPath = 'mock-svg-path.svg'; -const mockFilteredSvgPath = 'mock-filtered-svg-path.svg'; const mockRegistrationToken = 'REGISTRATION_TOKEN'; describe('RunnerListEmptyState', () => { @@ -17,12 +21,13 @@ describe('RunnerListEmptyState', () => { const findLink = () => wrapper.findComponent(GlLink); const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal); - const createComponent = ({ props, mountFn = shallowMountExtended } = {}) => { + const createComponent = ({ props, mountFn = shallowMountExtended, ...options } = {}) => { wrapper = mountFn(RunnerListEmptyState, { propsData: { - svgPath: mockSvgPath, - filteredSvgPath: mockFilteredSvgPath, + svgPath: emptyStateSvgPath, + filteredSvgPath: emptyStateFilteredSvgPath, registrationToken: mockRegistrationToken, + newRunnerPath, ...props, }, directives: { @@ -33,6 +38,7 @@ describe('RunnerListEmptyState', () => { GlSprintf, GlLink, }, + ...options, }); }; @@ -45,7 +51,7 @@ describe('RunnerListEmptyState', () => { }); it('renders an illustration', () => { - expect(findEmptyState().props('svgPath')).toBe(mockSvgPath); + expect(findEmptyState().props('svgPath')).toBe(emptyStateSvgPath); }); it('displays "no results" text with instructions', () => { @@ -56,10 +62,53 @@ describe('RunnerListEmptyState', () => { expect(findEmptyState().text()).toMatchInterpolatedText(`${title} ${desc}`); }); - it('opens a runner registration instructions modal with a link', () => { - const { value } = getBinding(findLink().element, 'gl-modal'); + describe('when create_runner_workflow is enabled', () => { + beforeEach(() => { + createComponent({ + provide: { + glFeatures: { createRunnerWorkflow: true }, + }, + }); + }); + + it('shows a link to the new runner page', () => { + expect(findLink().attributes('href')).toBe(newRunnerPath); + }); + }); + + describe('when create_runner_workflow is enabled and newRunnerPath not defined', () => { + beforeEach(() => { + createComponent({ + props: { + newRunnerPath: null, + }, + provide: { + glFeatures: { createRunnerWorkflow: true }, + }, + }); + }); + + it('opens a runner registration instructions modal with a link', () => { + const { value } = getBinding(findLink().element, 'gl-modal'); + + expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); + }); + }); + + describe('when create_runner_workflow is disabled', () => { + beforeEach(() => { + createComponent({ + provide: { + glFeatures: { createRunnerWorkflow: false }, + }, + }); + }); + + it('opens a runner registration instructions modal with a link', () => { + const { value } = getBinding(findLink().element, 'gl-modal'); - expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); + expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); + }); }); }); @@ -69,7 +118,7 @@ describe('RunnerListEmptyState', () => { }); it('renders an illustration', () => { - expect(findEmptyState().props('svgPath')).toBe(mockSvgPath); + expect(findEmptyState().props('svgPath')).toBe(emptyStateSvgPath); }); it('displays "no results" text', () => { @@ -92,7 +141,7 @@ describe('RunnerListEmptyState', () => { }); it('renders a "filtered search" illustration', () => { - expect(findEmptyState().props('svgPath')).toBe(mockFilteredSvgPath); + expect(findEmptyState().props('svgPath')).toBe(emptyStateFilteredSvgPath); }); it('displays "no filtered results" text', () => { diff --git a/spec/frontend/ci/runner/components/runner_list_spec.js b/spec/frontend/ci/runner/components/runner_list_spec.js index 1267d045623..2e5d1dbd063 100644 --- a/spec/frontend/ci/runner/components/runner_list_spec.js +++ b/spec/frontend/ci/runner/components/runner_list_spec.js @@ -177,30 +177,30 @@ describe('RunnerList', () => { }); describe('Scoped cell slots', () => { - it('Render #runner-name slot in "summary" cell', () => { + it('Render #runner-job-status-badge slot in "status" cell', () => { createComponent( { - scopedSlots: { 'runner-name': ({ runner }) => `Summary: ${runner.id}` }, + scopedSlots: { + 'runner-job-status-badge': ({ runner }) => `Job status ${runner.jobExecutionStatus}`, + }, }, mountExtended, ); - expect(findCell({ fieldKey: 'summary' }).text()).toContain(`Summary: ${mockRunners[0].id}`); + expect(findCell({ fieldKey: 'status' }).text()).toContain( + `Job status ${mockRunners[0].jobExecutionStatus}`, + ); }); - it('Render #runner-job-status-badge slot in "summary" cell', () => { + it('Render #runner-name slot in "summary" cell', () => { createComponent( { - scopedSlots: { - 'runner-job-status-badge': ({ runner }) => `Job status ${runner.jobExecutionStatus}`, - }, + scopedSlots: { 'runner-name': ({ runner }) => `Summary: ${runner.id}` }, }, mountExtended, ); - expect(findCell({ fieldKey: 'summary' }).text()).toContain( - `Job status ${mockRunners[0].jobExecutionStatus}`, - ); + expect(findCell({ fieldKey: 'summary' }).text()).toContain(`Summary: ${mockRunners[0].id}`); }); it('Render #runner-actions-cell slot in "actions" cell', () => { diff --git a/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js b/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js new file mode 100644 index 00000000000..db6fd2c369b --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js @@ -0,0 +1,96 @@ +import { nextTick } from 'vue'; +import { GlFormRadioGroup, GlIcon, GlLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RunnerPlatformsRadio from '~/ci/runner/components/runner_platforms_radio.vue'; +import { + LINUX_PLATFORM, + MACOS_PLATFORM, + WINDOWS_PLATFORM, + AWS_PLATFORM, + DOCKER_HELP_URL, + KUBERNETES_HELP_URL, +} from '~/ci/runner/constants'; + +import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue'; + +const mockProvide = { + awsImgPath: 'awsLogo.svg', + dockerImgPath: 'dockerLogo.svg', + kubernetesImgPath: 'kubernetesLogo.svg', +}; + +describe('RunnerPlatformsRadioGroup', () => { + let wrapper; + + const findFormRadioGroup = () => wrapper.findComponent(GlFormRadioGroup); + const findFormRadios = () => wrapper.findAllComponents(RunnerPlatformsRadio).wrappers; + const findFormRadioByText = (text) => + findFormRadios() + .filter((w) => w.text() === text) + .at(0); + + const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => { + wrapper = mountFn(RunnerPlatformsRadioGroup, { + propsData: { + value: null, + ...props, + }, + provide: mockProvide, + ...options, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('contains expected options with images', () => { + const labels = findFormRadios().map((w) => [w.text(), w.props('image')]); + + expect(labels).toEqual([ + ['Linux', null], + ['macOS', null], + ['Windows', null], + ['AWS', expect.any(String)], + ['Docker', expect.any(String)], + ['Kubernetes', expect.any(String)], + ]); + }); + + it('allows users to use radio group', async () => { + findFormRadioGroup().vm.$emit('input', MACOS_PLATFORM); + await nextTick(); + + expect(wrapper.emitted('input')[0]).toEqual([MACOS_PLATFORM]); + }); + + it.each` + text | value + ${'Linux'} | ${LINUX_PLATFORM} + ${'macOS'} | ${MACOS_PLATFORM} + ${'Windows'} | ${WINDOWS_PLATFORM} + ${'AWS'} | ${AWS_PLATFORM} + `('user can select "$text"', async ({ text, value }) => { + const radio = findFormRadioByText(text); + expect(radio.props('value')).toBe(value); + + radio.vm.$emit('input', value); + await nextTick(); + + expect(wrapper.emitted('input')[0]).toEqual([value]); + }); + + it.each` + text | href + ${'Docker'} | ${DOCKER_HELP_URL} + ${'Kubernetes'} | ${KUBERNETES_HELP_URL} + `('provides link to "$text" docs', async ({ text, href }) => { + const radio = findFormRadioByText(text); + + expect(radio.findComponent(GlLink).attributes()).toEqual({ + href, + target: '_blank', + }); + expect(radio.findComponent(GlIcon).props('name')).toBe('external-link'); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_platforms_radio_spec.js b/spec/frontend/ci/runner/components/runner_platforms_radio_spec.js new file mode 100644 index 00000000000..fb81edd1ae2 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_platforms_radio_spec.js @@ -0,0 +1,154 @@ +import { GlFormRadio } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import RunnerPlatformsRadio from '~/ci/runner/components/runner_platforms_radio.vue'; + +const mockImg = 'mock.svg'; +const mockValue = 'value'; +const mockValue2 = 'value2'; +const mockSlot = '<div>a</div>'; + +describe('RunnerPlatformsRadio', () => { + let wrapper; + + const findDiv = () => wrapper.find('div'); + const findImg = () => wrapper.find('img'); + const findFormRadio = () => wrapper.findComponent(GlFormRadio); + + const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => { + wrapper = mountFn(RunnerPlatformsRadio, { + propsData: { + image: mockImg, + value: mockValue, + ...props, + }, + ...options, + }); + }; + + describe('when its selectable', () => { + beforeEach(() => { + createComponent({ + props: { value: mockValue }, + }); + }); + + it('shows the item is clickable', () => { + expect(wrapper.classes('gl-cursor-pointer')).toBe(true); + }); + + it('shows radio option', () => { + expect(findFormRadio().attributes('value')).toBe(mockValue); + }); + + it('emits when item is clicked', async () => { + findDiv().trigger('click'); + + expect(wrapper.emitted('input')).toEqual([[mockValue]]); + }); + + it.each(['input', 'change'])('emits radio "%s" event', (event) => { + findFormRadio().vm.$emit(event, mockValue2); + + expect(wrapper.emitted(event)).toEqual([[mockValue2]]); + }); + + it('shows image', () => { + expect(findImg().attributes()).toMatchObject({ + src: mockImg, + 'aria-hidden': 'true', + }); + }); + + it('shows slot', () => { + createComponent({ + slots: { + default: mockSlot, + }, + }); + + expect(wrapper.html()).toContain(mockSlot); + }); + + describe('with no image', () => { + beforeEach(() => { + createComponent({ + props: { value: mockValue, image: null }, + }); + }); + + it('shows no image', () => { + expect(findImg().exists()).toBe(false); + }); + }); + }); + + describe('when its not selectable', () => { + beforeEach(() => { + createComponent({ + props: { value: null }, + }); + }); + + it('shows the item is clickable', () => { + expect(wrapper.classes('gl-cursor-pointer')).toBe(false); + }); + + it('does not emit when item is clicked', async () => { + findDiv().trigger('click'); + + expect(wrapper.emitted('input')).toBe(undefined); + }); + + it('does not show a radio option', () => { + expect(findFormRadio().exists()).toBe(false); + }); + + it('shows image', () => { + expect(findImg().attributes()).toMatchObject({ + src: mockImg, + 'aria-hidden': 'true', + }); + }); + + it('shows slot', () => { + createComponent({ + slots: { + default: mockSlot, + }, + }); + + expect(wrapper.html()).toContain(mockSlot); + }); + + describe('with no image', () => { + beforeEach(() => { + createComponent({ + props: { value: null, image: null }, + }); + }); + + it('shows no image', () => { + expect(findImg().exists()).toBe(false); + }); + }); + }); + + describe('when selected', () => { + beforeEach(() => { + createComponent({ + props: { checked: mockValue }, + }); + }); + + it('highlights the item', () => { + expect(wrapper.classes('gl-bg-blue-50')).toBe(true); + expect(wrapper.classes('gl-border-blue-500')).toBe(true); + }); + + it('shows radio option as selected', () => { + expect(findFormRadio().attributes('value')).toBe(mockValue); + expect(findFormRadio().props('checked')).toBe(mockValue); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js b/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js index 3dce5a509ca..b7d9d3ad23e 100644 --- a/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js +++ b/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js @@ -5,7 +5,7 @@ import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; - +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import TagToken, { TAG_SUGGESTIONS_PATH } from '~/ci/runner/components/search_tokens/tag_token.vue'; import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; import { getRecentlyUsedSuggestions } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; @@ -80,10 +80,10 @@ describe('TagToken', () => { beforeEach(() => { mock = new MockAdapter(axios); - mock.onGet(TAG_SUGGESTIONS_PATH, { params: { search: '' } }).reply(200, mockTags); + mock.onGet(TAG_SUGGESTIONS_PATH, { params: { search: '' } }).reply(HTTP_STATUS_OK, mockTags); mock .onGet(TAG_SUGGESTIONS_PATH, { params: { search: mockSearchTerm } }) - .reply(200, mockTagsFiltered); + .reply(HTTP_STATUS_OK, mockTagsFiltered); getRecentlyUsedSuggestions.mockReturnValue([]); }); @@ -163,7 +163,9 @@ describe('TagToken', () => { describe('when suggestions cannot be loaded', () => { beforeEach(async () => { - mock.onGet(TAG_SUGGESTIONS_PATH, { params: { search: '' } }).reply(500); + mock + .onGet(TAG_SUGGESTIONS_PATH, { params: { search: '' } }) + .reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); createComponent(); await waitForPromises(); diff --git a/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js b/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js index c6c3f3b7040..2ad31dea774 100644 --- a/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js +++ b/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import VueRouter from 'vue-router'; import VueApollo from 'vue-apollo'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -12,6 +13,9 @@ import RunnerDetails from '~/ci/runner/components/runner_details.vue'; import RunnerPauseButton from '~/ci/runner/components/runner_pause_button.vue'; import RunnerDeleteButton from '~/ci/runner/components/runner_delete_button.vue'; import RunnerEditButton from '~/ci/runner/components/runner_edit_button.vue'; +import RunnerDetailsTabs from '~/ci/runner/components/runner_details_tabs.vue'; +import RunnersJobs from '~/ci/runner/components/runner_jobs.vue'; + import runnerQuery from '~/ci/runner/graphql/show/runner.query.graphql'; import GroupRunnerShowApp from '~/ci/runner/group_runner_show/group_runner_show_app.vue'; import { captureException } from '~/ci/runner/sentry_utils'; @@ -31,6 +35,7 @@ const mockRunnersPath = '/groups/group1/-/runners'; const mockEditGroupRunnerPath = `/groups/group1/-/runners/${mockRunnerId}/edit`; Vue.use(VueApollo); +Vue.use(VueRouter); describe('GroupRunnerShowApp', () => { let wrapper; @@ -41,6 +46,8 @@ describe('GroupRunnerShowApp', () => { const findRunnerDeleteButton = () => wrapper.findComponent(RunnerDeleteButton); const findRunnerEditButton = () => wrapper.findComponent(RunnerEditButton); const findRunnerPauseButton = () => wrapper.findComponent(RunnerPauseButton); + const findRunnerDetailsTabs = () => wrapper.findComponent(RunnerDetailsTabs); + const findRunnersJobs = () => wrapper.findComponent(RunnersJobs); const mockRunnerQueryResult = (runner = {}) => { mockRunnerQuery = jest.fn().mockResolvedValue({ @@ -81,16 +88,23 @@ describe('GroupRunnerShowApp', () => { expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId }); }); - it('displays the header', async () => { + it('displays the runner header', () => { expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`); }); - it('displays edit, pause, delete buttons', async () => { - expect(findRunnerEditButton().exists()).toBe(true); + it('displays the runner edit and pause buttons', async () => { + expect(findRunnerEditButton().attributes('href')).toBe(mockEditGroupRunnerPath); expect(findRunnerPauseButton().exists()).toBe(true); expect(findRunnerDeleteButton().exists()).toBe(true); }); + it('shows runner details', () => { + expect(findRunnerDetailsTabs().props()).toEqual({ + runner: mockRunner, + showAccessHelp: true, + }); + }); + it('shows basic runner details', () => { const expected = `Description My Runner Last contact Never contacted @@ -104,17 +118,12 @@ describe('GroupRunnerShowApp', () => { Token expiry Runner authentication token expiration Runner authentication tokens will expire based on a set interval. - They will automatically rotate once expired. Learn more - Never expires + They will automatically rotate once expired. Learn more Never expires Tags None`.replace(/\s+/g, ' '); expect(wrapper.text().replace(/\s+/g, ' ')).toContain(expected); }); - it('renders runner details component', () => { - expect(findRunnerDetails().props('runner')).toEqual(mockRunner); - }); - describe('when runner cannot be updated', () => { beforeEach(async () => { mockRunnerQueryResult({ @@ -129,7 +138,7 @@ describe('GroupRunnerShowApp', () => { }); }); - it('does not display edit and pause buttons', () => { + it('does not display the runner edit and pause buttons', () => { expect(findRunnerEditButton().exists()).toBe(false); expect(findRunnerPauseButton().exists()).toBe(false); }); @@ -153,7 +162,7 @@ describe('GroupRunnerShowApp', () => { }); }); - it('does not display delete button', () => { + it('does not display the delete button', () => { expect(findRunnerDeleteButton().exists()).toBe(false); }); @@ -187,8 +196,17 @@ describe('GroupRunnerShowApp', () => { mockRunnerQueryResult(); createComponent(); + expect(findRunnerDetails().exists()).toBe(false); }); + + it('does not show runner jobs', () => { + mockRunnerQueryResult(); + + createComponent(); + + expect(findRunnersJobs().exists()).toBe(false); + }); }); describe('When there is an error', () => { diff --git a/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js index 1e5bb828dbf..39ea5cade28 100644 --- a/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js @@ -25,6 +25,7 @@ import RunnerActionsCell from '~/ci/runner/components/cells/runner_actions_cell. import RegistrationDropdown from '~/ci/runner/components/registration/registration_dropdown.vue'; import RunnerPagination from '~/ci/runner/components/runner_pagination.vue'; import RunnerMembershipToggle from '~/ci/runner/components/runner_membership_toggle.vue'; +import RunnerJobStatusBadge from '~/ci/runner/components/runner_job_status_badge.vue'; import { CREATED_ASC, @@ -35,6 +36,7 @@ import { I18N_STATUS_STALE, INSTANCE_TYPE, GROUP_TYPE, + JOBS_ROUTE_PATH, PARAM_KEY_PAUSED, PARAM_KEY_STATUS, PARAM_KEY_TAG, @@ -112,7 +114,6 @@ describe('GroupRunnersApp', () => { propsData: { registrationToken: mockRegistrationToken, groupFullPath: mockGroupFullPath, - groupRunnersLimitedCount: mockGroupRunnersCount, ...props, }, provide: { @@ -254,7 +255,7 @@ describe('GroupRunnersApp', () => { let showToast; const { webUrl, editUrl, node } = mockGroupRunnersEdges[0]; - const { id: graphqlId, shortSha } = node; + const { id: graphqlId, shortSha, jobExecutionStatus } = node; const id = getIdFromGraphQLId(graphqlId); const COUNT_QUERIES = 6; // Smart queries that display a filtered count of runners const FILTERED_COUNT_QUERIES = 6; // Smart queries that display a count of runners in tabs and single stats @@ -264,6 +265,13 @@ describe('GroupRunnersApp', () => { showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show'); }); + it('Shows job status and links to jobs', () => { + const badge = findRunnerRow(id).findByTestId('td-status').findComponent(RunnerJobStatusBadge); + + expect(badge.props('jobStatus')).toBe(jobExecutionStatus); + expect(badge.attributes('href')).toBe(`${webUrl}#${JOBS_ROUTE_PATH}`); + }); + it('view link is displayed correctly', () => { const viewLink = findRunnerRow(id).findByTestId('td-summary').findComponent(GlLink); @@ -466,7 +474,6 @@ describe('GroupRunnersApp', () => { propsData: { registrationToken: mockRegistrationToken, groupFullPath: mockGroupFullPath, - groupRunnersLimitedCount: mockGroupRunnersCount, }, }); }); @@ -482,7 +489,6 @@ describe('GroupRunnersApp', () => { propsData: { registrationToken: null, groupFullPath: mockGroupFullPath, - groupRunnersLimitedCount: mockGroupRunnersCount, }, }); }); diff --git a/spec/frontend/ci/runner/mock_data.js b/spec/frontend/ci/runner/mock_data.js index 525756ed513..5cdf0ea4e3b 100644 --- a/spec/frontend/ci/runner/mock_data.js +++ b/spec/frontend/ci/runner/mock_data.js @@ -304,6 +304,7 @@ export const mockSearchExamples = [ export const onlineContactTimeoutSecs = 2 * 60 * 60; export const staleTimeoutSecs = 7889238; // Ruby's `3.months` +export const newRunnerPath = '/runners/new'; export const emptyStateSvgPath = 'emptyStateSvgPath.svg'; export const emptyStateFilteredSvgPath = 'emptyStateFilteredSvgPath.svg'; |