diff options
Diffstat (limited to 'spec/frontend/environments')
36 files changed, 1596 insertions, 340 deletions
diff --git a/spec/frontend/environments/canary_ingress_spec.js b/spec/frontend/environments/canary_ingress_spec.js index 340740e6499..e0247731b63 100644 --- a/spec/frontend/environments/canary_ingress_spec.js +++ b/spec/frontend/environments/canary_ingress_spec.js @@ -23,7 +23,7 @@ describe('/environments/components/canary_ingress.vue', () => { ...props, }, directives: { - GlModal: createMockDirective(), + GlModal: createMockDirective('gl-modal'), }, ...options, }); @@ -33,14 +33,6 @@ describe('/environments/components/canary_ingress.vue', () => { createComponent(); }); - afterEach(() => { - if (wrapper) { - wrapper.destroy(); - } - - wrapper = null; - }); - describe('stable weight', () => { let stableWeightDropdown; diff --git a/spec/frontend/environments/canary_update_modal_spec.js b/spec/frontend/environments/canary_update_modal_spec.js index 31b1770da59..4fa7b34d817 100644 --- a/spec/frontend/environments/canary_update_modal_spec.js +++ b/spec/frontend/environments/canary_update_modal_spec.js @@ -30,14 +30,6 @@ describe('/environments/components/canary_update_modal.vue', () => { modal = wrapper.findComponent(GlModal); }; - afterEach(() => { - if (wrapper) { - wrapper.destroy(); - } - - wrapper = null; - }); - beforeEach(() => { createComponent(); }); @@ -47,7 +39,7 @@ describe('/environments/components/canary_update_modal.vue', () => { modalId: 'confirm-canary-change', actionPrimary: { text: 'Change ratio', - attributes: [{ variant: 'confirm' }], + attributes: { variant: 'confirm' }, }, actionCancel: { text: 'Cancel' }, }); diff --git a/spec/frontend/environments/confirm_rollback_modal_spec.js b/spec/frontend/environments/confirm_rollback_modal_spec.js index 2163814528a..d6601447cff 100644 --- a/spec/frontend/environments/confirm_rollback_modal_spec.js +++ b/spec/frontend/environments/confirm_rollback_modal_spec.js @@ -5,6 +5,7 @@ import VueApollo from 'vue-apollo'; import { trimText } from 'helpers/text_helper'; import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue'; import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import eventHub from '~/environments/event_hub'; describe('Confirm Rollback Modal Component', () => { @@ -53,6 +54,8 @@ describe('Confirm Rollback Modal Component', () => { }); }; + const findModal = () => component.findComponent(GlModal); + describe.each` hasMultipleCommits | environmentData | retryUrl | primaryPropsAttrs ${true} | ${envWithLastDeployment} | ${null} | ${[{ variant: 'danger' }]} @@ -73,7 +76,7 @@ describe('Confirm Rollback Modal Component', () => { hasMultipleCommits, retryUrl, }); - const modal = component.findComponent(GlModal); + const modal = findModal(); expect(modal.attributes('title')).toContain('Rollback'); expect(modal.attributes('title')).toContain('test'); @@ -92,7 +95,7 @@ describe('Confirm Rollback Modal Component', () => { hasMultipleCommits, }); - const modal = component.findComponent(GlModal); + const modal = findModal(); expect(modal.attributes('title')).toContain('Re-deploy'); expect(modal.attributes('title')).toContain('test'); @@ -110,7 +113,7 @@ describe('Confirm Rollback Modal Component', () => { }); const eventHubSpy = jest.spyOn(eventHub, '$emit'); - const modal = component.findComponent(GlModal); + const modal = findModal(); modal.vm.$emit('ok'); expect(eventHubSpy).toHaveBeenCalledWith('rollbackEnvironment', env); @@ -155,7 +158,7 @@ describe('Confirm Rollback Modal Component', () => { }, { apolloProvider }, ); - const modal = component.findComponent(GlModal); + const modal = findModal(); expect(trimText(modal.text())).toContain('commit abc0123'); expect(modal.text()).toContain('Are you sure you want to continue?'); @@ -177,7 +180,7 @@ describe('Confirm Rollback Modal Component', () => { }, { apolloProvider }, ); - const modal = component.findComponent(GlModal); + const modal = findModal(); expect(modal.attributes('title')).toContain('Rollback'); expect(modal.attributes('title')).toContain('test'); @@ -201,7 +204,7 @@ describe('Confirm Rollback Modal Component', () => { { apolloProvider }, ); - const modal = component.findComponent(GlModal); + const modal = findModal(); expect(modal.attributes('title')).toContain('Re-deploy'); expect(modal.attributes('title')).toContain('test'); @@ -220,7 +223,7 @@ describe('Confirm Rollback Modal Component', () => { { apolloProvider }, ); - const modal = component.findComponent(GlModal); + const modal = findModal(); modal.vm.$emit('ok'); await nextTick(); @@ -231,6 +234,25 @@ describe('Confirm Rollback Modal Component', () => { expect.anything(), ); }); + + it('should emit the "rollback" event when "ok" is clicked', async () => { + const env = { ...environmentData, isLastDeployment: true }; + + createComponent( + { + environment: env, + hasMultipleCommits, + graphql: true, + }, + { apolloProvider }, + ); + + const modal = findModal(); + modal.vm.$emit('ok'); + + await waitForPromises(); + expect(component.emitted('rollback')).toEqual([[]]); + }); }, ); }); diff --git a/spec/frontend/environments/delete_environment_modal_spec.js b/spec/frontend/environments/delete_environment_modal_spec.js index cc18bf754eb..96f6ce52a9c 100644 --- a/spec/frontend/environments/delete_environment_modal_spec.js +++ b/spec/frontend/environments/delete_environment_modal_spec.js @@ -6,10 +6,10 @@ import { s__, sprintf } from '~/locale'; import DeleteEnvironmentModal from '~/environments/components/delete_environment_modal.vue'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { resolvedEnvironment } from './graphql/mock_data'; -jest.mock('~/flash'); +jest.mock('~/alert'); Vue.use(VueApollo); describe('~/environments/components/delete_environment_modal.vue', () => { @@ -67,7 +67,7 @@ describe('~/environments/components/delete_environment_modal.vue', () => { ); }); - it('should flash a message on error', async () => { + it('should alert a message on error', async () => { createComponent({ apolloProvider: mockApollo }); deleteResolver.mockRejectedValue(); diff --git a/spec/frontend/environments/deploy_board_component_spec.js b/spec/frontend/environments/deploy_board_component_spec.js index 73a366457fb..f50efada91a 100644 --- a/spec/frontend/environments/deploy_board_component_spec.js +++ b/spec/frontend/environments/deploy_board_component_spec.js @@ -61,7 +61,7 @@ describe('Deploy Board', () => { const icon = iconSpan.findComponent(GlIcon); expect(tooltip.props('target')()).toBe(iconSpan.element); - expect(icon.props('name')).toBe('question'); + expect(icon.props('name')).toBe('question-o'); }); it('renders the canary weight selector', () => { @@ -116,7 +116,7 @@ describe('Deploy Board', () => { const icon = iconSpan.findComponent(GlIcon); expect(tooltip.props('target')()).toBe(iconSpan.element); - expect(icon.props('name')).toBe('question'); + expect(icon.props('name')).toBe('question-o'); }); it('renders the canary weight selector', () => { diff --git a/spec/frontend/environments/deploy_freeze_alert_spec.js b/spec/frontend/environments/deploy_freeze_alert_spec.js new file mode 100644 index 00000000000..b7202253e61 --- /dev/null +++ b/spec/frontend/environments/deploy_freeze_alert_spec.js @@ -0,0 +1,111 @@ +import { GlAlert, GlLink } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import DeployFreezeAlert from '~/environments/components/deploy_freeze_alert.vue'; +import deployFreezesQuery from '~/environments/graphql/queries/deploy_freezes.query.graphql'; +import { formatDate } from '~/lib/utils/datetime/date_format_utility'; + +const ENVIRONMENT_NAME = 'staging'; + +Vue.use(VueApollo); +describe('~/environments/components/deploy_freeze_alert.vue', () => { + let wrapper; + + const createWrapper = (deployFreezes = []) => { + const mockApollo = createMockApollo([ + [ + deployFreezesQuery, + jest.fn().mockResolvedValue({ + data: { + project: { + id: '1', + __typename: 'Project', + environment: { + id: '1', + __typename: 'Environment', + deployFreezes, + }, + }, + }, + }), + ], + ]); + wrapper = mountExtended(DeployFreezeAlert, { + apolloProvider: mockApollo, + provide: { + projectFullPath: 'gitlab-org/gitlab', + }, + propsData: { + name: ENVIRONMENT_NAME, + }, + }); + }; + + describe('with deploy freezes', () => { + let deployFreezes; + let alert; + + beforeEach(async () => { + deployFreezes = [ + { + __typename: 'CiFreezePeriod', + startTime: new Date('2020-02-01'), + endTime: new Date('2020-02-02'), + }, + { + __typename: 'CiFreezePeriod', + startTime: new Date('2020-01-01'), + endTime: new Date('2020-01-02'), + }, + ]; + + createWrapper(deployFreezes); + + await waitForPromises(); + + alert = wrapper.findComponent(GlAlert); + }); + + it('shows an alert', () => { + expect(alert.exists()).toBe(true); + }); + + it('shows the start time of the most recent freeze period', () => { + expect(alert.text()).toContain(`from ${formatDate(deployFreezes[1].startTime)}`); + }); + + it('shows the end time of the most recent freeze period', () => { + expect(alert.text()).toContain(`to ${formatDate(deployFreezes[1].endTime)}`); + }); + + it('shows a link to the docs', () => { + const link = alert.findComponent(GlLink); + expect(link.attributes('href')).toBe( + '/help/user/project/releases/index#prevent-unintentional-releases-by-setting-a-deploy-freeze', + ); + expect(link.text()).toBe('deploy freeze documentation'); + }); + }); + + describe('without deploy freezes', () => { + let deployFreezes; + let alert; + + beforeEach(async () => { + deployFreezes = []; + + createWrapper(deployFreezes); + + await waitForPromises(); + + alert = wrapper.findComponent(GlAlert); + }); + + it('does not show an alert', () => { + expect(alert.exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/environments/edit_environment_spec.js b/spec/frontend/environments/edit_environment_spec.js index fb1a8b8c00a..34f338fabe6 100644 --- a/spec/frontend/environments/edit_environment_spec.js +++ b/spec/frontend/environments/edit_environment_spec.js @@ -3,13 +3,13 @@ import MockAdapter from 'axios-mock-adapter'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import EditEnvironment from '~/environments/components/edit_environment.vue'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { visitUrl } from '~/lib/utils/url_utility'; jest.mock('~/lib/utils/url_utility'); -jest.mock('~/flash'); +jest.mock('~/alert'); const DEFAULT_OPTS = { provide: { @@ -37,7 +37,6 @@ describe('~/environments/components/edit.vue', () => { afterEach(() => { mock.restore(); - wrapper.destroy(); }); const findNameInput = () => wrapper.findByLabelText('Name'); diff --git a/spec/frontend/environments/empty_state_spec.js b/spec/frontend/environments/empty_state_spec.js index 02cf2dc3c68..593200859e4 100644 --- a/spec/frontend/environments/empty_state_spec.js +++ b/spec/frontend/environments/empty_state_spec.js @@ -11,12 +11,17 @@ describe('~/environments/components/empty_state.vue', () => { const findNewEnvironmentLink = () => wrapper.findByRole('link', { - name: s__('Environments|New environment'), + name: s__('Environments|Create an environment'), }); const findDocsLink = () => wrapper.findByRole('link', { - name: s__('Environments|How do I create an environment?'), + name: 'Learn more', + }); + + const finfEnablingReviewButton = () => + wrapper.findByRole('button', { + name: s__('Environments|Enable review apps'), }); const createWrapper = ({ propsData = {} } = {}) => @@ -29,42 +34,44 @@ describe('~/environments/components/empty_state.vue', () => { provide: { newEnvironmentPath: NEW_PATH }, }); - afterEach(() => { - wrapper.destroy(); - }); + describe('without search term', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); - it('shows an empty state for available environments', () => { - wrapper = createWrapper(); + it('shows an empty state environments', () => { + const title = wrapper.findByRole('heading', { + name: s__('Environments|Get started with environments'), + }); - const title = wrapper.findByRole('heading', { - name: s__("Environments|You don't have any environments."), + expect(title.exists()).toBe(true); }); - expect(title.exists()).toBe(true); - }); - - it('shows an empty state for stopped environments', () => { - wrapper = createWrapper({ propsData: { scope: ENVIRONMENTS_SCOPE.STOPPED } }); + it('shows a link to the the help path', () => { + const link = findDocsLink(); - const title = wrapper.findByRole('heading', { - name: s__("Environments|You don't have any stopped environments."), + expect(link.attributes('href')).toBe(HELP_PATH); }); - expect(title.exists()).toBe(true); - }); + it('shows a link to creating a new environment', () => { + const link = findNewEnvironmentLink(); - it('shows a link to the the help path', () => { - wrapper = createWrapper(); + expect(link.attributes('href')).toBe(NEW_PATH); + }); - const link = findDocsLink(); + it('shows a button to enable review apps', () => { + const button = finfEnablingReviewButton(); - expect(link.attributes('href')).toBe(HELP_PATH); - }); + expect(button.exists()).toBe(true); + }); + + it('should emit enable review', () => { + const button = finfEnablingReviewButton(); - it('hides a link to creating a new environment', () => { - const link = findNewEnvironmentLink(); + button.vm.$emit('click'); - expect(link.exists()).toBe(false); + expect(wrapper.emitted('enable-review')).toBeDefined(); + }); }); describe('with search term', () => { @@ -90,10 +97,16 @@ describe('~/environments/components/empty_state.vue', () => { expect(link.exists()).toBe(false); }); - it('shows a link to create a new environment', () => { + it('hide a link to create a new environment', () => { const link = findNewEnvironmentLink(); - expect(link.attributes('href')).toBe(NEW_PATH); + expect(link.exists()).toBe(false); + }); + + it('hide a button to enable review apps', () => { + const button = finfEnablingReviewButton(); + + expect(button.exists()).toBe(false); }); }); }); diff --git a/spec/frontend/environments/enable_review_app_modal_spec.js b/spec/frontend/environments/enable_review_app_modal_spec.js index 7939bd600dc..f5571609931 100644 --- a/spec/frontend/environments/enable_review_app_modal_spec.js +++ b/spec/frontend/environments/enable_review_app_modal_spec.js @@ -10,7 +10,7 @@ jest.mock('lodash/uniqueId', () => (x) => `${x}77`); const EXPECTED_COPY_PRE_ID = 'enable-review-app-copy-string-77'; -describe('Enable Review App Modal', () => { +describe('Enable Review Apps Modal', () => { let wrapper; let modal; @@ -18,10 +18,6 @@ describe('Enable Review App Modal', () => { const findInstructionAt = (i) => wrapper.findAll('ol li').at(i); const findCopyString = () => wrapper.find(`#${EXPECTED_COPY_PRE_ID}`); - afterEach(() => { - wrapper.destroy(); - }); - describe('renders the modal', () => { beforeEach(() => { wrapper = extendedWrapper( diff --git a/spec/frontend/environments/environment_actions_spec.js b/spec/frontend/environments/environment_actions_spec.js index 48483152f7a..b7e192839da 100644 --- a/spec/frontend/environments/environment_actions_spec.js +++ b/spec/frontend/environments/environment_actions_spec.js @@ -1,14 +1,8 @@ -import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlIcon } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; +import { GlDisclosureDropdown, GlDisclosureDropdownItem, GlIcon } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import EnvironmentActions from '~/environments/components/environment_actions.vue'; -import eventHub from '~/environments/event_hub'; -import actionMutation from '~/environments/graphql/mutations/action.mutation.graphql'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; -import createMockApollo from 'helpers/mock_apollo_helper'; jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); @@ -29,15 +23,9 @@ const expiredJobAction = { describe('EnvironmentActions Component', () => { let wrapper; - const findEnvironmentActionsButton = () => - wrapper.find('[data-testid="environment-actions-button"]'); - - function createComponent(props, { mountFn = shallowMount, options = {} } = {}) { - wrapper = mountFn(EnvironmentActions, { + function createComponent(props, { options = {} } = {}) { + wrapper = mount(EnvironmentActions, { propsData: { actions: [], ...props }, - directives: { - GlTooltip: createMockDirective(), - }, ...options, }); } @@ -46,30 +34,26 @@ describe('EnvironmentActions Component', () => { return createComponent({ actions: [scheduledJobAction, expiredJobAction] }, opts); } + const findDropdownItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); const findDropdownItem = (action) => { - const buttons = wrapper.findAllComponents(GlDropdownItem); - return buttons.filter((button) => button.text().startsWith(action.name)).at(0); + const items = findDropdownItems(); + return items.filter((item) => item.text().startsWith(action.name)).at(0); }; afterEach(() => { - wrapper.destroy(); confirmAction.mockReset(); }); it('should render a dropdown button with 2 icons', () => { - createComponent({}, { mountFn: mount }); - expect(wrapper.findComponent(GlDropdown).findAllComponents(GlIcon).length).toBe(2); - }); - - it('should render a dropdown button with aria-label description', () => { createComponent(); - expect(wrapper.findComponent(GlDropdown).attributes('aria-label')).toBe('Deploy to...'); + expect(wrapper.findComponent(GlDisclosureDropdown).findAllComponents(GlIcon).length).toBe(2); }); - it('should render a tooltip', () => { + it('should render a dropdown button with aria-label description', () => { createComponent(); - const tooltip = getBinding(findEnvironmentActionsButton().element, 'gl-tooltip'); - expect(tooltip).toBeDefined(); + expect(wrapper.findComponent(GlDisclosureDropdown).attributes('aria-label')).toBe( + 'Deploy to...', + ); }); describe('manual actions', () => { @@ -94,96 +78,31 @@ describe('EnvironmentActions Component', () => { }); it('should render a dropdown with the provided list of actions', () => { - expect(wrapper.findAllComponents(GlDropdownItem)).toHaveLength(actions.length); + expect(findDropdownItems()).toHaveLength(actions.length); }); it("should render a disabled action when it's not playable", () => { - const dropdownItems = wrapper.findAllComponents(GlDropdownItem); + const dropdownItems = findDropdownItems(); const lastDropdownItem = dropdownItems.at(dropdownItems.length - 1); - expect(lastDropdownItem.attributes('disabled')).toBe('true'); + expect(lastDropdownItem.find('button').attributes('disabled')).toBeDefined(); }); }); describe('scheduled jobs', () => { - let emitSpy; - - const clickAndConfirm = async ({ confirm = true } = {}) => { - confirmAction.mockResolvedValueOnce(confirm); - - findDropdownItem(scheduledJobAction).vm.$emit('click'); - await nextTick(); - }; - beforeEach(() => { - emitSpy = jest.fn(); - eventHub.$on('postAction', emitSpy); jest.spyOn(Date, 'now').mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime()); }); - describe('when postAction event is confirmed', () => { - beforeEach(async () => { - createComponentWithScheduledJobs({ mountFn: mount }); - clickAndConfirm(); - }); - - it('emits postAction event', () => { - expect(confirmAction).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith({ endpoint: scheduledJobAction.playPath }); - }); - - it('should render a dropdown button with a loading icon', () => { - expect(wrapper.findComponent(GlLoadingIcon).isVisible()).toBe(true); - }); - }); - - describe('when postAction event is denied', () => { - beforeEach(async () => { - createComponentWithScheduledJobs({ mountFn: mount }); - clickAndConfirm({ confirm: false }); - }); - - it('does not emit postAction event if confirmation is cancelled', () => { - expect(confirmAction).toHaveBeenCalled(); - expect(emitSpy).not.toHaveBeenCalled(); - }); - }); - it('displays the remaining time in the dropdown', () => { + confirmAction.mockResolvedValueOnce(true); createComponentWithScheduledJobs(); expect(findDropdownItem(scheduledJobAction).text()).toContain('24:00:00'); }); it('displays 00:00:00 for expired jobs in the dropdown', () => { + confirmAction.mockResolvedValueOnce(true); createComponentWithScheduledJobs(); expect(findDropdownItem(expiredJobAction).text()).toContain('00:00:00'); }); }); - - describe('graphql', () => { - Vue.use(VueApollo); - - const action = { - name: 'bar', - play_path: 'https://gitlab.com/play', - }; - - let mockApollo; - - beforeEach(() => { - mockApollo = createMockApollo(); - createComponent( - { actions: [action], graphql: true }, - { options: { apolloProvider: mockApollo } }, - ); - }); - - it('should trigger a graphql mutation on click', () => { - jest.spyOn(mockApollo.defaultClient, 'mutate'); - findDropdownItem(action).vm.$emit('click'); - expect(mockApollo.defaultClient.mutate).toHaveBeenCalledWith({ - mutation: actionMutation, - variables: { action }, - }); - }); - }); }); diff --git a/spec/frontend/environments/environment_details/components/deployment_actions_spec.js b/spec/frontend/environments/environment_details/components/deployment_actions_spec.js index 725c8c6479e..a0eb4c494e6 100644 --- a/spec/frontend/environments/environment_details/components/deployment_actions_spec.js +++ b/spec/frontend/environments/environment_details/components/deployment_actions_spec.js @@ -1,8 +1,15 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlButton } from '@gitlab/ui'; import DeploymentActions from '~/environments/environment_details/components/deployment_actions.vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { translations } from '~/environments/environment_details/constants'; import ActionsComponent from '~/environments/components/environment_actions.vue'; describe('~/environments/environment_details/components/deployment_actions.vue', () => { + Vue.use(VueApollo); let wrapper; const actionsData = [ @@ -14,34 +21,116 @@ describe('~/environments/environment_details/components/deployment_actions.vue', }, ]; - const createWrapper = ({ actions }) => { + const rollbackData = { + id: '123', + name: 'enironment-name', + lastDeployment: { + commit: { + shortSha: 'abcd1234', + }, + isLast: true, + }, + retryUrl: 'deployment/retry', + }; + + const mockSetEnvironmentToRollback = jest.fn(); + const mockResolvers = { + Mutation: { + setEnvironmentToRollback: mockSetEnvironmentToRollback, + }, + }; + const createWrapper = ({ actions, rollback, approvalEnvironment }) => { + const mockApollo = createMockApollo([], mockResolvers); return mountExtended(DeploymentActions, { + apolloProvider: mockApollo, + provide: { + projectPath: 'fullProjectPath', + }, propsData: { actions, + rollback, + approvalEnvironment, }, }); }; - describe('when there is no actions provided', () => { - beforeEach(() => { - wrapper = createWrapper({ actions: [] }); + const findRollbackButton = () => wrapper.findComponent(GlButton); + + describe('deployment actions', () => { + describe('when there is no actions provided', () => { + beforeEach(() => { + wrapper = createWrapper({ actions: [] }); + }); + + it('should not render actions component', () => { + const actionsComponent = wrapper.findComponent(ActionsComponent); + expect(actionsComponent.exists()).toBe(false); + }); }); - it('should not render actions component', () => { - const actionsComponent = wrapper.findComponent(ActionsComponent); - expect(actionsComponent.exists()).toBe(false); + describe('when there are actions provided', () => { + beforeEach(() => { + wrapper = createWrapper({ actions: actionsData }); + }); + + it('should render actions component', () => { + const actionsComponent = wrapper.findComponent(ActionsComponent); + expect(actionsComponent.exists()).toBe(true); + expect(actionsComponent.props().actions).toBe(actionsData); + }); }); }); - describe('when there are actions provided', () => { - beforeEach(() => { - wrapper = createWrapper({ actions: actionsData }); + describe('rollback action', () => { + describe('when there is no rollback data available', () => { + it('should not show a rollback button', () => { + wrapper = createWrapper({ actions: [] }); + const button = findRollbackButton(); + expect(button.exists()).toBe(false); + }); }); - it('should render actions component', () => { - const actionsComponent = wrapper.findComponent(ActionsComponent); - expect(actionsComponent.exists()).toBe(true); - expect(actionsComponent.props().actions).toBe(actionsData); - }); + describe.each([ + { isLast: true, buttonTitle: translations.redeployButtonTitle, icon: 'repeat' }, + { isLast: false, buttonTitle: translations.rollbackButtonTitle, icon: 'redo' }, + ])( + `when there is a rollback data available and the deployment isLast=$isLast`, + ({ isLast, buttonTitle, icon }) => { + let rollback; + beforeEach(() => { + const lastDeployment = { ...rollbackData.lastDeployment, isLast }; + rollback = { ...rollbackData }; + rollback.lastDeployment = lastDeployment; + wrapper = createWrapper({ actions: [], rollback }); + }); + + it('should show the rollback button', () => { + const button = findRollbackButton(); + expect(button.exists()).toBe(true); + }); + + it(`the rollback button should have "${icon}" icon`, () => { + const button = findRollbackButton(); + expect(button.props().icon).toBe(icon); + }); + + it(`the rollback button should have "${buttonTitle}" title`, () => { + const button = findRollbackButton(); + expect(button.attributes().title).toBe(buttonTitle); + }); + + it(`the rollback button click should send correct mutation`, async () => { + const button = findRollbackButton(); + button.vm.$emit('click'); + await waitForPromises(); + expect(mockSetEnvironmentToRollback).toHaveBeenCalledWith( + expect.anything(), + { environment: rollback }, + expect.anything(), + expect.anything(), + ); + }); + }, + ); }); }); diff --git a/spec/frontend/environments/environment_details/deployments_table_spec.js b/spec/frontend/environments/environment_details/deployments_table_spec.js new file mode 100644 index 00000000000..7dad5617383 --- /dev/null +++ b/spec/frontend/environments/environment_details/deployments_table_spec.js @@ -0,0 +1,58 @@ +import resolvedEnvironmentDetails from 'test_fixtures/graphql/environments/graphql/queries/environment_details.query.graphql.json'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import Commit from '~/vue_shared/components/commit.vue'; +import DeploymentStatusLink from '~/environments/environment_details/components/deployment_status_link.vue'; +import DeploymentJob from '~/environments/environment_details/components/deployment_job.vue'; +import DeploymentTriggerer from '~/environments/environment_details/components/deployment_triggerer.vue'; +import DeploymentActions from '~/environments/environment_details/components/deployment_actions.vue'; +import DeploymentsTable from '~/environments/environment_details/deployments_table.vue'; +import { convertToDeploymentTableRow } from '~/environments/helpers/deployment_data_transformation_helper'; + +const { environment } = resolvedEnvironmentDetails.data.project; +const deployments = environment.deployments.nodes.map((d) => + convertToDeploymentTableRow(d, environment), +); + +describe('~/environments/environment_details/index.vue', () => { + let wrapper; + + const createWrapper = (propsData = {}) => { + wrapper = mountExtended(DeploymentsTable, { + propsData: { + deployments, + ...propsData, + }, + }); + }; + + describe('deployment row', () => { + const [, , deployment] = deployments; + + let row; + + beforeEach(() => { + createWrapper(); + + row = wrapper.find('tr:nth-child(3)'); + }); + + it.each` + cell | component | props + ${'status'} | ${DeploymentStatusLink} | ${{ deploymentJob: deployment.job, status: deployment.status }} + ${'triggerer'} | ${DeploymentTriggerer} | ${{ triggerer: deployment.triggerer }} + ${'commit'} | ${Commit} | ${deployment.commit} + ${'job'} | ${DeploymentJob} | ${{ job: deployment.job }} + ${'created date'} | ${'[data-testid="deployment-created-at"]'} | ${{ time: deployment.created }} + ${'deployed date'} | ${'[data-testid="deployment-deployed-at"]'} | ${{ time: deployment.deployed }} + ${'deployment actions'} | ${DeploymentActions} | ${{ actions: deployment.actions, rollback: deployment.rollback, approvalEnvironment: deployment.deploymentApproval }} + `('should show the correct component for $cell', ({ component, props }) => { + expect(row.findComponent(component).props()).toMatchObject(props); + }); + + it('hides the deployed at timestamp for not-finished deployments', () => { + row = wrapper.find('tr'); + + expect(row.find('[data-testid="deployment-deployed-at"]').exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/environments/environment_details/page_spec.js b/spec/frontend/environments/environment_details/index_spec.js index 3a1a3238abe..4bf5194b86e 100644 --- a/spec/frontend/environments/environment_details/page_spec.js +++ b/spec/frontend/environments/environment_details/index_spec.js @@ -5,31 +5,63 @@ import resolvedEnvironmentDetails from 'test_fixtures/graphql/environments/graph import emptyEnvironmentDetails from 'test_fixtures/graphql/environments/graphql/queries/environment_details.query.graphql.empty.json'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import EnvironmentsDetailPage from '~/environments/environment_details/index.vue'; +import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue'; import EmptyState from '~/environments/environment_details/empty_state.vue'; import getEnvironmentDetails from '~/environments/graphql/queries/environment_details.query.graphql'; -import createMockApollo from '../../__helpers__/mock_apollo_helper'; -import waitForPromises from '../../__helpers__/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; -describe('~/environments/environment_details/page.vue', () => { +const GRAPHQL_ETAG_KEY = '/graphql/environments'; + +describe('~/environments/environment_details/index.vue', () => { Vue.use(VueApollo); let wrapper; + let routerMock; + + const emptyEnvironmentToRollbackData = { id: '', name: '', lastDeployment: null, retryUrl: '' }; + const environmentToRollbackMock = jest.fn(); + + const mockResolvers = { + Query: { + environmentToRollback: environmentToRollbackMock, + }, + }; const defaultWrapperParameters = { resolvedData: resolvedEnvironmentDetails, + environmentToRollbackData: emptyEnvironmentToRollbackData, }; - const createWrapper = ({ resolvedData } = defaultWrapperParameters) => { - const mockApollo = createMockApollo([ - [getEnvironmentDetails, jest.fn().mockResolvedValue(resolvedData)], - ]); + const createWrapper = ({ + resolvedData, + environmentToRollbackData, + } = defaultWrapperParameters) => { + const mockApollo = createMockApollo( + [[getEnvironmentDetails, jest.fn().mockResolvedValue(resolvedData)]], + mockResolvers, + ); + environmentToRollbackMock.mockReturnValue( + environmentToRollbackData || emptyEnvironmentToRollbackData, + ); + const projectFullPath = 'gitlab-group/test-project'; + routerMock = { + push: jest.fn(), + }; return mountExtended(EnvironmentsDetailPage, { apolloProvider: mockApollo, + provide: { + projectPath: projectFullPath, + graphqlEtagKey: GRAPHQL_ETAG_KEY, + }, propsData: { - projectFullPath: 'gitlab-group/test-project', + projectFullPath, environmentName: 'test-environment-name', }, + mocks: { + $router: routerMock, + }, }); }; @@ -48,10 +80,18 @@ describe('~/environments/environment_details/page.vue', () => { wrapper = createWrapper(); await waitForPromises(); }); - it('should render a table when query is loaded', async () => { + it('should render a table when query is loaded', () => { expect(wrapper.findComponent(GlLoadingIcon).exists()).not.toBe(true); expect(wrapper.findComponent(GlTableLite).exists()).toBe(true); }); + + describe('on rollback', () => { + it('sets the page back to default', () => { + wrapper.findComponent(ConfirmRollbackModal).vm.$emit('rollback'); + + expect(routerMock.push).toHaveBeenCalledWith({ query: {} }); + }); + }); }); describe('and there are no deployments', () => { @@ -60,7 +100,7 @@ describe('~/environments/environment_details/page.vue', () => { await waitForPromises(); }); - it('should render empty state component', async () => { + it('should render empty state component', () => { expect(wrapper.findComponent(GlTableLite).exists()).toBe(false); expect(wrapper.findComponent(EmptyState).exists()).toBe(true); }); diff --git a/spec/frontend/environments/environment_external_url_spec.js b/spec/frontend/environments/environment_external_url_spec.js index 5966993166b..2cccbb3b63c 100644 --- a/spec/frontend/environments/environment_external_url_spec.js +++ b/spec/frontend/environments/environment_external_url_spec.js @@ -1,35 +1,18 @@ import { mount } from '@vue/test-utils'; -import { s__, __ } from '~/locale'; +import { GlButton } from '@gitlab/ui'; import ExternalUrlComp from '~/environments/components/environment_external_url.vue'; -import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; describe('External URL Component', () => { let wrapper; - let externalUrl; + const externalUrl = 'https://gitlab.com'; - describe('with safe link', () => { - beforeEach(() => { - externalUrl = 'https://gitlab.com'; - wrapper = mount(ExternalUrlComp, { propsData: { externalUrl } }); - }); - - it('should link to the provided externalUrl prop', () => { - expect(wrapper.attributes('href')).toBe(externalUrl); - expect(wrapper.find('a').exists()).toBe(true); - }); + beforeEach(() => { + wrapper = mount(ExternalUrlComp, { propsData: { externalUrl } }); }); - describe('with unsafe link', () => { - beforeEach(() => { - externalUrl = 'postgres://gitlab'; - wrapper = mount(ExternalUrlComp, { propsData: { externalUrl } }); - }); - - it('should show a copy button instead', () => { - const button = wrapper.findComponent(ModalCopyButton); - expect(button.props('text')).toBe(externalUrl); - expect(button.text()).toBe(__('Copy URL')); - expect(button.props('title')).toBe(s__('Environments|Copy live environment URL')); - }); + it('should link to the provided externalUrl prop', () => { + const button = wrapper.findComponent(GlButton); + expect(button.attributes('href')).toEqual(externalUrl); + expect(button.props('isUnsafeLink')).toBe(true); }); }); diff --git a/spec/frontend/environments/environment_folder_spec.js b/spec/frontend/environments/environment_folder_spec.js index a37515bc3f7..4716f807657 100644 --- a/spec/frontend/environments/environment_folder_spec.js +++ b/spec/frontend/environments/environment_folder_spec.js @@ -35,10 +35,10 @@ describe('~/environments/components/environments_folder.vue', () => { ...propsData, }, stubs: { transition: stubTransition() }, - provide: { helpPagePath: '/help' }, + provide: { helpPagePath: '/help', projectId: '1' }, }); - beforeEach(async () => { + beforeEach(() => { environmentFolderMock = jest.fn(); [nestedEnvironment] = resolvedEnvironmentsApp.environments; environmentFolderMock.mockReturnValue(resolvedFolder); diff --git a/spec/frontend/environments/environment_form_spec.js b/spec/frontend/environments/environment_form_spec.js index b9b34bee80f..50e4e637aa3 100644 --- a/spec/frontend/environments/environment_form_spec.js +++ b/spec/frontend/environments/environment_form_spec.js @@ -15,19 +15,16 @@ const PROVIDE = { protectedEnvironmentSettingsPath: '/projects/not_real/settings describe('~/environments/components/form.vue', () => { let wrapper; - const createWrapper = (propsData = {}) => + const createWrapper = (propsData = {}, options = {}) => mountExtended(EnvironmentForm, { provide: PROVIDE, + ...options, propsData: { ...DEFAULT_PROPS, ...propsData, }, }); - afterEach(() => { - wrapper.destroy(); - }); - describe('default', () => { beforeEach(() => { wrapper = createWrapper(); @@ -105,6 +102,7 @@ describe('~/environments/components/form.vue', () => { wrapper = createWrapper({ loading: true }); expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); + describe('when a new environment is being created', () => { beforeEach(() => { wrapper = createWrapper({ @@ -133,6 +131,18 @@ describe('~/environments/components/form.vue', () => { }); }); + describe('when no protected environment link is provided', () => { + beforeEach(() => { + wrapper = createWrapper({ + provide: {}, + }); + }); + + it('does not show protected environment documentation', () => { + expect(wrapper.findByRole('link', { name: 'Protected environments' }).exists()).toBe(false); + }); + }); + describe('when an existing environment is being edited', () => { beforeEach(() => { wrapper = createWrapper({ diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js index dd909cf4473..e2b184adc8a 100644 --- a/spec/frontend/environments/environment_item_spec.js +++ b/spec/frontend/environments/environment_item_spec.js @@ -19,10 +19,6 @@ describe('Environment item', () => { let tracking; const factory = (options = {}) => { - // This destroys any wrappers created before a nested call to factory reassigns it - if (wrapper && wrapper.destroy) { - wrapper.destroy(); - } wrapper = mount(EnvironmentItem, { ...options, }); @@ -55,10 +51,7 @@ describe('Environment item', () => { const findUpcomingDeploymentAvatarLink = () => findUpcomingDeployment().findComponent(GlAvatarLink); const findUpcomingDeploymentAvatar = () => findUpcomingDeployment().findComponent(GlAvatar); - - afterEach(() => { - wrapper.destroy(); - }); + const findMonitoringLink = () => wrapper.find('[data-testid="environment-monitoring"]'); describe('when item is not folder', () => { it('should render environment name', () => { @@ -390,10 +383,6 @@ describe('Environment item', () => { }); }); - afterEach(() => { - wrapper.destroy(); - }); - it('should render folder icon and name', () => { expect(wrapper.find('.folder-name').text()).toContain(folder.name); expect(wrapper.find('.folder-icon')).toBeDefined(); @@ -446,4 +435,25 @@ describe('Environment item', () => { }); }); }); + + describe.each([true, false])( + 'when `remove_monitor_metrics` flag is %p', + (removeMonitorMetrics) => { + beforeEach(() => { + factory({ + propsData: { + model: { + metrics_path: 'http://0.0.0.0:3000/flightjs/Flight/-/metrics?environment=6', + }, + tableData, + }, + provide: { glFeatures: { removeMonitorMetrics } }, + }); + }); + + it(`${removeMonitorMetrics ? 'does not render' : 'renders'} link to metrics`, () => { + expect(findMonitoringLink().exists()).toBe(!removeMonitorMetrics); + }); + }, + ); }); diff --git a/spec/frontend/environments/environment_pin_spec.js b/spec/frontend/environments/environment_pin_spec.js index 170036b5b00..ee195b41bc8 100644 --- a/spec/frontend/environments/environment_pin_spec.js +++ b/spec/frontend/environments/environment_pin_spec.js @@ -11,10 +11,6 @@ describe('Pin Component', () => { let wrapper; const factory = (options = {}) => { - // This destroys any wrappers created before a nested call to factory reassigns it - if (wrapper && wrapper.destroy) { - wrapper.destroy(); - } wrapper = shallowMount(PinComponent, { ...options, }); @@ -31,10 +27,6 @@ describe('Pin Component', () => { }); }); - afterEach(() => { - wrapper.destroy(); - }); - it('should render the component with descriptive text', () => { expect(wrapper.text()).toBe('Prevent auto-stopping'); }); @@ -64,10 +56,6 @@ describe('Pin Component', () => { }); }); - afterEach(() => { - wrapper.destroy(); - }); - it('should render the component with descriptive text', () => { expect(wrapper.text()).toBe('Prevent auto-stopping'); }); diff --git a/spec/frontend/environments/environment_stop_spec.js b/spec/frontend/environments/environment_stop_spec.js index 851e24c22cc..3e27b8822e1 100644 --- a/spec/frontend/environments/environment_stop_spec.js +++ b/spec/frontend/environments/environment_stop_spec.js @@ -73,7 +73,7 @@ describe('Stop Component', () => { }); }); - it('should show a loading icon if the environment is currently stopping', async () => { + it('should show a loading icon if the environment is currently stopping', () => { expect(findButton().props('loading')).toBe(true); }); }); diff --git a/spec/frontend/environments/environment_table_spec.js b/spec/frontend/environments/environment_table_spec.js index a86cfdd56ba..f41d1324b81 100644 --- a/spec/frontend/environments/environment_table_spec.js +++ b/spec/frontend/environments/environment_table_spec.js @@ -16,10 +16,6 @@ describe('Environment table', () => { let wrapper; const factory = (options = {}) => { - // This destroys any wrappers created before a nested call to factory reassigns it - if (wrapper && wrapper.destroy) { - wrapper.destroy(); - } wrapper = mount(EnvironmentTable, { ...options, }); @@ -34,10 +30,6 @@ describe('Environment table', () => { }); }); - afterEach(() => { - wrapper.destroy(); - }); - it('Should render a table', async () => { const mockItem = { name: 'review', diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js index 986ecca4e84..dc450eb2aa7 100644 --- a/spec/frontend/environments/environments_app_spec.js +++ b/spec/frontend/environments/environments_app_spec.js @@ -96,10 +96,6 @@ describe('~/environments/components/environments_app.vue', () => { paginationMock = jest.fn(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('should request available environments if the scope is invalid', async () => { await createWrapperWithMocked({ environmentsApp: resolvedEnvironmentsApp, @@ -174,12 +170,8 @@ describe('~/environments/components/environments_app.vue', () => { folder: resolvedFolder, }); - const button = wrapper.findByRole('button', { name: s__('Environments|Enable review app') }); - button.trigger('click'); - - await nextTick(); - - expect(wrapper.findByText(s__('ReviewApp|Enable Review App')).exists()).toBe(true); + const button = wrapper.findByRole('button', { name: s__('Environments|Enable review apps') }); + expect(button.exists()).toBe(true); }); it('should not show a button to open the review app modal if review apps are configured', async () => { @@ -191,7 +183,7 @@ describe('~/environments/components/environments_app.vue', () => { folder: resolvedFolder, }); - const button = wrapper.findByRole('button', { name: s__('Environments|Enable review app') }); + const button = wrapper.findByRole('button', { name: s__('Environments|Enable review apps') }); expect(button.exists()).toBe(false); }); @@ -426,7 +418,7 @@ describe('~/environments/components/environments_app.vue', () => { ); }); - it('should sync search term from query params on load', async () => { + it('should sync search term from query params on load', () => { expect(searchBox.element.value).toBe('prod'); }); }); diff --git a/spec/frontend/environments/environments_detail_header_spec.js b/spec/frontend/environments/environments_detail_header_spec.js index 1f233c05fbf..9464aeff028 100644 --- a/spec/frontend/environments/environments_detail_header_spec.js +++ b/spec/frontend/environments/environments_detail_header_spec.js @@ -1,12 +1,11 @@ import { GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import { __, s__ } from '~/locale'; import DeleteEnvironmentModal from '~/environments/components/delete_environment_modal.vue'; import EnvironmentsDetailHeader from '~/environments/components/environments_detail_header.vue'; import StopEnvironmentModal from '~/environments/components/stop_environment_modal.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; -import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import DeployFreezeAlert from '~/environments/components/deploy_freeze_alert.vue'; import { createEnvironment } from './mock_data'; describe('Environments detail header component', () => { @@ -22,13 +21,14 @@ describe('Environments detail header component', () => { const findCancelAutoStopAtButton = () => wrapper.findByTestId('cancel-auto-stop-button'); const findCancelAutoStopAtForm = () => wrapper.findByTestId('cancel-auto-stop-form'); const findTerminalButton = () => wrapper.findByTestId('terminal-button'); - const findExternalUrlButton = () => wrapper.findByTestId('external-url-button'); + const findExternalUrlButton = () => wrapper.findComponentByTestId('external-url-button'); const findMetricsButton = () => wrapper.findByTestId('metrics-button'); const findEditButton = () => wrapper.findByTestId('edit-button'); const findStopButton = () => wrapper.findByTestId('stop-button'); const findDestroyButton = () => wrapper.findByTestId('destroy-button'); const findStopEnvironmentModal = () => wrapper.findComponent(StopEnvironmentModal); const findDeleteEnvironmentModal = () => wrapper.findComponent(DeleteEnvironmentModal); + const findDeployFreezeAlert = () => wrapper.findComponent(DeployFreezeAlert); const buttons = [ ['Cancel Auto Stop At', findCancelAutoStopAtButton], @@ -40,14 +40,17 @@ describe('Environments detail header component', () => { ['Destroy', findDestroyButton], ]; - const createWrapper = ({ props }) => { + const createWrapper = ({ props, glFeatures = {} }) => { wrapper = shallowMountExtended(EnvironmentsDetailHeader, { stubs: { GlSprintf, TimeAgo, }, + provide: { + glFeatures, + }, directives: { - GlTooltip: createMockDirective(), + GlTooltip: createMockDirective('gl-tooltip'), }, propsData: { canAdminEnvironment: false, @@ -59,10 +62,6 @@ describe('Environments detail header component', () => { }); }; - afterEach(() => { - wrapper.destroy(); - }); - describe('default state with minimal access', () => { beforeEach(() => { createWrapper({ props: { environment: createEnvironment({ externalUrl: null }) } }); @@ -175,6 +174,7 @@ describe('Environments detail header component', () => { it('displays the external url button with correct path', () => { expect(findExternalUrlButton().attributes('href')).toBe(externalUrl); + expect(findExternalUrlButton().props('isUnsafeLink')).toBe(true); }); }); @@ -199,6 +199,25 @@ describe('Environments detail header component', () => { expect(tooltip).toBeDefined(); expect(button.attributes('title')).toBe('See metrics'); }); + + describe.each([true, false])( + 'and `remove_monitor_metrics` flag is %p', + (removeMonitorMetrics) => { + beforeEach(() => { + createWrapper({ + props: { + environment: createEnvironment({ metricsUrl: 'my metrics url' }), + metricsPath, + }, + glFeatures: { removeMonitorMetrics }, + }); + }); + + it(`${removeMonitorMetrics ? 'does not render' : 'renders'} Metrics button`, () => { + expect(findMetricsButton().exists()).toBe(!removeMonitorMetrics); + }); + }, + ); }); describe('when has all admin rights', () => { @@ -246,22 +265,12 @@ describe('Environments detail header component', () => { }); }); - describe('when the environment has an unsafe external url', () => { - const externalUrl = 'postgres://staging'; - - beforeEach(() => { - createWrapper({ - props: { - environment: createEnvironment({ externalUrl }), - }, - }); - }); + describe('deploy freeze alert', () => { + it('passes the environment name to the alert', () => { + const environment = createEnvironment(); + createWrapper({ props: { environment } }); - it('should show a copy button instead', () => { - const button = wrapper.findComponent(ModalCopyButton); - expect(button.props('title')).toBe(s__('Environments|Copy live environment URL')); - expect(button.props('text')).toBe(externalUrl); - expect(button.text()).toBe(__('Copy URL')); + expect(findDeployFreezeAlert().props('name')).toBe(environment.name); }); }); }); diff --git a/spec/frontend/environments/environments_folder_view_spec.js b/spec/frontend/environments/environments_folder_view_spec.js index a87060f83d8..75fb3a31120 100644 --- a/spec/frontend/environments/environments_folder_view_spec.js +++ b/spec/frontend/environments/environments_folder_view_spec.js @@ -24,7 +24,6 @@ describe('Environments Folder View', () => { afterEach(() => { mock.restore(); - wrapper.destroy(); }); describe('successful request', () => { diff --git a/spec/frontend/environments/folder/environments_folder_view_spec.js b/spec/frontend/environments/folder/environments_folder_view_spec.js index 23506eb018d..6a40c68397b 100644 --- a/spec/frontend/environments/folder/environments_folder_view_spec.js +++ b/spec/frontend/environments/folder/environments_folder_view_spec.js @@ -123,22 +123,4 @@ describe('Environments Folder View', () => { expect(tabTable.find('.badge').text()).toContain('0'); }); }); - - describe('methods', () => { - beforeEach(() => { - mockEnvironments([]); - createWrapper(); - jest.spyOn(window.history, 'pushState').mockImplementation(() => {}); - return axios.waitForAll(); - }); - - describe('updateContent', () => { - it('should set given parameters', () => - wrapper.vm.updateContent({ scope: 'stopped', page: '4' }).then(() => { - expect(wrapper.vm.page).toEqual('4'); - expect(wrapper.vm.scope).toEqual('stopped'); - expect(wrapper.vm.requestData.page).toEqual('4'); - })); - }); - }); }); diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js index 5ea0be41614..addbf2c21dc 100644 --- a/spec/frontend/environments/graphql/mock_data.js +++ b/spec/frontend/environments/graphql/mock_data.js @@ -798,3 +798,112 @@ export const resolvedDeploymentDetails = { }, }, }; + +export const agent = { + project: 'agent-project', + id: 'gid://gitlab/ClusterAgent/1', + name: 'agent-name', + kubernetesNamespace: 'agent-namespace', +}; + +const runningPod = { status: { phase: 'Running' } }; +const pendingPod = { status: { phase: 'Pending' } }; +const succeededPod = { status: { phase: 'Succeeded' } }; +const failedPod = { status: { phase: 'Failed' } }; + +export const k8sPodsMock = [runningPod, runningPod, pendingPod, succeededPod, failedPod, failedPod]; + +export const k8sServicesMock = [ + { + metadata: { + name: 'my-first-service', + namespace: 'default', + creationTimestamp: new Date(), + }, + spec: { + ports: [ + { + name: 'https', + protocol: 'TCP', + port: 443, + targetPort: 8443, + }, + ], + clusterIP: '10.96.0.1', + externalIP: '-', + type: 'ClusterIP', + }, + }, + { + metadata: { + name: 'my-second-service', + namespace: 'default', + creationTimestamp: '2020-07-03T14:06:04Z', + }, + spec: { + ports: [ + { + name: 'http', + protocol: 'TCP', + appProtocol: 'http', + port: 80, + targetPort: 'http', + nodePort: 31989, + }, + { + name: 'https', + protocol: 'TCP', + appProtocol: 'https', + port: 443, + targetPort: 'https', + nodePort: 32679, + }, + ], + clusterIP: '10.105.219.238', + externalIP: '-', + type: 'NodePort', + }, + }, +]; + +const readyDeployment = { + status: { + conditions: [ + { type: 'Available', status: 'True' }, + { type: 'Progressing', status: 'True' }, + ], + }, +}; +const failedDeployment = { + status: { + conditions: [ + { type: 'Available', status: 'False' }, + { type: 'Progressing', status: 'False' }, + ], + }, +}; +const readyDaemonSet = { + status: { numberReady: 1, desiredNumberScheduled: 1, numberMisscheduled: 0 }, +}; +const failedDaemonSet = { + status: { numberMisscheduled: 1, numberReady: 0, desiredNumberScheduled: 1 }, +}; +const readySet = { spec: { replicas: 2 }, status: { readyReplicas: 2 } }; +const failedSet = { spec: { replicas: 2 }, status: { readyReplicas: 1 } }; +const completedJob = { spec: { completions: 1 }, status: { succeeded: 1, failed: 0 } }; +const failedJob = { spec: { completions: 1 }, status: { succeeded: 0, failed: 1 } }; +const completedCronJob = { + spec: { suspend: 0 }, + status: { active: 0, lastScheduleTime: new Date().toString() }, +}; +const suspendedCronJob = { spec: { suspend: 1 }, status: { active: 0, lastScheduleTime: '' } }; +const failedCronJob = { spec: { suspend: 0 }, status: { active: 2, lastScheduleTime: '' } }; + +export const k8sWorkloadsMock = { + DeploymentList: [readyDeployment, failedDeployment], + DaemonSetList: [readyDaemonSet, failedDaemonSet, failedDaemonSet], + StatefulSetList: [readySet, readySet, failedSet], + ReplicaSetList: [readySet, failedSet], + JobList: [completedJob, completedJob, failedJob], + CronJobList: [completedCronJob, suspendedCronJob, failedCronJob], +}; diff --git a/spec/frontend/environments/graphql/resolvers_spec.js b/spec/frontend/environments/graphql/resolvers_spec.js index 2c223d3a1a7..edffc00e185 100644 --- a/spec/frontend/environments/graphql/resolvers_spec.js +++ b/spec/frontend/environments/graphql/resolvers_spec.js @@ -1,4 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; +import { CoreV1Api, AppsV1Api, BatchV1Api } from '@gitlab/cluster-client'; import { s__ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; @@ -17,6 +18,8 @@ import { resolvedEnvironment, folder, resolvedFolder, + k8sPodsMock, + k8sServicesMock, } from './mock_data'; const ENDPOINT = `${TEST_HOST}/environments`; @@ -27,6 +30,14 @@ describe('~/frontend/environments/graphql/resolvers', () => { let mockApollo; let localState; + const configuration = { + basePath: 'kas-proxy/', + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + }, + }; + const namespace = 'default'; + beforeEach(() => { mockResolvers = resolvers(ENDPOINT); mock = new MockAdapter(axios); @@ -143,13 +154,178 @@ describe('~/frontend/environments/graphql/resolvers', () => { expect(environmentFolder).toEqual(resolvedFolder); }); }); - describe('stopEnvironment', () => { + describe('k8sPods', () => { + const mockPodsListFn = jest.fn().mockImplementation(() => { + return Promise.resolve({ + data: { + items: k8sPodsMock, + }, + }); + }); + + const mockNamespacedPodsListFn = jest.fn().mockImplementation(mockPodsListFn); + const mockAllPodsListFn = jest.fn().mockImplementation(mockPodsListFn); + + beforeEach(() => { + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1NamespacedPod') + .mockImplementation(mockNamespacedPodsListFn); + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1PodForAllNamespaces') + .mockImplementation(mockAllPodsListFn); + }); + + it('should request namespaced pods from the cluster_client library if namespace is specified', async () => { + const pods = await mockResolvers.Query.k8sPods(null, { configuration, namespace }); + + expect(mockNamespacedPodsListFn).toHaveBeenCalledWith(namespace); + expect(mockAllPodsListFn).not.toHaveBeenCalled(); + + expect(pods).toEqual(k8sPodsMock); + }); + it('should request all pods from the cluster_client library if namespace is not specified', async () => { + const pods = await mockResolvers.Query.k8sPods(null, { configuration, namespace: '' }); + + expect(mockAllPodsListFn).toHaveBeenCalled(); + expect(mockNamespacedPodsListFn).not.toHaveBeenCalled(); + + expect(pods).toEqual(k8sPodsMock); + }); + it('should throw an error if the API call fails', async () => { + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1PodForAllNamespaces') + .mockRejectedValue(new Error('API error')); + + await expect(mockResolvers.Query.k8sPods(null, { configuration })).rejects.toThrow( + 'API error', + ); + }); + }); + describe('k8sServices', () => { + const mockServicesListFn = jest.fn().mockImplementation(() => { + return Promise.resolve({ + data: { + items: k8sServicesMock, + }, + }); + }); + + beforeEach(() => { + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces') + .mockImplementation(mockServicesListFn); + }); + + it('should request services from the cluster_client library', async () => { + const services = await mockResolvers.Query.k8sServices(null, { configuration }); + + expect(mockServicesListFn).toHaveBeenCalled(); + + expect(services).toEqual(k8sServicesMock); + }); + it('should throw an error if the API call fails', async () => { + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces') + .mockRejectedValue(new Error('API error')); + + await expect(mockResolvers.Query.k8sServices(null, { configuration })).rejects.toThrow( + 'API error', + ); + }); + }); + describe('k8sWorkloads', () => { + const emptyImplementation = jest.fn().mockImplementation(() => { + return Promise.resolve({ + data: { + items: [], + }, + }); + }); + + const [ + mockNamespacedDeployment, + mockNamespacedDaemonSet, + mockNamespacedStatefulSet, + mockNamespacedReplicaSet, + mockNamespacedJob, + mockNamespacedCronJob, + mockAllDeployment, + mockAllDaemonSet, + mockAllStatefulSet, + mockAllReplicaSet, + mockAllJob, + mockAllCronJob, + ] = Array(12).fill(emptyImplementation); + + const namespacedMocks = [ + { method: 'listAppsV1NamespacedDeployment', api: AppsV1Api, spy: mockNamespacedDeployment }, + { method: 'listAppsV1NamespacedDaemonSet', api: AppsV1Api, spy: mockNamespacedDaemonSet }, + { method: 'listAppsV1NamespacedStatefulSet', api: AppsV1Api, spy: mockNamespacedStatefulSet }, + { method: 'listAppsV1NamespacedReplicaSet', api: AppsV1Api, spy: mockNamespacedReplicaSet }, + { method: 'listBatchV1NamespacedJob', api: BatchV1Api, spy: mockNamespacedJob }, + { method: 'listBatchV1NamespacedCronJob', api: BatchV1Api, spy: mockNamespacedCronJob }, + ]; + + const allMocks = [ + { method: 'listAppsV1DeploymentForAllNamespaces', api: AppsV1Api, spy: mockAllDeployment }, + { method: 'listAppsV1DaemonSetForAllNamespaces', api: AppsV1Api, spy: mockAllDaemonSet }, + { method: 'listAppsV1StatefulSetForAllNamespaces', api: AppsV1Api, spy: mockAllStatefulSet }, + { method: 'listAppsV1ReplicaSetForAllNamespaces', api: AppsV1Api, spy: mockAllReplicaSet }, + { method: 'listBatchV1JobForAllNamespaces', api: BatchV1Api, spy: mockAllJob }, + { method: 'listBatchV1CronJobForAllNamespaces', api: BatchV1Api, spy: mockAllCronJob }, + ]; + + beforeEach(() => { + [...namespacedMocks, ...allMocks].forEach((workloadMock) => { + jest + .spyOn(workloadMock.api.prototype, workloadMock.method) + .mockImplementation(workloadMock.spy); + }); + }); + + it('should request namespaced workload types from the cluster_client library if namespace is specified', async () => { + await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace }); + + namespacedMocks.forEach((workloadMock) => { + expect(workloadMock.spy).toHaveBeenCalledWith(namespace); + }); + }); + + it('should request all workload types from the cluster_client library if namespace is not specified', async () => { + await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace: '' }); + + allMocks.forEach((workloadMock) => { + expect(workloadMock.spy).toHaveBeenCalled(); + }); + }); + it('should pass fulfilled calls data if one of the API calls fail', async () => { + jest + .spyOn(AppsV1Api.prototype, 'listAppsV1DeploymentForAllNamespaces') + .mockRejectedValue(new Error('API error')); + + await expect( + mockResolvers.Query.k8sWorkloads(null, { configuration }), + ).resolves.toBeDefined(); + }); + it('should throw an error if all the API calls fail', async () => { + [...allMocks].forEach((workloadMock) => { + jest + .spyOn(workloadMock.api.prototype, workloadMock.method) + .mockRejectedValue(new Error('API error')); + }); + + await expect(mockResolvers.Query.k8sWorkloads(null, { configuration })).rejects.toThrow( + 'API error', + ); + }); + }); + describe('stopEnvironmentREST', () => { it('should post to the stop environment path', async () => { mock.onPost(ENDPOINT).reply(HTTP_STATUS_OK); const client = { writeQuery: jest.fn() }; const environment = { stopPath: ENDPOINT }; - await mockResolvers.Mutation.stopEnvironment(null, { environment }, { client }); + await mockResolvers.Mutation.stopEnvironmentREST(null, { environment }, { client }); expect(mock.history.post).toContainEqual( expect.objectContaining({ url: ENDPOINT, method: 'post' }), @@ -166,7 +342,7 @@ describe('~/frontend/environments/graphql/resolvers', () => { const client = { writeQuery: jest.fn() }; const environment = { stopPath: ENDPOINT }; - await mockResolvers.Mutation.stopEnvironment(null, { environment }, { client }); + await mockResolvers.Mutation.stopEnvironmentREST(null, { environment }, { client }); expect(mock.history.post).toContainEqual( expect.objectContaining({ url: ENDPOINT, method: 'post' }), diff --git a/spec/frontend/environments/helpers/__snapshots__/deployment_data_transformation_helper_spec.js.snap b/spec/frontend/environments/helpers/__snapshots__/deployment_data_transformation_helper_spec.js.snap index 326a28bd769..ec0fe0c5541 100644 --- a/spec/frontend/environments/helpers/__snapshots__/deployment_data_transformation_helper_spec.js.snap +++ b/spec/frontend/environments/helpers/__snapshots__/deployment_data_transformation_helper_spec.js.snap @@ -26,11 +26,37 @@ Object { }, "created": "2022-10-17T07:44:17Z", "deployed": "2022-10-17T07:44:43Z", + "deploymentApproval": Object { + "isApprovalActionAvailable": false, + }, "id": "31", "job": Object { "label": "deploy-prod (#860)", "webPath": "/gitlab-org/pipelinestest/-/jobs/860", }, + "rollback": Object { + "id": "gid://gitlab/Deployment/76", + "lastDeployment": Object { + "commit": Object { + "author": Object { + "avatarUrl": "/uploads/-/system/user/avatar/1/avatar.png", + "id": "gid://gitlab/User/1", + "name": "Administrator", + "webUrl": "http://gdk.test:3000/root", + }, + "authorEmail": "admin@example.com", + "authorGravatar": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "authorName": "Administrator", + "id": "gid://gitlab/CommitPresenter/0cb48dd5deddb7632fd7c3defb16075fc6c3ca74", + "message": "Update .gitlab-ci.yml file", + "shortId": "0cb48dd5", + "webUrl": "http://gdk.test:3000/gitlab-org/pipelinestest/-/commit/0cb48dd5deddb7632fd7c3defb16075fc6c3ca74", + }, + "isLast": false, + }, + "name": undefined, + "retryUrl": "/gitlab-org/pipelinestest/-/jobs/860/retry", + }, "status": "success", "triggerer": Object { "avatarUrl": "/uploads/-/system/user/avatar/1/avatar.png", @@ -60,8 +86,12 @@ Object { }, "created": "2022-10-17T07:44:17Z", "deployed": "2022-10-17T07:44:43Z", + "deploymentApproval": Object { + "isApprovalActionAvailable": false, + }, "id": "31", "job": undefined, + "rollback": null, "status": "success", "triggerer": Object { "avatarUrl": "/uploads/-/system/user/avatar/1/avatar.png", @@ -91,8 +121,12 @@ Object { }, "created": "2022-10-17T07:44:17Z", "deployed": "", + "deploymentApproval": Object { + "isApprovalActionAvailable": false, + }, "id": "31", "job": null, + "rollback": null, "status": "success", "triggerer": Object { "avatarUrl": "/uploads/-/system/user/avatar/1/avatar.png", diff --git a/spec/frontend/environments/kubernetes_agent_info_spec.js b/spec/frontend/environments/kubernetes_agent_info_spec.js new file mode 100644 index 00000000000..b1795065281 --- /dev/null +++ b/spec/frontend/environments/kubernetes_agent_info_spec.js @@ -0,0 +1,124 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlIcon, GlLink, GlSprintf, GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import KubernetesAgentInfo from '~/environments/components/kubernetes_agent_info.vue'; +import { AGENT_STATUSES, ACTIVE_CONNECTION_TIME } from '~/clusters_list/constants'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import getK8sClusterAgentQuery from '~/environments/graphql/queries/k8s_cluster_agent.query.graphql'; + +Vue.use(VueApollo); + +const propsData = { + agentName: 'my-agent', + agentId: '1', + agentProjectPath: 'path/to/agent-config-project', +}; + +const mockClusterAgent = { + id: '1', + name: 'token-1', + webPath: 'path/to/agent-page', +}; + +const connectedTimeNow = new Date(); +const connectedTimeInactive = new Date(connectedTimeNow.getTime() - ACTIVE_CONNECTION_TIME); + +describe('~/environments/components/kubernetes_agent_info.vue', () => { + let wrapper; + let agentQueryResponse; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findAgentLink = () => wrapper.findComponent(GlLink); + const findAgentStatus = () => wrapper.findByTestId('agent-status'); + const findAgentStatusIcon = () => findAgentStatus().findComponent(GlIcon); + const findAgentLastUsedDate = () => wrapper.findByTestId('agent-last-used-date'); + const findAlert = () => wrapper.findComponent(GlAlert); + + const createWrapper = ({ tokens = [], queryResponse = null } = {}) => { + const clusterAgent = { ...mockClusterAgent, tokens: { nodes: tokens } }; + + agentQueryResponse = + queryResponse || + jest.fn().mockResolvedValue({ data: { project: { id: 'project-1', clusterAgent } } }); + const apolloProvider = createMockApollo([[getK8sClusterAgentQuery, agentQueryResponse]]); + + wrapper = extendedWrapper( + shallowMount(KubernetesAgentInfo, { + apolloProvider, + propsData, + stubs: { TimeAgoTooltip, GlSprintf }, + }), + ); + }; + + describe('default', () => { + beforeEach(() => { + createWrapper(); + }); + + it('shows loading icon while fetching the agent details', async () => { + expect(findLoadingIcon().exists()).toBe(true); + await waitForPromises(); + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('sends expected params', async () => { + await waitForPromises(); + + const variables = { + agentName: propsData.agentName, + projectPath: propsData.agentProjectPath, + }; + + expect(agentQueryResponse).toHaveBeenCalledWith(variables); + }); + + it('renders the agent name with the link', async () => { + await waitForPromises(); + + expect(findAgentLink().attributes('href')).toBe(mockClusterAgent.webPath); + expect(findAgentLink().text()).toContain(mockClusterAgent.id); + }); + }); + + describe.each` + lastUsedAt | status | lastUsedText + ${null} | ${'unused'} | ${KubernetesAgentInfo.i18n.neverConnectedText} + ${connectedTimeNow} | ${'active'} | ${'just now'} + ${connectedTimeInactive} | ${'inactive'} | ${'8 minutes ago'} + `('when agent connection status is "$status"', ({ lastUsedAt, status, lastUsedText }) => { + beforeEach(async () => { + const tokens = [{ id: 'token-id', lastUsedAt }]; + createWrapper({ tokens }); + await waitForPromises(); + }); + + it('displays correct status text', () => { + expect(findAgentStatus().text()).toBe(AGENT_STATUSES[status].name); + }); + + it('displays correct status icon', () => { + expect(findAgentStatusIcon().props('name')).toBe(AGENT_STATUSES[status].icon); + expect(findAgentStatusIcon().attributes('class')).toBe(AGENT_STATUSES[status].class); + }); + + it('displays correct last used date status', () => { + expect(findAgentLastUsedDate().text()).toBe(lastUsedText); + }); + }); + + describe('when the agent query has errored', () => { + beforeEach(() => { + createWrapper({ clusterAgent: null, queryResponse: jest.fn().mockRejectedValue() }); + return waitForPromises(); + }); + + it('displays an alert message', () => { + expect(findAlert().text()).toBe(KubernetesAgentInfo.i18n.loadingError); + }); + }); +}); diff --git a/spec/frontend/environments/kubernetes_overview_spec.js b/spec/frontend/environments/kubernetes_overview_spec.js new file mode 100644 index 00000000000..394fd200edf --- /dev/null +++ b/spec/frontend/environments/kubernetes_overview_spec.js @@ -0,0 +1,131 @@ +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { GlCollapse, GlButton, GlAlert } from '@gitlab/ui'; +import KubernetesOverview from '~/environments/components/kubernetes_overview.vue'; +import KubernetesAgentInfo from '~/environments/components/kubernetes_agent_info.vue'; +import KubernetesPods from '~/environments/components/kubernetes_pods.vue'; +import KubernetesTabs from '~/environments/components/kubernetes_tabs.vue'; +import { agent } from './graphql/mock_data'; +import { mockKasTunnelUrl } from './mock_data'; + +const propsData = { + agentId: agent.id, + agentName: agent.name, + agentProjectPath: agent.project, + namespace: agent.kubernetesNamespace, +}; + +const provide = { + kasTunnelUrl: mockKasTunnelUrl, +}; + +const configuration = { + basePath: provide.kasTunnelUrl.replace(/\/$/, ''), + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + }, +}; + +describe('~/environments/components/kubernetes_overview.vue', () => { + let wrapper; + + const findCollapse = () => wrapper.findComponent(GlCollapse); + const findCollapseButton = () => wrapper.findComponent(GlButton); + const findAgentInfo = () => wrapper.findComponent(KubernetesAgentInfo); + const findKubernetesPods = () => wrapper.findComponent(KubernetesPods); + const findKubernetesTabs = () => wrapper.findComponent(KubernetesTabs); + const findAlert = () => wrapper.findComponent(GlAlert); + + const createWrapper = () => { + wrapper = shallowMount(KubernetesOverview, { + propsData, + provide, + }); + }; + + const toggleCollapse = async () => { + findCollapseButton().vm.$emit('click'); + await nextTick(); + }; + + describe('default', () => { + beforeEach(() => { + createWrapper(); + }); + + it('renders the kubernetes overview title', () => { + expect(wrapper.text()).toBe(KubernetesOverview.i18n.sectionTitle); + }); + }); + + describe('collapse', () => { + beforeEach(() => { + createWrapper(); + }); + + it('is collapsed by default', () => { + expect(findCollapse().props('visible')).toBeUndefined(); + expect(findCollapseButton().attributes('aria-label')).toBe(KubernetesOverview.i18n.expand); + expect(findCollapseButton().props('icon')).toBe('chevron-right'); + }); + + it("doesn't render components when the collapse is not visible", () => { + expect(findAgentInfo().exists()).toBe(false); + expect(findKubernetesPods().exists()).toBe(false); + }); + + it('opens on click', async () => { + findCollapseButton().vm.$emit('click'); + await nextTick(); + + expect(findCollapse().attributes('visible')).toBe('true'); + expect(findCollapseButton().attributes('aria-label')).toBe(KubernetesOverview.i18n.collapse); + expect(findCollapseButton().props('icon')).toBe('chevron-down'); + }); + }); + + describe('when section is expanded', () => { + beforeEach(() => { + createWrapper(); + toggleCollapse(); + }); + + it('renders kubernetes agent info', () => { + expect(findAgentInfo().props()).toEqual({ + agentName: agent.name, + agentId: agent.id, + agentProjectPath: agent.project, + }); + }); + + it('renders kubernetes pods', () => { + expect(findKubernetesPods().props()).toEqual({ + namespace: agent.kubernetesNamespace, + configuration, + }); + }); + + it('renders kubernetes tabs', () => { + expect(findKubernetesTabs().props()).toEqual({ + namespace: agent.kubernetesNamespace, + configuration, + }); + }); + }); + + describe('on cluster error', () => { + beforeEach(() => { + createWrapper(); + toggleCollapse(); + }); + + it('shows alert with the error message', async () => { + const error = 'Error message from pods'; + + findKubernetesPods().vm.$emit('cluster-error', error); + await nextTick(); + + expect(findAlert().text()).toBe(error); + }); + }); +}); diff --git a/spec/frontend/environments/kubernetes_pods_spec.js b/spec/frontend/environments/kubernetes_pods_spec.js new file mode 100644 index 00000000000..137309d7853 --- /dev/null +++ b/spec/frontend/environments/kubernetes_pods_spec.js @@ -0,0 +1,114 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import KubernetesPods from '~/environments/components/kubernetes_pods.vue'; +import { mockKasTunnelUrl } from './mock_data'; +import { k8sPodsMock } from './graphql/mock_data'; + +Vue.use(VueApollo); + +describe('~/environments/components/kubernetes_pods.vue', () => { + let wrapper; + + const namespace = 'my-kubernetes-namespace'; + const configuration = { + basePath: mockKasTunnelUrl, + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + }, + }; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findAllStats = () => wrapper.findAllComponents(GlSingleStat); + const findSingleStat = (at) => findAllStats().at(at); + + const createApolloProvider = () => { + const mockResolvers = { + Query: { + k8sPods: jest.fn().mockReturnValue(k8sPodsMock), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + const createWrapper = (apolloProvider = createApolloProvider()) => { + wrapper = shallowMount(KubernetesPods, { + propsData: { namespace, configuration }, + apolloProvider, + }); + }; + + describe('mounted', () => { + it('shows the loading icon', () => { + createWrapper(); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('hides the loading icon when the list of pods loaded', async () => { + createWrapper(); + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('when gets pods data', () => { + it('renders stats', async () => { + createWrapper(); + await waitForPromises(); + + expect(findAllStats()).toHaveLength(4); + }); + + it.each` + count | title | index + ${2} | ${KubernetesPods.i18n.runningPods} | ${0} + ${1} | ${KubernetesPods.i18n.pendingPods} | ${1} + ${1} | ${KubernetesPods.i18n.succeededPods} | ${2} + ${2} | ${KubernetesPods.i18n.failedPods} | ${3} + `( + 'renders stat with title "$title" and count "$count" at index $index', + async ({ count, title, index }) => { + createWrapper(); + await waitForPromises(); + + expect(findSingleStat(index).props()).toMatchObject({ + value: count, + title, + }); + }, + ); + }); + + describe('when gets an error from the cluster_client API', () => { + const error = new Error('Error from the cluster_client API'); + const createErroredApolloProvider = () => { + const mockResolvers = { + Query: { + k8sPods: jest.fn().mockRejectedValueOnce(error), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + beforeEach(async () => { + createWrapper(createErroredApolloProvider()); + await waitForPromises(); + }); + + it("doesn't show pods stats", () => { + expect(findAllStats()).toHaveLength(0); + }); + + it('emits an error message', () => { + expect(wrapper.emitted('cluster-error')).toMatchObject([[error]]); + }); + }); +}); diff --git a/spec/frontend/environments/kubernetes_summary_spec.js b/spec/frontend/environments/kubernetes_summary_spec.js new file mode 100644 index 00000000000..53b83079486 --- /dev/null +++ b/spec/frontend/environments/kubernetes_summary_spec.js @@ -0,0 +1,115 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlLoadingIcon, GlTab, GlBadge } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import KubernetesSummary from '~/environments/components/kubernetes_summary.vue'; +import { mockKasTunnelUrl } from './mock_data'; +import { k8sWorkloadsMock } from './graphql/mock_data'; + +Vue.use(VueApollo); + +describe('~/environments/components/kubernetes_summary.vue', () => { + let wrapper; + + const namespace = 'my-kubernetes-namespace'; + const configuration = { + basePath: mockKasTunnelUrl, + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + }, + }; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findTab = () => wrapper.findComponent(GlTab); + const findSummaryListItem = (at) => wrapper.findAllByTestId('summary-list-item').at(at); + + const createApolloProvider = () => { + const mockResolvers = { + Query: { + k8sWorkloads: jest.fn().mockReturnValue(k8sWorkloadsMock), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + const createWrapper = (apolloProvider = createApolloProvider()) => { + wrapper = shallowMountExtended(KubernetesSummary, { + propsData: { configuration, namespace }, + apolloProvider, + stubs: { + GlTab, + GlBadge, + }, + }); + }; + + describe('mounted', () => { + it('renders summary tab', () => { + createWrapper(); + + expect(findTab().text()).toMatchInterpolatedText(`${KubernetesSummary.i18n.summaryTitle} 0`); + }); + + it('shows the loading icon', () => { + createWrapper(); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + describe('when workloads data is loaded', () => { + beforeEach(async () => { + await createWrapper(); + await waitForPromises(); + }); + + it('hides the loading icon when the list of workload types loaded', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it.each` + type | successText | successCount | failedCount | suspendedCount | index + ${'Deployments'} | ${'ready'} | ${1} | ${1} | ${0} | ${0} + ${'DaemonSets'} | ${'ready'} | ${1} | ${2} | ${0} | ${1} + ${'StatefulSets'} | ${'ready'} | ${2} | ${1} | ${0} | ${2} + ${'ReplicaSets'} | ${'ready'} | ${1} | ${1} | ${0} | ${3} + ${'Jobs'} | ${'completed'} | ${2} | ${1} | ${0} | ${4} + ${'CronJobs'} | ${'ready'} | ${1} | ${1} | ${1} | ${5} + `( + 'populates view with the correct badges for workload type $type', + ({ type, successText, successCount, failedCount, suspendedCount, index }) => { + const findAllBadges = () => findSummaryListItem(index).findAllComponents(GlBadge); + const findBadgeByVariant = (variant) => + findAllBadges().wrappers.find((badge) => badge.props('variant') === variant); + + expect(findSummaryListItem(index).text()).toContain(type); + expect(findBadgeByVariant('success').text()).toBe(`${successCount} ${successText}`); + expect(findBadgeByVariant('danger').text()).toBe(`${failedCount} failed`); + if (suspendedCount > 0) { + expect(findBadgeByVariant('neutral').text()).toBe(`${suspendedCount} suspended`); + } + }, + ); + }); + + it('emits an error message when gets an error from the cluster_client API', async () => { + const error = new Error('Error from the cluster_client API'); + const createErroredApolloProvider = () => { + const mockResolvers = { + Query: { + k8sWorkloads: jest.fn().mockRejectedValueOnce(error), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + createWrapper(createErroredApolloProvider()); + await waitForPromises(); + + expect(wrapper.emitted('cluster-error')).toEqual([[error]]); + }); + }); +}); diff --git a/spec/frontend/environments/kubernetes_tabs_spec.js b/spec/frontend/environments/kubernetes_tabs_spec.js new file mode 100644 index 00000000000..429f267347b --- /dev/null +++ b/spec/frontend/environments/kubernetes_tabs_spec.js @@ -0,0 +1,168 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlLoadingIcon, GlTabs, GlTab, GlTable, GlPagination, GlBadge } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; +import { useFakeDate } from 'helpers/fake_date'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import KubernetesTabs from '~/environments/components/kubernetes_tabs.vue'; +import KubernetesSummary from '~/environments/components/kubernetes_summary.vue'; +import { SERVICES_LIMIT_PER_PAGE } from '~/environments/constants'; +import { mockKasTunnelUrl } from './mock_data'; +import { k8sServicesMock } from './graphql/mock_data'; + +Vue.use(VueApollo); + +describe('~/environments/components/kubernetes_tabs.vue', () => { + let wrapper; + + const namespace = 'my-kubernetes-namespace'; + const configuration = { + basePath: mockKasTunnelUrl, + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + }, + }; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findTabs = () => wrapper.findComponent(GlTabs); + const findTab = () => wrapper.findComponent(GlTab); + const findTable = () => wrapper.findComponent(GlTable); + const findPagination = () => wrapper.findComponent(GlPagination); + const findKubernetesSummary = () => wrapper.findComponent(KubernetesSummary); + + const createApolloProvider = () => { + const mockResolvers = { + Query: { + k8sServices: jest.fn().mockReturnValue(k8sServicesMock), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + const createWrapper = (apolloProvider = createApolloProvider()) => { + wrapper = shallowMountExtended(KubernetesTabs, { + propsData: { configuration, namespace }, + apolloProvider, + stubs: { + GlTab, + GlTable: stubComponent(GlTable, { + props: ['items', 'per-page'], + }), + GlBadge, + }, + }); + }; + + describe('mounted', () => { + it('shows tabs', () => { + createWrapper(); + + expect(findTabs().exists()).toBe(true); + }); + + it('renders summary tab', () => { + createWrapper(); + + expect(findKubernetesSummary().props()).toEqual({ namespace, configuration }); + }); + + it('renders services tab', () => { + createWrapper(); + + expect(findTab().text()).toMatchInterpolatedText(`${KubernetesTabs.i18n.servicesTitle} 0`); + }); + }); + + describe('services tab', () => { + useFakeDate(2020, 6, 6); + it('shows the loading icon', () => { + createWrapper(); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + describe('when services data is loaded', () => { + beforeEach(async () => { + createWrapper(); + await waitForPromises(); + }); + + it('hides the loading icon when the list of services loaded', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders services table when gets services data', () => { + expect(findTable().props('perPage')).toBe(SERVICES_LIMIT_PER_PAGE); + expect(findTable().props('items')).toMatchObject([ + { + name: 'my-first-service', + namespace: 'default', + type: 'ClusterIP', + clusterIP: '10.96.0.1', + externalIP: '-', + ports: '443/TCP', + age: '0s', + }, + { + name: 'my-second-service', + namespace: 'default', + type: 'NodePort', + clusterIP: '10.105.219.238', + externalIP: '-', + ports: '80:31989/TCP, 443:32679/TCP', + age: '2d', + }, + ]); + }); + + it("doesn't render pagination when services are less then SERVICES_LIMIT_PER_PAGE", async () => { + createWrapper(); + await waitForPromises(); + + expect(findPagination().exists()).toBe(false); + }); + }); + + it('shows pagination when services are more then SERVICES_LIMIT_PER_PAGE', async () => { + const createApolloProviderWithPagination = () => { + const mockResolvers = { + Query: { + k8sServices: jest + .fn() + .mockReturnValue( + Array.from({ length: 6 }, () => k8sServicesMock).flatMap((array) => array), + ), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + createWrapper(createApolloProviderWithPagination()); + await waitForPromises(); + + expect(findPagination().exists()).toBe(true); + }); + + it('emits an error message when gets an error from the cluster_client API', async () => { + const error = new Error('Error from the cluster_client API'); + const createErroredApolloProvider = () => { + const mockResolvers = { + Query: { + k8sServices: jest.fn().mockRejectedValueOnce(error), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + createWrapper(createErroredApolloProvider()); + await waitForPromises(); + + expect(wrapper.emitted('cluster-error')).toEqual([[error]]); + }); + }); +}); diff --git a/spec/frontend/environments/mock_data.js b/spec/frontend/environments/mock_data.js index a6d67c26304..bd2c6b7c892 100644 --- a/spec/frontend/environments/mock_data.js +++ b/spec/frontend/environments/mock_data.js @@ -313,6 +313,8 @@ const createEnvironment = (data = {}) => ({ ...data, }); +const mockKasTunnelUrl = 'https://kas.gitlab.com/k8s-proxy'; + export { environment, environmentsList, @@ -321,4 +323,5 @@ export { tableData, deployBoardMockData, createEnvironment, + mockKasTunnelUrl, }; diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js index 76cd09cfb4e..5583e737dd8 100644 --- a/spec/frontend/environments/new_environment_item_spec.js +++ b/spec/frontend/environments/new_environment_item_spec.js @@ -7,9 +7,12 @@ import { stubTransition } from 'helpers/stub_transition'; import { formatDate, getTimeago } from '~/lib/utils/datetime_utility'; import { __, s__, sprintf } from '~/locale'; import EnvironmentItem from '~/environments/components/new_environment_item.vue'; +import EnvironmentActions from '~/environments/components/environment_actions.vue'; import Deployment from '~/environments/components/deployment.vue'; import DeployBoardWrapper from '~/environments/components/deploy_board_wrapper.vue'; -import { resolvedEnvironment, rolloutStatus } from './graphql/mock_data'; +import KubernetesOverview from '~/environments/components/kubernetes_overview.vue'; +import { resolvedEnvironment, rolloutStatus, agent } from './graphql/mock_data'; +import { mockKasTunnelUrl } from './mock_data'; Vue.use(VueApollo); @@ -20,15 +23,24 @@ describe('~/environments/components/new_environment_item.vue', () => { return createMockApollo(); }; - const createWrapper = ({ propsData = {}, apolloProvider } = {}) => + const createWrapper = ({ propsData = {}, provideData = {}, apolloProvider } = {}) => mountExtended(EnvironmentItem, { apolloProvider, propsData: { environment: resolvedEnvironment, ...propsData }, - provide: { helpPagePath: '/help', projectId: '1', projectPath: '/1' }, + provide: { + helpPagePath: '/help', + projectId: '1', + projectPath: '/1', + kasTunnelUrl: mockKasTunnelUrl, + ...provideData, + }, stubs: { transition: stubTransition() }, }); const findDeployment = () => wrapper.findComponent(Deployment); + const findActions = () => wrapper.findComponent(EnvironmentActions); + const findKubernetesOverview = () => wrapper.findComponent(KubernetesOverview); + const findMonitoringLink = () => wrapper.find('[data-testid="environment-monitoring"]'); const expandCollapsedSection = async () => { const button = wrapper.findByRole('button', { name: __('Expand') }); @@ -37,10 +49,6 @@ describe('~/environments/components/new_environment_item.vue', () => { return button; }; - afterEach(() => { - wrapper?.destroy(); - }); - it('displays the name when not in a folder', () => { wrapper = createWrapper({ apolloProvider: createApolloProvider() }); @@ -126,9 +134,7 @@ describe('~/environments/components/new_environment_item.vue', () => { it('shows a dropdown if there are actions to perform', () => { wrapper = createWrapper({ apolloProvider: createApolloProvider() }); - const actions = wrapper.findByRole('button', { name: __('Deploy to...') }); - - expect(actions.exists()).toBe(true); + expect(findActions().exists()).toBe(true); }); it('does not show a dropdown if there are no actions to perform', () => { @@ -142,22 +148,20 @@ describe('~/environments/components/new_environment_item.vue', () => { }, }); - const actions = wrapper.findByRole('button', { name: __('Deploy to...') }); - - expect(actions.exists()).toBe(false); + expect(findActions().exists()).toBe(false); }); it('passes all the actions down to the action component', () => { wrapper = createWrapper({ apolloProvider: createApolloProvider() }); - const action = wrapper.findByRole('menuitem', { name: 'deploy-staging' }); - - expect(action.exists()).toBe(true); + expect(findActions().props('actions')).toMatchObject( + resolvedEnvironment.lastDeployment.manualActions, + ); }); }); describe('stop', () => { - it('shows a buton to stop the environment if the environment is available', () => { + it('shows a button to stop the environment if the environment is available', () => { wrapper = createWrapper({ apolloProvider: createApolloProvider() }); const stop = wrapper.findByRole('button', { name: s__('Environments|Stop environment') }); @@ -165,7 +169,7 @@ describe('~/environments/components/new_environment_item.vue', () => { expect(stop.exists()).toBe(true); }); - it('does not show a buton to stop the environment if the environment is stopped', () => { + it('does not show a button to stop the environment if the environment is stopped', () => { wrapper = createWrapper({ propsData: { environment: { ...resolvedEnvironment, canStop: false } }, apolloProvider: createApolloProvider(), @@ -311,7 +315,25 @@ describe('~/environments/components/new_environment_item.vue', () => { expect(rollback.exists()).toBe(false); }); + + describe.each([true, false])( + 'when `remove_monitor_metrics` flag is %p', + (removeMonitorMetrics) => { + beforeEach(() => { + wrapper = createWrapper({ + propsData: { environment: { ...resolvedEnvironment, metricsPath: '/metrics' } }, + apolloProvider: createApolloProvider(), + provideData: { glFeatures: { removeMonitorMetrics } }, + }); + }); + + it(`${removeMonitorMetrics ? 'does not render' : 'renders'} link to metrics`, () => { + expect(findMonitoringLink().exists()).toBe(!removeMonitorMetrics); + }); + }, + ); }); + describe('terminal', () => { it('shows the link to the terminal if set up', () => { wrapper = createWrapper({ @@ -384,6 +406,7 @@ describe('~/environments/components/new_environment_item.vue', () => { const button = await expandCollapsedSection(); expect(button.attributes('aria-label')).toBe(__('Collapse')); + expect(button.props('category')).toBe('secondary'); expect(collapse.attributes('visible')).toBe('visible'); expect(icon.props('name')).toBe('chevron-lg-down'); expect(environmentName.classes('gl-font-weight-bold')).toBe(true); @@ -515,4 +538,72 @@ describe('~/environments/components/new_environment_item.vue', () => { expect(deployBoard.exists()).toBe(false); }); }); + + describe('kubernetes overview', () => { + const environmentWithAgent = { + ...resolvedEnvironment, + agent, + }; + + it('should render if the feature flag is enabled and the environment has an agent object with the required data specified', () => { + wrapper = createWrapper({ + propsData: { environment: environmentWithAgent }, + provideData: { + glFeatures: { + kasUserAccessProject: true, + }, + }, + apolloProvider: createApolloProvider(), + }); + + expandCollapsedSection(); + + expect(findKubernetesOverview().props()).toMatchObject({ + agentProjectPath: agent.project, + agentName: agent.name, + agentId: agent.id, + namespace: agent.kubernetesNamespace, + }); + }); + + it('should not render if the feature flag is not enabled', () => { + wrapper = createWrapper({ + propsData: { environment: environmentWithAgent }, + apolloProvider: createApolloProvider(), + }); + + expandCollapsedSection(); + + expect(findKubernetesOverview().exists()).toBe(false); + }); + + it('should not render if the environment has no agent object', () => { + wrapper = createWrapper({ + apolloProvider: createApolloProvider(), + }); + + expandCollapsedSection(); + + expect(findKubernetesOverview().exists()).toBe(false); + }); + + it('should not render if the environment has an agent object without agent id specified', () => { + const environment = { + ...resolvedEnvironment, + agent: { + project: agent.project, + name: agent.name, + }, + }; + + wrapper = createWrapper({ + propsData: { environment }, + apolloProvider: createApolloProvider(), + }); + + expandCollapsedSection(); + + expect(findKubernetesOverview().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/environments/new_environment_spec.js b/spec/frontend/environments/new_environment_spec.js index a8cc05b297b..743f4ad6786 100644 --- a/spec/frontend/environments/new_environment_spec.js +++ b/spec/frontend/environments/new_environment_spec.js @@ -3,13 +3,13 @@ import MockAdapter from 'axios-mock-adapter'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import NewEnvironment from '~/environments/components/new_environment.vue'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { visitUrl } from '~/lib/utils/url_utility'; jest.mock('~/lib/utils/url_utility'); -jest.mock('~/flash'); +jest.mock('~/alert'); const DEFAULT_OPTS = { provide: { @@ -41,7 +41,6 @@ describe('~/environments/components/new.vue', () => { afterEach(() => { mock.restore(); - wrapper.destroy(); }); const showsLoading = () => wrapper.findComponent(GlLoadingIcon).exists(); diff --git a/spec/frontend/environments/stop_stale_environments_modal_spec.js b/spec/frontend/environments/stop_stale_environments_modal_spec.js index a2ab4f707b5..3d28ceba318 100644 --- a/spec/frontend/environments/stop_stale_environments_modal_spec.js +++ b/spec/frontend/environments/stop_stale_environments_modal_spec.js @@ -18,7 +18,6 @@ describe('~/environments/components/stop_stale_environments_modal.vue', () => { let wrapper; let mock; let before; - let originalGon; const createWrapper = (opts = {}) => shallowMount(StopStaleEnvironmentsModal, { @@ -28,8 +27,7 @@ describe('~/environments/components/stop_stale_environments_modal.vue', () => { }); beforeEach(() => { - originalGon = window.gon; - window.gon = { api_version: 'v4' }; + window.gon.api_version = 'v4'; mock = new MockAdapter(axios); jest.spyOn(axios, 'post'); @@ -39,17 +37,15 @@ describe('~/environments/components/stop_stale_environments_modal.vue', () => { afterEach(() => { mock.restore(); - wrapper.destroy(); jest.resetAllMocks(); - window.gon = originalGon; }); - it('sets the correct min and max dates', async () => { + it('sets the correct min and max dates', () => { expect(before.props().minDate.toISOString()).toBe(TEN_YEARS_AGO.toISOString()); expect(before.props().maxDate.toISOString()).toBe(ONE_WEEK_AGO.toISOString()); }); - it('requests cleanup when submit is clicked', async () => { + it('requests cleanup when submit is clicked', () => { mock.onPost().replyOnce(HTTP_STATUS_OK); wrapper.findComponent(GlModal).vm.$emit('primary'); const url = STOP_STALE_ENVIRONMENTS_PATH.replace(':id', 1).replace(':version', 'v4'); |