diff options
Diffstat (limited to 'spec/frontend')
420 files changed, 24157 insertions, 5552 deletions
diff --git a/spec/frontend/__mocks__/@gitlab/ui.js b/spec/frontend/__mocks__/@gitlab/ui.js index d65fab80d3b..ea36f1dabaf 100644 --- a/spec/frontend/__mocks__/@gitlab/ui.js +++ b/spec/frontend/__mocks__/@gitlab/ui.js @@ -7,22 +7,23 @@ export * from '@gitlab/ui'; * * This mock decouples those tests from the implementation, removing the need to set * them up specially just for these tooltips. + * + * Mocking the modules using the full file path allows the mocks to take effect + * when the modules are imported in this project (`gitlab`) **and** when they + * are imported internally in `@gitlab/ui`. */ -export const GlTooltipDirective = { + +jest.mock('@gitlab/ui/dist/directives/tooltip.js', () => ({ bind() {}, -}; +})); -export const GlTooltip = { +jest.mock('@gitlab/ui/dist/components/base/tooltip/tooltip.js', () => ({ render(h) { return h('div', this.$attrs, this.$slots.default); }, -}; - -export const GlPopoverDirective = { - bind() {}, -}; +})); -export const GlPopover = { +jest.mock('@gitlab/ui/dist/components/base/popover/popover.js', () => ({ props: { cssClasses: { type: Array, @@ -33,4 +34,4 @@ export const GlPopover = { render(h) { return h('div', this.$attrs, Object.keys(this.$slots).map(s => this.$slots[s])); }, -}; +})); diff --git a/spec/frontend/__mocks__/document-register-element/index.js b/spec/frontend/__mocks__/document-register-element/index.js new file mode 100644 index 00000000000..2d1ec238274 --- /dev/null +++ b/spec/frontend/__mocks__/document-register-element/index.js @@ -0,0 +1 @@ +export default () => {}; diff --git a/spec/frontend/__mocks__/monaco-editor/index.js b/spec/frontend/__mocks__/monaco-editor/index.js index 7c53cfb5174..b9602d69b74 100644 --- a/spec/frontend/__mocks__/monaco-editor/index.js +++ b/spec/frontend/__mocks__/monaco-editor/index.js @@ -8,9 +8,11 @@ import 'monaco-editor/esm/vs/language/css/monaco.contribution'; import 'monaco-editor/esm/vs/language/json/monaco.contribution'; import 'monaco-editor/esm/vs/language/html/monaco.contribution'; import 'monaco-editor/esm/vs/basic-languages/monaco.contribution'; +import 'monaco-yaml/esm/monaco.contribution'; // This language starts trying to spin up web workers which obviously breaks in Jest environment jest.mock('monaco-editor/esm/vs/language/typescript/tsMode'); +jest.mock('monaco-yaml/esm/yamlMode'); export * from 'monaco-editor/esm/vs/editor/editor.api'; export default global.monaco; diff --git a/spec/frontend/alert_management/components/alert_management_detail_spec.js b/spec/frontend/alert_management/components/alert_management_detail_spec.js index 14e45a4f563..daa730d3b9f 100644 --- a/spec/frontend/alert_management/components/alert_management_detail_spec.js +++ b/spec/frontend/alert_management/components/alert_management_detail_spec.js @@ -3,7 +3,7 @@ import { GlAlert, GlLoadingIcon, GlTable } from '@gitlab/ui'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import AlertDetails from '~/alert_management/components/alert_details.vue'; -import createIssueQuery from '~/alert_management/graphql/mutations/create_issue_from_alert.graphql'; +import createIssueMutation from '~/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql'; import { joinPaths } from '~/lib/utils/url_utility'; import { trackAlertsDetailsViewsOptions, @@ -19,18 +19,20 @@ describe('AlertDetails', () => { let mock; const projectPath = 'root/alerts'; const projectIssuesPath = 'root/alerts/-/issues'; + const projectId = '1'; const findDetailsTable = () => wrapper.find(GlTable); function mountComponent({ data, loading = false, mountMethod = shallowMount, stubs = {} } = {}) { wrapper = mountMethod(AlertDetails, { - propsData: { + provide: { alertId: 'alertId', projectPath, projectIssuesPath, + projectId, }, data() { - return { alert: { ...mockAlert }, ...data }; + return { alert: { ...mockAlert }, sidebarStatus: false, ...data }; }, mocks: { $apollo: { @@ -39,6 +41,7 @@ describe('AlertDetails', () => { alert: { loading, }, + sidebarStatus: {}, }, }, }, @@ -52,9 +55,7 @@ describe('AlertDetails', () => { afterEach(() => { if (wrapper) { - if (wrapper) { - wrapper.destroy(); - } + wrapper.destroy(); } mock.restore(); }); @@ -133,7 +134,7 @@ describe('AlertDetails', () => { it('should display "View issue" button that links the issue page when issue exists', () => { const issueIid = '3'; mountComponent({ - data: { alert: { ...mockAlert, issueIid } }, + data: { alert: { ...mockAlert, issueIid }, sidebarStatus: false }, }); expect(findViewIssueBtn().exists()).toBe(true); expect(findViewIssueBtn().attributes('href')).toBe(joinPaths(projectIssuesPath, issueIid)); @@ -146,8 +147,11 @@ describe('AlertDetails', () => { mountMethod: mount, data: { alert: { ...mockAlert, issueIid } }, }); - expect(findViewIssueBtn().exists()).toBe(false); - expect(findCreateIssueBtn().exists()).toBe(true); + + return wrapper.vm.$nextTick().then(() => { + expect(findViewIssueBtn().exists()).toBe(false); + expect(findCreateIssueBtn().exists()).toBe(true); + }); }); it('calls `$apollo.mutate` with `createIssueQuery`', () => { @@ -158,7 +162,7 @@ describe('AlertDetails', () => { findCreateIssueBtn().trigger('click'); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: createIssueQuery, + mutation: createIssueMutation, variables: { iid: mockAlert.iid, projectPath, @@ -208,6 +212,13 @@ describe('AlertDetails', () => { expect(wrapper.find(GlAlert).exists()).toBe(true); }); + it('renders html-errors correctly', () => { + mountComponent({ + data: { errored: true, sidebarErrorMessage: '<span data-testid="htmlError" />' }, + }); + expect(wrapper.find('[data-testid="htmlError"]').exists()).toBe(true); + }); + it('does not display an error when dismissed', () => { mountComponent({ data: { errored: true, isErrorDismissed: true } }); expect(wrapper.find(GlAlert).exists()).toBe(false); diff --git a/spec/frontend/alert_management/components/alert_management_empty_state_spec.js b/spec/frontend/alert_management/components/alert_management_empty_state_spec.js new file mode 100644 index 00000000000..0d1214211d3 --- /dev/null +++ b/spec/frontend/alert_management/components/alert_management_empty_state_spec.js @@ -0,0 +1,54 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlEmptyState } from '@gitlab/ui'; +import AlertManagementEmptyState from '~/alert_management/components/alert_management_empty_state.vue'; + +describe('AlertManagementEmptyState', () => { + let wrapper; + + function mountComponent({ + props = { + alertManagementEnabled: false, + userCanEnableAlertManagement: false, + }, + stubs = {}, + } = {}) { + wrapper = shallowMount(AlertManagementEmptyState, { + propsData: { + enableAlertManagementPath: '/link', + emptyAlertSvgPath: 'illustration/path', + ...props, + }, + stubs, + }); + } + + beforeEach(() => { + mountComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + const EmptyState = () => wrapper.find(GlEmptyState); + + describe('Empty state', () => { + it('shows empty state', () => { + expect(EmptyState().exists()).toBe(true); + }); + + it('show OpsGenie integration state when OpsGenie mcv is true', () => { + mountComponent({ + props: { + alertManagementEnabled: false, + userCanEnableAlertManagement: false, + opsgenieMvcEnabled: true, + opsgenieMvcTargetUrl: 'https://opsgenie-url.com', + }, + }); + expect(EmptyState().props('title')).toBe('Opsgenie is enabled'); + }); + }); +}); diff --git a/spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js b/spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js new file mode 100644 index 00000000000..4644406c037 --- /dev/null +++ b/spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js @@ -0,0 +1,57 @@ +import { shallowMount } from '@vue/test-utils'; +import AlertManagementList from '~/alert_management/components/alert_management_list_wrapper.vue'; +import { trackAlertListViewsOptions } from '~/alert_management/constants'; +import mockAlerts from '../mocks/alerts.json'; +import Tracking from '~/tracking'; + +describe('AlertManagementList', () => { + let wrapper; + + function mountComponent({ + props = { + alertManagementEnabled: false, + userCanEnableAlertManagement: false, + }, + data = {}, + stubs = {}, + } = {}) { + wrapper = shallowMount(AlertManagementList, { + propsData: { + projectPath: 'gitlab-org/gitlab', + enableAlertManagementPath: '/link', + populatingAlertsHelpUrl: '/help/help-page.md#populating-alert-data', + emptyAlertSvgPath: 'illustration/path', + ...props, + }, + data() { + return data; + }, + stubs, + }); + } + + beforeEach(() => { + mountComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('Snowplow tracking', () => { + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: { list: mockAlerts } }, + }); + }); + + it('should track alert list page views', () => { + const { category, action } = trackAlertListViewsOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action); + }); + }); +}); diff --git a/spec/frontend/alert_management/components/alert_management_sidebar_todo_spec.js b/spec/frontend/alert_management/components/alert_management_sidebar_todo_spec.js new file mode 100644 index 00000000000..fe08cf2c10a --- /dev/null +++ b/spec/frontend/alert_management/components/alert_management_sidebar_todo_spec.js @@ -0,0 +1,76 @@ +import { mount } from '@vue/test-utils'; +import SidebarTodo from '~/alert_management/components/sidebar/sidebar_todo.vue'; +import AlertMarkTodo from '~/alert_management/graphql/mutations/alert_todo_create.graphql'; +import mockAlerts from '../mocks/alerts.json'; + +const mockAlert = mockAlerts[0]; + +describe('Alert Details Sidebar To Do', () => { + let wrapper; + + function mountComponent({ data, sidebarCollapsed = true, loading = false, stubs = {} } = {}) { + wrapper = mount(SidebarTodo, { + propsData: { + alert: { ...mockAlert }, + ...data, + sidebarCollapsed, + projectPath: 'projectPath', + }, + mocks: { + $apollo: { + mutate: jest.fn(), + queries: { + alert: { + loading, + }, + }, + }, + }, + stubs, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('updating the alert to do', () => { + const mockUpdatedMutationResult = { + data: { + updateAlertTodo: { + errors: [], + alert: {}, + }, + }, + }; + + beforeEach(() => { + mountComponent({ + data: { alert: mockAlert }, + sidebarCollapsed: false, + loading: false, + }); + }); + + it('renders a button for adding a To Do', () => { + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find('[data-testid="alert-todo-button"]').text()).toBe('Add a To Do'); + }); + }); + + it('calls `$apollo.mutate` with `AlertMarkTodo` mutation and variables containing `iid`, `todoEvent`, & `projectPath`', () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); + + return wrapper.vm.$nextTick().then(() => { + wrapper.find('button').trigger('click'); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: AlertMarkTodo, + variables: { + iid: '1527542', + projectPath: 'projectPath', + }, + }); + }); + }); + }); +}); diff --git a/spec/frontend/alert_management/components/alert_management_list_spec.js b/spec/frontend/alert_management/components/alert_management_table_spec.js index 0154e5fa112..f316126432e 100644 --- a/spec/frontend/alert_management/components/alert_management_list_spec.js +++ b/spec/frontend/alert_management/components/alert_management_table_spec.js @@ -1,6 +1,5 @@ import { mount } from '@vue/test-utils'; import { - GlEmptyState, GlTable, GlAlert, GlLoadingIcon, @@ -11,28 +10,22 @@ import { GlTab, GlBadge, GlPagination, + GlSearchBoxByType, } from '@gitlab/ui'; import { visitUrl } from '~/lib/utils/url_utility'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; -import createFlash from '~/flash'; -import AlertManagementList from '~/alert_management/components/alert_management_list.vue'; -import { - ALERTS_STATUS_TABS, - trackAlertListViewsOptions, - trackAlertStatusUpdateOptions, -} from '~/alert_management/constants'; -import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.graphql'; +import AlertManagementTable from '~/alert_management/components/alert_management_table.vue'; +import { ALERTS_STATUS_TABS, trackAlertStatusUpdateOptions } from '~/alert_management/constants'; +import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.mutation.graphql'; import mockAlerts from '../mocks/alerts.json'; import Tracking from '~/tracking'; -jest.mock('~/flash'); - jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn().mockName('visitUrlMock'), joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths, })); -describe('AlertManagementList', () => { +describe('AlertManagementTable', () => { let wrapper; const findAlertsTable = () => wrapper.find(GlTable); @@ -49,6 +42,8 @@ describe('AlertManagementList', () => { const findSeverityFields = () => wrapper.findAll('[data-testid="severityField"]'); const findSeverityColumnHeader = () => wrapper.findAll('th').at(0); const findPagination = () => wrapper.find(GlPagination); + const findSearch = () => wrapper.find(GlSearchBoxByType); + const findIssueFields = () => wrapper.findAll('[data-testid="issueField"]'); const alertsCount = { open: 14, triggered: 10, @@ -66,11 +61,10 @@ describe('AlertManagementList', () => { loading = false, stubs = {}, } = {}) { - wrapper = mount(AlertManagementList, { + wrapper = mount(AlertManagementTable, { propsData: { projectPath: 'gitlab-org/gitlab', - enableAlertManagementPath: '/link', - emptyAlertSvgPath: 'illustration/path', + populatingAlertsHelpUrl: '/help/help-page.md#populating-alert-data', ...props, }, data() { @@ -92,7 +86,7 @@ describe('AlertManagementList', () => { } beforeEach(() => { - mountComponent(); + mountComponent({ data: { alerts: mockAlerts, alertsCount } }); }); afterEach(() => { @@ -101,12 +95,6 @@ describe('AlertManagementList', () => { } }); - describe('Empty state', () => { - it('shows empty state', () => { - expect(wrapper.find(GlEmptyState).exists()).toBe(true); - }); - }); - describe('Status Filter Tabs', () => { beforeEach(() => { mountComponent({ @@ -206,6 +194,15 @@ describe('AlertManagementList', () => { expect(findStatusDropdown().exists()).toBe(true); }); + it('does not display a dropdown status header', () => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, + loading: false, + }); + expect(findStatusDropdown().contains('.dropdown-title')).toBe(false); + }); + it('shows correct severity icons', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, @@ -278,6 +275,37 @@ describe('AlertManagementList', () => { expect(visitUrl).toHaveBeenCalledWith('/1527542/details'); }); + describe('alert issue links', () => { + beforeEach(() => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, + loading: false, + }); + }); + + it('shows "None" when no link exists', () => { + expect( + findIssueFields() + .at(0) + .text(), + ).toBe('None'); + }); + + it('renders a link when one exists', () => { + expect( + findIssueFields() + .at(1) + .text(), + ).toBe('#1'); + expect( + findIssueFields() + .at(1) + .attributes('href'), + ).toBe('/gitlab-org/gitlab/-/issues/1'); + }); + }); + describe('handle date fields', () => { it('should display time ago dates when values provided', () => { mountComponent({ @@ -289,7 +317,6 @@ describe('AlertManagementList', () => { iid: 1, status: 'acknowledged', startedAt: '2020-03-17T23:18:14.996Z', - endedAt: '2020-04-17T23:18:14.996Z', severity: 'high', assignees: { nodes: [] }, }, @@ -300,7 +327,7 @@ describe('AlertManagementList', () => { }, loading: false, }); - expect(findDateFields().length).toBe(2); + expect(findDateFields().length).toBe(1); }); it('should not display time ago dates when values not provided', () => { @@ -312,7 +339,6 @@ describe('AlertManagementList', () => { iid: 1, status: 'acknowledged', startedAt: null, - endedAt: null, severity: 'high', }, ], @@ -323,6 +349,40 @@ describe('AlertManagementList', () => { }); expect(findDateFields().exists()).toBe(false); }); + + describe('New Alert indicator', () => { + const oldAlert = mockAlerts[0]; + + const newAlert = { ...oldAlert, isNew: true }; + + it('should highlight the row when alert is new', () => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: { list: [newAlert] }, alertsCount, errored: false }, + loading: false, + }); + + expect( + findAlerts() + .at(0) + .classes(), + ).toContain('new-alert'); + }); + + it('should not highlight the row when alert is not new', () => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: { list: [oldAlert] }, alertsCount, errored: false }, + loading: false, + }); + + expect( + findAlerts() + .at(0) + .classes(), + ).not.toContain('new-alert'); + }); + }); }); }); @@ -388,14 +448,38 @@ describe('AlertManagementList', () => { }); }); - it('calls `createFlash` when request fails', () => { + it('shows an error when request fails', () => { jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); findFirstStatusOption().vm.$emit('click'); + wrapper.setData({ + errored: true, + }); - setImmediate(() => { - expect(createFlash).toHaveBeenCalledWith( - 'There was an error while updating the status of the alert. Please try again.', - ); + return wrapper.vm.$nextTick(() => { + expect(wrapper.find('[data-testid="alert-error"]').exists()).toBe(true); + }); + }); + + it('shows an error when response includes HTML errors', () => { + const mockUpdatedMutationErrorResult = { + data: { + updateAlertStatus: { + errors: ['<span data-testid="htmlError" />'], + alert: { + iid, + status: 'acknowledged', + }, + }, + }, + }; + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationErrorResult); + findFirstStatusOption().vm.$emit('click'); + wrapper.setData({ errored: true }); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.contains('[data-testid="alert-error"]')).toBe(true); + expect(wrapper.contains('[data-testid="htmlError"]')).toBe(true); }); }); }); @@ -410,11 +494,6 @@ describe('AlertManagementList', () => { }); }); - it('should track alert list page views', () => { - const { category, action } = trackAlertListViewsOptions; - expect(Tracking.event).toHaveBeenCalledWith(category, action); - }); - it('should track alert status updates', () => { Tracking.event.mockClear(); jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({}); @@ -438,14 +517,14 @@ describe('AlertManagementList', () => { it('does NOT show pagination control when list is smaller than default page size', () => { findStatusTabs().vm.$emit('input', 3); - wrapper.vm.$nextTick(() => { + return wrapper.vm.$nextTick(() => { expect(findPagination().exists()).toBe(false); }); }); it('shows pagination control when list is larger than default page size', () => { findStatusTabs().vm.$emit('input', 0); - wrapper.vm.$nextTick(() => { + return wrapper.vm.$nextTick(() => { expect(findPagination().exists()).toBe(true); }); }); @@ -486,4 +565,26 @@ describe('AlertManagementList', () => { }); }); }); + + describe('Search', () => { + beforeEach(() => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, + loading: false, + }); + }); + + it('renders the search component', () => { + expect(findSearch().exists()).toBe(true); + }); + + it('sets the `searchTerm` graphql variable', () => { + const SEARCH_TERM = 'Simple Alert'; + + findSearch().vm.$emit('input', SEARCH_TERM); + + expect(wrapper.vm.$data.searchTerm).toBe(SEARCH_TERM); + }); + }); }); diff --git a/spec/frontend/alert_management/components/alert_metrics_spec.js b/spec/frontend/alert_management/components/alert_metrics_spec.js new file mode 100644 index 00000000000..c188363ddc2 --- /dev/null +++ b/spec/frontend/alert_management/components/alert_metrics_spec.js @@ -0,0 +1,67 @@ +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import AlertMetrics from '~/alert_management/components/alert_metrics.vue'; +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; + +jest.mock('~/monitoring/stores', () => ({ + monitoringDashboard: {}, +})); + +const mockEmbedName = 'MetricsEmbedStub'; + +jest.mock('~/monitoring/components/embeds/metric_embed.vue', () => ({ + name: mockEmbedName, + render(h) { + return h('div'); + }, +})); + +describe('Alert Metrics', () => { + let wrapper; + const mock = new MockAdapter(axios); + + function mountComponent({ props } = {}) { + wrapper = shallowMount(AlertMetrics, { + propsData: { + ...props, + }, + stubs: { + MetricEmbed: true, + }, + }); + } + + const findChart = () => wrapper.find({ name: mockEmbedName }); + const findEmptyState = () => wrapper.find({ ref: 'emptyState' }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + afterAll(() => { + mock.restore(); + }); + + describe('Empty state', () => { + it('should display a message when metrics dashboard url is not provided ', () => { + mountComponent(); + expect(findChart().exists()).toBe(false); + expect(findEmptyState().text()).toBe("Metrics weren't available in the alerts payload."); + }); + }); + + describe('Chart', () => { + it('should be rendered when dashboard url is provided', async () => { + mountComponent({ props: { dashboardUrl: 'metrics.url' } }); + + await waitForPromises(); + await wrapper.vm.$nextTick(); + + expect(findEmptyState().exists()).toBe(false); + expect(findChart().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/alert_management/components/alert_sidebar_status_spec.js b/spec/frontend/alert_management/components/alert_sidebar_status_spec.js deleted file mode 100644 index 94643966a43..00000000000 --- a/spec/frontend/alert_management/components/alert_sidebar_status_spec.js +++ /dev/null @@ -1,107 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; -import { trackAlertStatusUpdateOptions } from '~/alert_management/constants'; -import AlertSidebarStatus from '~/alert_management/components/sidebar/sidebar_status.vue'; -import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.graphql'; -import Tracking from '~/tracking'; -import mockAlerts from '../mocks/alerts.json'; - -const mockAlert = mockAlerts[0]; - -describe('Alert Details Sidebar Status', () => { - let wrapper; - const findStatusDropdownItem = () => wrapper.find(GlDropdownItem); - const findStatusLoadingIcon = () => wrapper.find(GlLoadingIcon); - - function mountComponent({ data, sidebarCollapsed = true, loading = false, stubs = {} } = {}) { - wrapper = shallowMount(AlertSidebarStatus, { - propsData: { - alert: { ...mockAlert }, - ...data, - sidebarCollapsed, - projectPath: 'projectPath', - }, - mocks: { - $apollo: { - mutate: jest.fn(), - queries: { - alert: { - loading, - }, - }, - }, - }, - stubs, - }); - } - - afterEach(() => { - if (wrapper) { - wrapper.destroy(); - } - }); - - describe('updating the alert status', () => { - const mockUpdatedMutationResult = { - data: { - updateAlertStatus: { - errors: [], - alert: { - status: 'acknowledged', - }, - }, - }, - }; - - beforeEach(() => { - mountComponent({ - data: { alert: mockAlert }, - sidebarCollapsed: false, - loading: false, - }); - }); - - it('calls `$apollo.mutate` with `updateAlertStatus` mutation and variables containing `iid`, `status`, & `projectPath`', () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); - findStatusDropdownItem().vm.$emit('click'); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: updateAlertStatus, - variables: { - iid: '1527542', - status: 'TRIGGERED', - projectPath: 'projectPath', - }, - }); - }); - - it('stops updating when the request fails', () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); - findStatusDropdownItem().vm.$emit('click'); - expect(findStatusLoadingIcon().exists()).toBe(false); - expect(wrapper.find('[data-testid="status"]').text()).toBe('Triggered'); - }); - }); - - describe('Snowplow tracking', () => { - beforeEach(() => { - jest.spyOn(Tracking, 'event'); - mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alert: mockAlert }, - loading: false, - }); - }); - - it('should track alert status updates', () => { - Tracking.event.mockClear(); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({}); - findStatusDropdownItem().vm.$emit('click'); - const status = findStatusDropdownItem().text(); - setImmediate(() => { - const { category, action, label } = trackAlertStatusUpdateOptions; - expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property: status }); - }); - }); - }); -}); diff --git a/spec/frontend/alert_management/components/alert_managment_sidebar_assignees_spec.js b/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js index 5dbd83dbdac..db086782424 100644 --- a/spec/frontend/alert_management/components/alert_managment_sidebar_assignees_spec.js +++ b/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js @@ -4,8 +4,8 @@ import MockAdapter from 'axios-mock-adapter'; import { GlDropdownItem } from '@gitlab/ui'; import SidebarAssignee from '~/alert_management/components/sidebar/sidebar_assignee.vue'; import SidebarAssignees from '~/alert_management/components/sidebar/sidebar_assignees.vue'; -import AlertSetAssignees from '~/alert_management/graphql/mutations/alert_set_assignees.graphql'; -import mockAlerts from '../mocks/alerts.json'; +import AlertSetAssignees from '~/alert_management/graphql/mutations/alert_set_assignees.mutation.graphql'; +import mockAlerts from '../../mocks/alerts.json'; const mockAlert = mockAlerts[0]; @@ -33,6 +33,7 @@ describe('Alert Details Sidebar Assignees', () => { ...data, sidebarCollapsed, projectPath: 'projectPath', + projectId: '1', }, mocks: { $apollo: { @@ -58,7 +59,7 @@ describe('Alert Details Sidebar Assignees', () => { describe('updating the alert status', () => { const mockUpdatedMutationResult = { data: { - updateAlertStatus: { + alertSetAssignees: { errors: [], alert: { assigneeUsernames: ['root'], @@ -69,7 +70,7 @@ describe('Alert Details Sidebar Assignees', () => { beforeEach(() => { mock = new MockAdapter(axios); - const path = '/autocomplete/users.json'; + const path = '/-/autocomplete/users.json'; const users = [ { avatar_url: @@ -124,6 +125,26 @@ describe('Alert Details Sidebar Assignees', () => { }); }); + it('shows an error when request contains error messages', () => { + wrapper.setData({ isDropdownSearching: false }); + const errorMutationResult = { + data: { + alertSetAssignees: { + errors: ['There was a problem for sure.'], + alert: {}, + }, + }, + }; + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(errorMutationResult); + + return wrapper.vm.$nextTick().then(() => { + const SideBarAssigneeItem = wrapper.findAll(SidebarAssignee).at(0); + SideBarAssigneeItem.vm.$emit('click'); + expect(wrapper.emitted('alert-refresh')).toBeUndefined(); + }); + }); + it('stops updating and cancels loading when the request fails', () => { jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); wrapper.vm.updateAlertAssignees('root'); diff --git a/spec/frontend/alert_management/components/alert_sidebar_spec.js b/spec/frontend/alert_management/components/sidebar/alert_sidebar_spec.js index 80c4d9e0650..5235ae63fee 100644 --- a/spec/frontend/alert_management/components/alert_sidebar_spec.js +++ b/spec/frontend/alert_management/components/sidebar/alert_sidebar_spec.js @@ -3,7 +3,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import AlertSidebar from '~/alert_management/components/alert_sidebar.vue'; import SidebarAssignees from '~/alert_management/components/sidebar/sidebar_assignees.vue'; -import mockAlerts from '../mocks/alerts.json'; +import mockAlerts from '../../mocks/alerts.json'; const mockAlert = mockAlerts[0]; @@ -11,19 +11,28 @@ describe('Alert Details Sidebar', () => { let wrapper; let mock; - function mountComponent({ - sidebarCollapsed = true, - mountMethod = shallowMount, - stubs = {}, - alert = {}, - } = {}) { + function mountComponent({ mountMethod = shallowMount, stubs = {}, alert = {} } = {}) { wrapper = mountMethod(AlertSidebar, { + data() { + return { + sidebarStatus: false, + }; + }, propsData: { alert, - sidebarCollapsed, + }, + provide: { projectPath: 'projectPath', + projectId: '1', }, stubs, + mocks: { + $apollo: { + queries: { + sidebarStatus: {}, + }, + }, + }, }); } @@ -41,7 +50,7 @@ describe('Alert Details Sidebar', () => { }); it('open as default', () => { - expect(wrapper.props('sidebarCollapsed')).toBe(true); + expect(wrapper.classes('right-sidebar-expanded')).toBe(true); }); it('should render side bar assignee dropdown', () => { diff --git a/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js b/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js new file mode 100644 index 00000000000..c2eaf540e9c --- /dev/null +++ b/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js @@ -0,0 +1,129 @@ +import { mount } from '@vue/test-utils'; +import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; +import { trackAlertStatusUpdateOptions } from '~/alert_management/constants'; +import AlertSidebarStatus from '~/alert_management/components/sidebar/sidebar_status.vue'; +import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.mutation.graphql'; +import Tracking from '~/tracking'; +import mockAlerts from '../../mocks/alerts.json'; + +const mockAlert = mockAlerts[0]; + +describe('Alert Details Sidebar Status', () => { + let wrapper; + const findStatusDropdown = () => wrapper.find(GlDropdown); + const findStatusDropdownItem = () => wrapper.find(GlDropdownItem); + const findStatusLoadingIcon = () => wrapper.find(GlLoadingIcon); + + function mountComponent({ data, sidebarCollapsed = true, loading = false, stubs = {} } = {}) { + wrapper = mount(AlertSidebarStatus, { + propsData: { + alert: { ...mockAlert }, + ...data, + sidebarCollapsed, + projectPath: 'projectPath', + }, + mocks: { + $apollo: { + mutate: jest.fn(), + queries: { + alert: { + loading, + }, + }, + }, + }, + stubs, + }); + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('Alert Sidebar Dropdown Status', () => { + beforeEach(() => { + mountComponent({ + data: { alert: mockAlert }, + sidebarCollapsed: false, + loading: false, + }); + }); + + it('displays status dropdown', () => { + expect(findStatusDropdown().exists()).toBe(true); + }); + + it('displays the dropdown status header', () => { + expect(findStatusDropdown().contains('.dropdown-title')).toBe(true); + }); + + describe('updating the alert status', () => { + const mockUpdatedMutationResult = { + data: { + updateAlertStatus: { + errors: [], + alert: { + status: 'acknowledged', + }, + }, + }, + }; + + beforeEach(() => { + mountComponent({ + data: { alert: mockAlert }, + sidebarCollapsed: false, + loading: false, + }); + }); + + it('calls `$apollo.mutate` with `updateAlertStatus` mutation and variables containing `iid`, `status`, & `projectPath`', () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); + findStatusDropdownItem().vm.$emit('click'); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: updateAlertStatus, + variables: { + iid: '1527542', + status: 'TRIGGERED', + projectPath: 'projectPath', + }, + }); + }); + + it('stops updating when the request fails', () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); + findStatusDropdownItem().vm.$emit('click'); + expect(findStatusLoadingIcon().exists()).toBe(false); + expect(wrapper.find('[data-testid="status"]').text()).toBe('Triggered'); + }); + }); + + describe('Snowplow tracking', () => { + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alert: mockAlert }, + loading: false, + }); + }); + + it('should track alert status updates', () => { + Tracking.event.mockClear(); + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({}); + findStatusDropdownItem().vm.$emit('click'); + const status = findStatusDropdownItem().text(); + setImmediate(() => { + const { category, action, label } = trackAlertStatusUpdateOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action, { + label, + property: status, + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/alert_management/components/alert_management_system_note_spec.js b/spec/frontend/alert_management/components/system_notes/alert_management_system_note_spec.js index 87dc36cc7cb..8dd663e55d9 100644 --- a/spec/frontend/alert_management/components/alert_management_system_note_spec.js +++ b/spec/frontend/alert_management/components/system_notes/alert_management_system_note_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import SystemNote from '~/alert_management/components/system_notes/system_note.vue'; -import mockAlerts from '../mocks/alerts.json'; +import mockAlerts from '../../mocks/alerts.json'; const mockAlert = mockAlerts[1]; @@ -28,7 +28,11 @@ describe('Alert Details System Note', () => { }); it('renders the correct system note', () => { - expect(wrapper.find('.note-wrapper').attributes('id')).toBe('note_1628'); + const noteId = wrapper.find('.note-wrapper').attributes('id'); + const iconRoute = wrapper.find('use').attributes('href'); + + expect(noteId).toBe('note_1628'); + expect(iconRoute.includes('user')).toBe(true); }); }); }); diff --git a/spec/frontend/alert_management/mocks/alerts.json b/spec/frontend/alert_management/mocks/alerts.json index 312d1756790..f63019d1e5c 100644 --- a/spec/frontend/alert_management/mocks/alerts.json +++ b/spec/frontend/alert_management/mocks/alerts.json @@ -20,6 +20,7 @@ "endedAt": "2020-04-17T23:18:14.996Z", "status": "ACKNOWLEDGED", "assignees": { "nodes": [{ "username": "root" }] }, + "issueIid": "1", "notes": { "nodes": [ { @@ -32,7 +33,8 @@ "name": "Administrator", "username": "root", "webUrl": "http://192.168.1.4:3000/root" - } + }, + "systemNoteIconName": "user" } ] } diff --git a/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap b/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap new file mode 100644 index 00000000000..1f5c3a80fbb --- /dev/null +++ b/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap @@ -0,0 +1,48 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AlertsSettingsForm with default values renders the initial template 1`] = ` +"<div> + <!----> + <div data-testid=\\"alert-settings-description\\" class=\\"gl-mt-5\\"> + <p> + <gl-sprintf-stub message=\\"You must provide this URL and authorization key to authorize an external service to send alerts to GitLab. You can provide this URL and key to multiple services. After configuring an external service, alerts from your service will display on the GitLab %{linkStart}Alerts%{linkEnd} page.\\"></gl-sprintf-stub> + </p> + <p> + <gl-sprintf-stub message=\\"Review your external service's documentation to learn where to provide this information to your external service, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.\\"></gl-sprintf-stub> + </p> + </div> + <gl-form-stub> + <gl-form-group-stub label=\\"Integrations\\" label-for=\\"integrations\\" label-class=\\"label-bold\\"> + <gl-form-select-stub options=\\"[object Object],[object Object],[object Object]\\" data-testid=\\"alert-settings-select\\" value=\\"generic\\"></gl-form-select-stub> <span class=\\"gl-text-gray-400\\"><gl-sprintf-stub message=\\"Learn more about our %{linkStart}upcoming integrations%{linkEnd}\\"></gl-sprintf-stub></span> + </gl-form-group-stub> + <gl-form-group-stub label=\\"Active\\" label-for=\\"activated\\" label-class=\\"label-bold\\"> + <toggle-button-stub id=\\"activated\\"></toggle-button-stub> + </gl-form-group-stub> + <!----> + <gl-form-group-stub label=\\"Webhook URL\\" label-for=\\"url\\" label-class=\\"label-bold\\"> + <gl-form-input-group-stub value=\\"/alerts/notify.json\\" predefinedoptions=\\"[object Object]\\" id=\\"url\\" readonly=\\"\\"></gl-form-input-group-stub> <span class=\\"gl-text-gray-400\\"> + + </span> + </gl-form-group-stub> + <gl-form-group-stub label=\\"Authorization key\\" label-for=\\"authorization-key\\" label-class=\\"label-bold\\"> + <gl-form-input-group-stub value=\\"abcedfg123\\" predefinedoptions=\\"[object Object]\\" id=\\"authorization-key\\" readonly=\\"\\" class=\\"gl-mb-2\\"></gl-form-input-group-stub> + <gl-button-stub category=\\"tertiary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\" class=\\"gl-mt-3\\" role=\\"button\\" tabindex=\\"0\\">Reset key</gl-button-stub> + <gl-modal-stub modalid=\\"authKeyModal\\" titletag=\\"h4\\" modalclass=\\"\\" size=\\"md\\" title=\\"Reset key\\" ok-title=\\"Reset key\\" ok-variant=\\"danger\\"> + Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in. + </gl-modal-stub> + </gl-form-group-stub> + <gl-form-group-stub label=\\"Alert test payload\\" label-for=\\"alert-json\\" label-class=\\"label-bold\\"> + <gl-form-textarea-stub noresize=\\"true\\" id=\\"alert-json\\" disabled=\\"true\\" state=\\"true\\" placeholder=\\"Enter test alert JSON....\\" rows=\\"6\\" max-rows=\\"10\\"></gl-form-textarea-stub> + </gl-form-group-stub> + <gl-button-stub category=\\"tertiary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\">Test alert payload</gl-button-stub> + <div class=\\"footer-block row-content-block gl-display-flex gl-justify-content-space-between\\"> + <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\"> + Save changes + </gl-button-stub> + <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\"> + Cancel + </gl-button-stub> + </div> + </gl-form-stub> +</div>" +`; diff --git a/spec/frontend/alert_settings/alert_settings_form_spec.js b/spec/frontend/alert_settings/alert_settings_form_spec.js new file mode 100644 index 00000000000..5a04d768645 --- /dev/null +++ b/spec/frontend/alert_settings/alert_settings_form_spec.js @@ -0,0 +1,233 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { shallowMount } from '@vue/test-utils'; +import { GlModal, GlAlert } from '@gitlab/ui'; +import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue'; +import ToggleButton from '~/vue_shared/components/toggle_button.vue'; + +const PROMETHEUS_URL = '/prometheus/alerts/notify.json'; +const GENERIC_URL = '/alerts/notify.json'; +const KEY = 'abcedfg123'; +const INVALID_URL = 'http://invalid'; +const ACTIVATED = false; + +const defaultProps = { + generic: { + initialAuthorizationKey: KEY, + formPath: INVALID_URL, + url: GENERIC_URL, + alertsSetupUrl: INVALID_URL, + alertsUsageUrl: INVALID_URL, + activated: ACTIVATED, + }, + prometheus: { + prometheusAuthorizationKey: KEY, + prometheusFormPath: INVALID_URL, + prometheusUrl: PROMETHEUS_URL, + activated: ACTIVATED, + }, + opsgenie: { + opsgenieMvcIsAvailable: true, + formPath: INVALID_URL, + activated: ACTIVATED, + opsgenieMvcTargetUrl: GENERIC_URL, + }, +}; + +describe('AlertsSettingsForm', () => { + let wrapper; + let mockAxios; + + const createComponent = (props = defaultProps, { methods } = {}, data) => { + wrapper = shallowMount(AlertsSettingsForm, { + data() { + return { ...data }; + }, + propsData: { + ...defaultProps, + ...props, + }, + methods, + }); + }; + + const findSelect = () => wrapper.find('[data-testid="alert-settings-select"]'); + const findJsonInput = () => wrapper.find('#alert-json'); + const findUrl = () => wrapper.find('#url'); + const findAuthorizationKey = () => wrapper.find('#authorization-key'); + const findApiUrl = () => wrapper.find('#api-url'); + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + setFixtures(` + <div> + <span class="js-service-active-status fa fa-circle" data-value="true"></span> + <span class="js-service-active-status fa fa-power-off" data-value="false"></span> + </div>`); + }); + + afterEach(() => { + wrapper.destroy(); + mockAxios.restore(); + }); + + describe('with default values', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the initial template', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + + describe('reset key', () => { + it('triggers resetKey method', () => { + const resetGenericKey = jest.fn(); + const methods = { resetGenericKey }; + createComponent(defaultProps, { methods }); + + wrapper.find(GlModal).vm.$emit('ok'); + + expect(resetGenericKey).toHaveBeenCalled(); + }); + + it('updates the authorization key on success', () => { + const formPath = 'some/path'; + mockAxios.onPut(formPath, { service: { token: '' } }).replyOnce(200, { token: 'newToken' }); + createComponent({ generic: { ...defaultProps.generic, formPath } }); + + return wrapper.vm.resetGenericKey().then(() => { + expect(findAuthorizationKey().attributes('value')).toBe('newToken'); + }); + }); + + it('shows a alert message on error', () => { + const formPath = 'some/path'; + mockAxios.onPut(formPath).replyOnce(404); + + createComponent({ generic: { ...defaultProps.generic, formPath } }); + + return wrapper.vm.resetGenericKey().then(() => { + expect(wrapper.find(GlAlert).exists()).toBe(true); + }); + }); + }); + + describe('activate toggle', () => { + it('triggers toggleActivated method', () => { + const toggleService = jest.fn(); + const methods = { toggleService }; + createComponent(defaultProps, { methods }); + + wrapper.find(ToggleButton).vm.$emit('change', true); + + expect(toggleService).toHaveBeenCalled(); + }); + + describe('error is encountered', () => { + beforeEach(() => { + const formPath = 'some/path'; + mockAxios.onPut(formPath).replyOnce(500); + }); + + it('restores previous value', () => { + createComponent({ generic: { ...defaultProps.generic, initialActivated: false } }); + return wrapper.vm.resetGenericKey().then(() => { + expect(wrapper.find(ToggleButton).props('value')).toBe(false); + }); + }); + }); + }); + + describe('prometheus is active', () => { + beforeEach(() => { + createComponent( + { prometheus: { ...defaultProps.prometheus, prometheusIsActivated: true } }, + {}, + { + selectedEndpoint: 'prometheus', + }, + ); + }); + + it('renders a valid "select"', () => { + expect(findSelect().exists()).toBe(true); + }); + + it('shows the API URL input', () => { + expect(findApiUrl().exists()).toBe(true); + }); + + it('shows the correct default API URL', () => { + expect(findUrl().attributes('value')).toBe(PROMETHEUS_URL); + }); + }); + + describe('opsgenie is active', () => { + beforeEach(() => { + createComponent( + { opsgenie: { ...defaultProps.opsgenie, opsgenieMvcActivated: true } }, + {}, + { + selectedEndpoint: 'opsgenie', + }, + ); + }); + + it('shows a input for the opsgenie target URL', () => { + expect(findApiUrl().exists()).toBe(true); + expect(findSelect().attributes('value')).toBe('opsgenie'); + }); + }); + + describe('trigger test alert', () => { + beforeEach(() => { + createComponent({ generic: { ...defaultProps.generic, initialActivated: true } }, {}, true); + }); + + it('should enable the JSON input', () => { + expect(findJsonInput().exists()).toBe(true); + expect(findJsonInput().props('value')).toBe(null); + }); + + it('should validate JSON input', () => { + createComponent({ generic: { ...defaultProps.generic } }, true, { + testAlertJson: '{ "value": "test" }', + }); + + findJsonInput().vm.$emit('change'); + return wrapper.vm.$nextTick().then(() => { + expect(findJsonInput().attributes('state')).toBe('true'); + }); + }); + + describe('alert service is toggled', () => { + it('should show a info alert if successful', () => { + const formPath = 'some/path'; + const toggleService = true; + mockAxios.onPut(formPath).replyOnce(200); + + createComponent({ generic: { ...defaultProps.generic, formPath } }); + + return wrapper.vm.toggleActivated(toggleService).then(() => { + expect(wrapper.find(GlAlert).attributes('variant')).toBe('info'); + }); + }); + + it('should show a error alert if failed', () => { + const formPath = 'some/path'; + const toggleService = true; + mockAxios.onPut(formPath).replyOnce(422, { + errors: 'Error message to display', + }); + + createComponent({ generic: { ...defaultProps.generic, formPath } }); + + return wrapper.vm.toggleActivated(toggleService).then(() => { + expect(wrapper.find(GlAlert).attributes('variant')).toBe('danger'); + }); + }); + }); + }); +}); diff --git a/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js b/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js index c7c15c8fd44..610f9d6b9bd 100644 --- a/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js +++ b/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js @@ -15,6 +15,7 @@ const defaultProps = { alertsSetupUrl: 'http://invalid', alertsUsageUrl: 'http://invalid', initialActivated: false, + isDisabled: false, }; describe('AlertsServiceForm', () => { @@ -166,4 +167,17 @@ describe('AlertsServiceForm', () => { }); }); }); + + describe('form is disabled', () => { + beforeEach(() => { + createComponent({ isDisabled: true }); + }); + + it('cannot be toggled', () => { + wrapper.find(ToggleButton).vm.$emit('change'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(ToggleButton).props('disabledInput')).toBe(true); + }); + }); + }); }); diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index c1a23d441b3..c94637e04af 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -46,6 +46,77 @@ describe('Api', () => { }); }); + describe('packages', () => { + const projectId = 'project_a'; + const packageId = 'package_b'; + const apiResponse = [{ id: 1, name: 'foo' }]; + + describe('groupPackages', () => { + const groupId = 'group_a'; + + it('fetch all group packages', () => { + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/packages`; + jest.spyOn(axios, 'get'); + mock.onGet(expectedUrl).replyOnce(200, apiResponse); + + return Api.groupPackages(groupId).then(({ data }) => { + expect(data).toEqual(apiResponse); + expect(axios.get).toHaveBeenCalledWith(expectedUrl, {}); + }); + }); + }); + + describe('projectPackages', () => { + it('fetch all project packages', () => { + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/packages`; + jest.spyOn(axios, 'get'); + mock.onGet(expectedUrl).replyOnce(200, apiResponse); + + return Api.projectPackages(projectId).then(({ data }) => { + expect(data).toEqual(apiResponse); + expect(axios.get).toHaveBeenCalledWith(expectedUrl, {}); + }); + }); + }); + + describe('buildProjectPackageUrl', () => { + it('returns the right url', () => { + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/packages/${packageId}`; + const url = Api.buildProjectPackageUrl(projectId, packageId); + expect(url).toEqual(expectedUrl); + }); + }); + + describe('projectPackage', () => { + it('fetch package details', () => { + const expectedUrl = `foo`; + jest.spyOn(Api, 'buildProjectPackageUrl').mockReturnValue(expectedUrl); + jest.spyOn(axios, 'get'); + mock.onGet(expectedUrl).replyOnce(200, apiResponse); + + return Api.projectPackage(projectId, packageId).then(({ data }) => { + expect(data).toEqual(apiResponse); + expect(axios.get).toHaveBeenCalledWith(expectedUrl); + }); + }); + }); + + describe('deleteProjectPackage', () => { + it('delete a package', () => { + const expectedUrl = `foo`; + + jest.spyOn(Api, 'buildProjectPackageUrl').mockReturnValue(expectedUrl); + jest.spyOn(axios, 'delete'); + mock.onDelete(expectedUrl).replyOnce(200, true); + + return Api.deleteProjectPackage(projectId, packageId).then(({ data }) => { + expect(data).toEqual(true); + expect(axios.delete).toHaveBeenCalledWith(expectedUrl); + }); + }); + }); + }); + describe('group', () => { it('fetches a group', done => { const groupId = '123456'; @@ -366,6 +437,30 @@ describe('Api', () => { }); }); + describe('commit', () => { + const projectId = 'user/project'; + const sha = 'abcd0123'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${encodeURIComponent( + projectId, + )}/repository/commits/${sha}`; + + it('fetches a single commit', () => { + mock.onGet(expectedUrl).reply(200, { id: sha }); + + return Api.commit(projectId, sha).then(({ data: commit }) => { + expect(commit.id).toBe(sha); + }); + }); + + it('fetches a single commit without stats', () => { + mock.onGet(expectedUrl, { params: { stats: false } }).reply(200, { id: sha }); + + return Api.commit(projectId, sha, { stats: false }).then(({ data: commit }) => { + expect(commit.id).toBe(sha); + }); + }); + }); + describe('issueTemplate', () => { it('fetches an issue template', done => { const namespace = 'some namespace'; diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js index 754f0702b84..6cfbc6024af 100644 --- a/spec/frontend/awards_handler_spec.js +++ b/spec/frontend/awards_handler_spec.js @@ -1,63 +1,61 @@ import $ from 'jquery'; import Cookies from 'js-cookie'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; import loadAwardsHandler from '~/awards_handler'; -import '~/lib/utils/common_utils'; -import waitForPromises from './helpers/wait_for_promises'; +import { setTestTimeout } from './helpers/timeout'; +import { EMOJI_VERSION } from '~/emoji'; +import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; window.gl = window.gl || {}; window.gon = window.gon || {}; -let openAndWaitForEmojiMenu; +let mock; let awardsHandler = null; const urlRoot = gon.relative_url_root; -const lazyAssert = (done, assertFn) => { - jest.runOnlyPendingTimers(); - waitForPromises() - .then(() => { - assertFn(); - done(); - }) - .catch(e => { - throw e; - }); -}; - describe('AwardsHandler', () => { + useFakeRequestAnimationFrame(); + + const emojiData = getJSONFixture('emojis/emojis.json'); preloadFixtures('snippets/show.html'); - beforeEach(done => { - loadFixtures('snippets/show.html'); - loadAwardsHandler(true) - .then(obj => { - awardsHandler = obj; - jest.spyOn(awardsHandler, 'postEmoji').mockImplementation((button, url, emoji, cb) => cb()); - done(); - }) - .catch(done.fail); - - let isEmojiMenuBuilt = false; - openAndWaitForEmojiMenu = () => { - return new Promise(resolve => { - if (isEmojiMenuBuilt) { - resolve(); - } else { - $('.js-add-award') - .eq(0) - .click(); - const $menu = $('.emoji-menu'); - $menu.one('build-emoji-menu-finish', () => { - isEmojiMenuBuilt = true; - resolve(); - }); - } + + const openAndWaitForEmojiMenu = (sel = '.js-add-award') => { + $(sel) + .eq(0) + .click(); + + jest.advanceTimersByTime(200); + + const $menu = $('.emoji-menu'); + + return new Promise(resolve => { + $menu.one('build-emoji-menu-finish', () => { + resolve(); }); - }; + }); + }; + + beforeEach(async () => { + // These tests have had some timeout issues + // https://gitlab.com/gitlab-org/gitlab/-/issues/221086 + setTestTimeout(6000); + + mock = new MockAdapter(axios); + mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, emojiData); + + loadFixtures('snippets/show.html'); + + awardsHandler = await loadAwardsHandler(true); + jest.spyOn(awardsHandler, 'postEmoji').mockImplementation((button, url, emoji, cb) => cb()); }); afterEach(() => { // restore original url root value gon.relative_url_root = urlRoot; + mock.restore(); + // Undo what we did to the shared <body> $('body').removeAttr('data-page'); @@ -65,55 +63,45 @@ describe('AwardsHandler', () => { }); describe('::showEmojiMenu', () => { - it('should show emoji menu when Add emoji button clicked', done => { - $('.js-add-award') - .eq(0) - .click(); - lazyAssert(done, () => { - const $emojiMenu = $('.emoji-menu'); + it('should show emoji menu when Add emoji button clicked', async () => { + await openAndWaitForEmojiMenu(); - expect($emojiMenu.length).toBe(1); - expect($emojiMenu.hasClass('is-visible')).toBe(true); - expect($emojiMenu.find('.js-emoji-menu-search').length).toBe(1); - expect($('.js-awards-block.current').length).toBe(1); - }); + const $emojiMenu = $('.emoji-menu'); + + expect($emojiMenu.length).toBe(1); + expect($emojiMenu.hasClass('is-visible')).toBe(true); + expect($emojiMenu.find('.js-emoji-menu-search').length).toBe(1); + expect($('.js-awards-block.current').length).toBe(1); }); - it('should also show emoji menu for the smiley icon in notes', done => { - $('.js-add-award.note-action-button').click(); - lazyAssert(done, () => { - const $emojiMenu = $('.emoji-menu'); + it('should also show emoji menu for the smiley icon in notes', async () => { + await openAndWaitForEmojiMenu('.js-add-award.note-action-button'); - expect($emojiMenu.length).toBe(1); - }); + const $emojiMenu = $('.emoji-menu'); + + expect($emojiMenu.length).toBe(1); }); - it('should remove emoji menu when body is clicked', done => { - $('.js-add-award') - .eq(0) - .click(); - lazyAssert(done, () => { - const $emojiMenu = $('.emoji-menu'); - $('body').click(); + it('should remove emoji menu when body is clicked', async () => { + await openAndWaitForEmojiMenu(); - expect($emojiMenu.length).toBe(1); - expect($emojiMenu.hasClass('is-visible')).toBe(false); - expect($('.js-awards-block.current').length).toBe(0); - }); + const $emojiMenu = $('.emoji-menu'); + $('body').click(); + + expect($emojiMenu.length).toBe(1); + expect($emojiMenu.hasClass('is-visible')).toBe(false); + expect($('.js-awards-block.current').length).toBe(0); }); - it('should not remove emoji menu when search is clicked', done => { - $('.js-add-award') - .eq(0) - .click(); - lazyAssert(done, () => { - const $emojiMenu = $('.emoji-menu'); - $('.emoji-search').click(); + it('should not remove emoji menu when search is clicked', async () => { + await openAndWaitForEmojiMenu(); - expect($emojiMenu.length).toBe(1); - expect($emojiMenu.hasClass('is-visible')).toBe(true); - expect($('.js-awards-block.current').length).toBe(1); - }); + const $emojiMenu = $('.emoji-menu'); + $('.emoji-search').click(); + + expect($emojiMenu.length).toBe(1); + expect($emojiMenu.hasClass('is-visible')).toBe(true); + expect($('.js-awards-block.current').length).toBe(1); }); }); @@ -261,48 +249,39 @@ describe('AwardsHandler', () => { }); describe('::searchEmojis', () => { - it('should filter the emoji', done => { - openAndWaitForEmojiMenu() - .then(() => { - expect($('[data-name=angel]').is(':visible')).toBe(true); - expect($('[data-name=anger]').is(':visible')).toBe(true); - awardsHandler.searchEmojis('ali'); - - expect($('[data-name=angel]').is(':visible')).toBe(false); - expect($('[data-name=anger]').is(':visible')).toBe(false); - expect($('[data-name=alien]').is(':visible')).toBe(true); - expect($('.js-emoji-menu-search').val()).toBe('ali'); - }) - .then(done) - .catch(err => { - done.fail(`Failed to open and build emoji menu: ${err.message}`); - }); + it('should filter the emoji', async () => { + await openAndWaitForEmojiMenu(); + + expect($('[data-name=angel]').is(':visible')).toBe(true); + expect($('[data-name=anger]').is(':visible')).toBe(true); + awardsHandler.searchEmojis('ali'); + + expect($('[data-name=angel]').is(':visible')).toBe(false); + expect($('[data-name=anger]').is(':visible')).toBe(false); + expect($('[data-name=alien]').is(':visible')).toBe(true); + expect($('.js-emoji-menu-search').val()).toBe('ali'); }); - it('should clear the search when searching for nothing', done => { - openAndWaitForEmojiMenu() - .then(() => { - awardsHandler.searchEmojis('ali'); - - expect($('[data-name=angel]').is(':visible')).toBe(false); - expect($('[data-name=anger]').is(':visible')).toBe(false); - expect($('[data-name=alien]').is(':visible')).toBe(true); - awardsHandler.searchEmojis(''); - - expect($('[data-name=angel]').is(':visible')).toBe(true); - expect($('[data-name=anger]').is(':visible')).toBe(true); - expect($('[data-name=alien]').is(':visible')).toBe(true); - expect($('.js-emoji-menu-search').val()).toBe(''); - }) - .then(done) - .catch(err => { - done.fail(`Failed to open and build emoji menu: ${err.message}`); - }); + it('should clear the search when searching for nothing', async () => { + await openAndWaitForEmojiMenu(); + + awardsHandler.searchEmojis('ali'); + + expect($('[data-name=angel]').is(':visible')).toBe(false); + expect($('[data-name=anger]').is(':visible')).toBe(false); + expect($('[data-name=alien]').is(':visible')).toBe(true); + awardsHandler.searchEmojis(''); + + expect($('[data-name=angel]').is(':visible')).toBe(true); + expect($('[data-name=anger]').is(':visible')).toBe(true); + expect($('[data-name=alien]').is(':visible')).toBe(true); + expect($('.js-emoji-menu-search').val()).toBe(''); }); }); describe('emoji menu', () => { const emojiSelector = '[data-name="sunglasses"]'; + const openEmojiMenuAndAddEmoji = () => { return openAndWaitForEmojiMenu().then(() => { const $menu = $('.emoji-menu'); @@ -318,32 +297,23 @@ describe('AwardsHandler', () => { }); }; - it('should add selected emoji to awards block', done => { - openEmojiMenuAndAddEmoji() - .then(done) - .catch(err => { - done.fail(`Failed to open and build emoji menu: ${err.message}`); - }); + it('should add selected emoji to awards block', async () => { + await openEmojiMenuAndAddEmoji(); }); - it('should remove already selected emoji', done => { - openEmojiMenuAndAddEmoji() - .then(() => { - $('.js-add-award') - .eq(0) - .click(); - const $block = $('.js-awards-block'); - const $emoji = $('.emoji-menu').find( - `.emoji-menu-list:not(.frequent-emojis) ${emojiSelector}`, - ); - $emoji.click(); - - expect($block.find(emojiSelector).length).toBe(0); - }) - .then(done) - .catch(err => { - done.fail(`Failed to open and build emoji menu: ${err.message}`); - }); + it('should remove already selected emoji', async () => { + await openEmojiMenuAndAddEmoji(); + + $('.js-add-award') + .eq(0) + .click(); + const $block = $('.js-awards-block'); + const $emoji = $('.emoji-menu').find( + `.emoji-menu-list:not(.frequent-emojis) ${emojiSelector}`, + ); + $emoji.click(); + + expect($block.find(emojiSelector).length).toBe(0); }); }); @@ -353,37 +323,27 @@ describe('AwardsHandler', () => { Cookies.set('frequently_used_emojis', ''); }); - it('shouldn\'t have any "Frequently used" heading if no frequently used emojis', done => { - return openAndWaitForEmojiMenu() - .then(() => { - const emojiMenu = document.querySelector('.emoji-menu'); - Array.prototype.forEach.call(emojiMenu.querySelectorAll('.emoji-menu-title'), title => { - expect(title.textContent.trim().toLowerCase()).not.toBe('frequently used'); - }); - }) - .then(done) - .catch(err => { - done.fail(`Failed to open and build emoji menu: ${err.message}`); - }); + it('shouldn\'t have any "Frequently used" heading if no frequently used emojis', async () => { + await openAndWaitForEmojiMenu(); + + const emojiMenu = document.querySelector('.emoji-menu'); + Array.prototype.forEach.call(emojiMenu.querySelectorAll('.emoji-menu-title'), title => { + expect(title.textContent.trim().toLowerCase()).not.toBe('frequently used'); + }); }); - it('should have any frequently used section when there are frequently used emojis', done => { + it('should have any frequently used section when there are frequently used emojis', async () => { awardsHandler.addEmojiToFrequentlyUsedList('8ball'); - return openAndWaitForEmojiMenu() - .then(() => { - const emojiMenu = document.querySelector('.emoji-menu'); - const hasFrequentlyUsedHeading = Array.prototype.some.call( - emojiMenu.querySelectorAll('.emoji-menu-title'), - title => title.textContent.trim().toLowerCase() === 'frequently used', - ); - - expect(hasFrequentlyUsedHeading).toBe(true); - }) - .then(done) - .catch(err => { - done.fail(`Failed to open and build emoji menu: ${err.message}`); - }); + await openAndWaitForEmojiMenu(); + + const emojiMenu = document.querySelector('.emoji-menu'); + const hasFrequentlyUsedHeading = Array.prototype.some.call( + emojiMenu.querySelectorAll('.emoji-menu-title'), + title => title.textContent.trim().toLowerCase() === 'frequently used', + ); + + expect(hasFrequentlyUsedHeading).toBe(true); }); it('should disregard invalid frequently used emoji that are being attempted to be added', () => { diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js index 2ec114d026a..4bac6d4e3dc 100644 --- a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js +++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js @@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import * as actions from '~/batch_comments/stores/modules/batch_comments/actions'; import axios from '~/lib/utils/axios_utils'; +import { TEST_HOST } from 'jest/helpers/test_constants'; describe('Batch comments store actions', () => { let res = {}; @@ -33,7 +34,7 @@ describe('Batch comments store actions', () => { testAction( actions.addDraftToDiscussion, - { endpoint: gl.TEST_HOST, data: 'test' }, + { endpoint: TEST_HOST, data: 'test' }, null, [{ type: 'ADD_NEW_DRAFT', payload: res }], [], @@ -46,7 +47,7 @@ describe('Batch comments store actions', () => { testAction( actions.addDraftToDiscussion, - { endpoint: gl.TEST_HOST, data: 'test' }, + { endpoint: TEST_HOST, data: 'test' }, null, [], [], @@ -62,7 +63,7 @@ describe('Batch comments store actions', () => { testAction( actions.createNewDraft, - { endpoint: gl.TEST_HOST, data: 'test' }, + { endpoint: TEST_HOST, data: 'test' }, null, [{ type: 'ADD_NEW_DRAFT', payload: res }], [], @@ -73,14 +74,7 @@ describe('Batch comments store actions', () => { it('does not commit ADD_NEW_DRAFT if errors returned', done => { mock.onAny().reply(500); - testAction( - actions.createNewDraft, - { endpoint: gl.TEST_HOST, data: 'test' }, - null, - [], - [], - done, - ); + testAction(actions.createNewDraft, { endpoint: TEST_HOST, data: 'test' }, null, [], [], done); }); }); @@ -90,7 +84,7 @@ describe('Batch comments store actions', () => { beforeEach(() => { getters = { getNotesData: { - draftsDiscardPath: gl.TEST_HOST, + draftsDiscardPath: TEST_HOST, }, }; }); @@ -137,7 +131,7 @@ describe('Batch comments store actions', () => { beforeEach(() => { getters = { getNotesData: { - draftsPath: gl.TEST_HOST, + draftsPath: TEST_HOST, }, }; }); @@ -171,7 +165,7 @@ describe('Batch comments store actions', () => { dispatch = jest.fn(); commit = jest.fn(); getters = { - getNotesData: { draftsPublishPath: gl.TEST_HOST, discussionsPath: gl.TEST_HOST }, + getNotesData: { draftsPublishPath: TEST_HOST, discussionsPath: TEST_HOST }, }; rootGetters = { discussionsStructuredByLineCode: 'discussions' }; }); @@ -208,7 +202,7 @@ describe('Batch comments store actions', () => { describe('discardReview', () => { it('commits mutations', done => { const getters = { - getNotesData: { draftsDiscardPath: gl.TEST_HOST }, + getNotesData: { draftsDiscardPath: TEST_HOST }, }; const commit = jest.fn(); mock.onAny().reply(200); @@ -225,7 +219,7 @@ describe('Batch comments store actions', () => { it('commits error mutations', done => { const getters = { - getNotesData: { draftsDiscardPath: gl.TEST_HOST }, + getNotesData: { draftsDiscardPath: TEST_HOST }, }; const commit = jest.fn(); mock.onAny().reply(500); @@ -247,7 +241,7 @@ describe('Batch comments store actions', () => { beforeEach(() => { getters = { getNotesData: { - draftsPath: gl.TEST_HOST, + draftsPath: TEST_HOST, }, }; }); diff --git a/spec/frontend/behaviors/copy_as_gfm_spec.js b/spec/frontend/behaviors/copy_as_gfm_spec.js index cf96ac488a8..33af9bc135e 100644 --- a/spec/frontend/behaviors/copy_as_gfm_spec.js +++ b/spec/frontend/behaviors/copy_as_gfm_spec.js @@ -1,3 +1,4 @@ +import * as commonUtils from '~/lib/utils/common_utils'; import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; describe('CopyAsGFM', () => { @@ -27,7 +28,7 @@ describe('CopyAsGFM', () => { } it('wraps pasted code when not already in code tags', () => { - jest.spyOn(window.gl.utils, 'insertText').mockImplementation((el, textFunc) => { + jest.spyOn(commonUtils, 'insertText').mockImplementation((el, textFunc) => { const insertedText = textFunc('This is code: ', ''); expect(insertedText).toEqual('`code`'); @@ -37,7 +38,7 @@ describe('CopyAsGFM', () => { }); it('does not wrap pasted code when already in code tags', () => { - jest.spyOn(window.gl.utils, 'insertText').mockImplementation((el, textFunc) => { + jest.spyOn(commonUtils, 'insertText').mockImplementation((el, textFunc) => { const insertedText = textFunc('This is code: `', '`'); expect(insertedText).toEqual('code'); diff --git a/spec/frontend/behaviors/gl_emoji_spec.js b/spec/frontend/behaviors/gl_emoji_spec.js new file mode 100644 index 00000000000..7ea0bafc328 --- /dev/null +++ b/spec/frontend/behaviors/gl_emoji_spec.js @@ -0,0 +1,110 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import { initEmojiMap, EMOJI_VERSION } from '~/emoji'; +import installGlEmojiElement from '~/behaviors/gl_emoji'; + +import * as EmojiUnicodeSupport from '~/emoji/support'; +import waitForPromises from 'jest/helpers/wait_for_promises'; + +jest.mock('~/emoji/support'); + +describe('gl_emoji', () => { + let mock; + const emojiData = getJSONFixture('emojis/emojis.json'); + + beforeAll(() => { + jest.spyOn(EmojiUnicodeSupport, 'default').mockReturnValue(true); + installGlEmojiElement(); + }); + + function markupToDomElement(markup) { + const div = document.createElement('div'); + div.innerHTML = markup; + document.body.appendChild(div); + + return div.firstElementChild; + } + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, emojiData); + + return initEmojiMap().catch(() => {}); + }); + + afterEach(() => { + mock.restore(); + + document.body.innerHTML = ''; + }); + + describe.each([ + [ + 'bomb emoji just with name attribute', + '<gl-emoji data-name="bomb"></gl-emoji>', + '<gl-emoji data-name="bomb" data-unicode-version="6.0" title="bomb">💣</gl-emoji>', + '<gl-emoji data-name="bomb" data-unicode-version="6.0" title="bomb"><img class="emoji" title=":bomb:" alt=":bomb:" src="/-/emojis/1/bomb.png" width="20" height="20" align="absmiddle"></gl-emoji>', + ], + [ + 'bomb emoji with name attribute and unicode version', + '<gl-emoji data-name="bomb" data-unicode-version="6.0">💣</gl-emoji>', + '<gl-emoji data-name="bomb" data-unicode-version="6.0">💣</gl-emoji>', + '<gl-emoji data-name="bomb" data-unicode-version="6.0"><img class="emoji" title=":bomb:" alt=":bomb:" src="/-/emojis/1/bomb.png" width="20" height="20" align="absmiddle"></gl-emoji>', + ], + [ + 'bomb emoji with sprite fallback', + '<gl-emoji data-fallback-sprite-class="emoji-bomb" data-name="bomb"></gl-emoji>', + '<gl-emoji data-fallback-sprite-class="emoji-bomb" data-name="bomb" data-unicode-version="6.0" title="bomb">💣</gl-emoji>', + '<gl-emoji data-fallback-sprite-class="emoji-bomb" data-name="bomb" data-unicode-version="6.0" title="bomb" class="emoji-icon emoji-bomb">💣</gl-emoji>', + ], + [ + 'bomb emoji with image fallback', + '<gl-emoji data-fallback-src="/bomb.png" data-name="bomb"></gl-emoji>', + '<gl-emoji data-fallback-src="/bomb.png" data-name="bomb" data-unicode-version="6.0" title="bomb">💣</gl-emoji>', + '<gl-emoji data-fallback-src="/bomb.png" data-name="bomb" data-unicode-version="6.0" title="bomb"><img class="emoji" title=":bomb:" alt=":bomb:" src="/bomb.png" width="20" height="20" align="absmiddle"></gl-emoji>', + ], + [ + 'invalid emoji', + '<gl-emoji data-name="invalid_emoji"></gl-emoji>', + '<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament">❔</gl-emoji>', + '<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament"><img class="emoji" title=":grey_question:" alt=":grey_question:" src="/-/emojis/1/grey_question.png" width="20" height="20" align="absmiddle"></gl-emoji>', + ], + ])('%s', (name, markup, withEmojiSupport, withoutEmojiSupport) => { + it(`renders correctly with emoji support`, async () => { + jest.spyOn(EmojiUnicodeSupport, 'default').mockReturnValue(true); + const glEmojiElement = markupToDomElement(markup); + + await waitForPromises(); + + expect(glEmojiElement.outerHTML).toBe(withEmojiSupport); + }); + + it(`renders correctly without emoji support`, async () => { + jest.spyOn(EmojiUnicodeSupport, 'default').mockReturnValue(false); + const glEmojiElement = markupToDomElement(markup); + + await waitForPromises(); + + expect(glEmojiElement.outerHTML).toBe(withoutEmojiSupport); + }); + }); + + it('Adds sprite CSS if emojis are not supported', async () => { + const testPath = '/test-path.css'; + jest.spyOn(EmojiUnicodeSupport, 'default').mockReturnValue(false); + window.gon.emoji_sprites_css_path = testPath; + + expect(document.head.querySelector(`link[href="${testPath}"]`)).toBe(null); + expect(window.gon.emoji_sprites_css_added).toBeFalsy(); + + markupToDomElement( + '<gl-emoji data-fallback-sprite-class="emoji-bomb" data-name="bomb"></gl-emoji>', + ); + await waitForPromises(); + + expect(document.head.querySelector(`link[href="${testPath}"]`).outerHTML).toBe( + '<link rel="stylesheet" href="/test-path.css">', + ); + expect(window.gon.emoji_sprites_css_added).toBe(true); + }); +}); diff --git a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js index 6391a544985..baedbf5771a 100644 --- a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js +++ b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js @@ -46,7 +46,7 @@ describe('ShortcutsIssuable', () => { }); describe('replyWithSelectedText', () => { - // Stub window.gl.utils.getSelectedFragment to return a node with the provided HTML. + // Stub getSelectedFragment to return a node with the provided HTML. const stubSelection = (html, invalidNode) => { getSelectedFragment.mockImplementation(() => { const documentFragment = document.createDocumentFragment(); diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap index 005b2c5da1c..0f5b3cd3f5e 100644 --- a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap +++ b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap @@ -8,6 +8,7 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = ` <file-icon-stub aria-hidden="true" cssclasses="mr-2" + filemode="" filename="foo/bar/dummy.md" size="18" /> diff --git a/spec/frontend/blob/components/blob_content_error_spec.js b/spec/frontend/blob/components/blob_content_error_spec.js index 6eb5cfb71aa..508b1ed7e68 100644 --- a/spec/frontend/blob/components/blob_content_error_spec.js +++ b/spec/frontend/blob/components/blob_content_error_spec.js @@ -24,9 +24,9 @@ describe('Blob Content Error component', () => { describe('collapsed and too large blobs', () => { it.each` - error | reason | options - ${BLOB_RENDER_ERRORS.REASONS.COLLAPSED} | ${'it is larger than 1.00 MiB'} | ${[BLOB_RENDER_ERRORS.OPTIONS.LOAD.text, BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]} - ${BLOB_RENDER_ERRORS.REASONS.TOO_LARGE} | ${'it is larger than 100.00 MiB'} | ${[BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]} + error | reason | options + ${BLOB_RENDER_ERRORS.REASONS.COLLAPSED} | ${'it is larger than 1.00 MiB'} | ${[BLOB_RENDER_ERRORS.OPTIONS.LOAD.text, BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]} + ${BLOB_RENDER_ERRORS.REASONS.TOO_LARGE} | ${'it is larger than 10.00 MiB'} | ${[BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]} `('renders correct reason for $error.id', ({ error, reason, options }) => { createComponent({ viewerError: error.id, diff --git a/spec/frontend/blob/components/blob_header_default_actions_spec.js b/spec/frontend/blob/components/blob_header_default_actions_spec.js index 0247a12d8d3..529e7cc85f5 100644 --- a/spec/frontend/blob/components/blob_header_default_actions_spec.js +++ b/spec/frontend/blob/components/blob_header_default_actions_spec.js @@ -13,7 +13,6 @@ describe('Blob Header Default Actions', () => { let wrapper; let btnGroup; let buttons; - const hrefPrefix = 'http://localhost'; function createComponent(propsData = {}) { wrapper = mount(BlobHeaderActions, { @@ -47,11 +46,11 @@ describe('Blob Header Default Actions', () => { }); it('correct href attribute on RAW button', () => { - expect(buttons.at(1).vm.$el.href).toBe(`${hrefPrefix}${Blob.rawPath}`); + expect(buttons.at(1).attributes('href')).toBe(Blob.rawPath); }); it('correct href attribute on Download button', () => { - expect(buttons.at(2).vm.$el.href).toBe(`${hrefPrefix}${Blob.rawPath}?inline=false`); + expect(buttons.at(2).attributes('href')).toBe(`${Blob.rawPath}?inline=false`); }); it('does not render "Copy file contents" button as disables if the viewer is Simple', () => { diff --git a/spec/frontend/blob/components/blob_header_viewer_switcher_spec.js b/spec/frontend/blob/components/blob_header_viewer_switcher_spec.js index 0f34d6419d3..f1a7ac8b21a 100644 --- a/spec/frontend/blob/components/blob_header_viewer_switcher_spec.js +++ b/spec/frontend/blob/components/blob_header_viewer_switcher_spec.js @@ -6,7 +6,7 @@ import { SIMPLE_BLOB_VIEWER, SIMPLE_BLOB_VIEWER_TITLE, } from '~/blob/components/constants'; -import { GlButtonGroup, GlDeprecatedButton } from '@gitlab/ui'; +import { GlButtonGroup, GlButton } from '@gitlab/ui'; describe('Blob Header Viewer Switcher', () => { let wrapper; @@ -35,7 +35,7 @@ describe('Blob Header Viewer Switcher', () => { beforeEach(() => { createComponent(); btnGroup = wrapper.find(GlButtonGroup); - buttons = wrapper.findAll(GlDeprecatedButton); + buttons = wrapper.findAll(GlButton); }); it('renders gl-button-group component', () => { @@ -57,7 +57,7 @@ describe('Blob Header Viewer Switcher', () => { function factory(propsData = {}) { createComponent(propsData); - buttons = wrapper.findAll(GlDeprecatedButton); + buttons = wrapper.findAll(GlButton); simpleBtn = buttons.at(0); richBtn = buttons.at(1); diff --git a/spec/frontend/blob/components/mock_data.js b/spec/frontend/blob/components/mock_data.js index 0f7193846ff..58aa1dc6dc9 100644 --- a/spec/frontend/blob/components/mock_data.js +++ b/spec/frontend/blob/components/mock_data.js @@ -32,6 +32,20 @@ export const Blob = { }, }; +export const BinaryBlob = { + binary: true, + name: 'dummy.png', + path: 'foo/bar/dummy.png', + rawPath: '/flightjs/flight/snippets/51/raw', + size: 75, + simpleViewer: { + ...SimpleViewerMock, + }, + richViewer: { + ...RichViewerMock, + }, +}; + export const RichBlobContentMock = { richData: '<h1>Rich</h1>', }; diff --git a/spec/frontend/blob_edit/edit_blob_spec.js b/spec/frontend/blob_edit/edit_blob_spec.js new file mode 100644 index 00000000000..9642b55b9b4 --- /dev/null +++ b/spec/frontend/blob_edit/edit_blob_spec.js @@ -0,0 +1,31 @@ +import EditBlob from '~/blob_edit/edit_blob'; +import EditorLite from '~/editor/editor_lite'; +import MarkdownExtension from '~/editor/editor_markdown_ext'; + +jest.mock('~/editor/editor_lite'); +jest.mock('~/editor/editor_markdown_ext'); + +describe('Blob Editing', () => { + beforeEach(() => { + setFixtures( + `<div class="js-edit-blob-form"><div id="file_path"></div><div id="iditor"></div><input id="file-content"></div>`, + ); + }); + + const initEditor = (isMarkdown = false) => { + return new EditBlob({ + isMarkdown, + monacoEnabled: true, + }); + }; + + it('does not load MarkdownExtension by default', async () => { + await initEditor(); + expect(EditorLite.prototype.use).not.toHaveBeenCalled(); + }); + + it('loads MarkdownExtension only for the markdown files', async () => { + await initEditor(true); + expect(EditorLite.prototype.use).toHaveBeenCalledWith(MarkdownExtension); + }); +}); diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index ee427bc2154..94f607698d7 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -3,14 +3,15 @@ import { mount } from '@vue/test-utils'; import boardsStore from '~/boards/stores/boards_store'; import boardForm from '~/boards/components/board_form.vue'; import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; +import { TEST_HOST } from 'jest/helpers/test_constants'; describe('board_form.vue', () => { let wrapper; const propsData = { canAdminBoard: false, - labelsPath: `${gl.TEST_HOST}/labels/path`, - milestonePath: `${gl.TEST_HOST}/milestone/path`, + labelsPath: `${TEST_HOST}/labels/path`, + milestonePath: `${TEST_HOST}/milestone/path`, }; const findModal = () => wrapper.find(DeprecatedModal); diff --git a/spec/frontend/boards/issue_card_spec.js b/spec/frontend/boards/issue_card_spec.js index 09b5c664bee..15750a161ae 100644 --- a/spec/frontend/boards/issue_card_spec.js +++ b/spec/frontend/boards/issue_card_spec.js @@ -6,7 +6,7 @@ import '~/boards/models/assignee'; import '~/boards/models/issue'; import '~/boards/models/list'; import IssueCardInner from '~/boards/components/issue_card_inner.vue'; -import { listObj } from '../../javascripts/boards/mock_data'; +import { listObj } from './mock_data'; import store from '~/boards/stores'; import { GlLabel } from '@gitlab/ui'; diff --git a/spec/frontend/ci_variable_list/components/ci_key_field_spec.js b/spec/frontend/ci_variable_list/components/ci_key_field_spec.js deleted file mode 100644 index bcc29f22dd1..00000000000 --- a/spec/frontend/ci_variable_list/components/ci_key_field_spec.js +++ /dev/null @@ -1,244 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { GlButton, GlFormInput } from '@gitlab/ui'; -import { AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION } from '~/ci_variable_list/constants'; -import CiKeyField from '~/ci_variable_list/components/ci_key_field.vue'; - -import { - awsTokens, - awsTokenList, -} from '~/ci_variable_list/components/ci_variable_autocomplete_tokens'; - -const doTimes = (num, fn) => { - for (let i = 0; i < num; i += 1) { - fn(); - } -}; - -describe('Ci Key field', () => { - let wrapper; - - const createComponent = () => { - wrapper = mount({ - data() { - return { - inputVal: '', - tokens: awsTokenList, - }; - }, - components: { CiKeyField }, - template: ` - <div> - <ci-key-field - v-model="inputVal" - :token-list="tokens" - /> - </div> - `, - }); - }; - - const findDropdown = () => wrapper.find('#ci-variable-dropdown'); - const findDropdownOptions = () => wrapper.findAll(GlButton).wrappers.map(item => item.text()); - const findInput = () => wrapper.find(GlFormInput); - const findInputValue = () => findInput().element.value; - const setInput = val => findInput().setValue(val); - const clickDown = () => findInput().trigger('keydown.down'); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('match and filter functionality', () => { - beforeEach(() => { - createComponent(); - }); - - it('is closed when the input is empty', () => { - expect(findInput().isVisible()).toBe(true); - expect(findInputValue()).toBe(''); - expect(findDropdown().isVisible()).toBe(false); - }); - - it('is open when the input text matches a token', () => { - setInput('AWS'); - return wrapper.vm.$nextTick().then(() => { - expect(findDropdown().isVisible()).toBe(true); - }); - }); - - it('shows partial matches at string start', () => { - setInput('AWS'); - return wrapper.vm.$nextTick().then(() => { - expect(findDropdown().isVisible()).toBe(true); - expect(findDropdownOptions()).toEqual(awsTokenList); - }); - }); - - it('shows partial matches mid-string', () => { - setInput('D'); - return wrapper.vm.$nextTick().then(() => { - expect(findDropdown().isVisible()).toBe(true); - expect(findDropdownOptions()).toEqual([ - awsTokens[AWS_ACCESS_KEY_ID].name, - awsTokens[AWS_DEFAULT_REGION].name, - ]); - }); - }); - - it('is closed when the text does not match', () => { - setInput('elephant'); - return wrapper.vm.$nextTick().then(() => { - expect(findDropdown().isVisible()).toBe(false); - }); - }); - }); - - describe('keyboard navigation in dropdown', () => { - beforeEach(() => { - createComponent(); - }); - - describe('on down arrow + enter', () => { - it('selects the next item in the list and closes the dropdown', () => { - setInput('AWS'); - return wrapper.vm - .$nextTick() - .then(() => { - findInput().trigger('keydown.down'); - findInput().trigger('keydown.enter'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(findInputValue()).toBe(awsTokenList[0]); - }); - }); - - it('loops to the top when it reaches the bottom', () => { - setInput('AWS'); - return wrapper.vm - .$nextTick() - .then(() => { - doTimes(findDropdownOptions().length + 1, clickDown); - findInput().trigger('keydown.enter'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(findInputValue()).toBe(awsTokenList[0]); - }); - }); - }); - - describe('on up arrow + enter', () => { - it('selects the previous item in the list and closes the dropdown', () => { - setInput('AWS'); - return wrapper.vm - .$nextTick() - .then(() => { - doTimes(3, clickDown); - findInput().trigger('keydown.up'); - findInput().trigger('keydown.enter'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(findInputValue()).toBe(awsTokenList[1]); - }); - }); - - it('loops to the bottom when it reaches the top', () => { - setInput('AWS'); - return wrapper.vm - .$nextTick() - .then(() => { - findInput().trigger('keydown.down'); - findInput().trigger('keydown.up'); - findInput().trigger('keydown.enter'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(findInputValue()).toBe(awsTokenList[awsTokenList.length - 1]); - }); - }); - }); - - describe('on enter with no item highlighted', () => { - it('does not select any item and closes the dropdown', () => { - setInput('AWS'); - return wrapper.vm - .$nextTick() - .then(() => { - findInput().trigger('keydown.enter'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(findInputValue()).toBe('AWS'); - }); - }); - }); - - describe('on click', () => { - it('selects the clicked item regardless of arrow highlight', () => { - setInput('AWS'); - return wrapper.vm - .$nextTick() - .then(() => { - wrapper.find(GlButton).trigger('click'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(findInputValue()).toBe(awsTokenList[0]); - }); - }); - }); - - describe('on tab', () => { - it('selects entered text, closes dropdown', () => { - setInput('AWS'); - return wrapper.vm - .$nextTick() - .then(() => { - findInput().trigger('keydown.tab'); - doTimes(2, clickDown); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(findInputValue()).toBe('AWS'); - expect(findDropdown().isVisible()).toBe(false); - }); - }); - }); - - describe('on esc', () => { - describe('when dropdown is open', () => { - it('closes dropdown and does not select anything', () => { - setInput('AWS'); - return wrapper.vm - .$nextTick() - .then(() => { - findInput().trigger('keydown.esc'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(findInputValue()).toBe('AWS'); - expect(findDropdown().isVisible()).toBe(false); - }); - }); - }); - - describe('when dropdown is closed', () => { - it('clears the input field', () => { - setInput('elephant'); - return wrapper.vm - .$nextTick() - .then(() => { - expect(findDropdown().isVisible()).toBe(false); - findInput().trigger('keydown.esc'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(findInputValue()).toBe(''); - }); - }); - }); - }); - }); -}); diff --git a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js index 094fdcdc185..ad398d6ccd6 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js @@ -1,10 +1,8 @@ import Vuex from 'vuex'; import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlDeprecatedButton, GlFormCombobox } from '@gitlab/ui'; import { AWS_ACCESS_KEY_ID } from '~/ci_variable_list/constants'; import CiVariableModal from '~/ci_variable_list/components/ci_variable_modal.vue'; -import CiKeyField from '~/ci_variable_list/components/ci_key_field.vue'; -import { awsTokens } from '~/ci_variable_list/components/ci_variable_autocomplete_tokens'; import createStore from '~/ci_variable_list/store'; import mockData from '../services/mock_data'; import ModalStub from '../stubs'; @@ -51,7 +49,7 @@ describe('Ci variable modal', () => { }); it('does not render the autocomplete dropdown', () => { - expect(wrapper.contains(CiKeyField)).toBe(false); + expect(wrapper.contains(GlFormCombobox)).toBe(false); }); }); @@ -60,7 +58,7 @@ describe('Ci variable modal', () => { createComponent(shallowMount); }); it('renders the autocomplete dropdown', () => { - expect(wrapper.find(CiKeyField).exists()).toBe(true); + expect(wrapper.find(GlFormCombobox).exists()).toBe(true); }); }); }); @@ -159,10 +157,7 @@ describe('Ci variable modal', () => { it('Update variable button dispatches updateVariable with correct variable', () => { addOrUpdateButton(2).vm.$emit('click'); - expect(store.dispatch).toHaveBeenCalledWith( - 'updateVariable', - store.state.variableBeingEdited, - ); + expect(store.dispatch).toHaveBeenCalledWith('updateVariable'); }); it('Resets the editing state once modal is hidden', () => { @@ -172,36 +167,13 @@ describe('Ci variable modal', () => { it('dispatches deleteVariable with correct variable to delete', () => { deleteVariableButton().vm.$emit('click'); - expect(store.dispatch).toHaveBeenCalledWith('deleteVariable', mockData.mockVariables[0]); + expect(store.dispatch).toHaveBeenCalledWith('deleteVariable'); }); }); describe('Validations', () => { const maskError = 'This variable can not be masked.'; - describe('when the key state is invalid', () => { - beforeEach(() => { - const [variable] = mockData.mockVariables; - const invalidKeyVariable = { - ...variable, - key: AWS_ACCESS_KEY_ID, - value: 'AKIAIOSFODNN7EXAMPLEjdhy', - secret_value: 'AKIAIOSFODNN7EXAMPLEjdhy', - }; - createComponent(mount); - store.state.variable = invalidKeyVariable; - }); - - it('disables the submit button', () => { - expect(addOrUpdateButton(1).attributes('disabled')).toBeTruthy(); - }); - - it('shows the correct error text', () => { - const errorText = awsTokens[AWS_ACCESS_KEY_ID].invalidMessage; - expect(findModal().text()).toContain(errorText); - }); - }); - describe('when the mask state is invalid', () => { beforeEach(() => { const [variable] = mockData.mockVariables; @@ -225,39 +197,14 @@ describe('Ci variable modal', () => { }); }); - describe('when the mask and key states are invalid', () => { - beforeEach(() => { - const [variable] = mockData.mockVariables; - const invalidMaskandKeyVariable = { - ...variable, - key: AWS_ACCESS_KEY_ID, - value: 'AKIAIOSFODNN7EXAMPLEjdhyd:;', - secret_value: 'AKIAIOSFODNN7EXAMPLEjdhyd:;', - masked: true, - }; - createComponent(mount); - store.state.variable = invalidMaskandKeyVariable; - }); - - it('disables the submit button', () => { - expect(addOrUpdateButton(1).attributes('disabled')).toBeTruthy(); - }); - - it('shows the correct error text', () => { - const errorText = awsTokens[AWS_ACCESS_KEY_ID].invalidMessage; - expect(findModal().text()).toContain(maskError); - expect(findModal().text()).toContain(errorText); - }); - }); - describe('when both states are valid', () => { beforeEach(() => { const [variable] = mockData.mockVariables; const validMaskandKeyVariable = { ...variable, key: AWS_ACCESS_KEY_ID, - value: 'AKIAIOSFODNN7EXAMPLE', - secret_value: 'AKIAIOSFODNN7EXAMPLE', + value: '12345678', + secret_value: '87654321', masked: true, }; createComponent(mount); @@ -268,12 +215,6 @@ describe('Ci variable modal', () => { it('does not disable the submit button', () => { expect(addOrUpdateButton(1).attributes('disabled')).toBeFalsy(); }); - - it('shows no error text', () => { - const errorText = awsTokens[AWS_ACCESS_KEY_ID].invalidMessage; - expect(findModal().text()).not.toContain(maskError); - expect(findModal().text()).not.toContain(errorText); - }); }); }); }); diff --git a/spec/frontend/ci_variable_list/services/mock_data.js b/spec/frontend/ci_variable_list/services/mock_data.js index 7dab33050d9..44f4db93c63 100644 --- a/spec/frontend/ci_variable_list/services/mock_data.js +++ b/spec/frontend/ci_variable_list/services/mock_data.js @@ -42,6 +42,7 @@ export default { key: 'test_var', masked: false, protected: false, + protected_variable: false, secret_value: 'test_val', value: 'test_val', variable_type: 'Variable', @@ -52,6 +53,7 @@ export default { key: 'test_var_2', masked: false, protected: false, + protected_variable: false, secret_value: 'test_val_2', value: 'test_val_2', variable_type: 'File', diff --git a/spec/frontend/ci_variable_list/store/actions_spec.js b/spec/frontend/ci_variable_list/store/actions_spec.js index 12b4311d0f5..eb565d4c979 100644 --- a/spec/frontend/ci_variable_list/store/actions_spec.js +++ b/spec/frontend/ci_variable_list/store/actions_spec.js @@ -91,7 +91,7 @@ describe('CI variable list store actions', () => { testAction( actions.deleteVariable, - mockVariable, + {}, state, [], [ @@ -110,7 +110,7 @@ describe('CI variable list store actions', () => { testAction( actions.deleteVariable, - mockVariable, + {}, state, [], [ @@ -134,7 +134,7 @@ describe('CI variable list store actions', () => { testAction( actions.updateVariable, - mockVariable, + {}, state, [], [ @@ -286,4 +286,66 @@ describe('CI variable list store actions', () => { ); }); }); + + describe('Update variable values', () => { + it('updateVariableKey', () => { + testAction( + actions.updateVariableKey, + { key: mockVariable.key }, + {}, + [ + { + type: types.UPDATE_VARIABLE_KEY, + payload: mockVariable.key, + }, + ], + [], + ); + }); + + it('updateVariableValue', () => { + testAction( + actions.updateVariableValue, + { secret_value: mockVariable.value }, + {}, + [ + { + type: types.UPDATE_VARIABLE_VALUE, + payload: mockVariable.value, + }, + ], + [], + ); + }); + + it('updateVariableType', () => { + testAction( + actions.updateVariableType, + { variable_type: mockVariable.variable_type }, + {}, + [{ type: types.UPDATE_VARIABLE_TYPE, payload: mockVariable.variable_type }], + [], + ); + }); + + it('updateVariableProtected', () => { + testAction( + actions.updateVariableProtected, + { protected_variable: mockVariable.protected }, + {}, + [{ type: types.UPDATE_VARIABLE_PROTECTED, payload: mockVariable.protected }], + [], + ); + }); + + it('updateVariableMasked', () => { + testAction( + actions.updateVariableMasked, + { masked: mockVariable.masked }, + {}, + [{ type: types.UPDATE_VARIABLE_MASKED, payload: mockVariable.masked }], + [], + ); + }); + }); }); diff --git a/spec/frontend/ci_variable_list/store/mutations_spec.js b/spec/frontend/ci_variable_list/store/mutations_spec.js index 1934d108957..663b3486a17 100644 --- a/spec/frontend/ci_variable_list/store/mutations_spec.js +++ b/spec/frontend/ci_variable_list/store/mutations_spec.js @@ -4,15 +4,6 @@ import * as types from '~/ci_variable_list/store/mutation_types'; describe('CI variable list mutations', () => { let stateCopy; - const variableBeingEdited = { - environment_scope: '*', - id: 63, - key: 'test_var', - masked: false, - protected: false, - value: 'test_val', - variable_type: 'env_var', - }; beforeEach(() => { stateCopy = state(); @@ -29,18 +20,18 @@ describe('CI variable list mutations', () => { }); describe('VARIABLE_BEING_EDITED', () => { - it('should set variable that is being edited', () => { - mutations[types.VARIABLE_BEING_EDITED](stateCopy, variableBeingEdited); + it('should set the variable that is being edited', () => { + mutations[types.VARIABLE_BEING_EDITED](stateCopy); - expect(stateCopy.variableBeingEdited).toEqual(variableBeingEdited); + expect(stateCopy.variableBeingEdited).toBe(true); }); }); describe('RESET_EDITING', () => { - it('should reset variableBeingEdited to null', () => { + it('should reset variableBeingEdited to false', () => { mutations[types.RESET_EDITING](stateCopy); - expect(stateCopy.variableBeingEdited).toEqual(null); + expect(stateCopy.variableBeingEdited).toBe(false); }); }); @@ -50,7 +41,7 @@ describe('CI variable list mutations', () => { variable_type: 'Variable', key: '', secret_value: '', - protected: false, + protected_variable: false, masked: false, environment_scope: 'All (default)', }; @@ -74,15 +65,7 @@ describe('CI variable list mutations', () => { describe('SET_ENVIRONMENT_SCOPE', () => { const environment = 'production'; - it('should set scope to variable being updated if updating variable', () => { - stateCopy.variableBeingEdited = variableBeingEdited; - - mutations[types.SET_ENVIRONMENT_SCOPE](stateCopy, environment); - - expect(stateCopy.variableBeingEdited.environment_scope).toBe('production'); - }); - - it('should set scope to variable if adding new variable', () => { + it('should set environment scope on variable', () => { mutations[types.SET_ENVIRONMENT_SCOPE](stateCopy, environment); expect(stateCopy.variable.environment_scope).toBe('production'); @@ -102,7 +85,52 @@ describe('CI variable list mutations', () => { it('should set protected value to true', () => { mutations[types.SET_VARIABLE_PROTECTED](stateCopy); - expect(stateCopy.variable.protected).toBe(true); + expect(stateCopy.variable.protected_variable).toBe(true); + }); + }); + + describe('UPDATE_VARIABLE_KEY', () => { + it('should update variable key value', () => { + const key = 'new_var'; + mutations[types.UPDATE_VARIABLE_KEY](stateCopy, key); + + expect(stateCopy.variable.key).toBe(key); + }); + }); + + describe('UPDATE_VARIABLE_VALUE', () => { + it('should update variable value', () => { + const value = 'variable_value'; + mutations[types.UPDATE_VARIABLE_VALUE](stateCopy, value); + + expect(stateCopy.variable.secret_value).toBe(value); + }); + }); + + describe('UPDATE_VARIABLE_TYPE', () => { + it('should update variable type value', () => { + const type = 'File'; + mutations[types.UPDATE_VARIABLE_TYPE](stateCopy, type); + + expect(stateCopy.variable.variable_type).toBe(type); + }); + }); + + describe('UPDATE_VARIABLE_PROTECTED', () => { + it('should update variable protected value', () => { + const protectedValue = true; + mutations[types.UPDATE_VARIABLE_PROTECTED](stateCopy, protectedValue); + + expect(stateCopy.variable.protected_variable).toBe(protectedValue); + }); + }); + + describe('UPDATE_VARIABLE_MASKED', () => { + it('should update variable masked value', () => { + const masked = true; + mutations[types.UPDATE_VARIABLE_MASKED](stateCopy, masked); + + expect(stateCopy.variable.masked).toBe(masked); }); }); }); diff --git a/spec/frontend/close_reopen_report_toggle_spec.js b/spec/frontend/close_reopen_report_toggle_spec.js index f6b5e4bed87..d2ce6298c5c 100644 --- a/spec/frontend/close_reopen_report_toggle_spec.js +++ b/spec/frontend/close_reopen_report_toggle_spec.js @@ -274,12 +274,7 @@ describe('CloseReopenReportToggle', () => { { input: button, valueAttribute: 'data-url', - inputAttribute: 'href', - }, - { - input: button, - valueAttribute: 'data-method', - inputAttribute: 'data-method', + inputAttribute: 'data-endpoint', }, ], }); diff --git a/spec/frontend/clusters/components/remove_cluster_confirmation_spec.js b/spec/frontend/clusters/components/remove_cluster_confirmation_spec.js index 091d4e07987..f448948843a 100644 --- a/spec/frontend/clusters/components/remove_cluster_confirmation_spec.js +++ b/spec/frontend/clusters/components/remove_cluster_confirmation_spec.js @@ -28,7 +28,7 @@ describe('Remove cluster confirmation modal', () => { describe('split button dropdown', () => { const findModal = () => wrapper.find(GlModal).vm; - const findSplitButton = () => wrapper.find(SplitButton).vm; + const findSplitButton = () => wrapper.find(SplitButton); beforeEach(() => { createComponent({ clusterName: 'my-test-cluster' }); @@ -36,7 +36,7 @@ describe('Remove cluster confirmation modal', () => { }); it('opens modal with "cleanup" option', () => { - findSplitButton().$emit('remove-cluster-and-cleanup'); + findSplitButton().vm.$emit('remove-cluster-and-cleanup'); return wrapper.vm.$nextTick().then(() => { expect(findModal().show).toHaveBeenCalled(); @@ -45,12 +45,23 @@ describe('Remove cluster confirmation modal', () => { }); it('opens modal without "cleanup" option', () => { - findSplitButton().$emit('remove-cluster'); + findSplitButton().vm.$emit('remove-cluster'); return wrapper.vm.$nextTick().then(() => { expect(findModal().show).toHaveBeenCalled(); expect(wrapper.vm.confirmCleanup).toEqual(false); }); }); + + describe('with cluster management project', () => { + beforeEach(() => { + createComponent({ hasManagementProject: true }); + }); + + it('renders regular button instead', () => { + expect(findSplitButton().exists()).toBe(false); + expect(wrapper.find('[data-testid="btnRemove"]').exists()).toBe(true); + }); + }); }); }); diff --git a/spec/frontend/clusters_list/components/ancestor_notice_spec.js b/spec/frontend/clusters_list/components/ancestor_notice_spec.js new file mode 100644 index 00000000000..c931912eaf9 --- /dev/null +++ b/spec/frontend/clusters_list/components/ancestor_notice_spec.js @@ -0,0 +1,51 @@ +import AncestorNotice from '~/clusters_list/components/ancestor_notice.vue'; +import ClusterStore from '~/clusters_list/store'; +import { shallowMount } from '@vue/test-utils'; +import { GlLink, GlSprintf } from '@gitlab/ui'; + +describe('ClustersAncestorNotice', () => { + let store; + let wrapper; + + const createWrapper = () => { + store = ClusterStore({ ancestorHelperPath: '/some/ancestor/path' }); + wrapper = shallowMount(AncestorNotice, { store, stubs: { GlSprintf } }); + return wrapper.vm.$nextTick(); + }; + + beforeEach(() => { + return createWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when cluster does not have ancestors', () => { + beforeEach(() => { + store.state.hasAncestorClusters = false; + return wrapper.vm.$nextTick(); + }); + + it('displays no notice', () => { + expect(wrapper.isEmpty()).toBe(true); + }); + }); + + describe('when cluster has ancestors', () => { + beforeEach(() => { + store.state.hasAncestorClusters = true; + return wrapper.vm.$nextTick(); + }); + + it('displays notice text', () => { + expect(wrapper.text()).toContain( + 'Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters.', + ); + }); + + it('displays link', () => { + expect(wrapper.contains(GlLink)).toBe(true); + }); + }); +}); diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js index 07faee7e50b..deb275a9bb9 100644 --- a/spec/frontend/clusters_list/components/clusters_spec.js +++ b/spec/frontend/clusters_list/components/clusters_spec.js @@ -4,7 +4,7 @@ import ClusterStore from '~/clusters_list/store'; import MockAdapter from 'axios-mock-adapter'; import { apiData } from '../mock_data'; import { mount } from '@vue/test-utils'; -import { GlLoadingIcon, GlTable, GlPagination } from '@gitlab/ui'; +import { GlLoadingIcon, GlPagination, GlSkeletonLoading, GlTable } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; describe('Clusters', () => { @@ -64,7 +64,7 @@ describe('Clusters', () => { describe('clusters table', () => { describe('when data is loading', () => { beforeEach(() => { - wrapper.vm.$store.state.loading = true; + wrapper.vm.$store.state.loadingClusters = true; return wrapper.vm.$nextTick(); }); @@ -131,19 +131,48 @@ describe('Clusters', () => { }); describe('nodes present', () => { - it.each` - nodeSize | lineNumber - ${'Unknown'} | ${0} - ${'1'} | ${1} - ${'2'} | ${2} - ${'1'} | ${3} - ${'1'} | ${4} - ${'Unknown'} | ${5} - `('renders node size for each cluster', ({ nodeSize, lineNumber }) => { - const sizes = findTable().findAll('td:nth-child(3)'); - const size = sizes.at(lineNumber); - - expect(size.text()).toBe(nodeSize); + describe('nodes while loading', () => { + it.each` + nodeSize | lineNumber + ${null} | ${0} + ${'1'} | ${1} + ${'2'} | ${2} + ${'1'} | ${3} + ${'1'} | ${4} + ${null} | ${5} + `('renders node size for each cluster', ({ nodeSize, lineNumber }) => { + const sizes = findTable().findAll('td:nth-child(3)'); + const size = sizes.at(lineNumber); + + if (nodeSize) { + expect(size.text()).toBe(nodeSize); + } else { + expect(size.find(GlSkeletonLoading).exists()).toBe(true); + } + }); + }); + + describe('nodes finish loading', () => { + beforeEach(() => { + wrapper.vm.$store.state.loadingNodes = false; + return wrapper.vm.$nextTick(); + }); + + it.each` + nodeSize | lineNumber + ${'Unknown'} | ${0} + ${'1'} | ${1} + ${'2'} | ${2} + ${'1'} | ${3} + ${'1'} | ${4} + ${'Unknown'} | ${5} + `('renders node size for each cluster', ({ nodeSize, lineNumber }) => { + const sizes = findTable().findAll('td:nth-child(3)'); + const size = sizes.at(lineNumber); + + expect(size.text()).toBe(nodeSize); + expect(size.find(GlSkeletonLoading).exists()).toBe(false); + }); }); describe('nodes with unknown quantity', () => { diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js index 74e351a3704..c8556350747 100644 --- a/spec/frontend/clusters_list/store/actions_spec.js +++ b/spec/frontend/clusters_list/store/actions_spec.js @@ -13,6 +13,28 @@ import * as Sentry from '@sentry/browser'; jest.mock('~/flash.js'); describe('Clusters store actions', () => { + let captureException; + + describe('reportSentryError', () => { + beforeEach(() => { + captureException = jest.spyOn(Sentry, 'captureException'); + }); + + afterEach(() => { + captureException.mockRestore(); + }); + + it('should report sentry error', done => { + const sentryError = new Error('New Sentry Error'); + const tag = 'sentryErrorTag'; + + testAction(actions.reportSentryError, { error: sentryError, tag }, {}, [], [], () => { + expect(captureException).toHaveBeenCalledWith(sentryError); + done(); + }); + }); + }); + describe('fetchClusters', () => { let mock; @@ -48,8 +70,9 @@ describe('Clusters store actions', () => { { endpoint: apiData.endpoint }, {}, [ + { type: types.SET_LOADING_NODES, payload: true }, { type: types.SET_CLUSTERS_DATA, payload: { data: apiData, paginationInformation } }, - { type: types.SET_LOADING_STATE, payload: false }, + { type: types.SET_LOADING_CLUSTERS, payload: false }, ], [], () => done(), @@ -63,8 +86,20 @@ describe('Clusters store actions', () => { actions.fetchClusters, { endpoint: apiData.endpoint }, {}, - [{ type: types.SET_LOADING_STATE, payload: false }], - [], + [ + { type: types.SET_LOADING_NODES, payload: true }, + { type: types.SET_LOADING_CLUSTERS, payload: false }, + { type: types.SET_LOADING_NODES, payload: false }, + ], + [ + { + type: 'reportSentryError', + payload: { + error: new Error('Request failed with status code 400'), + tag: 'fetchClustersErrorCallback', + }, + }, + ], () => { expect(flashError).toHaveBeenCalledWith(expect.stringMatching('error')); done(); @@ -73,7 +108,6 @@ describe('Clusters store actions', () => { }); describe('multiple api requests', () => { - let captureException; let pollRequest; let pollStop; @@ -81,7 +115,6 @@ describe('Clusters store actions', () => { const pollHeaders = { 'poll-interval': pollInterval, ...headers }; beforeEach(() => { - captureException = jest.spyOn(Sentry, 'captureException'); pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); pollStop = jest.spyOn(Poll.prototype, 'stop'); @@ -89,7 +122,6 @@ describe('Clusters store actions', () => { }); afterEach(() => { - captureException.mockRestore(); pollRequest.mockRestore(); pollStop.mockRestore(); }); @@ -100,8 +132,9 @@ describe('Clusters store actions', () => { { endpoint: apiData.endpoint }, {}, [ + { type: types.SET_LOADING_NODES, payload: true }, { type: types.SET_CLUSTERS_DATA, payload: { data: apiData, paginationInformation } }, - { type: types.SET_LOADING_STATE, payload: false }, + { type: types.SET_LOADING_CLUSTERS, payload: false }, ], [], () => { @@ -149,17 +182,27 @@ describe('Clusters store actions', () => { { endpoint: apiData.endpoint }, {}, [ + { type: types.SET_LOADING_NODES, payload: true }, { type: types.SET_CLUSTERS_DATA, payload: { data: badApiResponse, paginationInformation }, }, - { type: types.SET_LOADING_STATE, payload: false }, + { type: types.SET_LOADING_CLUSTERS, payload: false }, + { type: types.SET_LOADING_CLUSTERS, payload: false }, + { type: types.SET_LOADING_NODES, payload: false }, + ], + [ + { + type: 'reportSentryError', + payload: { + error: new Error('clusters.every is not a function'), + tag: 'fetchClustersSuccessCallback', + }, + }, ], - [], () => { expect(pollRequest).toHaveBeenCalledTimes(1); expect(pollStop).toHaveBeenCalledTimes(1); - expect(captureException).toHaveBeenCalledTimes(1); done(); }, ); diff --git a/spec/frontend/clusters_list/store/mutations_spec.js b/spec/frontend/clusters_list/store/mutations_spec.js new file mode 100644 index 00000000000..df0dfe587b6 --- /dev/null +++ b/spec/frontend/clusters_list/store/mutations_spec.js @@ -0,0 +1,60 @@ +import * as types from '~/clusters_list/store/mutation_types'; +import { apiData } from '../mock_data'; +import getInitialState from '~/clusters_list/store/state'; +import mutations from '~/clusters_list/store/mutations'; + +describe('Admin statistics panel mutations', () => { + let state; + + const paginationInformation = { + nextPage: 1, + page: 1, + perPage: 20, + previousPage: 1, + total: apiData.clusters.length, + totalPages: 1, + }; + + beforeEach(() => { + state = getInitialState(); + }); + + describe(`${types.SET_CLUSTERS_DATA}`, () => { + it('sets clusters and pagination values', () => { + mutations[types.SET_CLUSTERS_DATA](state, { data: apiData, paginationInformation }); + + expect(state.clusters).toBe(apiData.clusters); + expect(state.clustersPerPage).toBe(paginationInformation.perPage); + expect(state.hasAncestorClusters).toBe(apiData.has_ancestor_clusters); + expect(state.totalCulsters).toBe(paginationInformation.total); + }); + }); + + describe(`${types.SET_LOADING_CLUSTERS}`, () => { + it('sets value to false', () => { + expect(state.loadingClusters).toBe(true); + + mutations[types.SET_LOADING_CLUSTERS](state, false); + + expect(state.loadingClusters).toBe(false); + }); + }); + + describe(`${types.SET_LOADING_NODES}`, () => { + it('sets value to false', () => { + expect(state.loadingNodes).toBe(true); + + mutations[types.SET_LOADING_NODES](state, false); + + expect(state.loadingNodes).toBe(false); + }); + }); + + describe(`${types.SET_PAGE}`, () => { + it('changes page value', () => { + mutations[types.SET_PAGE](state, 123); + + expect(state.page).toBe(123); + }); + }); +}); diff --git a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap index 7079ddfc2ab..161c2bade05 100644 --- a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap +++ b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap @@ -10,53 +10,81 @@ exports[`Code navigation popover component renders popover 1`] = ` style="left: 0px;" /> - <div - class="border-bottom" + <gl-tabs-stub + contentclass="gl-py-0" + nav-class="gl-hidden" + theme="indigo" > - <pre - class="border-0 bg-transparent m-0 code highlight" + <gl-tab-stub + title="Definition" > - <span - class="line" - lang="javascript" + <div + class="overflow-auto code-navigation-popover-container" > - <span - class="k" + <div + class="" > - function - </span> - <span> - main() { - </span> - </span> - <span - class="line" - lang="javascript" + <pre + class="border-0 bg-transparent m-0 code highlight text-wrap" + > + <span + class="line" + lang="javascript" + > + <span + class="k" + > + function + </span> + <span> + main() { + </span> + </span> + <span + class="line" + lang="javascript" + > + <span> + } + </span> + </span> + </pre> + </div> + </div> + + <div + class="popover-body border-top" > - <span> - } - </span> - </span> - </pre> - </div> - - <div - class="popover-body" - > - <gl-button-stub - category="tertiary" - class="w-100" - data-testid="go-to-definition-btn" - href="http://gitlab.com/test.js#L20" - icon="" - size="medium" - target="_blank" - variant="default" + <gl-button-stub + category="tertiary" + class="w-100" + data-testid="go-to-definition-btn" + href="http://gitlab.com/test.js" + icon="" + size="medium" + target="_blank" + variant="default" + > + + Go to definition + + </gl-button-stub> + </div> + </gl-tab-stub> + + <gl-tab-stub + class="py-2" + data-testid="references-tab" > + + <p + class="gl-my-4 gl-px-4" + > + + No references found - Go to definition - - </gl-button-stub> - </div> + </p> + </gl-tab-stub> + </gl-tabs-stub> </div> `; diff --git a/spec/frontend/code_navigation/components/popover_spec.js b/spec/frontend/code_navigation/components/popover_spec.js index b3f814f1be4..7b323cfab72 100644 --- a/spec/frontend/code_navigation/components/popover_spec.js +++ b/spec/frontend/code_navigation/components/popover_spec.js @@ -26,7 +26,8 @@ const MOCK_CODE_DATA = Object.freeze({ ], }, ], - definition_path: 'test.js#L20', + definition_path: 'test.js', + definitionLineNumber: 20, }); const MOCK_DOCS_DATA = Object.freeze({ @@ -39,6 +40,17 @@ const MOCK_DOCS_DATA = Object.freeze({ definition_path: 'test.js#L20', }); +const MOCK_DATA_WITH_REFERENCES = Object.freeze({ + hover: [ + { + language: null, + value: 'console.log', + }, + ], + references: [{ path: 'index.js' }, { path: 'app.js' }], + definition_path: 'test.js#L20', +}); + let wrapper; function factory({ position, data, definitionPathPrefix, blobPath = 'index.js' }) { @@ -63,6 +75,16 @@ describe('Code navigation popover component', () => { expect(wrapper.element).toMatchSnapshot(); }); + it('srender references tab with empty text when no references exist', () => { + factory({ + position: { x: 0, y: 0, height: 0 }, + data: MOCK_CODE_DATA, + definitionPathPrefix: DEFINITION_PATH_PREFIX, + }); + + expect(wrapper.find('[data-testid="references-tab"]').text()).toContain('No references found'); + }); + it('renders link with hash to current file', () => { factory({ position: { x: 0, y: 0, height: 0 }, @@ -74,6 +96,17 @@ describe('Code navigation popover component', () => { expect(wrapper.find('[data-testid="go-to-definition-btn"]').attributes('href')).toBe('#L20'); }); + it('renders list of references', () => { + factory({ + position: { x: 0, y: 0, height: 0 }, + data: MOCK_DATA_WITH_REFERENCES, + definitionPathPrefix: DEFINITION_PATH_PREFIX, + }); + + expect(wrapper.find('[data-testid="references-tab"]').exists()).toBe(true); + expect(wrapper.findAll('[data-testid="reference-link"]').length).toBe(2); + }); + describe('code output', () => { it('renders code output', () => { factory({ diff --git a/spec/frontend/code_navigation/store/actions_spec.js b/spec/frontend/code_navigation/store/actions_spec.js index 4cf77ed1be5..fbd93b10a14 100644 --- a/spec/frontend/code_navigation/store/actions_spec.js +++ b/spec/frontend/code_navigation/store/actions_spec.js @@ -69,7 +69,12 @@ describe('Code navigation actions', () => { payload: { path: 'index.js', normalizedData: { - '0:0': { start_line: 0, start_char: 0, hover: { value: '123' } }, + '0:0': { + definitionLineNumber: 0, + start_line: 0, + start_char: 0, + hover: { value: '123' }, + }, }, }, }, @@ -91,7 +96,12 @@ describe('Code navigation actions', () => { payload: { path: 'index.js', normalizedData: { - '0:0': { start_line: 0, start_char: 0, hover: { value: '123' } }, + '0:0': { + definitionLineNumber: 0, + start_line: 0, + start_char: 0, + hover: { value: '123' }, + }, }, }, }, @@ -159,7 +169,9 @@ describe('Code navigation actions', () => { let target; beforeEach(() => { - setFixtures('<div data-path="index.js"><div class="js-test"></div></div>'); + setFixtures( + '<div data-path="index.js"><div class="line"><div class="js-test"></div></div></div>', + ); target = document.querySelector('.js-test'); }); @@ -186,7 +198,7 @@ describe('Code navigation actions', () => { payload: { blobPath: 'index.js', definition: { hover: 'test' }, - position: { height: 0, x: 0, y: 0 }, + position: { height: 0, x: 0, y: 0, lineIndex: 0 }, }, }, ], @@ -210,7 +222,7 @@ describe('Code navigation actions', () => { payload: { blobPath: 'index.js', definition: { hover: 'test' }, - position: { height: 0, x: 0, y: 0 }, + position: { height: 0, x: 0, y: 0, lineIndex: 0 }, }, }, ], @@ -235,7 +247,7 @@ describe('Code navigation actions', () => { payload: { blobPath: 'index.js', definition: { hover: 'test' }, - position: { height: 0, x: 0, y: 0 }, + position: { height: 0, x: 0, y: 0, lineIndex: 0 }, }, }, ], diff --git a/spec/frontend/code_navigation/utils/index_spec.js b/spec/frontend/code_navigation/utils/index_spec.js index 58c48a90075..6a01249d2a3 100644 --- a/spec/frontend/code_navigation/utils/index_spec.js +++ b/spec/frontend/code_navigation/utils/index_spec.js @@ -36,7 +36,7 @@ describe('setCurrentHoverElement', () => { describe('addInteractionClass', () => { beforeEach(() => { setFixtures( - '<div data-path="index.js"><div class="blob-content"><div id="LC1"><span>console</span><span>.</span><span>log</span></div><div id="LC2"><span>function</span></div></div></div>', + '<div data-path="index.js"><div class="blob-content"><div id="LC1" class="line"><span>console</span><span>.</span><span>log</span></div><div id="LC2" class="line"><span>function</span></div></div></div>', ); }); diff --git a/spec/frontend/cycle_analytics/stage_nav_item_spec.js b/spec/frontend/cycle_analytics/stage_nav_item_spec.js index 480bb756731..1fe80d3b1ce 100644 --- a/spec/frontend/cycle_analytics/stage_nav_item_spec.js +++ b/spec/frontend/cycle_analytics/stage_nav_item_spec.js @@ -10,7 +10,6 @@ describe('StageNavItem', () => { const func = shallow ? shallowMount : mount; return func(StageNavItem, { propsData: { - canEdit: false, isActive: false, isUserAllowed: false, isDefaultStage: true, @@ -125,7 +124,7 @@ describe('StageNavItem', () => { describe('User can edit stages', () => { beforeEach(() => { - wrapper = createComponent({ canEdit: true, isUserAllowed: true }, false); + wrapper = createComponent({ isUserAllowed: true }, false); }); afterEach(() => { diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js index 557f53e864f..102e8e0664c 100644 --- a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js @@ -4,7 +4,7 @@ import notes from '../../mock_data/notes'; import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue'; import DesignNote from '~/design_management/components/design_notes/design_note.vue'; import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue'; -import createNoteMutation from '~/design_management/graphql/mutations/createNote.mutation.graphql'; +import createNoteMutation from '~/design_management/graphql/mutations/create_note.mutation.graphql'; import toggleResolveDiscussionMutation from '~/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; import ToggleRepliesWidget from '~/design_management/components/design_notes/toggle_replies_widget.vue'; diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js index 430cf8722fe..82b607eb77d 100644 --- a/spec/frontend/design_management/pages/design/index_spec.js +++ b/spec/frontend/design_management/pages/design/index_spec.js @@ -5,8 +5,8 @@ import { ApolloMutation } from 'vue-apollo'; import createFlash from '~/flash'; import DesignIndex from '~/design_management/pages/design/index.vue'; import DesignSidebar from '~/design_management/components/design_sidebar.vue'; -import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue'; -import createImageDiffNoteMutation from '~/design_management/graphql/mutations/createImageDiffNote.mutation.graphql'; +import DesignPresentation from '~/design_management/components/design_presentation.vue'; +import createImageDiffNoteMutation from '~/design_management/graphql/mutations/create_image_diff_note.mutation.graphql'; import design from '../../mock_data/design'; import mockResponseWithDesigns from '../../mock_data/designs'; import mockResponseNoDesigns from '../../mock_data/no_designs'; @@ -26,6 +26,15 @@ jest.mock('mousetrap', () => ({ unbind: jest.fn(), })); +const focusInput = jest.fn(); + +const DesignReplyForm = { + template: '<div><textarea ref="textarea"></textarea></div>', + methods: { + focusInput, + }, +}; + const localVue = createLocalVue(); localVue.use(VueRouter); @@ -64,6 +73,7 @@ describe('Design management design index page', () => { const findDiscussionForm = () => wrapper.find(DesignReplyForm); const findSidebar = () => wrapper.find(DesignSidebar); + const findDesignPresentation = () => wrapper.find(DesignPresentation); function createComponent(loading = false, data = {}) { const $apollo = { @@ -83,6 +93,7 @@ describe('Design management design index page', () => { stubs: { ApolloMutation, DesignSidebar, + DesignReplyForm, }, data() { return { @@ -153,13 +164,29 @@ describe('Design management design index page', () => { }, }); - wrapper.vm.openCommentForm({ x: 0, y: 0 }); + findDesignPresentation().vm.$emit('openCommentForm', { x: 0, y: 0 }); return wrapper.vm.$nextTick().then(() => { expect(findDiscussionForm().exists()).toBe(true); }); }); + it('keeps new discussion form focused', () => { + createComponent(false, { + design: { + ...design, + discussions: { + nodes: [], + }, + }, + annotationCoordinates, + }); + + findDesignPresentation().vm.$emit('openCommentForm', { x: 10, y: 10 }); + + expect(focusInput).toHaveBeenCalled(); + }); + it('sends a mutation on submitting form and closes form', () => { createComponent(false, { design: { diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index d4e9bae3e89..d3761bf09e9 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -3,7 +3,7 @@ import { ApolloMutation } from 'vue-apollo'; import VueRouter from 'vue-router'; import { GlEmptyState } from '@gitlab/ui'; import Index from '~/design_management/pages/index.vue'; -import uploadDesignQuery from '~/design_management/graphql/mutations/uploadDesign.mutation.graphql'; +import uploadDesignQuery from '~/design_management/graphql/mutations/upload_design.mutation.graphql'; import DesignDestroyer from '~/design_management/components/design_destroyer.vue'; import DesignDropzone from '~/design_management/components/upload/design_dropzone.vue'; import DeleteButton from '~/design_management/components/delete_button.vue'; diff --git a/spec/frontend/design_management/utils/tracking_spec.js b/spec/frontend/design_management/utils/tracking_spec.js index 9fa5eae55b3..0549fb44956 100644 --- a/spec/frontend/design_management/utils/tracking_spec.js +++ b/spec/frontend/design_management/utils/tracking_spec.js @@ -8,7 +8,7 @@ function getTrackingSpy(key) { describe('Tracking Events', () => { describe('trackDesignDetailView', () => { const eventKey = 'projects:issues:design'; - const eventName = 'design_viewed'; + const eventName = 'view_design'; it('trackDesignDetailView fires a tracking event when called', () => { const trackingSpy = getTrackingSpy(eventKey); @@ -20,11 +20,14 @@ describe('Tracking Events', () => { eventName, expect.objectContaining({ label: eventName, - value: { - 'internal-object-refrerer': '', - 'design-collection-owner': '', - 'design-version-number': 1, - 'design-is-current-version': false, + context: { + schema: expect.any(String), + data: { + 'design-version-number': 1, + 'design-is-current-version': false, + 'internal-object-referrer': '', + 'design-collection-owner': '', + }, }, }), ); @@ -40,11 +43,14 @@ describe('Tracking Events', () => { eventName, expect.objectContaining({ label: eventName, - value: { - 'internal-object-refrerer': 'from-a-test', - 'design-collection-owner': 'test', - 'design-version-number': 100, - 'design-is-current-version': true, + context: { + schema: expect.any(String), + data: { + 'design-version-number': 100, + 'design-is-current-version': true, + 'internal-object-referrer': 'from-a-test', + 'design-collection-owner': 'test', + }, }, }), ); diff --git a/spec/frontend/design_management_new/components/__snapshots__/design_note_pin_spec.js.snap b/spec/frontend/design_management_new/components/__snapshots__/design_note_pin_spec.js.snap new file mode 100644 index 00000000000..4c848256e5b --- /dev/null +++ b/spec/frontend/design_management_new/components/__snapshots__/design_note_pin_spec.js.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design note pin component should match the snapshot of note when repositioning 1`] = ` +<button + aria-label="Comment form position" + class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center btn-transparent comment-indicator" + style="left: 10px; top: 10px; cursor: move;" + type="button" +> + <icon-stub + name="image-comment-dark" + size="16" + /> +</button> +`; + +exports[`Design note pin component should match the snapshot of note with index 1`] = ` +<button + aria-label="Comment '1' position" + class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center js-image-badge badge badge-pill" + style="left: 10px; top: 10px;" + type="button" +> + + 1 + +</button> +`; + +exports[`Design note pin component should match the snapshot of note without index 1`] = ` +<button + aria-label="Comment form position" + class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center btn-transparent comment-indicator" + style="left: 10px; top: 10px;" + type="button" +> + <icon-stub + name="image-comment-dark" + size="16" + /> +</button> +`; diff --git a/spec/frontend/design_management_new/components/__snapshots__/design_presentation_spec.js.snap b/spec/frontend/design_management_new/components/__snapshots__/design_presentation_spec.js.snap new file mode 100644 index 00000000000..189962c5b2e --- /dev/null +++ b/spec/frontend/design_management_new/components/__snapshots__/design_presentation_spec.js.snap @@ -0,0 +1,104 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management design presentation component currentCommentForm is equal to current annotation position when isAnnotating is true 1`] = ` +<div + class="h-100 w-100 p-3 overflow-auto position-relative" +> + <div + class="h-100 w-100 d-flex align-items-center position-relative" + > + <design-image-stub + image="test.jpg" + name="test" + scale="1" + /> + + <design-overlay-stub + currentcommentform="[object Object]" + dimensions="[object Object]" + notes="" + position="[object Object]" + /> + </div> +</div> +`; + +exports[`Design management design presentation component currentCommentForm is null when isAnnotating is false 1`] = ` +<div + class="h-100 w-100 p-3 overflow-auto position-relative" +> + <div + class="h-100 w-100 d-flex align-items-center position-relative" + > + <design-image-stub + image="test.jpg" + name="test" + scale="1" + /> + + <design-overlay-stub + dimensions="[object Object]" + notes="" + position="[object Object]" + /> + </div> +</div> +`; + +exports[`Design management design presentation component currentCommentForm is null when isAnnotating is true but annotation position is falsey 1`] = ` +<div + class="h-100 w-100 p-3 overflow-auto position-relative" +> + <div + class="h-100 w-100 d-flex align-items-center position-relative" + > + <design-image-stub + image="test.jpg" + name="test" + scale="1" + /> + + <design-overlay-stub + dimensions="[object Object]" + notes="" + position="[object Object]" + /> + </div> +</div> +`; + +exports[`Design management design presentation component renders empty state when no image provided 1`] = ` +<div + class="h-100 w-100 p-3 overflow-auto position-relative" +> + <div + class="h-100 w-100 d-flex align-items-center position-relative" + > + <!----> + + <!----> + </div> +</div> +`; + +exports[`Design management design presentation component renders image and overlay when image provided 1`] = ` +<div + class="h-100 w-100 p-3 overflow-auto position-relative" +> + <div + class="h-100 w-100 d-flex align-items-center position-relative" + > + <design-image-stub + image="test.jpg" + name="test" + scale="1" + /> + + <design-overlay-stub + dimensions="[object Object]" + notes="" + position="[object Object]" + /> + </div> +</div> +`; diff --git a/spec/frontend/design_management_new/components/__snapshots__/design_scaler_spec.js.snap b/spec/frontend/design_management_new/components/__snapshots__/design_scaler_spec.js.snap new file mode 100644 index 00000000000..cb4575cbd11 --- /dev/null +++ b/spec/frontend/design_management_new/components/__snapshots__/design_scaler_spec.js.snap @@ -0,0 +1,115 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management design scaler component minus and reset buttons are disabled when scale === 1 1`] = ` +<div + class="design-scaler btn-group" + role="group" +> + <button + class="btn" + disabled="disabled" + > + <span + class="d-flex-center gl-icon s16" + > + + – + + </span> + </button> + + <button + class="btn" + disabled="disabled" + > + <gl-icon-stub + name="redo" + size="16" + /> + </button> + + <button + class="btn" + > + <gl-icon-stub + name="plus" + size="16" + /> + </button> +</div> +`; + +exports[`Design management design scaler component minus and reset buttons are enabled when scale > 1 1`] = ` +<div + class="design-scaler btn-group" + role="group" +> + <button + class="btn" + > + <span + class="d-flex-center gl-icon s16" + > + + – + + </span> + </button> + + <button + class="btn" + > + <gl-icon-stub + name="redo" + size="16" + /> + </button> + + <button + class="btn" + > + <gl-icon-stub + name="plus" + size="16" + /> + </button> +</div> +`; + +exports[`Design management design scaler component plus button is disabled when scale === 2 1`] = ` +<div + class="design-scaler btn-group" + role="group" +> + <button + class="btn" + > + <span + class="d-flex-center gl-icon s16" + > + + – + + </span> + </button> + + <button + class="btn" + > + <gl-icon-stub + name="redo" + size="16" + /> + </button> + + <button + class="btn" + disabled="disabled" + > + <gl-icon-stub + name="plus" + size="16" + /> + </button> +</div> +`; diff --git a/spec/frontend/design_management_new/components/__snapshots__/image_spec.js.snap b/spec/frontend/design_management_new/components/__snapshots__/image_spec.js.snap new file mode 100644 index 00000000000..acaa62b11eb --- /dev/null +++ b/spec/frontend/design_management_new/components/__snapshots__/image_spec.js.snap @@ -0,0 +1,68 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management large image component renders image 1`] = ` +<div + class="m-auto js-design-image" +> + <!----> + + <img + alt="test" + class="mh-100 img-fluid" + src="test.jpg" + /> +</div> +`; + +exports[`Design management large image component renders loading state 1`] = ` +<div + class="m-auto js-design-image" + isloading="true" +> + <!----> + + <img + alt="" + class="mh-100 img-fluid" + src="" + /> +</div> +`; + +exports[`Design management large image component renders media broken icon on error 1`] = ` +<gl-icon-stub + class="text-secondary-100" + name="media-broken" + size="48" +/> +`; + +exports[`Design management large image component sets correct classes and styles if imageStyle is set 1`] = ` +<div + class="m-auto js-design-image" +> + <!----> + + <img + alt="test" + class="mh-100" + src="test.jpg" + style="width: 100px; height: 100px;" + /> +</div> +`; + +exports[`Design management large image component zoom sets image style when zoomed 1`] = ` +<div + class="m-auto js-design-image" +> + <!----> + + <img + alt="test" + class="mh-100" + src="test.jpg" + style="width: 200px; height: 200px;" + /> +</div> +`; diff --git a/spec/frontend/design_management_new/components/delete_button_spec.js b/spec/frontend/design_management_new/components/delete_button_spec.js new file mode 100644 index 00000000000..218c58847a6 --- /dev/null +++ b/spec/frontend/design_management_new/components/delete_button_spec.js @@ -0,0 +1,51 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; +import BatchDeleteButton from '~/design_management_new/components/delete_button.vue'; + +describe('Batch delete button component', () => { + let wrapper; + + const findButton = () => wrapper.find(GlButton); + const findModal = () => wrapper.find(GlModal); + + function createComponent(isDeleting = false) { + wrapper = shallowMount(BatchDeleteButton, { + propsData: { + isDeleting, + }, + directives: { + GlModalDirective, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders non-disabled button by default', () => { + createComponent(); + + expect(findButton().exists()).toBe(true); + expect(findButton().attributes('disabled')).toBeFalsy(); + }); + + it('renders disabled button when design is deleting', () => { + createComponent(true); + expect(findButton().attributes('disabled')).toBeTruthy(); + }); + + it('emits `deleteSelectedDesigns` event on modal ok click', () => { + createComponent(); + findButton().vm.$emit('click'); + return wrapper.vm + .$nextTick() + .then(() => { + findModal().vm.$emit('ok'); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(wrapper.emitted().deleteSelectedDesigns).toBeTruthy(); + }); + }); +}); diff --git a/spec/frontend/design_management_new/components/design_note_pin_spec.js b/spec/frontend/design_management_new/components/design_note_pin_spec.js new file mode 100644 index 00000000000..8e2caa604f4 --- /dev/null +++ b/spec/frontend/design_management_new/components/design_note_pin_spec.js @@ -0,0 +1,49 @@ +import { shallowMount } from '@vue/test-utils'; +import DesignNotePin from '~/design_management_new/components/design_note_pin.vue'; + +describe('Design note pin component', () => { + let wrapper; + + function createComponent(propsData = {}) { + wrapper = shallowMount(DesignNotePin, { + propsData: { + position: { + left: '10px', + top: '10px', + }, + ...propsData, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('should match the snapshot of note without index', () => { + createComponent(); + expect(wrapper.element).toMatchSnapshot(); + }); + + it('should match the snapshot of note with index', () => { + createComponent({ label: 1 }); + expect(wrapper.element).toMatchSnapshot(); + }); + + it('should match the snapshot of note when repositioning', () => { + createComponent({ repositioning: true }); + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('pinStyle', () => { + it('sets cursor to `move` when repositioning = true', () => { + createComponent({ repositioning: true }); + expect(wrapper.vm.pinStyle.cursor).toBe('move'); + }); + + it('does not set cursor when repositioning = false', () => { + createComponent(); + expect(wrapper.vm.pinStyle.cursor).toBe(undefined); + }); + }); +}); diff --git a/spec/frontend/design_management_new/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management_new/components/design_notes/__snapshots__/design_note_spec.js.snap new file mode 100644 index 00000000000..b55bacb6fc5 --- /dev/null +++ b/spec/frontend/design_management_new/components/design_notes/__snapshots__/design_note_spec.js.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design note component should match the snapshot 1`] = ` +<timeline-entry-item-stub + class="design-note note-form" + id="note_123" +> + <user-avatar-link-stub + imgalt="" + imgcssclasses="" + imgsize="40" + imgsrc="" + linkhref="" + tooltipplacement="top" + tooltiptext="" + username="" + /> + + <div + class="d-flex justify-content-between" + > + <div> + <a + class="js-user-link" + data-user-id="author-id" + > + <span + class="note-header-author-name bold" + > + + </span> + + <!----> + + <span + class="note-headline-light" + > + @ + </span> + </a> + + <span + class="note-headline-light note-headline-meta" + > + <span + class="system-note-message" + /> + + <!----> + </span> + </div> + + <div + class="gl-display-flex" + > + + <!----> + </div> + </div> + + <div + class="note-text js-note-text md" + data-qa-selector="note_content" + /> + +</timeline-entry-item-stub> +`; diff --git a/spec/frontend/design_management_new/components/design_notes/__snapshots__/design_reply_form_spec.js.snap b/spec/frontend/design_management_new/components/design_notes/__snapshots__/design_reply_form_spec.js.snap new file mode 100644 index 00000000000..e01c79e3520 --- /dev/null +++ b/spec/frontend/design_management_new/components/design_notes/__snapshots__/design_reply_form_spec.js.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design reply form component renders button text as "Comment" when creating a comment 1`] = ` +"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\"> + <!----> + Comment +</button>" +`; + +exports[`Design reply form component renders button text as "Save comment" when creating a comment 1`] = ` +"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\"> + <!----> + Save comment +</button>" +`; diff --git a/spec/frontend/design_management_new/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management_new/components/design_notes/design_discussion_spec.js new file mode 100644 index 00000000000..401ce64e859 --- /dev/null +++ b/spec/frontend/design_management_new/components/design_notes/design_discussion_spec.js @@ -0,0 +1,322 @@ +import { mount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import notes from '../../mock_data/notes'; +import DesignDiscussion from '~/design_management_new/components/design_notes/design_discussion.vue'; +import DesignNote from '~/design_management_new/components/design_notes/design_note.vue'; +import DesignReplyForm from '~/design_management_new/components/design_notes/design_reply_form.vue'; +import createNoteMutation from '~/design_management_new/graphql/mutations/create_note.mutation.graphql'; +import toggleResolveDiscussionMutation from '~/design_management_new/graphql/mutations/toggle_resolve_discussion.mutation.graphql'; +import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; +import ToggleRepliesWidget from '~/design_management_new/components/design_notes/toggle_replies_widget.vue'; + +const discussion = { + id: '0', + resolved: false, + resolvable: true, + notes, +}; + +describe('Design discussions component', () => { + let wrapper; + + const findDesignNotes = () => wrapper.findAll(DesignNote); + const findReplyPlaceholder = () => wrapper.find(ReplyPlaceholder); + const findReplyForm = () => wrapper.find(DesignReplyForm); + const findRepliesWidget = () => wrapper.find(ToggleRepliesWidget); + const findResolveButton = () => wrapper.find('[data-testid="resolve-button"]'); + const findResolveIcon = () => wrapper.find('[data-testid="resolve-icon"]'); + const findResolvedMessage = () => wrapper.find('[data-testid="resolved-message"]'); + const findResolveLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findResolveCheckbox = () => wrapper.find('[data-testid="resolve-checkbox"]'); + + const mutationVariables = { + mutation: createNoteMutation, + update: expect.anything(), + variables: { + input: { + noteableId: 'noteable-id', + body: 'test', + discussionId: '0', + }, + }, + }; + const mutate = jest.fn(() => Promise.resolve()); + const $apollo = { + mutate, + }; + + function createComponent(props = {}, data = {}) { + wrapper = mount(DesignDiscussion, { + propsData: { + resolvedDiscussionsExpanded: true, + discussion, + noteableId: 'noteable-id', + designId: 'design-id', + discussionIndex: 1, + discussionWithOpenForm: '', + ...props, + }, + data() { + return { + ...data, + }; + }, + provide: { + projectPath: 'project-path', + issueIid: '1', + }, + mocks: { + $apollo, + $route: { + hash: '#note_1', + }, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when discussion is not resolvable', () => { + beforeEach(() => { + createComponent({ + discussion: { + ...discussion, + resolvable: false, + }, + }); + }); + + it('does not render an icon to resolve a thread', () => { + expect(findResolveIcon().exists()).toBe(false); + }); + + it('does not render a checkbox in reply form', () => { + findReplyPlaceholder().vm.$emit('onMouseDown'); + + return wrapper.vm.$nextTick().then(() => { + expect(findResolveCheckbox().exists()).toBe(false); + }); + }); + }); + + describe('when discussion is unresolved', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders correct amount of discussion notes', () => { + expect(findDesignNotes()).toHaveLength(2); + expect(findDesignNotes().wrappers.every(w => w.isVisible())).toBe(true); + }); + + it('renders reply placeholder', () => { + expect(findReplyPlaceholder().isVisible()).toBe(true); + }); + + it('does not render toggle replies widget', () => { + expect(findRepliesWidget().exists()).toBe(false); + }); + + it('renders a correct icon to resolve a thread', () => { + expect(findResolveIcon().props('name')).toBe('check-circle'); + }); + + it('renders a checkbox with Resolve thread text in reply form', () => { + findReplyPlaceholder().vm.$emit('onClick'); + wrapper.setProps({ discussionWithOpenForm: discussion.id }); + + return wrapper.vm.$nextTick().then(() => { + expect(findResolveCheckbox().text()).toBe('Resolve thread'); + }); + }); + + it('does not render resolved message', () => { + expect(findResolvedMessage().exists()).toBe(false); + }); + }); + + describe('when discussion is resolved', () => { + beforeEach(() => { + createComponent({ + discussion: { + ...discussion, + resolved: true, + resolvedBy: notes[0].author, + resolvedAt: '2020-05-08T07:10:45Z', + }, + }); + }); + + it('shows only the first note', () => { + expect( + findDesignNotes() + .at(0) + .isVisible(), + ).toBe(true); + expect( + findDesignNotes() + .at(1) + .isVisible(), + ).toBe(false); + }); + + it('renders resolved message', () => { + expect(findResolvedMessage().exists()).toBe(true); + }); + + it('does not show renders reply placeholder', () => { + expect(findReplyPlaceholder().isVisible()).toBe(false); + }); + + it('renders toggle replies widget with correct props', () => { + expect(findRepliesWidget().exists()).toBe(true); + expect(findRepliesWidget().props()).toEqual({ + collapsed: true, + replies: notes.slice(1), + }); + }); + + it('renders a correct icon to resolve a thread', () => { + expect(findResolveIcon().props('name')).toBe('check-circle-filled'); + }); + + describe('when replies are expanded', () => { + beforeEach(() => { + findRepliesWidget().vm.$emit('toggle'); + return wrapper.vm.$nextTick(); + }); + + it('renders replies widget with collapsed prop equal to false', () => { + expect(findRepliesWidget().props('collapsed')).toBe(false); + }); + + it('renders the second note', () => { + expect( + findDesignNotes() + .at(1) + .isVisible(), + ).toBe(true); + }); + + it('renders a reply placeholder', () => { + expect(findReplyPlaceholder().isVisible()).toBe(true); + }); + + it('renders a checkbox with Unresolve thread text in reply form', () => { + findReplyPlaceholder().vm.$emit('onClick'); + wrapper.setProps({ discussionWithOpenForm: discussion.id }); + + return wrapper.vm.$nextTick().then(() => { + expect(findResolveCheckbox().text()).toBe('Unresolve thread'); + }); + }); + }); + }); + + it('hides reply placeholder and opens form on placeholder click', () => { + createComponent(); + findReplyPlaceholder().vm.$emit('onClick'); + wrapper.setProps({ discussionWithOpenForm: discussion.id }); + + return wrapper.vm.$nextTick().then(() => { + expect(findReplyPlaceholder().exists()).toBe(false); + expect(findReplyForm().exists()).toBe(true); + }); + }); + + it('calls mutation on submitting form and closes the form', () => { + createComponent( + { discussionWithOpenForm: discussion.id }, + { discussionComment: 'test', isFormRendered: true }, + ); + + findReplyForm().vm.$emit('submitForm'); + expect(mutate).toHaveBeenCalledWith(mutationVariables); + + return mutate() + .then(() => { + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(findReplyForm().exists()).toBe(false); + }); + }); + + it('clears the discussion comment on closing comment form', () => { + createComponent( + { discussionWithOpenForm: discussion.id }, + { discussionComment: 'test', isFormRendered: true }, + ); + + return wrapper.vm + .$nextTick() + .then(() => { + findReplyForm().vm.$emit('cancelForm'); + + expect(wrapper.vm.discussionComment).toBe(''); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(findReplyForm().exists()).toBe(false); + }); + }); + + it('applies correct class to design notes when discussion is highlighted', () => { + createComponent( + {}, + { + activeDiscussion: { + id: notes[0].id, + source: 'pin', + }, + }, + ); + + expect(wrapper.findAll(DesignNote).wrappers.every(note => note.classes('gl-bg-blue-50'))).toBe( + true, + ); + }); + + it('calls toggleResolveDiscussion mutation on resolve thread button click', () => { + createComponent(); + findResolveButton().trigger('click'); + expect(mutate).toHaveBeenCalledWith({ + mutation: toggleResolveDiscussionMutation, + variables: { + id: discussion.id, + resolve: true, + }, + }); + return wrapper.vm.$nextTick(() => { + expect(findResolveLoadingIcon().exists()).toBe(true); + }); + }); + + it('calls toggleResolveDiscussion mutation after adding a note if checkbox was checked', () => { + createComponent( + { discussionWithOpenForm: discussion.id }, + { discussionComment: 'test', isFormRendered: true }, + ); + findResolveButton().trigger('click'); + findReplyForm().vm.$emit('submitForm'); + + return mutate().then(() => { + expect(mutate).toHaveBeenCalledWith({ + mutation: toggleResolveDiscussionMutation, + variables: { + id: discussion.id, + resolve: true, + }, + }); + }); + }); + + it('emits openForm event on opening the form', () => { + createComponent(); + findReplyPlaceholder().vm.$emit('onClick'); + + expect(wrapper.emitted('openForm')).toBeTruthy(); + }); +}); diff --git a/spec/frontend/design_management_new/components/design_notes/design_note_spec.js b/spec/frontend/design_management_new/components/design_notes/design_note_spec.js new file mode 100644 index 00000000000..b0e3e85b9c6 --- /dev/null +++ b/spec/frontend/design_management_new/components/design_notes/design_note_spec.js @@ -0,0 +1,170 @@ +import { shallowMount } from '@vue/test-utils'; +import { ApolloMutation } from 'vue-apollo'; +import DesignNote from '~/design_management_new/components/design_notes/design_note.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import DesignReplyForm from '~/design_management_new/components/design_notes/design_reply_form.vue'; + +const scrollIntoViewMock = jest.fn(); +const note = { + id: 'gid://gitlab/DiffNote/123', + author: { + id: 'author-id', + }, + body: 'test', + userPermissions: { + adminNote: false, + }, +}; +HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; + +const $route = { + hash: '#note_123', +}; + +const mutate = jest.fn().mockResolvedValue({ data: { updateNote: {} } }); + +describe('Design note component', () => { + let wrapper; + + const findUserAvatar = () => wrapper.find(UserAvatarLink); + const findUserLink = () => wrapper.find('.js-user-link'); + const findReplyForm = () => wrapper.find(DesignReplyForm); + const findEditButton = () => wrapper.find('.js-note-edit'); + const findNoteContent = () => wrapper.find('.js-note-text'); + + function createComponent(props = {}, data = { isEditing: false }) { + wrapper = shallowMount(DesignNote, { + propsData: { + note: {}, + ...props, + }, + data() { + return { + ...data, + }; + }, + mocks: { + $route, + $apollo: { + mutate, + }, + }, + stubs: { + ApolloMutation, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('should match the snapshot', () => { + createComponent({ + note, + }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('should render an author', () => { + createComponent({ + note, + }); + + expect(findUserAvatar().exists()).toBe(true); + expect(findUserLink().exists()).toBe(true); + }); + + it('should render a time ago tooltip if note has createdAt property', () => { + createComponent({ + note: { + ...note, + createdAt: '2019-07-26T15:02:20Z', + }, + }); + + expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true); + }); + + it('should trigger a scrollIntoView method', () => { + createComponent({ + note, + }); + + expect(scrollIntoViewMock).toHaveBeenCalled(); + }); + + it('should not render edit icon when user does not have a permission', () => { + createComponent({ + note, + }); + + expect(findEditButton().exists()).toBe(false); + }); + + describe('when user has a permission to edit note', () => { + it('should open an edit form on edit button click', () => { + createComponent({ + note: { + ...note, + userPermissions: { + adminNote: true, + }, + }, + }); + + findEditButton().trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(findReplyForm().exists()).toBe(true); + expect(findNoteContent().exists()).toBe(false); + }); + }); + + describe('when edit form is rendered', () => { + beforeEach(() => { + createComponent( + { + note: { + ...note, + userPermissions: { + adminNote: true, + }, + }, + }, + { isEditing: true }, + ); + }); + + it('should not render note content and should render reply form', () => { + expect(findNoteContent().exists()).toBe(false); + expect(findReplyForm().exists()).toBe(true); + }); + + it('hides the form on hideForm event', () => { + findReplyForm().vm.$emit('cancelForm'); + + return wrapper.vm.$nextTick().then(() => { + expect(findReplyForm().exists()).toBe(false); + expect(findNoteContent().exists()).toBe(true); + }); + }); + + it('calls a mutation on submitForm event and hides a form', () => { + findReplyForm().vm.$emit('submitForm'); + expect(mutate).toHaveBeenCalled(); + + return mutate() + .then(() => { + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(findReplyForm().exists()).toBe(false); + expect(findNoteContent().exists()).toBe(true); + }); + }); + }); + }); +}); diff --git a/spec/frontend/design_management_new/components/design_notes/design_reply_form_spec.js b/spec/frontend/design_management_new/components/design_notes/design_reply_form_spec.js new file mode 100644 index 00000000000..9c1d6154516 --- /dev/null +++ b/spec/frontend/design_management_new/components/design_notes/design_reply_form_spec.js @@ -0,0 +1,184 @@ +import { mount } from '@vue/test-utils'; +import DesignReplyForm from '~/design_management_new/components/design_notes/design_reply_form.vue'; + +const showModal = jest.fn(); + +const GlModal = { + template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>', + methods: { + show: showModal, + }, +}; + +describe('Design reply form component', () => { + let wrapper; + + const findTextarea = () => wrapper.find('textarea'); + const findSubmitButton = () => wrapper.find({ ref: 'submitButton' }); + const findCancelButton = () => wrapper.find({ ref: 'cancelButton' }); + const findModal = () => wrapper.find({ ref: 'cancelCommentModal' }); + + function createComponent(props = {}, mountOptions = {}) { + wrapper = mount(DesignReplyForm, { + propsData: { + value: '', + isSaving: false, + ...props, + }, + stubs: { GlModal }, + ...mountOptions, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('textarea has focus after component mount', () => { + // We need to attach to document, so that `document.activeElement` is properly set in jsdom + createComponent({}, { attachToDocument: true }); + + expect(findTextarea().element).toEqual(document.activeElement); + }); + + it('renders button text as "Comment" when creating a comment', () => { + createComponent(); + + expect(findSubmitButton().html()).toMatchSnapshot(); + }); + + it('renders button text as "Save comment" when creating a comment', () => { + createComponent({ isNewComment: false }); + + expect(findSubmitButton().html()).toMatchSnapshot(); + }); + + describe('when form has no text', () => { + beforeEach(() => { + createComponent({ + value: '', + }); + }); + + it('submit button is disabled', () => { + expect(findSubmitButton().attributes().disabled).toBeTruthy(); + }); + + it('does not emit submitForm event on textarea ctrl+enter keydown', () => { + findTextarea().trigger('keydown.enter', { + ctrlKey: true, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('submitForm')).toBeFalsy(); + }); + }); + + it('does not emit submitForm event on textarea meta+enter keydown', () => { + findTextarea().trigger('keydown.enter', { + metaKey: true, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('submitForm')).toBeFalsy(); + }); + }); + + it('emits cancelForm event on pressing escape button on textarea', () => { + findTextarea().trigger('keyup.esc'); + + expect(wrapper.emitted('cancelForm')).toBeTruthy(); + }); + + it('emits cancelForm event on clicking Cancel button', () => { + findCancelButton().vm.$emit('click'); + + expect(wrapper.emitted('cancelForm')).toHaveLength(1); + }); + }); + + describe('when form has text', () => { + beforeEach(() => { + createComponent({ + value: 'test', + }); + }); + + it('submit button is enabled', () => { + expect(findSubmitButton().attributes().disabled).toBeFalsy(); + }); + + it('emits submitForm event on Comment button click', () => { + findSubmitButton().vm.$emit('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('submitForm')).toBeTruthy(); + }); + }); + + it('emits submitForm event on textarea ctrl+enter keydown', () => { + findTextarea().trigger('keydown.enter', { + ctrlKey: true, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('submitForm')).toBeTruthy(); + }); + }); + + it('emits submitForm event on textarea meta+enter keydown', () => { + findTextarea().trigger('keydown.enter', { + metaKey: true, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('submitForm')).toBeTruthy(); + }); + }); + + it('emits input event on changing textarea content', () => { + findTextarea().setValue('test2'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('input')).toBeTruthy(); + }); + }); + + it('emits cancelForm event on Escape key if text was not changed', () => { + findTextarea().trigger('keyup.esc'); + + expect(wrapper.emitted('cancelForm')).toBeTruthy(); + }); + + it('opens confirmation modal on Escape key when text has changed', () => { + wrapper.setProps({ value: 'test2' }); + + return wrapper.vm.$nextTick().then(() => { + findTextarea().trigger('keyup.esc'); + expect(showModal).toHaveBeenCalled(); + }); + }); + + it('emits cancelForm event on Cancel button click if text was not changed', () => { + findCancelButton().trigger('click'); + + expect(wrapper.emitted('cancelForm')).toBeTruthy(); + }); + + it('opens confirmation modal on Cancel button click when text has changed', () => { + wrapper.setProps({ value: 'test2' }); + + return wrapper.vm.$nextTick().then(() => { + findCancelButton().trigger('click'); + expect(showModal).toHaveBeenCalled(); + }); + }); + + it('emits cancelForm event on modal Ok button click', () => { + findTextarea().trigger('keyup.esc'); + findModal().vm.$emit('ok'); + + expect(wrapper.emitted('cancelForm')).toBeTruthy(); + }); + }); +}); diff --git a/spec/frontend/design_management_new/components/design_notes/toggle_replies_widget_spec.js b/spec/frontend/design_management_new/components/design_notes/toggle_replies_widget_spec.js new file mode 100644 index 00000000000..d3c89075a24 --- /dev/null +++ b/spec/frontend/design_management_new/components/design_notes/toggle_replies_widget_spec.js @@ -0,0 +1,98 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon, GlButton, GlLink } from '@gitlab/ui'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import ToggleRepliesWidget from '~/design_management_new/components/design_notes/toggle_replies_widget.vue'; +import notes from '../../mock_data/notes'; + +describe('Toggle replies widget component', () => { + let wrapper; + + const findToggleWrapper = () => wrapper.find('[data-testid="toggle-comments-wrapper"]'); + const findIcon = () => wrapper.find(GlIcon); + const findButton = () => wrapper.find(GlButton); + const findAuthorLink = () => wrapper.find(GlLink); + const findTimeAgo = () => wrapper.find(TimeAgoTooltip); + + function createComponent(props = {}) { + wrapper = shallowMount(ToggleRepliesWidget, { + propsData: { + collapsed: true, + replies: notes, + ...props, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when replies are collapsed', () => { + beforeEach(() => { + createComponent(); + }); + + it('should not have expanded class', () => { + expect(findToggleWrapper().classes()).not.toContain('expanded'); + }); + + it('should render chevron-right icon', () => { + expect(findIcon().props('name')).toBe('chevron-right'); + }); + + it('should have replies length on button', () => { + expect(findButton().text()).toBe('2 replies'); + }); + + it('should render a link to the last reply author', () => { + expect(findAuthorLink().exists()).toBe(true); + expect(findAuthorLink().text()).toBe(notes[1].author.name); + expect(findAuthorLink().attributes('href')).toBe(notes[1].author.webUrl); + }); + + it('should render correct time ago tooltip', () => { + expect(findTimeAgo().exists()).toBe(true); + expect(findTimeAgo().props('time')).toBe(notes[1].createdAt); + }); + }); + + describe('when replies are expanded', () => { + beforeEach(() => { + createComponent({ collapsed: false }); + }); + + it('should have expanded class', () => { + expect(findToggleWrapper().classes()).toContain('expanded'); + }); + + it('should render chevron-down icon', () => { + expect(findIcon().props('name')).toBe('chevron-down'); + }); + + it('should have Collapse replies text on button', () => { + expect(findButton().text()).toBe('Collapse replies'); + }); + + it('should not have a link to the last reply author', () => { + expect(findAuthorLink().exists()).toBe(false); + }); + + it('should not render time ago tooltip', () => { + expect(findTimeAgo().exists()).toBe(false); + }); + }); + + it('should emit toggle event on icon click', () => { + createComponent(); + findIcon().vm.$emit('click', new MouseEvent('click')); + + expect(wrapper.emitted('toggle')).toHaveLength(1); + }); + + it('should emit toggle event on button click', () => { + createComponent(); + findButton().vm.$emit('click', new MouseEvent('click')); + + expect(wrapper.emitted('toggle')).toHaveLength(1); + }); +}); diff --git a/spec/frontend/design_management_new/components/design_overlay_spec.js b/spec/frontend/design_management_new/components/design_overlay_spec.js new file mode 100644 index 00000000000..4ca69c143a8 --- /dev/null +++ b/spec/frontend/design_management_new/components/design_overlay_spec.js @@ -0,0 +1,410 @@ +import { mount } from '@vue/test-utils'; +import DesignOverlay from '~/design_management_new/components/design_overlay.vue'; +import updateActiveDiscussion from '~/design_management_new/graphql/mutations/update_active_discussion.mutation.graphql'; +import notes from '../mock_data/notes'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '~/design_management_new/constants'; + +const mutate = jest.fn(() => Promise.resolve()); + +describe('Design overlay component', () => { + let wrapper; + + const mockDimensions = { width: 100, height: 100 }; + + const findOverlay = () => wrapper.find('.image-diff-overlay'); + const findAllNotes = () => wrapper.findAll('.js-image-badge'); + const findCommentBadge = () => wrapper.find('.comment-indicator'); + const findFirstBadge = () => findAllNotes().at(0); + const findSecondBadge = () => findAllNotes().at(1); + + const clickAndDragBadge = (elem, fromPoint, toPoint) => { + elem.trigger('mousedown', { clientX: fromPoint.x, clientY: fromPoint.y }); + return wrapper.vm.$nextTick().then(() => { + elem.trigger('mousemove', { clientX: toPoint.x, clientY: toPoint.y }); + return wrapper.vm.$nextTick(); + }); + }; + + function createComponent(props = {}, data = {}) { + wrapper = mount(DesignOverlay, { + propsData: { + dimensions: mockDimensions, + position: { + top: '0', + left: '0', + }, + resolvedDiscussionsExpanded: false, + ...props, + }, + data() { + return { + activeDiscussion: { + id: null, + source: null, + }, + ...data, + }; + }, + mocks: { + $apollo: { + mutate, + }, + }, + }); + } + + it('should have correct inline style', () => { + createComponent(); + + expect(wrapper.find('.image-diff-overlay').attributes().style).toBe( + 'width: 100px; height: 100px; top: 0px; left: 0px;', + ); + }); + + it('should emit `openCommentForm` when clicking on overlay', () => { + createComponent(); + const newCoordinates = { + x: 10, + y: 10, + }; + + wrapper + .find('.image-diff-overlay-add-comment') + .trigger('mouseup', { offsetX: newCoordinates.x, offsetY: newCoordinates.y }); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('openCommentForm')).toEqual([ + [{ x: newCoordinates.x, y: newCoordinates.y }], + ]); + }); + }); + + describe('with notes', () => { + it('should render only the first note', () => { + createComponent({ + notes, + }); + expect(findAllNotes()).toHaveLength(1); + }); + + describe('with resolved discussions toggle expanded', () => { + beforeEach(() => { + createComponent({ + notes, + resolvedDiscussionsExpanded: true, + }); + }); + + it('should render all notes', () => { + expect(findAllNotes()).toHaveLength(notes.length); + }); + + it('should have set the correct position for each note badge', () => { + expect(findFirstBadge().attributes().style).toBe('left: 10px; top: 15px;'); + expect(findSecondBadge().attributes().style).toBe('left: 50px; top: 50px;'); + }); + + it('should apply resolved class to the resolved note pin', () => { + expect(findSecondBadge().classes()).toContain('resolved'); + }); + + it('when there is an active discussion, should apply inactive class to all pins besides the active one', () => { + wrapper.setData({ + activeDiscussion: { + id: notes[0].id, + source: 'discussion', + }, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(findSecondBadge().classes()).toContain('inactive'); + }); + }); + }); + + it('should recalculate badges positions on window resize', () => { + createComponent({ + notes, + dimensions: { + width: 400, + height: 400, + }, + }); + + expect(findFirstBadge().attributes().style).toBe('left: 40px; top: 60px;'); + + wrapper.setProps({ + dimensions: { + width: 200, + height: 200, + }, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 30px;'); + }); + }); + + it('should call an update active discussion mutation when clicking a note without moving it', () => { + const note = notes[0]; + const { position } = note; + const mutationVariables = { + mutation: updateActiveDiscussion, + variables: { + id: note.id, + source: ACTIVE_DISCUSSION_SOURCE_TYPES.pin, + }, + }; + + findFirstBadge().trigger('mousedown', { clientX: position.x, clientY: position.y }); + + return wrapper.vm.$nextTick().then(() => { + findFirstBadge().trigger('mouseup', { clientX: position.x, clientY: position.y }); + expect(mutate).toHaveBeenCalledWith(mutationVariables); + }); + }); + }); + + describe('when moving notes', () => { + it('should update badge style when note is being moved', () => { + createComponent({ + notes, + }); + + const { position } = notes[0]; + + return clickAndDragBadge( + findFirstBadge(), + { x: position.x, y: position.y }, + { x: 20, y: 20 }, + ).then(() => { + expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 20px; cursor: move;'); + }); + }); + + it('should emit `moveNote` event when note-moving action ends', () => { + createComponent({ notes }); + const note = notes[0]; + const { position } = note; + const newCoordinates = { x: 20, y: 20 }; + + wrapper.setData({ + movingNoteNewPosition: { + ...position, + ...newCoordinates, + }, + movingNoteStartPosition: { + noteId: notes[0].id, + discussionId: notes[0].discussion.id, + ...position, + }, + }); + + const badge = findFirstBadge(); + return clickAndDragBadge(badge, { x: position.x, y: position.y }, newCoordinates) + .then(() => { + badge.trigger('mouseup'); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(wrapper.emitted('moveNote')).toEqual([ + [ + { + noteId: notes[0].id, + discussionId: notes[0].discussion.id, + coordinates: newCoordinates, + }, + ], + ]); + }); + }); + + describe('without [adminNote] permission', () => { + const mockNoteNotAuthorised = { + ...notes[0], + userPermissions: { + adminNote: false, + }, + }; + + const mockNoteCoordinates = { + x: mockNoteNotAuthorised.position.x, + y: mockNoteNotAuthorised.position.y, + }; + + it('should be unable to move a note', () => { + createComponent({ + dimensions: mockDimensions, + notes: [mockNoteNotAuthorised], + }); + + const badge = findAllNotes().at(0); + return clickAndDragBadge(badge, { ...mockNoteCoordinates }, { x: 20, y: 20 }).then(() => { + // note position should not change after a click-and-drag attempt + expect(findFirstBadge().attributes().style).toContain( + `left: ${mockNoteCoordinates.x}px; top: ${mockNoteCoordinates.y}px;`, + ); + }); + }); + }); + }); + + describe('with a new form', () => { + it('should render a new comment badge', () => { + createComponent({ + currentCommentForm: { + ...notes[0].position, + }, + }); + + expect(findCommentBadge().exists()).toBe(true); + expect(findCommentBadge().attributes().style).toBe('left: 10px; top: 15px;'); + }); + + describe('when moving the comment badge', () => { + it('should update badge style to reflect new position', () => { + const { position } = notes[0]; + + createComponent({ + currentCommentForm: { + ...position, + }, + }); + + return clickAndDragBadge( + findCommentBadge(), + { x: position.x, y: position.y }, + { x: 20, y: 20 }, + ).then(() => { + expect(findCommentBadge().attributes().style).toBe( + 'left: 20px; top: 20px; cursor: move;', + ); + }); + }); + + it('should update badge style when note-moving action ends', () => { + const { position } = notes[0]; + createComponent({ + currentCommentForm: { + ...position, + }, + }); + + const commentBadge = findCommentBadge(); + const toPoint = { x: 20, y: 20 }; + + return clickAndDragBadge(commentBadge, { x: position.x, y: position.y }, toPoint) + .then(() => { + commentBadge.trigger('mouseup'); + // simulates the currentCommentForm being updated in index.vue component, and + // propagated back down to this prop + wrapper.setProps({ + currentCommentForm: { height: position.height, width: position.width, ...toPoint }, + }); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(commentBadge.attributes().style).toBe('left: 20px; top: 20px;'); + }); + }); + + it.each` + element | getElementFunc | event + ${'overlay'} | ${findOverlay} | ${'mouseleave'} + ${'comment badge'} | ${findCommentBadge} | ${'mouseup'} + `( + 'should emit `openCommentForm` event when $event fired on $element element', + ({ getElementFunc, event }) => { + createComponent({ + notes, + currentCommentForm: { + ...notes[0].position, + }, + }); + + const newCoordinates = { x: 20, y: 20 }; + wrapper.setData({ + movingNoteStartPosition: { + ...notes[0].position, + }, + movingNoteNewPosition: { + ...notes[0].position, + ...newCoordinates, + }, + }); + + getElementFunc().trigger(event); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('openCommentForm')).toEqual([[newCoordinates]]); + }); + }, + ); + }); + }); + + describe('getMovingNotePositionDelta', () => { + it('should calculate delta correctly from state', () => { + createComponent(); + + wrapper.setData({ + movingNoteStartPosition: { + clientX: 10, + clientY: 20, + }, + }); + + const mockMouseEvent = { + clientX: 30, + clientY: 10, + }; + + expect(wrapper.vm.getMovingNotePositionDelta(mockMouseEvent)).toEqual({ + deltaX: 20, + deltaY: -10, + }); + }); + }); + + describe('isPositionInOverlay', () => { + createComponent({ dimensions: mockDimensions }); + + it.each` + test | coordinates | expectedResult + ${'within overlay bounds'} | ${{ x: 50, y: 50 }} | ${true} + ${'outside overlay bounds'} | ${{ x: 101, y: 101 }} | ${false} + `('returns [$expectedResult] when position is $test', ({ coordinates, expectedResult }) => { + const position = { ...mockDimensions, ...coordinates }; + + expect(wrapper.vm.isPositionInOverlay(position)).toBe(expectedResult); + }); + }); + + describe('getNoteRelativePosition', () => { + it('calculates position correctly', () => { + createComponent({ dimensions: mockDimensions }); + const position = { x: 50, y: 50, width: 200, height: 200 }; + + expect(wrapper.vm.getNoteRelativePosition(position)).toEqual({ left: 25, top: 25 }); + }); + }); + + describe('canMoveNote', () => { + it.each` + adminNotePermission | canMoveNoteResult + ${true} | ${true} + ${false} | ${false} + ${undefined} | ${false} + `( + 'returns [$canMoveNoteResult] when [adminNote permission] is [$adminNotePermission]', + ({ adminNotePermission, canMoveNoteResult }) => { + createComponent(); + + const note = { + userPermissions: { + adminNote: adminNotePermission, + }, + }; + expect(wrapper.vm.canMoveNote(note)).toBe(canMoveNoteResult); + }, + ); + }); +}); diff --git a/spec/frontend/design_management_new/components/design_presentation_spec.js b/spec/frontend/design_management_new/components/design_presentation_spec.js new file mode 100644 index 00000000000..d043a762cd2 --- /dev/null +++ b/spec/frontend/design_management_new/components/design_presentation_spec.js @@ -0,0 +1,553 @@ +import { shallowMount } from '@vue/test-utils'; +import DesignPresentation from '~/design_management_new/components/design_presentation.vue'; +import DesignOverlay from '~/design_management_new/components/design_overlay.vue'; + +const mockOverlayData = { + overlayDimensions: { + width: 100, + height: 100, + }, + overlayPosition: { + top: '0', + left: '0', + }, +}; + +describe('Design management design presentation component', () => { + let wrapper; + + function createComponent( + { + image, + imageName, + discussions = [], + isAnnotating = false, + resolvedDiscussionsExpanded = false, + } = {}, + data = {}, + stubs = {}, + ) { + wrapper = shallowMount(DesignPresentation, { + propsData: { + image, + imageName, + discussions, + isAnnotating, + resolvedDiscussionsExpanded, + }, + stubs, + }); + + wrapper.setData(data); + wrapper.element.scrollTo = jest.fn(); + } + + const findOverlayCommentButton = () => wrapper.find('.image-diff-overlay-add-comment'); + + /** + * Spy on $refs and mock given values + * @param {Object} viewportDimensions {width, height} + * @param {Object} childDimensions {width, height} + * @param {Float} scrollTopPerc 0 < x < 1 + * @param {Float} scrollLeftPerc 0 < x < 1 + */ + function mockRefDimensions( + ref, + viewportDimensions, + childDimensions, + scrollTopPerc, + scrollLeftPerc, + ) { + jest.spyOn(ref, 'scrollWidth', 'get').mockReturnValue(childDimensions.width); + jest.spyOn(ref, 'scrollHeight', 'get').mockReturnValue(childDimensions.height); + jest.spyOn(ref, 'offsetWidth', 'get').mockReturnValue(viewportDimensions.width); + jest.spyOn(ref, 'offsetHeight', 'get').mockReturnValue(viewportDimensions.height); + jest + .spyOn(ref, 'scrollLeft', 'get') + .mockReturnValue((childDimensions.width - viewportDimensions.width) * scrollLeftPerc); + jest + .spyOn(ref, 'scrollTop', 'get') + .mockReturnValue((childDimensions.height - viewportDimensions.height) * scrollTopPerc); + } + + function clickDragExplore(startCoords, endCoords, { useTouchEvents, mouseup } = {}) { + const event = useTouchEvents + ? { + mousedown: 'touchstart', + mousemove: 'touchmove', + mouseup: 'touchend', + } + : { + mousedown: 'mousedown', + mousemove: 'mousemove', + mouseup: 'mouseup', + }; + + const addCommentOverlay = findOverlayCommentButton(); + + // triggering mouse events on this element best simulates + // reality, as it is the lowest-level node that needs to + // respond to mouse events + addCommentOverlay.trigger(event.mousedown, { + clientX: startCoords.clientX, + clientY: startCoords.clientY, + }); + return wrapper.vm + .$nextTick() + .then(() => { + addCommentOverlay.trigger(event.mousemove, { + clientX: endCoords.clientX, + clientY: endCoords.clientY, + }); + + return wrapper.vm.$nextTick(); + }) + .then(() => { + if (mouseup) { + addCommentOverlay.trigger(event.mouseup); + return wrapper.vm.$nextTick(); + } + + return undefined; + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders image and overlay when image provided', () => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + }, + mockOverlayData, + ); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('renders empty state when no image provided', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('openCommentForm event emits correct data', () => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + }, + mockOverlayData, + ); + + wrapper.vm.openCommentForm({ x: 1, y: 1 }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('openCommentForm')).toEqual([ + [{ ...mockOverlayData.overlayDimensions, x: 1, y: 1 }], + ]); + }); + }); + + describe('currentCommentForm', () => { + it('is null when isAnnotating is false', () => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + }, + mockOverlayData, + ); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.currentCommentForm).toBeNull(); + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('is null when isAnnotating is true but annotation position is falsey', () => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + isAnnotating: true, + }, + mockOverlayData, + ); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.currentCommentForm).toBeNull(); + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('is equal to current annotation position when isAnnotating is true', () => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + isAnnotating: true, + }, + { + ...mockOverlayData, + currentAnnotationPosition: { + x: 1, + y: 1, + width: 100, + height: 100, + }, + }, + ); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.currentCommentForm).toEqual({ + x: 1, + y: 1, + width: 100, + height: 100, + }); + expect(wrapper.element).toMatchSnapshot(); + }); + }); + }); + + describe('setOverlayPosition', () => { + beforeEach(() => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + }, + mockOverlayData, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('sets overlay position correctly when overlay is smaller than viewport', () => { + jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(200); + jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(200); + + wrapper.vm.setOverlayPosition(); + expect(wrapper.vm.overlayPosition).toEqual({ + left: `calc(50% - ${mockOverlayData.overlayDimensions.width / 2}px)`, + top: `calc(50% - ${mockOverlayData.overlayDimensions.height / 2}px)`, + }); + }); + + it('sets overlay position correctly when overlay width is larger than viewports', () => { + jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(50); + jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(200); + + wrapper.vm.setOverlayPosition(); + expect(wrapper.vm.overlayPosition).toEqual({ + left: '0', + top: `calc(50% - ${mockOverlayData.overlayDimensions.height / 2}px)`, + }); + }); + + it('sets overlay position correctly when overlay height is larger than viewports', () => { + jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(200); + jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(50); + + wrapper.vm.setOverlayPosition(); + expect(wrapper.vm.overlayPosition).toEqual({ + left: `calc(50% - ${mockOverlayData.overlayDimensions.width / 2}px)`, + top: '0', + }); + }); + }); + + describe('getViewportCenter', () => { + beforeEach(() => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + }, + mockOverlayData, + ); + }); + + it('calculate center correctly with no scroll', () => { + mockRefDimensions( + wrapper.vm.$refs.presentationViewport, + { width: 10, height: 10 }, + { width: 20, height: 20 }, + 0, + 0, + ); + + expect(wrapper.vm.getViewportCenter()).toEqual({ + x: 5, + y: 5, + }); + }); + + it('calculate center correctly with some scroll', () => { + mockRefDimensions( + wrapper.vm.$refs.presentationViewport, + { width: 10, height: 10 }, + { width: 20, height: 20 }, + 0.5, + 0.5, + ); + + expect(wrapper.vm.getViewportCenter()).toEqual({ + x: 10, + y: 10, + }); + }); + + it('Returns default case if no overflow (scrollWidth==offsetWidth, etc.)', () => { + mockRefDimensions( + wrapper.vm.$refs.presentationViewport, + { width: 20, height: 20 }, + { width: 20, height: 20 }, + 0.5, + 0.5, + ); + + expect(wrapper.vm.getViewportCenter()).toEqual({ + x: 10, + y: 10, + }); + }); + }); + + describe('scaleZoomFocalPoint', () => { + it('scales focal point correctly when zooming in', () => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + }, + { + ...mockOverlayData, + zoomFocalPoint: { + x: 5, + y: 5, + width: 50, + height: 50, + }, + }, + ); + + wrapper.vm.scaleZoomFocalPoint(); + expect(wrapper.vm.zoomFocalPoint).toEqual({ + x: 10, + y: 10, + width: 100, + height: 100, + }); + }); + + it('scales focal point correctly when zooming out', () => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + }, + { + ...mockOverlayData, + zoomFocalPoint: { + x: 10, + y: 10, + width: 200, + height: 200, + }, + }, + ); + + wrapper.vm.scaleZoomFocalPoint(); + expect(wrapper.vm.zoomFocalPoint).toEqual({ + x: 5, + y: 5, + width: 100, + height: 100, + }); + }); + }); + + describe('onImageResize', () => { + it('sets zoom focal point on initial load', () => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + }, + mockOverlayData, + ); + + wrapper.setMethods({ + shiftZoomFocalPoint: jest.fn(), + scaleZoomFocalPoint: jest.fn(), + scrollToFocalPoint: jest.fn(), + }); + + wrapper.vm.onImageResize({ width: 10, height: 10 }); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.shiftZoomFocalPoint).toHaveBeenCalled(); + expect(wrapper.vm.initialLoad).toBe(false); + }); + }); + + it('calls scaleZoomFocalPoint and scrollToFocalPoint after initial load', () => { + wrapper.vm.onImageResize({ width: 10, height: 10 }); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.scaleZoomFocalPoint).toHaveBeenCalled(); + expect(wrapper.vm.scrollToFocalPoint).toHaveBeenCalled(); + }); + }); + }); + + describe('onPresentationMousedown', () => { + it.each` + scenario | width | height + ${'width overflows'} | ${101} | ${100} + ${'height overflows'} | ${100} | ${101} + ${'width and height overflows'} | ${200} | ${200} + `('sets lastDragPosition when design $scenario', ({ width, height }) => { + createComponent(); + mockRefDimensions( + wrapper.vm.$refs.presentationViewport, + { width: 100, height: 100 }, + { width, height }, + ); + + const newLastDragPosition = { x: 2, y: 2 }; + wrapper.vm.onPresentationMousedown({ + clientX: newLastDragPosition.x, + clientY: newLastDragPosition.y, + }); + + expect(wrapper.vm.lastDragPosition).toStrictEqual(newLastDragPosition); + }); + + it('does not set lastDragPosition if design does not overflow', () => { + const lastDragPosition = { x: 1, y: 1 }; + + createComponent({}, { lastDragPosition }); + mockRefDimensions( + wrapper.vm.$refs.presentationViewport, + { width: 100, height: 100 }, + { width: 50, height: 50 }, + ); + + wrapper.vm.onPresentationMousedown({ clientX: 2, clientY: 2 }); + + // check lastDragPosition is unchanged + expect(wrapper.vm.lastDragPosition).toStrictEqual(lastDragPosition); + }); + }); + + describe('getAnnotationPositon', () => { + it.each` + coordinates | overlayDimensions | position + ${{ x: 100, y: 100 }} | ${{ width: 50, height: 50 }} | ${{ x: 100, y: 100, width: 50, height: 50 }} + ${{ x: 100.2, y: 100.5 }} | ${{ width: 50.6, height: 50.0 }} | ${{ x: 100, y: 101, width: 51, height: 50 }} + `('returns correct annotation position', ({ coordinates, overlayDimensions, position }) => { + createComponent(undefined, { + overlayDimensions: { + width: overlayDimensions.width, + height: overlayDimensions.height, + }, + }); + + expect(wrapper.vm.getAnnotationPositon(coordinates)).toStrictEqual(position); + }); + }); + + describe('when design is overflowing', () => { + beforeEach(() => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + }, + mockOverlayData, + { + 'design-overlay': DesignOverlay, + }, + ); + + // mock a design that overflows + mockRefDimensions( + wrapper.vm.$refs.presentationViewport, + { width: 10, height: 10 }, + { width: 20, height: 20 }, + 0, + 0, + ); + }); + + it('opens a comment form if design was not dragged', () => { + const addCommentOverlay = findOverlayCommentButton(); + const startCoords = { + clientX: 1, + clientY: 1, + }; + + addCommentOverlay.trigger('mousedown', { + clientX: startCoords.clientX, + clientY: startCoords.clientY, + }); + + return wrapper.vm + .$nextTick() + .then(() => { + addCommentOverlay.trigger('mouseup'); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(wrapper.emitted('openCommentForm')).toBeDefined(); + }); + }); + + describe('when clicking and dragging', () => { + it.each` + description | useTouchEvents + ${'with touch events'} | ${true} + ${'without touch events'} | ${false} + `('calls scrollTo with correct arguments $description', ({ useTouchEvents }) => { + return clickDragExplore( + { clientX: 0, clientY: 0 }, + { clientX: 10, clientY: 10 }, + { useTouchEvents }, + ).then(() => { + expect(wrapper.element.scrollTo).toHaveBeenCalledTimes(1); + expect(wrapper.element.scrollTo).toHaveBeenCalledWith(-10, -10); + }); + }); + + it('does not open a comment form when drag position exceeds buffer', () => { + return clickDragExplore( + { clientX: 0, clientY: 0 }, + { clientX: 10, clientY: 10 }, + { mouseup: true }, + ).then(() => { + expect(wrapper.emitted('openCommentForm')).toBeFalsy(); + }); + }); + + it('opens a comment form when drag position is within buffer', () => { + return clickDragExplore( + { clientX: 0, clientY: 0 }, + { clientX: 1, clientY: 0 }, + { mouseup: true }, + ).then(() => { + expect(wrapper.emitted('openCommentForm')).toBeDefined(); + }); + }); + }); + }); +}); diff --git a/spec/frontend/design_management_new/components/design_scaler_spec.js b/spec/frontend/design_management_new/components/design_scaler_spec.js new file mode 100644 index 00000000000..5ff2554cd60 --- /dev/null +++ b/spec/frontend/design_management_new/components/design_scaler_spec.js @@ -0,0 +1,67 @@ +import { shallowMount } from '@vue/test-utils'; +import DesignScaler from '~/design_management_new/components/design_scaler.vue'; + +describe('Design management design scaler component', () => { + let wrapper; + + function createComponent(propsData, data = {}) { + wrapper = shallowMount(DesignScaler, { + propsData, + }); + wrapper.setData(data); + } + + afterEach(() => { + wrapper.destroy(); + }); + + const getButton = type => { + const buttonTypeOrder = ['minus', 'reset', 'plus']; + const buttons = wrapper.findAll('button'); + return buttons.at(buttonTypeOrder.indexOf(type)); + }; + + it('emits @scale event when "plus" button clicked', () => { + createComponent(); + + getButton('plus').trigger('click'); + expect(wrapper.emitted('scale')).toEqual([[1.2]]); + }); + + it('emits @scale event when "reset" button clicked (scale > 1)', () => { + createComponent({}, { scale: 1.6 }); + return wrapper.vm.$nextTick().then(() => { + getButton('reset').trigger('click'); + expect(wrapper.emitted('scale')).toEqual([[1]]); + }); + }); + + it('emits @scale event when "minus" button clicked (scale > 1)', () => { + createComponent({}, { scale: 1.6 }); + + return wrapper.vm.$nextTick().then(() => { + getButton('minus').trigger('click'); + expect(wrapper.emitted('scale')).toEqual([[1.4]]); + }); + }); + + it('minus and reset buttons are disabled when scale === 1', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('minus and reset buttons are enabled when scale > 1', () => { + createComponent({}, { scale: 1.2 }); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('plus button is disabled when scale === 2', () => { + createComponent({}, { scale: 2 }); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); +}); diff --git a/spec/frontend/design_management_new/components/design_sidebar_spec.js b/spec/frontend/design_management_new/components/design_sidebar_spec.js new file mode 100644 index 00000000000..f1d442a7b21 --- /dev/null +++ b/spec/frontend/design_management_new/components/design_sidebar_spec.js @@ -0,0 +1,236 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlCollapse, GlPopover } from '@gitlab/ui'; +import Cookies from 'js-cookie'; +import DesignSidebar from '~/design_management_new/components/design_sidebar.vue'; +import Participants from '~/sidebar/components/participants/participants.vue'; +import DesignDiscussion from '~/design_management_new/components/design_notes/design_discussion.vue'; +import design from '../mock_data/design'; +import updateActiveDiscussionMutation from '~/design_management_new/graphql/mutations/update_active_discussion.mutation.graphql'; + +const updateActiveDiscussionMutationVariables = { + mutation: updateActiveDiscussionMutation, + variables: { + id: design.discussions.nodes[0].notes.nodes[0].id, + source: 'discussion', + }, +}; + +const $route = { + params: { + id: '1', + }, +}; + +const cookieKey = 'hide_design_resolved_comments_popover'; + +const mutate = jest.fn().mockResolvedValue(); + +describe('Design management design sidebar component', () => { + let wrapper; + + const findDiscussions = () => wrapper.findAll(DesignDiscussion); + const findFirstDiscussion = () => findDiscussions().at(0); + const findUnresolvedDiscussions = () => wrapper.findAll('[data-testid="unresolved-discussion"]'); + const findResolvedDiscussions = () => wrapper.findAll('[data-testid="resolved-discussion"]'); + const findParticipants = () => wrapper.find(Participants); + const findCollapsible = () => wrapper.find(GlCollapse); + const findToggleResolvedCommentsButton = () => wrapper.find('[data-testid="resolved-comments"]'); + const findPopover = () => wrapper.find(GlPopover); + const findNewDiscussionDisclaimer = () => + wrapper.find('[data-testid="new-discussion-disclaimer"]'); + + function createComponent(props = {}) { + wrapper = shallowMount(DesignSidebar, { + propsData: { + design, + resolvedDiscussionsExpanded: false, + markdownPreviewPath: '', + ...props, + }, + mocks: { + $route, + $apollo: { + mutate, + }, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders participants', () => { + createComponent(); + + expect(findParticipants().exists()).toBe(true); + }); + + it('passes the correct amount of participants to the Participants component', () => { + createComponent(); + + expect(findParticipants().props('participants')).toHaveLength(1); + }); + + describe('when has no discussions', () => { + beforeEach(() => { + createComponent({ + design: { + ...design, + discussions: { + nodes: [], + }, + }, + }); + }); + + it('does not render discussions', () => { + expect(findDiscussions().exists()).toBe(false); + }); + + it('renders a message about possibility to create a new discussion', () => { + expect(findNewDiscussionDisclaimer().exists()).toBe(true); + }); + }); + + describe('when has discussions', () => { + beforeEach(() => { + Cookies.set(cookieKey, true); + createComponent(); + }); + + it('renders correct amount of unresolved discussions', () => { + expect(findUnresolvedDiscussions()).toHaveLength(1); + }); + + it('renders correct amount of resolved discussions', () => { + expect(findResolvedDiscussions()).toHaveLength(1); + }); + + it('has resolved comments collapsible collapsed', () => { + expect(findCollapsible().attributes('visible')).toBeUndefined(); + }); + + it('emits toggleResolveComments event on resolve comments button click', () => { + findToggleResolvedCommentsButton().vm.$emit('click'); + expect(wrapper.emitted('toggleResolvedComments')).toHaveLength(1); + }); + + it('opens a collapsible when resolvedDiscussionsExpanded prop changes to true', () => { + expect(findCollapsible().attributes('visible')).toBeUndefined(); + wrapper.setProps({ + resolvedDiscussionsExpanded: true, + }); + return wrapper.vm.$nextTick().then(() => { + expect(findCollapsible().attributes('visible')).toBe('true'); + }); + }); + + it('does not popover about resolved comments', () => { + expect(findPopover().exists()).toBe(false); + }); + + it('sends a mutation to set an active discussion when clicking on a discussion', () => { + findFirstDiscussion().trigger('click'); + + expect(mutate).toHaveBeenCalledWith(updateActiveDiscussionMutationVariables); + }); + + it('sends a mutation to reset an active discussion when clicking outside of discussion', () => { + wrapper.trigger('click'); + + expect(mutate).toHaveBeenCalledWith({ + ...updateActiveDiscussionMutationVariables, + variables: { id: undefined, source: 'discussion' }, + }); + }); + + it('emits correct event on discussion create note error', () => { + findFirstDiscussion().vm.$emit('createNoteError', 'payload'); + expect(wrapper.emitted('onDesignDiscussionError')).toEqual([['payload']]); + }); + + it('emits correct event on discussion update note error', () => { + findFirstDiscussion().vm.$emit('updateNoteError', 'payload'); + expect(wrapper.emitted('updateNoteError')).toEqual([['payload']]); + }); + + it('emits correct event on discussion resolve error', () => { + findFirstDiscussion().vm.$emit('resolveDiscussionError', 'payload'); + expect(wrapper.emitted('resolveDiscussionError')).toEqual([['payload']]); + }); + + it('changes prop correctly on opening discussion form', () => { + findFirstDiscussion().vm.$emit('openForm', 'some-id'); + + return wrapper.vm.$nextTick().then(() => { + expect(findFirstDiscussion().props('discussionWithOpenForm')).toBe('some-id'); + }); + }); + }); + + describe('when all discussions are resolved', () => { + beforeEach(() => { + createComponent({ + design: { + ...design, + discussions: { + nodes: [ + { + id: 'discussion-id', + replyId: 'discussion-reply-id', + resolved: true, + notes: { + nodes: [ + { + id: 'note-id', + body: '123', + author: { + name: 'Administrator', + username: 'root', + webUrl: 'link-to-author', + avatarUrl: 'link-to-avatar', + }, + }, + ], + }, + }, + ], + }, + }, + }); + }); + + it('renders a message about possibility to create a new discussion', () => { + expect(findNewDiscussionDisclaimer().exists()).toBe(true); + }); + + it('does not render unresolved discussions', () => { + expect(findUnresolvedDiscussions()).toHaveLength(0); + }); + }); + + describe('when showing resolved discussions for the first time', () => { + beforeEach(() => { + Cookies.set(cookieKey, false); + createComponent(); + }); + + it('renders a popover if we show resolved comments collapsible for the first time', () => { + expect(findPopover().exists()).toBe(true); + }); + + it('dismisses a popover on the outside click', () => { + wrapper.trigger('click'); + return wrapper.vm.$nextTick(() => { + expect(findPopover().exists()).toBe(false); + }); + }); + + it(`sets a ${cookieKey} cookie on clicking outside the popover`, () => { + jest.spyOn(Cookies, 'set'); + wrapper.trigger('click'); + expect(Cookies.set).toHaveBeenCalledWith(cookieKey, 'true', { expires: 365 * 10 }); + }); + }); +}); diff --git a/spec/frontend/design_management_new/components/image_spec.js b/spec/frontend/design_management_new/components/image_spec.js new file mode 100644 index 00000000000..c1a8a8767df --- /dev/null +++ b/spec/frontend/design_management_new/components/image_spec.js @@ -0,0 +1,133 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import DesignImage from '~/design_management_new/components/image.vue'; + +describe('Design management large image component', () => { + let wrapper; + + function createComponent(propsData, data = {}) { + wrapper = shallowMount(DesignImage, { + propsData, + }); + wrapper.setData(data); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders loading state', () => { + createComponent({ + isLoading: true, + }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders image', () => { + createComponent({ + isLoading: false, + image: 'test.jpg', + name: 'test', + }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('sets correct classes and styles if imageStyle is set', () => { + createComponent( + { + isLoading: false, + image: 'test.jpg', + name: 'test', + }, + { + imageStyle: { + width: '100px', + height: '100px', + }, + }, + ); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('renders media broken icon on error', () => { + createComponent({ + isLoading: false, + image: 'test.jpg', + name: 'test', + }); + + const image = wrapper.find('img'); + image.trigger('error'); + return wrapper.vm.$nextTick().then(() => { + expect(image.isVisible()).toBe(false); + expect(wrapper.find(GlIcon).element).toMatchSnapshot(); + }); + }); + + describe('zoom', () => { + const baseImageWidth = 100; + const baseImageHeight = 100; + + beforeEach(() => { + createComponent( + { + isLoading: false, + image: 'test.jpg', + name: 'test', + }, + { + imageStyle: { + width: `${baseImageWidth}px`, + height: `${baseImageHeight}px`, + }, + baseImageSize: { + width: baseImageWidth, + height: baseImageHeight, + }, + }, + ); + + jest.spyOn(wrapper.vm.$refs.contentImg, 'offsetWidth', 'get').mockReturnValue(baseImageWidth); + jest + .spyOn(wrapper.vm.$refs.contentImg, 'offsetHeight', 'get') + .mockReturnValue(baseImageHeight); + }); + + it('emits @resize event on zoom', () => { + const zoomAmount = 2; + wrapper.vm.zoom(zoomAmount); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('resize')).toEqual([ + [{ width: baseImageWidth * zoomAmount, height: baseImageHeight * zoomAmount }], + ]); + }); + }); + + it('emits @resize event with base image size when scale=1', () => { + wrapper.vm.zoom(1); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('resize')).toEqual([ + [{ width: baseImageWidth, height: baseImageHeight }], + ]); + }); + }); + + it('sets image style when zoomed', () => { + const zoomAmount = 2; + wrapper.vm.zoom(zoomAmount); + expect(wrapper.vm.imageStyle).toEqual({ + width: `${baseImageWidth * zoomAmount}px`, + height: `${baseImageHeight * zoomAmount}px`, + }); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/spec/frontend/design_management_new/components/list/__snapshots__/item_spec.js.snap b/spec/frontend/design_management_new/components/list/__snapshots__/item_spec.js.snap new file mode 100644 index 00000000000..8c6e20cb54c --- /dev/null +++ b/spec/frontend/design_management_new/components/list/__snapshots__/item_spec.js.snap @@ -0,0 +1,472 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management list item component when item appears in view after image is loaded renders media broken icon when image onerror triggered 1`] = ` +<gl-icon-stub + class="text-secondary" + name="media-broken" + size="32" +/> +`; + +exports[`Design management list item component with no notes renders item with correct status icon for creation event 1`] = ` +<router-link-stub + class="card cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new" + to="[object Object]" +> + <div + class="card-body p-0 d-flex-center overflow-hidden position-relative" + > + <div + class="design-event position-absolute" + > + <span + aria-label="Added in this version" + title="Added in this version" + > + <icon-stub + class="text-success-500" + name="file-addition-solid" + size="18" + /> + </span> + </div> + + <gl-intersection-observer-stub + options="[object Object]" + > + <!----> + + <img + alt="test" + class="block mx-auto mw-100 mh-100 design-img" + data-qa-selector="design_image" + src="" + /> + </gl-intersection-observer-stub> + </div> + + <div + class="card-footer d-flex w-100" + > + <div + class="d-flex flex-column str-truncated-100" + > + <span + class="bold str-truncated-100" + data-qa-selector="design_file_name" + > + test + </span> + + <span + class="str-truncated-100" + > + + Updated + <timeago-stub + cssclass="" + time="01-01-2019" + tooltipplacement="bottom" + /> + </span> + </div> + + <!----> + </div> +</router-link-stub> +`; + +exports[`Design management list item component with no notes renders item with correct status icon for deletion event 1`] = ` +<router-link-stub + class="card cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new" + to="[object Object]" +> + <div + class="card-body p-0 d-flex-center overflow-hidden position-relative" + > + <div + class="design-event position-absolute" + > + <span + aria-label="Deleted in this version" + title="Deleted in this version" + > + <icon-stub + class="text-danger-500" + name="file-deletion-solid" + size="18" + /> + </span> + </div> + + <gl-intersection-observer-stub + options="[object Object]" + > + <!----> + + <img + alt="test" + class="block mx-auto mw-100 mh-100 design-img" + data-qa-selector="design_image" + src="" + /> + </gl-intersection-observer-stub> + </div> + + <div + class="card-footer d-flex w-100" + > + <div + class="d-flex flex-column str-truncated-100" + > + <span + class="bold str-truncated-100" + data-qa-selector="design_file_name" + > + test + </span> + + <span + class="str-truncated-100" + > + + Updated + <timeago-stub + cssclass="" + time="01-01-2019" + tooltipplacement="bottom" + /> + </span> + </div> + + <!----> + </div> +</router-link-stub> +`; + +exports[`Design management list item component with no notes renders item with correct status icon for modification event 1`] = ` +<router-link-stub + class="card cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new" + to="[object Object]" +> + <div + class="card-body p-0 d-flex-center overflow-hidden position-relative" + > + <div + class="design-event position-absolute" + > + <span + aria-label="Modified in this version" + title="Modified in this version" + > + <icon-stub + class="text-primary-500" + name="file-modified-solid" + size="18" + /> + </span> + </div> + + <gl-intersection-observer-stub + options="[object Object]" + > + <!----> + + <img + alt="test" + class="block mx-auto mw-100 mh-100 design-img" + data-qa-selector="design_image" + src="" + /> + </gl-intersection-observer-stub> + </div> + + <div + class="card-footer d-flex w-100" + > + <div + class="d-flex flex-column str-truncated-100" + > + <span + class="bold str-truncated-100" + data-qa-selector="design_file_name" + > + test + </span> + + <span + class="str-truncated-100" + > + + Updated + <timeago-stub + cssclass="" + time="01-01-2019" + tooltipplacement="bottom" + /> + </span> + </div> + + <!----> + </div> +</router-link-stub> +`; + +exports[`Design management list item component with no notes renders item with no status icon for none event 1`] = ` +<router-link-stub + class="card cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new" + to="[object Object]" +> + <div + class="card-body p-0 d-flex-center overflow-hidden position-relative" + > + <!----> + + <gl-intersection-observer-stub + options="[object Object]" + > + <!----> + + <img + alt="test" + class="block mx-auto mw-100 mh-100 design-img" + data-qa-selector="design_image" + src="" + /> + </gl-intersection-observer-stub> + </div> + + <div + class="card-footer d-flex w-100" + > + <div + class="d-flex flex-column str-truncated-100" + > + <span + class="bold str-truncated-100" + data-qa-selector="design_file_name" + > + test + </span> + + <span + class="str-truncated-100" + > + + Updated + <timeago-stub + cssclass="" + time="01-01-2019" + tooltipplacement="bottom" + /> + </span> + </div> + + <!----> + </div> +</router-link-stub> +`; + +exports[`Design management list item component with no notes renders loading spinner when isUploading is true 1`] = ` +<router-link-stub + class="card cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new" + to="[object Object]" +> + <div + class="card-body p-0 d-flex-center overflow-hidden position-relative" + > + <!----> + + <gl-intersection-observer-stub + options="[object Object]" + > + <gl-loading-icon-stub + color="orange" + label="Loading" + size="md" + /> + + <img + alt="test" + class="block mx-auto mw-100 mh-100 design-img" + data-qa-selector="design_image" + src="" + style="display: none;" + /> + </gl-intersection-observer-stub> + </div> + + <div + class="card-footer d-flex w-100" + > + <div + class="d-flex flex-column str-truncated-100" + > + <span + class="bold str-truncated-100" + data-qa-selector="design_file_name" + > + test + </span> + + <span + class="str-truncated-100" + > + + Updated + <timeago-stub + cssclass="" + time="01-01-2019" + tooltipplacement="bottom" + /> + </span> + </div> + + <!----> + </div> +</router-link-stub> +`; + +exports[`Design management list item component with notes renders item with multiple comments 1`] = ` +<router-link-stub + class="card cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new" + to="[object Object]" +> + <div + class="card-body p-0 d-flex-center overflow-hidden position-relative" + > + <!----> + + <gl-intersection-observer-stub + options="[object Object]" + > + <!----> + + <img + alt="test" + class="block mx-auto mw-100 mh-100 design-img" + data-qa-selector="design_image" + src="" + /> + </gl-intersection-observer-stub> + </div> + + <div + class="card-footer d-flex w-100" + > + <div + class="d-flex flex-column str-truncated-100" + > + <span + class="bold str-truncated-100" + data-qa-selector="design_file_name" + > + test + </span> + + <span + class="str-truncated-100" + > + + Updated + <timeago-stub + cssclass="" + time="01-01-2019" + tooltipplacement="bottom" + /> + </span> + </div> + + <div + class="ml-auto d-flex align-items-center text-secondary" + > + <icon-stub + class="ml-1" + name="comments" + size="16" + /> + + <span + aria-label="2 comments" + class="ml-1" + > + + 2 + + </span> + </div> + </div> +</router-link-stub> +`; + +exports[`Design management list item component with notes renders item with single comment 1`] = ` +<router-link-stub + class="card cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new" + to="[object Object]" +> + <div + class="card-body p-0 d-flex-center overflow-hidden position-relative" + > + <!----> + + <gl-intersection-observer-stub + options="[object Object]" + > + <!----> + + <img + alt="test" + class="block mx-auto mw-100 mh-100 design-img" + data-qa-selector="design_image" + src="" + /> + </gl-intersection-observer-stub> + </div> + + <div + class="card-footer d-flex w-100" + > + <div + class="d-flex flex-column str-truncated-100" + > + <span + class="bold str-truncated-100" + data-qa-selector="design_file_name" + > + test + </span> + + <span + class="str-truncated-100" + > + + Updated + <timeago-stub + cssclass="" + time="01-01-2019" + tooltipplacement="bottom" + /> + </span> + </div> + + <div + class="ml-auto d-flex align-items-center text-secondary" + > + <icon-stub + class="ml-1" + name="comments" + size="16" + /> + + <span + aria-label="1 comment" + class="ml-1" + > + + 1 + + </span> + </div> + </div> +</router-link-stub> +`; diff --git a/spec/frontend/design_management_new/components/list/item_spec.js b/spec/frontend/design_management_new/components/list/item_spec.js new file mode 100644 index 00000000000..5e3e6832acb --- /dev/null +++ b/spec/frontend/design_management_new/components/list/item_spec.js @@ -0,0 +1,168 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlIcon, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; +import VueRouter from 'vue-router'; +import Item from '~/design_management_new/components/list/item.vue'; + +const localVue = createLocalVue(); +localVue.use(VueRouter); +const router = new VueRouter(); + +// Referenced from: doc/api/graphql/reference/gitlab_schema.graphql:DesignVersionEvent +const DESIGN_VERSION_EVENT = { + CREATION: 'CREATION', + DELETION: 'DELETION', + MODIFICATION: 'MODIFICATION', + NO_CHANGE: 'NONE', +}; + +describe('Design management list item component', () => { + let wrapper; + + function createComponent({ + notesCount = 0, + event = DESIGN_VERSION_EVENT.NO_CHANGE, + isUploading = false, + imageLoading = false, + } = {}) { + wrapper = shallowMount(Item, { + localVue, + router, + propsData: { + id: 1, + filename: 'test', + image: 'http://via.placeholder.com/300', + isUploading, + event, + notesCount, + updatedAt: '01-01-2019', + }, + data() { + return { + imageLoading, + }; + }, + stubs: ['router-link'], + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when item is not in view', () => { + it('image is not rendered', () => { + createComponent(); + + const image = wrapper.find('img'); + expect(image.attributes('src')).toBe(''); + }); + }); + + describe('when item appears in view', () => { + let image; + let glIntersectionObserver; + + beforeEach(() => { + createComponent(); + image = wrapper.find('img'); + glIntersectionObserver = wrapper.find(GlIntersectionObserver); + + glIntersectionObserver.vm.$emit('appear'); + return wrapper.vm.$nextTick(); + }); + + describe('before image is loaded', () => { + it('renders loading spinner', () => { + expect(wrapper.find(GlLoadingIcon)).toExist(); + }); + }); + + describe('after image is loaded', () => { + beforeEach(() => { + image.trigger('load'); + return wrapper.vm.$nextTick(); + }); + + it('renders an image', () => { + expect(image.attributes('src')).toBe('http://via.placeholder.com/300'); + expect(image.isVisible()).toBe(true); + }); + + it('renders media broken icon when image onerror triggered', () => { + image.trigger('error'); + return wrapper.vm.$nextTick().then(() => { + expect(image.isVisible()).toBe(false); + expect(wrapper.find(GlIcon).element).toMatchSnapshot(); + }); + }); + + describe('when imageV432x230 and image provided', () => { + it('renders imageV432x230 image', () => { + const mockSrc = 'mock-imageV432x230-url'; + wrapper.setProps({ imageV432x230: mockSrc }); + + return wrapper.vm.$nextTick().then(() => { + expect(image.attributes('src')).toBe(mockSrc); + }); + }); + }); + + describe('when image disappears from view and then reappears', () => { + beforeEach(() => { + glIntersectionObserver.vm.$emit('appear'); + return wrapper.vm.$nextTick(); + }); + + it('renders an image', () => { + expect(image.isVisible()).toBe(true); + }); + }); + }); + }); + + describe('with notes', () => { + it('renders item with single comment', () => { + createComponent({ notesCount: 1 }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders item with multiple comments', () => { + createComponent({ notesCount: 2 }); + + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('with no notes', () => { + it('renders item with no status icon for none event', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders item with correct status icon for modification event', () => { + createComponent({ event: DESIGN_VERSION_EVENT.MODIFICATION }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders item with correct status icon for deletion event', () => { + createComponent({ event: DESIGN_VERSION_EVENT.DELETION }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders item with correct status icon for creation event', () => { + createComponent({ event: DESIGN_VERSION_EVENT.CREATION }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders loading spinner when isUploading is true', () => { + createComponent({ isUploading: true }); + + expect(wrapper.element).toMatchSnapshot(); + }); + }); +}); diff --git a/spec/frontend/design_management_new/components/toolbar/__snapshots__/index_spec.js.snap b/spec/frontend/design_management_new/components/toolbar/__snapshots__/index_spec.js.snap new file mode 100644 index 00000000000..f251171ecda --- /dev/null +++ b/spec/frontend/design_management_new/components/toolbar/__snapshots__/index_spec.js.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management toolbar component renders design and updated data 1`] = ` +<header + class="d-flex p-2 bg-white align-items-center js-design-header" +> + <a + aria-label="Go back to designs" + class="mr-3 text-plain d-flex justify-content-center align-items-center" + data-testid="close-design" + > + <icon-stub + name="close" + size="18" + /> + </a> + + <div + class="overflow-hidden d-flex align-items-center" + > + <h2 + class="m-0 str-truncated-100 gl-font-base" + > + test.jpg + </h2> + + <small + class="text-secondary" + > + Updated 1 hour ago by Test Name + </small> + </div> + + <pagination-stub + class="ml-auto flex-shrink-0" + id="1" + /> + + <gl-deprecated-button-stub + class="mr-2" + href="/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d" + size="md" + variant="secondary" + > + <icon-stub + name="download" + size="18" + /> + </gl-deprecated-button-stub> + + <delete-button-stub + buttonclass="" + buttonsize="medium" + buttonvariant="danger" + hasselecteddesigns="true" + > + <icon-stub + name="remove" + size="18" + /> + </delete-button-stub> +</header> +`; diff --git a/spec/frontend/design_management_new/components/toolbar/__snapshots__/pagination_button_spec.js.snap b/spec/frontend/design_management_new/components/toolbar/__snapshots__/pagination_button_spec.js.snap new file mode 100644 index 00000000000..08662a04f15 --- /dev/null +++ b/spec/frontend/design_management_new/components/toolbar/__snapshots__/pagination_button_spec.js.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management pagination button component disables button when no design is passed 1`] = ` +<router-link-stub + aria-label="Test title" + class="btn btn-default disabled" + disabled="true" + to="[object Object]" +> + <icon-stub + name="angle-right" + size="16" + /> +</router-link-stub> +`; + +exports[`Design management pagination button component renders router-link 1`] = ` +<router-link-stub + aria-label="Test title" + class="btn btn-default" + to="[object Object]" +> + <icon-stub + name="angle-right" + size="16" + /> +</router-link-stub> +`; diff --git a/spec/frontend/design_management_new/components/toolbar/__snapshots__/pagination_spec.js.snap b/spec/frontend/design_management_new/components/toolbar/__snapshots__/pagination_spec.js.snap new file mode 100644 index 00000000000..0197b4bff79 --- /dev/null +++ b/spec/frontend/design_management_new/components/toolbar/__snapshots__/pagination_spec.js.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management pagination component hides components when designs are empty 1`] = `<!---->`; + +exports[`Design management pagination component renders pagination buttons 1`] = ` +<div + class="d-flex align-items-center" +> + + 0 of 2 + + <div + class="btn-group ml-3 mr-3" + > + <pagination-button-stub + class="js-previous-design" + iconname="angle-left" + title="Go to previous design" + /> + + <pagination-button-stub + class="js-next-design" + design="[object Object]" + iconname="angle-right" + title="Go to next design" + /> + </div> +</div> +`; diff --git a/spec/frontend/design_management_new/components/toolbar/index_spec.js b/spec/frontend/design_management_new/components/toolbar/index_spec.js new file mode 100644 index 00000000000..eb5ae15ed58 --- /dev/null +++ b/spec/frontend/design_management_new/components/toolbar/index_spec.js @@ -0,0 +1,123 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import VueRouter from 'vue-router'; +import Toolbar from '~/design_management_new/components/toolbar/index.vue'; +import DeleteButton from '~/design_management_new/components/delete_button.vue'; +import { DESIGNS_ROUTE_NAME } from '~/design_management_new/router/constants'; +import { GlDeprecatedButton } from '@gitlab/ui'; + +const localVue = createLocalVue(); +localVue.use(VueRouter); +const router = new VueRouter(); + +const RouterLinkStub = { + props: { + to: { + type: Object, + }, + }, + render(createElement) { + return createElement('a', {}, this.$slots.default); + }, +}; + +describe('Design management toolbar component', () => { + let wrapper; + + function createComponent(isLoading = false, createDesign = true, props) { + const updatedAt = new Date(); + updatedAt.setHours(updatedAt.getHours() - 1); + + wrapper = shallowMount(Toolbar, { + localVue, + router, + propsData: { + id: '1', + isLatestVersion: true, + isLoading, + isDeleting: false, + filename: 'test.jpg', + updatedAt: updatedAt.toString(), + updatedBy: { + name: 'Test Name', + }, + image: '/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d', + ...props, + }, + stubs: { + 'router-link': RouterLinkStub, + }, + }); + + wrapper.setData({ + permissions: { + createDesign, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders design and updated data', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('links back to designs list', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + const link = wrapper.find('a'); + + expect(link.props('to')).toEqual({ + name: DESIGNS_ROUTE_NAME, + query: { + version: undefined, + }, + }); + }); + }); + + it('renders delete button on latest designs version with logged in user', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(DeleteButton).exists()).toBe(true); + }); + }); + + it('does not render delete button on non-latest version', () => { + createComponent(false, true, { isLatestVersion: false }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(DeleteButton).exists()).toBe(false); + }); + }); + + it('does not render delete button when user is not logged in', () => { + createComponent(false, false); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(DeleteButton).exists()).toBe(false); + }); + }); + + it('emits `delete` event on deleteButton `deleteSelectedDesigns` event', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + wrapper.find(DeleteButton).vm.$emit('deleteSelectedDesigns'); + expect(wrapper.emitted().delete).toBeTruthy(); + }); + }); + + it('renders download button with correct link', () => { + expect(wrapper.find(GlDeprecatedButton).attributes('href')).toBe( + '/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d', + ); + }); +}); diff --git a/spec/frontend/design_management_new/components/toolbar/pagination_button_spec.js b/spec/frontend/design_management_new/components/toolbar/pagination_button_spec.js new file mode 100644 index 00000000000..5f33d65fc1f --- /dev/null +++ b/spec/frontend/design_management_new/components/toolbar/pagination_button_spec.js @@ -0,0 +1,61 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import VueRouter from 'vue-router'; +import PaginationButton from '~/design_management_new/components/toolbar/pagination_button.vue'; +import { DESIGN_ROUTE_NAME } from '~/design_management_new/router/constants'; + +const localVue = createLocalVue(); +localVue.use(VueRouter); +const router = new VueRouter(); + +describe('Design management pagination button component', () => { + let wrapper; + + function createComponent(design = null) { + wrapper = shallowMount(PaginationButton, { + localVue, + router, + propsData: { + design, + title: 'Test title', + iconName: 'angle-right', + }, + stubs: ['router-link'], + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('disables button when no design is passed', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders router-link', () => { + createComponent({ id: '2' }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('designLink', () => { + it('returns empty link when design is null', () => { + createComponent(); + + expect(wrapper.vm.designLink).toEqual({}); + }); + + it('returns design link', () => { + createComponent({ id: '2', filename: 'test' }); + + wrapper.vm.$router.replace('/root/test-project/issues/1/designs/test?version=1'); + + expect(wrapper.vm.designLink).toEqual({ + name: DESIGN_ROUTE_NAME, + params: { id: 'test' }, + query: { version: '1' }, + }); + }); + }); +}); diff --git a/spec/frontend/design_management_new/components/toolbar/pagination_spec.js b/spec/frontend/design_management_new/components/toolbar/pagination_spec.js new file mode 100644 index 00000000000..45dce15e292 --- /dev/null +++ b/spec/frontend/design_management_new/components/toolbar/pagination_spec.js @@ -0,0 +1,79 @@ +/* global Mousetrap */ +import 'mousetrap'; +import { shallowMount } from '@vue/test-utils'; +import Pagination from '~/design_management_new/components/toolbar/pagination.vue'; +import { DESIGN_ROUTE_NAME } from '~/design_management_new/router/constants'; + +const push = jest.fn(); +const $router = { + push, +}; + +const $route = { + path: '/designs/design-2', + query: {}, +}; + +describe('Design management pagination component', () => { + let wrapper; + + function createComponent() { + wrapper = shallowMount(Pagination, { + propsData: { + id: '2', + }, + mocks: { + $router, + $route, + }, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('hides components when designs are empty', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders pagination buttons', () => { + wrapper.setData({ + designs: [{ id: '1' }, { id: '2' }], + }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('keyboard buttons navigation', () => { + beforeEach(() => { + wrapper.setData({ + designs: [{ filename: '1' }, { filename: '2' }, { filename: '3' }], + }); + }); + + it('routes to previous design on Left button', () => { + Mousetrap.trigger('left'); + expect(push).toHaveBeenCalledWith({ + name: DESIGN_ROUTE_NAME, + params: { id: '1' }, + query: {}, + }); + }); + + it('routes to next design on Right button', () => { + Mousetrap.trigger('right'); + expect(push).toHaveBeenCalledWith({ + name: DESIGN_ROUTE_NAME, + params: { id: '3' }, + query: {}, + }); + }); + }); +}); diff --git a/spec/frontend/design_management_new/components/upload/__snapshots__/button_spec.js.snap b/spec/frontend/design_management_new/components/upload/__snapshots__/button_spec.js.snap new file mode 100644 index 00000000000..b498becc606 --- /dev/null +++ b/spec/frontend/design_management_new/components/upload/__snapshots__/button_spec.js.snap @@ -0,0 +1,85 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management upload button component renders inverted upload design button 1`] = ` +<div + isinverted="true" +> + <gl-button-stub + category="tertiary" + icon="" + size="small" + title="Adding a design with the same filename replaces the file in a new version." + variant="success" + > + + Upload designs + + <!----> + </gl-button-stub> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="design_file" + type="file" + /> +</div> +`; + +exports[`Design management upload button component renders loading icon 1`] = ` +<div> + <gl-button-stub + category="tertiary" + disabled="true" + icon="" + size="small" + title="Adding a design with the same filename replaces the file in a new version." + variant="success" + > + + Upload designs + + <gl-loading-icon-stub + class="ml-1" + color="orange" + inline="true" + label="Loading" + size="sm" + /> + </gl-button-stub> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="design_file" + type="file" + /> +</div> +`; + +exports[`Design management upload button component renders upload design button 1`] = ` +<div> + <gl-button-stub + category="tertiary" + icon="" + size="small" + title="Adding a design with the same filename replaces the file in a new version." + variant="success" + > + + Upload designs + + <!----> + </gl-button-stub> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="design_file" + type="file" + /> +</div> +`; diff --git a/spec/frontend/design_management_new/components/upload/__snapshots__/design_dropzone_spec.js.snap b/spec/frontend/design_management_new/components/upload/__snapshots__/design_dropzone_spec.js.snap new file mode 100644 index 00000000000..c53c6c889b0 --- /dev/null +++ b/spec/frontend/design_management_new/components/upload/__snapshots__/design_dropzone_spec.js.snap @@ -0,0 +1,501 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management dropzone component when dragging renders correct template when drag event contains files 1`] = ` +<div + class="w-100 position-relative" +> + <button + class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + > + <div + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" + data-testid="dropzone-area" + > + <gl-icon-stub + class="gl-mb-2" + name="upload" + size="24" + /> + + <p + class="gl-font-weight-bold gl-mb-0" + > + <gl-sprintf-stub + message="Drop or %{linkStart}upload%{linkEnd} Designs to attach" + /> + </p> + </div> + </button> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="design_file" + type="file" + /> + + <transition-stub + name="design-dropzone-fade" + > + <div + class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + style="" + > + <div + class="mw-50 text-center" + style="display: none;" + > + <h3 + class="" + > + Oh no! + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 text-center" + style="" + > + <h3 + class="" + > + Incoming! + </h3> + + <span> + Drop your designs to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; + +exports[`Design management dropzone component when dragging renders correct template when drag event contains files and text 1`] = ` +<div + class="w-100 position-relative" +> + <button + class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + > + <div + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" + data-testid="dropzone-area" + > + <gl-icon-stub + class="gl-mb-2" + name="upload" + size="24" + /> + + <p + class="gl-font-weight-bold gl-mb-0" + > + <gl-sprintf-stub + message="Drop or %{linkStart}upload%{linkEnd} Designs to attach" + /> + </p> + </div> + </button> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="design_file" + type="file" + /> + + <transition-stub + name="design-dropzone-fade" + > + <div + class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + style="" + > + <div + class="mw-50 text-center" + style="display: none;" + > + <h3 + class="" + > + Oh no! + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 text-center" + style="" + > + <h3 + class="" + > + Incoming! + </h3> + + <span> + Drop your designs to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; + +exports[`Design management dropzone component when dragging renders correct template when drag event contains text 1`] = ` +<div + class="w-100 position-relative" +> + <button + class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + > + <div + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" + data-testid="dropzone-area" + > + <gl-icon-stub + class="gl-mb-2" + name="upload" + size="24" + /> + + <p + class="gl-font-weight-bold gl-mb-0" + > + <gl-sprintf-stub + message="Drop or %{linkStart}upload%{linkEnd} Designs to attach" + /> + </p> + </div> + </button> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="design_file" + type="file" + /> + + <transition-stub + name="design-dropzone-fade" + > + <div + class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + style="" + > + <div + class="mw-50 text-center" + > + <h3 + class="" + > + Oh no! + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 text-center" + style="display: none;" + > + <h3 + class="" + > + Incoming! + </h3> + + <span> + Drop your designs to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; + +exports[`Design management dropzone component when dragging renders correct template when drag event is empty 1`] = ` +<div + class="w-100 position-relative" +> + <button + class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + > + <div + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" + data-testid="dropzone-area" + > + <gl-icon-stub + class="gl-mb-2" + name="upload" + size="24" + /> + + <p + class="gl-font-weight-bold gl-mb-0" + > + <gl-sprintf-stub + message="Drop or %{linkStart}upload%{linkEnd} Designs to attach" + /> + </p> + </div> + </button> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="design_file" + type="file" + /> + + <transition-stub + name="design-dropzone-fade" + > + <div + class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + style="" + > + <div + class="mw-50 text-center" + > + <h3 + class="" + > + Oh no! + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 text-center" + style="display: none;" + > + <h3 + class="" + > + Incoming! + </h3> + + <span> + Drop your designs to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; + +exports[`Design management dropzone component when dragging renders correct template when dragging stops 1`] = ` +<div + class="w-100 position-relative" +> + <button + class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + > + <div + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" + data-testid="dropzone-area" + > + <gl-icon-stub + class="gl-mb-2" + name="upload" + size="24" + /> + + <p + class="gl-font-weight-bold gl-mb-0" + > + <gl-sprintf-stub + message="Drop or %{linkStart}upload%{linkEnd} Designs to attach" + /> + </p> + </div> + </button> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="design_file" + type="file" + /> + + <transition-stub + name="design-dropzone-fade" + > + <div + class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + style="display: none;" + > + <div + class="mw-50 text-center" + > + <h3 + class="" + > + Oh no! + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 text-center" + style="display: none;" + > + <h3 + class="" + > + Incoming! + </h3> + + <span> + Drop your designs to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; + +exports[`Design management dropzone component when no slot provided renders default dropzone card 1`] = ` +<div + class="w-100 position-relative" +> + <button + class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + > + <div + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" + data-testid="dropzone-area" + > + <gl-icon-stub + class="gl-mb-2" + name="upload" + size="24" + /> + + <p + class="gl-font-weight-bold gl-mb-0" + > + <gl-sprintf-stub + message="Drop or %{linkStart}upload%{linkEnd} Designs to attach" + /> + </p> + </div> + </button> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="design_file" + type="file" + /> + + <transition-stub + name="design-dropzone-fade" + > + <div + class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + style="display: none;" + > + <div + class="mw-50 text-center" + > + <h3 + class="" + > + Oh no! + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 text-center" + style="display: none;" + > + <h3 + class="" + > + Incoming! + </h3> + + <span> + Drop your designs to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; + +exports[`Design management dropzone component when slot provided renders dropzone with slot content 1`] = ` +<div + class="w-100 position-relative" +> + <div> + dropzone slot + </div> + + <transition-stub + name="design-dropzone-fade" + > + <div + class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + style="display: none;" + > + <div + class="mw-50 text-center" + > + <h3 + class="" + > + Oh no! + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 text-center" + style="display: none;" + > + <h3 + class="" + > + Incoming! + </h3> + + <span> + Drop your designs to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; diff --git a/spec/frontend/design_management_new/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management_new/components/upload/__snapshots__/design_version_dropdown_spec.js.snap new file mode 100644 index 00000000000..0d16acdef54 --- /dev/null +++ b/spec/frontend/design_management_new/components/upload/__snapshots__/design_version_dropdown_spec.js.snap @@ -0,0 +1,141 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management design version dropdown component renders design version dropdown button 1`] = ` +<gl-new-dropdown-stub + category="tertiary" + class="design-version-dropdown" + headertext="" + issueiid="" + projectpath="" + size="small" + text="Showing Latest Version" + variant="default" +> + <gl-new-dropdown-item-stub + avatarurl="" + iconcolor="" + iconname="" + iconrightname="" + secondarytext="" + > + <router-link-stub + class="d-flex js-version-link" + to="[object Object]" + > + <div + class="flex-grow-1 ml-2" + > + <div> + <strong> + Version 2 + + <span> + (latest) + </span> + </strong> + </div> + </div> + + <i + class="fa fa-check pull-right" + /> + </router-link-stub> + </gl-new-dropdown-item-stub> + <gl-new-dropdown-item-stub + avatarurl="" + iconcolor="" + iconname="" + iconrightname="" + secondarytext="" + > + <router-link-stub + class="d-flex js-version-link" + to="[object Object]" + > + <div + class="flex-grow-1 ml-2" + > + <div> + <strong> + Version 1 + + <!----> + </strong> + </div> + </div> + + <!----> + </router-link-stub> + </gl-new-dropdown-item-stub> +</gl-new-dropdown-stub> +`; + +exports[`Design management design version dropdown component renders design version list 1`] = ` +<gl-new-dropdown-stub + category="tertiary" + class="design-version-dropdown" + headertext="" + issueiid="" + projectpath="" + size="small" + text="Showing Latest Version" + variant="default" +> + <gl-new-dropdown-item-stub + avatarurl="" + iconcolor="" + iconname="" + iconrightname="" + secondarytext="" + > + <router-link-stub + class="d-flex js-version-link" + to="[object Object]" + > + <div + class="flex-grow-1 ml-2" + > + <div> + <strong> + Version 2 + + <span> + (latest) + </span> + </strong> + </div> + </div> + + <i + class="fa fa-check pull-right" + /> + </router-link-stub> + </gl-new-dropdown-item-stub> + <gl-new-dropdown-item-stub + avatarurl="" + iconcolor="" + iconname="" + iconrightname="" + secondarytext="" + > + <router-link-stub + class="d-flex js-version-link" + to="[object Object]" + > + <div + class="flex-grow-1 ml-2" + > + <div> + <strong> + Version 1 + + <!----> + </strong> + </div> + </div> + + <!----> + </router-link-stub> + </gl-new-dropdown-item-stub> +</gl-new-dropdown-stub> +`; diff --git a/spec/frontend/design_management_new/components/upload/button_spec.js b/spec/frontend/design_management_new/components/upload/button_spec.js new file mode 100644 index 00000000000..7f751982491 --- /dev/null +++ b/spec/frontend/design_management_new/components/upload/button_spec.js @@ -0,0 +1,59 @@ +import { shallowMount } from '@vue/test-utils'; +import UploadButton from '~/design_management_new/components/upload/button.vue'; + +describe('Design management upload button component', () => { + let wrapper; + + function createComponent(isSaving = false, isInverted = false) { + wrapper = shallowMount(UploadButton, { + propsData: { + isSaving, + isInverted, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders upload design button', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders inverted upload design button', () => { + createComponent(false, true); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders loading icon', () => { + createComponent(true); + + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('onFileUploadChange', () => { + it('emits upload event', () => { + createComponent(); + + wrapper.vm.onFileUploadChange({ target: { files: 'test' } }); + + expect(wrapper.emitted().upload[0]).toEqual(['test']); + }); + }); + + describe('openFileUpload', () => { + it('triggers click on input', () => { + createComponent(); + + const clickSpy = jest.spyOn(wrapper.find('input').element, 'click'); + + wrapper.vm.openFileUpload(); + + expect(clickSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/design_management_new/components/upload/design_dropzone_spec.js b/spec/frontend/design_management_new/components/upload/design_dropzone_spec.js new file mode 100644 index 00000000000..c48cbb10172 --- /dev/null +++ b/spec/frontend/design_management_new/components/upload/design_dropzone_spec.js @@ -0,0 +1,151 @@ +import { shallowMount } from '@vue/test-utils'; +import DesignDropzone from '~/design_management_new/components/upload/design_dropzone.vue'; +import createFlash from '~/flash'; +import { GlIcon } from '@gitlab/ui'; + +jest.mock('~/flash'); + +describe('Design management dropzone component', () => { + let wrapper; + + const mockDragEvent = ({ types = ['Files'], files = [] }) => { + return { dataTransfer: { types, files } }; + }; + + const findDropzoneCard = () => wrapper.find('.design-dropzone-card'); + const findDropzoneArea = () => wrapper.find('[data-testid="dropzone-area"]'); + const findIcon = () => wrapper.find(GlIcon); + + function createComponent({ slots = {}, data = {}, props = {} } = {}) { + wrapper = shallowMount(DesignDropzone, { + slots, + propsData: { + hasDesigns: true, + ...props, + }, + data() { + return data; + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when slot provided', () => { + it('renders dropzone with slot content', () => { + createComponent({ + slots: { + default: ['<div>dropzone slot</div>'], + }, + }); + + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('when no slot provided', () => { + it('renders default dropzone card', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('triggers click event on file input element when clicked', () => { + createComponent(); + const clickSpy = jest.spyOn(wrapper.find('input').element, 'click'); + + findDropzoneCard().trigger('click'); + expect(clickSpy).toHaveBeenCalled(); + }); + }); + + describe('when dragging', () => { + it.each` + description | eventPayload + ${'is empty'} | ${{}} + ${'contains text'} | ${mockDragEvent({ types: ['text'] })} + ${'contains files and text'} | ${mockDragEvent({ types: ['Files', 'text'] })} + ${'contains files'} | ${mockDragEvent({ types: ['Files'] })} + `('renders correct template when drag event $description', ({ eventPayload }) => { + createComponent(); + + wrapper.trigger('dragenter', eventPayload); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('renders correct template when dragging stops', () => { + createComponent(); + + wrapper.trigger('dragenter'); + return wrapper.vm + .$nextTick() + .then(() => { + wrapper.trigger('dragleave'); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + }); + + describe('when dropping', () => { + it('emits upload event', () => { + createComponent(); + const mockFile = { name: 'test', type: 'image/jpg' }; + const mockEvent = mockDragEvent({ files: [mockFile] }); + + wrapper.trigger('dragenter', mockEvent); + return wrapper.vm + .$nextTick() + .then(() => { + wrapper.trigger('drop', mockEvent); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(wrapper.emitted().change[0]).toEqual([[mockFile]]); + }); + }); + }); + + describe('ondrop', () => { + const mockData = { dragCounter: 1, isDragDataValid: true }; + + describe('when drag data is valid', () => { + it('emits upload event for valid files', () => { + createComponent({ data: mockData }); + + const mockFile = { type: 'image/jpg' }; + const mockEvent = mockDragEvent({ files: [mockFile] }); + + wrapper.vm.ondrop(mockEvent); + expect(wrapper.emitted().change[0]).toEqual([[mockFile]]); + }); + + it('calls createFlash when files are invalid', () => { + createComponent({ data: mockData }); + + const mockEvent = mockDragEvent({ files: [{ type: 'audio/midi' }] }); + + wrapper.vm.ondrop(mockEvent); + expect(createFlash).toHaveBeenCalledTimes(1); + }); + }); + }); + + it('applies correct classes when there are no designs or no design saving loader', () => { + createComponent({ props: { hasDesigns: false } }); + expect(findDropzoneArea().classes()).not.toContain('gl-flex-direction-column'); + expect(findIcon().classes()).toEqual(['gl-mr-4']); + }); + + it('applies correct classes when there are designs or design saving loader', () => { + createComponent({ props: { hasDesigns: true } }); + expect(findDropzoneArea().classes()).toContain('gl-flex-direction-column'); + expect(findIcon().classes()).toEqual(['gl-mb-2']); + }); +}); diff --git a/spec/frontend/design_management_new/components/upload/design_version_dropdown_spec.js b/spec/frontend/design_management_new/components/upload/design_version_dropdown_spec.js new file mode 100644 index 00000000000..74e7f3f88fc --- /dev/null +++ b/spec/frontend/design_management_new/components/upload/design_version_dropdown_spec.js @@ -0,0 +1,114 @@ +import { shallowMount } from '@vue/test-utils'; +import DesignVersionDropdown from '~/design_management_new/components/upload/design_version_dropdown.vue'; +import { GlNewDropdown, GlNewDropdownItem } from '@gitlab/ui'; +import mockAllVersions from './mock_data/all_versions'; + +const LATEST_VERSION_ID = 3; +const PREVIOUS_VERSION_ID = 2; + +const designRouteFactory = versionId => ({ + path: `/designs?version=${versionId}`, + query: { + version: `${versionId}`, + }, +}); + +const MOCK_ROUTE = { + path: '/designs', + query: {}, +}; + +describe('Design management design version dropdown component', () => { + let wrapper; + + function createComponent({ maxVersions = -1, $route = MOCK_ROUTE } = {}) { + wrapper = shallowMount(DesignVersionDropdown, { + propsData: { + projectPath: '', + issueIid: '', + }, + mocks: { + $route, + }, + stubs: ['router-link'], + }); + + wrapper.setData({ + allVersions: maxVersions > -1 ? mockAllVersions.slice(0, maxVersions) : mockAllVersions, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + const findVersionLink = index => wrapper.findAll('.js-version-link').at(index); + + it('renders design version dropdown button', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('renders design version list', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('selected version name', () => { + it('has "latest" on most recent version item', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + expect(findVersionLink(0).text()).toContain('latest'); + }); + }); + }); + + describe('versions list', () => { + it('displays latest version text by default', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(GlNewDropdown).attributes('text')).toBe('Showing Latest Version'); + }); + }); + + it('displays latest version text when only 1 version is present', () => { + createComponent({ maxVersions: 1 }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(GlNewDropdown).attributes('text')).toBe('Showing Latest Version'); + }); + }); + + it('displays version text when the current version is not the latest', () => { + createComponent({ $route: designRouteFactory(PREVIOUS_VERSION_ID) }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(GlNewDropdown).attributes('text')).toBe(`Showing Version #1`); + }); + }); + + it('displays latest version text when the current version is the latest', () => { + createComponent({ $route: designRouteFactory(LATEST_VERSION_ID) }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(GlNewDropdown).attributes('text')).toBe('Showing Latest Version'); + }); + }); + + it('should have the same length as apollo query', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.findAll(GlNewDropdownItem)).toHaveLength(wrapper.vm.allVersions.length); + }); + }); + }); +}); diff --git a/spec/frontend/design_management_new/components/upload/mock_data/all_versions.js b/spec/frontend/design_management_new/components/upload/mock_data/all_versions.js new file mode 100644 index 00000000000..e76bbd261bd --- /dev/null +++ b/spec/frontend/design_management_new/components/upload/mock_data/all_versions.js @@ -0,0 +1,14 @@ +export default [ + { + node: { + id: 'gid://gitlab/DesignManagement::Version/3', + sha: '0945756378e0b1588b9dd40d5a6b99e8b7198f55', + }, + }, + { + node: { + id: 'gid://gitlab/DesignManagement::Version/2', + sha: '5b063fef0cd7213b312db65b30e24f057df21b20', + }, + }, +]; diff --git a/spec/frontend/design_management_new/mock_data/all_versions.js b/spec/frontend/design_management_new/mock_data/all_versions.js new file mode 100644 index 00000000000..c389fdb8747 --- /dev/null +++ b/spec/frontend/design_management_new/mock_data/all_versions.js @@ -0,0 +1,8 @@ +export default [ + { + node: { + id: 'gid://gitlab/DesignManagement::Version/1', + sha: 'b389071a06c153509e11da1f582005b316667001', + }, + }, +]; diff --git a/spec/frontend/design_management_new/mock_data/design.js b/spec/frontend/design_management_new/mock_data/design.js new file mode 100644 index 00000000000..675198b9408 --- /dev/null +++ b/spec/frontend/design_management_new/mock_data/design.js @@ -0,0 +1,74 @@ +export default { + id: 'design-id', + filename: 'test.jpg', + fullPath: 'full-design-path', + image: 'test.jpg', + updatedAt: '01-01-2019', + updatedBy: { + name: 'test', + }, + issue: { + title: 'My precious issue', + webPath: 'full-issue-path', + webUrl: 'full-issue-url', + participants: { + edges: [ + { + node: { + name: 'Administrator', + username: 'root', + webUrl: 'link-to-author', + avatarUrl: 'link-to-avatar', + }, + }, + ], + }, + }, + discussions: { + nodes: [ + { + id: 'discussion-id', + replyId: 'discussion-reply-id', + resolved: false, + notes: { + nodes: [ + { + id: 'note-id', + body: '123', + author: { + name: 'Administrator', + username: 'root', + webUrl: 'link-to-author', + avatarUrl: 'link-to-avatar', + }, + }, + ], + }, + }, + { + id: 'discussion-resolved', + replyId: 'discussion-reply-resolved', + resolved: true, + notes: { + nodes: [ + { + id: 'note-resolved', + body: '123', + author: { + name: 'Administrator', + username: 'root', + webUrl: 'link-to-author', + avatarUrl: 'link-to-avatar', + }, + }, + ], + }, + }, + ], + }, + diffRefs: { + headSha: 'headSha', + baseSha: 'baseSha', + startSha: 'startSha', + }, +}; diff --git a/spec/frontend/design_management_new/mock_data/designs.js b/spec/frontend/design_management_new/mock_data/designs.js new file mode 100644 index 00000000000..07f5c1b7457 --- /dev/null +++ b/spec/frontend/design_management_new/mock_data/designs.js @@ -0,0 +1,17 @@ +import design from './design'; + +export default { + project: { + issue: { + designCollection: { + designs: { + edges: [ + { + node: design, + }, + ], + }, + }, + }, + }, +}; diff --git a/spec/frontend/design_management_new/mock_data/no_designs.js b/spec/frontend/design_management_new/mock_data/no_designs.js new file mode 100644 index 00000000000..9db0ffcade2 --- /dev/null +++ b/spec/frontend/design_management_new/mock_data/no_designs.js @@ -0,0 +1,11 @@ +export default { + project: { + issue: { + designCollection: { + designs: { + edges: [], + }, + }, + }, + }, +}; diff --git a/spec/frontend/design_management_new/mock_data/notes.js b/spec/frontend/design_management_new/mock_data/notes.js new file mode 100644 index 00000000000..80cb3944786 --- /dev/null +++ b/spec/frontend/design_management_new/mock_data/notes.js @@ -0,0 +1,46 @@ +export default [ + { + id: 'note-id-1', + index: 1, + position: { + height: 100, + width: 100, + x: 10, + y: 15, + }, + author: { + name: 'John', + webUrl: 'link-to-john-profile', + }, + createdAt: '2020-05-08T07:10:45Z', + userPermissions: { + adminNote: true, + }, + discussion: { + id: 'discussion-id-1', + }, + resolved: false, + }, + { + id: 'note-id-2', + index: 2, + position: { + height: 50, + width: 50, + x: 25, + y: 25, + }, + author: { + name: 'Mary', + webUrl: 'link-to-mary-profile', + }, + createdAt: '2020-05-08T07:10:45Z', + userPermissions: { + adminNote: true, + }, + discussion: { + id: 'discussion-id-2', + }, + resolved: true, + }, +]; diff --git a/spec/frontend/design_management_new/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management_new/pages/__snapshots__/index_spec.js.snap new file mode 100644 index 00000000000..3d1fe143ac3 --- /dev/null +++ b/spec/frontend/design_management_new/pages/__snapshots__/index_spec.js.snap @@ -0,0 +1,317 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management index page designs does not render toolbar when there is no permission 1`] = ` +<div + class="gl-mt-5" + data-testid="designs-root" +> + <!----> + + <div + class="mt-4" + > + <ol + class="list-unstyled row" + > + <!----> + + <li + class="gl-flex-direction-column col-md-6 col-lg-3 gl-mb-3" + data-testid="design-dropzone-wrapper" + > + <design-dropzone-stub + class="design-list-item design-list-item-new" + hasdesigns="true" + /> + </li> + + <li + class="col-md-6 col-lg-3 gl-mb-3" + > + <design-dropzone-stub + hasdesigns="true" + > + <design-stub + event="NONE" + filename="design-1-name" + id="design-1" + image="design-1-image" + notescount="0" + /> + </design-dropzone-stub> + + <!----> + </li> + <li + class="col-md-6 col-lg-3 gl-mb-3" + > + <design-dropzone-stub + hasdesigns="true" + > + <design-stub + event="NONE" + filename="design-2-name" + id="design-2" + image="design-2-image" + notescount="1" + /> + </design-dropzone-stub> + + <!----> + </li> + <li + class="col-md-6 col-lg-3 gl-mb-3" + > + <design-dropzone-stub + hasdesigns="true" + > + <design-stub + event="NONE" + filename="design-3-name" + id="design-3" + image="design-3-image" + notescount="0" + /> + </design-dropzone-stub> + + <!----> + </li> + </ol> + </div> + + <router-view-stub + name="default" + /> +</div> +`; + +exports[`Design management index page designs renders designs list and header with upload button 1`] = ` +<div + class="gl-mt-5" + data-testid="designs-root" +> + <header + class="row-content-block border-top-0 p-2 d-flex" + > + <div + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full" + > + <div> + <span + class="gl-font-weight-bold gl-mr-3" + > + Designs + </span> + + <design-version-dropdown-stub /> + </div> + + <div + class="qa-selector-toolbar gl-display-flex" + > + <gl-button-stub + category="tertiary" + class="gl-mr-2 js-select-all" + icon="" + size="small" + variant="link" + > + Select all + + </gl-button-stub> + + <div> + <delete-button-stub + buttonclass="gl-mr-4" + buttonsize="small" + buttonvariant="danger" + > + + Delete selected + + <!----> + </delete-button-stub> + </div> + + <upload-button-stub /> + </div> + </div> + </header> + + <div + class="mt-4" + > + <ol + class="list-unstyled row" + > + <!----> + + <li + class="gl-flex-direction-column col-md-6 col-lg-3 gl-mb-3" + data-testid="design-dropzone-wrapper" + > + <design-dropzone-stub + class="design-list-item design-list-item-new" + hasdesigns="true" + /> + </li> + + <li + class="col-md-6 col-lg-3 gl-mb-3" + > + <design-dropzone-stub + hasdesigns="true" + > + <design-stub + event="NONE" + filename="design-1-name" + id="design-1" + image="design-1-image" + notescount="0" + /> + </design-dropzone-stub> + + <input + class="design-checkbox" + type="checkbox" + /> + </li> + <li + class="col-md-6 col-lg-3 gl-mb-3" + > + <design-dropzone-stub + hasdesigns="true" + > + <design-stub + event="NONE" + filename="design-2-name" + id="design-2" + image="design-2-image" + notescount="1" + /> + </design-dropzone-stub> + + <input + class="design-checkbox" + type="checkbox" + /> + </li> + <li + class="col-md-6 col-lg-3 gl-mb-3" + > + <design-dropzone-stub + hasdesigns="true" + > + <design-stub + event="NONE" + filename="design-3-name" + id="design-3" + image="design-3-image" + notescount="0" + /> + </design-dropzone-stub> + + <input + class="design-checkbox" + type="checkbox" + /> + </li> + </ol> + </div> + + <router-view-stub + name="default" + /> +</div> +`; + +exports[`Design management index page designs renders error 1`] = ` +<div + class="gl-mt-5" + data-testid="designs-root" +> + <!----> + + <div + class="mt-4" + > + <gl-alert-stub + dismisslabel="Dismiss" + primarybuttonlink="" + primarybuttontext="" + secondarybuttonlink="" + secondarybuttontext="" + title="" + variant="danger" + > + + An error occurred while loading designs. Please try again. + + </gl-alert-stub> + </div> + + <router-view-stub + name="default" + /> +</div> +`; + +exports[`Design management index page designs renders loading icon 1`] = ` +<div + class="gl-mt-5" + data-testid="designs-root" +> + <!----> + + <div + class="mt-4" + > + <gl-loading-icon-stub + color="orange" + label="Loading" + size="md" + /> + </div> + + <router-view-stub + name="default" + /> +</div> +`; + +exports[`Design management index page when has no designs renders design dropzone 1`] = ` +<div + class="gl-mt-5" + data-testid="designs-root" +> + <!----> + + <div + class="mt-4" + > + <ol + class="list-unstyled row" + > + <span + class="gl-font-weight-bold gl-font-weight-bold gl-ml-5 gl-mb-4" + > + Designs + </span> + + <li + class="col-12" + data-testid="design-dropzone-wrapper" + > + <design-dropzone-stub + class="" + /> + </li> + + </ol> + </div> + + <router-view-stub + name="default" + /> +</div> +`; diff --git a/spec/frontend/design_management_new/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management_new/pages/design/__snapshots__/index_spec.js.snap new file mode 100644 index 00000000000..83bcebd513e --- /dev/null +++ b/spec/frontend/design_management_new/pages/design/__snapshots__/index_spec.js.snap @@ -0,0 +1,216 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management design index page renders design index 1`] = ` +<div + class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row" +> + <div + class="d-flex overflow-hidden flex-grow-1 flex-column position-relative" + > + <design-destroyer-stub + filenames="test.jpg" + iid="1" + project-path="project-path" + /> + + <!----> + + <design-presentation-stub + discussions="[object Object],[object Object]" + image="test.jpg" + imagename="test.jpg" + scale="1" + /> + + <div + class="design-scaler-wrapper position-absolute mb-4 d-flex-center" + > + <design-scaler-stub /> + </div> + </div> + + <div + class="image-notes" + > + <h2 + class="gl-font-weight-bold gl-mt-0" + > + + My precious issue + + </h2> + + <a + class="gl-text-gray-600 gl-text-decoration-none gl-mb-6 gl-display-block" + href="full-issue-url" + > + ull-issue-path + </a> + + <participants-stub + class="gl-mb-4" + numberoflessparticipants="7" + participants="[object Object]" + /> + + <!----> + + <design-discussion-stub + data-testid="unresolved-discussion" + designid="test" + discussion="[object Object]" + discussionwithopenform="" + markdownpreviewpath="/project-path/preview_markdown?target_type=Issue" + noteableid="design-id" + /> + + <gl-button-stub + category="tertiary" + class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-mb-4" + data-testid="resolved-comments" + icon="chevron-right" + id="resolved-comments" + size="medium" + variant="link" + > + Resolved Comments (1) + + </gl-button-stub> + + <gl-popover-stub + container="popovercontainer" + cssclasses="" + placement="top" + show="true" + target="resolved-comments" + title="Resolved Comments" + > + <p> + + Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below + + </p> + + <a + href="#" + rel="noopener noreferrer" + target="_blank" + > + Learn more about resolving comments + </a> + </gl-popover-stub> + + <gl-collapse-stub + class="gl-mt-3" + > + <design-discussion-stub + data-testid="resolved-discussion" + designid="test" + discussion="[object Object]" + discussionwithopenform="" + markdownpreviewpath="/project-path/preview_markdown?target_type=Issue" + noteableid="design-id" + /> + </gl-collapse-stub> + + </div> +</div> +`; + +exports[`Design management design index page sets loading state 1`] = ` +<div + class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row" +> + <gl-loading-icon-stub + class="align-self-center" + color="orange" + label="Loading" + size="xl" + /> +</div> +`; + +exports[`Design management design index page with error GlAlert is rendered in correct position with correct content 1`] = ` +<div + class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row" +> + <div + class="d-flex overflow-hidden flex-grow-1 flex-column position-relative" + > + <design-destroyer-stub + filenames="test.jpg" + iid="1" + project-path="project-path" + /> + + <div + class="p-3" + > + <gl-alert-stub + dismissible="true" + dismisslabel="Dismiss" + primarybuttonlink="" + primarybuttontext="" + secondarybuttonlink="" + secondarybuttontext="" + title="" + variant="danger" + > + + woops + + </gl-alert-stub> + </div> + + <design-presentation-stub + discussions="" + image="test.jpg" + imagename="test.jpg" + scale="1" + /> + + <div + class="design-scaler-wrapper position-absolute mb-4 d-flex-center" + > + <design-scaler-stub /> + </div> + </div> + + <div + class="image-notes" + > + <h2 + class="gl-font-weight-bold gl-mt-0" + > + + My precious issue + + </h2> + + <a + class="gl-text-gray-600 gl-text-decoration-none gl-mb-6 gl-display-block" + href="full-issue-url" + > + ull-issue-path + </a> + + <participants-stub + class="gl-mb-4" + numberoflessparticipants="7" + participants="[object Object]" + /> + + <h2 + class="new-discussion-disclaimer gl-font-base gl-m-0 gl-mb-4" + data-testid="new-discussion-disclaimer" + > + + Click the image where you'd like to start a new discussion + + </h2> + + <!----> + + </div> +</div> +`; diff --git a/spec/frontend/design_management_new/pages/design/index_spec.js b/spec/frontend/design_management_new/pages/design/index_spec.js new file mode 100644 index 00000000000..3822b0b3b71 --- /dev/null +++ b/spec/frontend/design_management_new/pages/design/index_spec.js @@ -0,0 +1,294 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueRouter from 'vue-router'; +import { GlAlert } from '@gitlab/ui'; +import { ApolloMutation } from 'vue-apollo'; +import createFlash from '~/flash'; +import DesignIndex from '~/design_management_new/pages/design/index.vue'; +import DesignSidebar from '~/design_management_new/components/design_sidebar.vue'; +import DesignPresentation from '~/design_management_new/components/design_presentation.vue'; +import createImageDiffNoteMutation from '~/design_management_new/graphql/mutations/create_image_diff_note.mutation.graphql'; +import design from '../../mock_data/design'; +import mockResponseWithDesigns from '../../mock_data/designs'; +import mockResponseNoDesigns from '../../mock_data/no_designs'; +import mockAllVersions from '../../mock_data/all_versions'; +import { + DESIGN_NOT_FOUND_ERROR, + DESIGN_VERSION_NOT_EXIST_ERROR, +} from '~/design_management_new/utils/error_messages'; +import { DESIGNS_ROUTE_NAME } from '~/design_management_new/router/constants'; +import createRouter from '~/design_management_new/router'; +import * as utils from '~/design_management_new/utils/design_management_utils'; +import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management_new/constants'; + +jest.mock('~/flash'); +jest.mock('mousetrap', () => ({ + bind: jest.fn(), + unbind: jest.fn(), +})); + +const focusInput = jest.fn(); + +const DesignReplyForm = { + template: '<div><textarea ref="textarea"></textarea></div>', + methods: { + focusInput, + }, +}; + +const localVue = createLocalVue(); +localVue.use(VueRouter); + +describe('Design management design index page', () => { + let wrapper; + let router; + + const newComment = 'new comment'; + const annotationCoordinates = { + x: 10, + y: 10, + width: 100, + height: 100, + }; + const createDiscussionMutationVariables = { + mutation: createImageDiffNoteMutation, + update: expect.anything(), + variables: { + input: { + body: newComment, + noteableId: design.id, + position: { + headSha: 'headSha', + baseSha: 'baseSha', + startSha: 'startSha', + paths: { + newPath: 'full-design-path', + }, + ...annotationCoordinates, + }, + }, + }, + }; + + const mutate = jest.fn().mockResolvedValue(); + + const findDiscussionForm = () => wrapper.find(DesignReplyForm); + const findSidebar = () => wrapper.find(DesignSidebar); + const findDesignPresentation = () => wrapper.find(DesignPresentation); + + function createComponent(loading = false, data = {}) { + const $apollo = { + queries: { + design: { + loading, + }, + }, + mutate, + }; + + router = createRouter(); + + wrapper = shallowMount(DesignIndex, { + propsData: { id: '1' }, + mocks: { $apollo }, + stubs: { + ApolloMutation, + DesignSidebar, + DesignReplyForm, + }, + provide: { + issueIid: '1', + projectPath: 'project-path', + }, + data() { + return { + activeDiscussion: { + id: null, + source: null, + }, + ...data, + }; + }, + localVue, + router, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when navigating', () => { + it('applies fullscreen layout', () => { + const mockEl = { + classList: { + add: jest.fn(), + remove: jest.fn(), + }, + }; + jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockEl); + createComponent(true); + + wrapper.vm.$router.push('/designs/test'); + expect(mockEl.classList.add).toHaveBeenCalledTimes(1); + expect(mockEl.classList.add).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST); + }); + }); + + it('sets loading state', () => { + createComponent(true); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders design index', () => { + createComponent(false, { design }); + + expect(wrapper.element).toMatchSnapshot(); + expect(wrapper.find(GlAlert).exists()).toBe(false); + }); + + it('passes correct props to sidebar component', () => { + createComponent(false, { design }); + + expect(findSidebar().props()).toEqual({ + design, + markdownPreviewPath: '/project-path/preview_markdown?target_type=Issue', + resolvedDiscussionsExpanded: false, + }); + }); + + it('opens a new discussion form', () => { + createComponent(false, { + design: { + ...design, + discussions: { + nodes: [], + }, + }, + }); + + findDesignPresentation().vm.$emit('openCommentForm', { x: 0, y: 0 }); + + return wrapper.vm.$nextTick().then(() => { + expect(findDiscussionForm().exists()).toBe(true); + }); + }); + + it('keeps new discussion form focused', () => { + createComponent(false, { + design: { + ...design, + discussions: { + nodes: [], + }, + }, + annotationCoordinates, + }); + + findDesignPresentation().vm.$emit('openCommentForm', { x: 10, y: 10 }); + + expect(focusInput).toHaveBeenCalled(); + }); + + it('sends a mutation on submitting form and closes form', () => { + createComponent(false, { + design: { + ...design, + discussions: { + nodes: [], + }, + }, + annotationCoordinates, + comment: newComment, + }); + + findDiscussionForm().vm.$emit('submitForm'); + expect(mutate).toHaveBeenCalledWith(createDiscussionMutationVariables); + + return wrapper.vm + .$nextTick() + .then(() => { + return mutate({ variables: createDiscussionMutationVariables }); + }) + .then(() => { + expect(findDiscussionForm().exists()).toBe(false); + }); + }); + + it('closes the form and clears the comment on canceling form', () => { + createComponent(false, { + design: { + ...design, + discussions: { + nodes: [], + }, + }, + annotationCoordinates, + comment: newComment, + }); + + findDiscussionForm().vm.$emit('cancelForm'); + + expect(wrapper.vm.comment).toBe(''); + + return wrapper.vm.$nextTick().then(() => { + expect(findDiscussionForm().exists()).toBe(false); + }); + }); + + describe('with error', () => { + beforeEach(() => { + createComponent(false, { + design: { + ...design, + discussions: { + nodes: [], + }, + }, + errorMessage: 'woops', + }); + }); + + it('GlAlert is rendered in correct position with correct content', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('onDesignQueryResult', () => { + describe('with no designs', () => { + it('redirects to /designs', () => { + createComponent(true); + router.push = jest.fn(); + + wrapper.vm.onDesignQueryResult({ data: mockResponseNoDesigns, loading: false }); + return wrapper.vm.$nextTick().then(() => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith(DESIGN_NOT_FOUND_ERROR); + expect(router.push).toHaveBeenCalledTimes(1); + expect(router.push).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME }); + }); + }); + }); + + describe('when no design exists for given version', () => { + it('redirects to /designs', () => { + createComponent(true); + wrapper.setData({ + allVersions: mockAllVersions, + }); + + // attempt to query for a version of the design that doesn't exist + router.push({ query: { version: '999' } }); + router.push = jest.fn(); + + wrapper.vm.onDesignQueryResult({ data: mockResponseWithDesigns, loading: false }); + return wrapper.vm.$nextTick().then(() => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith(DESIGN_VERSION_NOT_EXIST_ERROR); + expect(router.push).toHaveBeenCalledTimes(1); + expect(router.push).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/design_management_new/pages/index_spec.js b/spec/frontend/design_management_new/pages/index_spec.js new file mode 100644 index 00000000000..40a462eabb8 --- /dev/null +++ b/spec/frontend/design_management_new/pages/index_spec.js @@ -0,0 +1,571 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { ApolloMutation } from 'vue-apollo'; +import VueRouter from 'vue-router'; +import { GlEmptyState } from '@gitlab/ui'; +import Index from '~/design_management_new/pages/index.vue'; +import uploadDesignQuery from '~/design_management_new/graphql/mutations/upload_design.mutation.graphql'; +import DesignDestroyer from '~/design_management_new/components/design_destroyer.vue'; +import DesignDropzone from '~/design_management_new/components/upload/design_dropzone.vue'; +import DeleteButton from '~/design_management_new/components/delete_button.vue'; +import { DESIGNS_ROUTE_NAME } from '~/design_management_new/router/constants'; +import { + EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE, + EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE, +} from '~/design_management_new/utils/error_messages'; +import createFlash from '~/flash'; +import createRouter from '~/design_management_new/router'; +import * as utils from '~/design_management_new/utils/design_management_utils'; +import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management_new/constants'; + +jest.mock('~/flash.js'); +const mockPageEl = { + classList: { + remove: jest.fn(), + }, +}; +jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockPageEl); + +const localVue = createLocalVue(); +const router = createRouter(); +localVue.use(VueRouter); + +const mockDesigns = [ + { + id: 'design-1', + image: 'design-1-image', + filename: 'design-1-name', + event: 'NONE', + notesCount: 0, + }, + { + id: 'design-2', + image: 'design-2-image', + filename: 'design-2-name', + event: 'NONE', + notesCount: 1, + }, + { + id: 'design-3', + image: 'design-3-image', + filename: 'design-3-name', + event: 'NONE', + notesCount: 0, + }, +]; + +const mockVersion = { + node: { + id: 'gid://gitlab/DesignManagement::Version/1', + }, +}; + +describe('Design management index page', () => { + let mutate; + let wrapper; + + const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox'); + const findSelectAllButton = () => wrapper.find('.js-select-all'); + const findToolbar = () => wrapper.find('.qa-selector-toolbar'); + const findDeleteButton = () => wrapper.find(DeleteButton); + const findDropzone = () => wrapper.findAll(DesignDropzone).at(0); + const dropzoneClasses = () => findDropzone().classes(); + const findDropzoneWrapper = () => wrapper.find('[data-testid="design-dropzone-wrapper"]'); + const findFirstDropzoneWithDesign = () => wrapper.findAll(DesignDropzone).at(1); + + function createComponent({ + loading = false, + designs = [], + allVersions = [], + createDesign = true, + stubs = {}, + mockMutate = jest.fn().mockResolvedValue(), + } = {}) { + mutate = mockMutate; + const $apollo = { + queries: { + designs: { + loading, + }, + permissions: { + loading, + }, + }, + mutate, + }; + + wrapper = shallowMount(Index, { + data() { + return { + designs, + allVersions, + permissions: { + createDesign, + }, + }; + }, + mocks: { $apollo }, + localVue, + router, + stubs: { DesignDestroyer, ApolloMutation, ...stubs }, + attachToDocument: true, + provide: { + projectPath: 'project-path', + issueIid: '1', + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('designs', () => { + it('renders loading icon', () => { + createComponent({ loading: true }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders error', () => { + createComponent(); + + wrapper.setData({ error: true }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('renders a toolbar with buttons when there are designs', () => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + + expect(findToolbar().exists()).toBe(true); + }); + + it('renders designs list and header with upload button', () => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('does not render toolbar when there is no permission', () => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion], createDesign: false }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('has correct classes applied to design dropzone', () => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + expect(dropzoneClasses()).toContain('design-list-item'); + expect(dropzoneClasses()).toContain('design-list-item-new'); + }); + + it('has correct classes applied to dropzone wrapper', () => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + expect(findDropzoneWrapper().classes()).toEqual([ + 'gl-flex-direction-column', + 'col-md-6', + 'col-lg-3', + 'gl-mb-3', + ]); + }); + }); + + describe('when has no designs', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders design dropzone', () => + wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + })); + + it('has correct classes applied to design dropzone', () => { + expect(dropzoneClasses()).not.toContain('design-list-item'); + expect(dropzoneClasses()).not.toContain('design-list-item-new'); + }); + + it('has correct classes applied to dropzone wrapper', () => { + expect(findDropzoneWrapper().classes()).toEqual(['col-12']); + }); + + it('does not render a toolbar with buttons', () => + wrapper.vm.$nextTick().then(() => { + expect(findToolbar().exists()).toBe(false); + })); + }); + + describe('uploading designs', () => { + it('calls mutation on upload', () => { + createComponent({ stubs: { GlEmptyState } }); + + const mutationVariables = { + update: expect.anything(), + context: { + hasUpload: true, + }, + mutation: uploadDesignQuery, + variables: { + files: [{ name: 'test' }], + projectPath: 'project-path', + iid: '1', + }, + optimisticResponse: { + __typename: 'Mutation', + designManagementUpload: { + __typename: 'DesignManagementUploadPayload', + designs: [ + { + __typename: 'Design', + id: expect.anything(), + image: '', + imageV432x230: '', + filename: 'test', + fullPath: '', + event: 'NONE', + notesCount: 0, + diffRefs: { + __typename: 'DiffRefs', + baseSha: '', + startSha: '', + headSha: '', + }, + discussions: { + __typename: 'DesignDiscussion', + nodes: [], + }, + versions: { + __typename: 'DesignVersionConnection', + edges: { + __typename: 'DesignVersionEdge', + node: { + __typename: 'DesignVersion', + id: expect.anything(), + sha: expect.anything(), + }, + }, + }, + }, + ], + skippedDesigns: [], + errors: [], + }, + }, + }; + + return wrapper.vm + .$nextTick() + .then(() => { + findDropzone().vm.$emit('change', [{ name: 'test' }]); + expect(mutate).toHaveBeenCalledWith(mutationVariables); + expect(wrapper.vm.filesToBeSaved).toEqual([{ name: 'test' }]); + expect(wrapper.vm.isSaving).toBeTruthy(); + }) + .then(() => { + expect(dropzoneClasses()).toContain('design-list-item'); + expect(dropzoneClasses()).toContain('design-list-item-new'); + }); + }); + + it('sets isSaving', () => { + createComponent(); + + const uploadDesign = wrapper.vm.onUploadDesign([ + { + name: 'test', + }, + ]); + + expect(wrapper.vm.isSaving).toBe(true); + + return uploadDesign.then(() => { + expect(wrapper.vm.isSaving).toBe(false); + }); + }); + + it('updates state appropriately after upload complete', () => { + createComponent({ stubs: { GlEmptyState } }); + wrapper.setData({ filesToBeSaved: [{ name: 'test' }] }); + + wrapper.vm.onUploadDesignDone(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.filesToBeSaved).toEqual([]); + expect(wrapper.vm.isSaving).toBeFalsy(); + expect(wrapper.vm.isLatestVersion).toBe(true); + }); + }); + + it('updates state appropriately after upload error', () => { + createComponent({ stubs: { GlEmptyState } }); + wrapper.setData({ filesToBeSaved: [{ name: 'test' }] }); + + wrapper.vm.onUploadDesignError(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.filesToBeSaved).toEqual([]); + expect(wrapper.vm.isSaving).toBeFalsy(); + expect(createFlash).toHaveBeenCalled(); + + createFlash.mockReset(); + }); + }); + + it('does not call mutation if createDesign is false', () => { + createComponent({ createDesign: false }); + + wrapper.vm.onUploadDesign([]); + + expect(mutate).not.toHaveBeenCalled(); + }); + + describe('upload count limit', () => { + const MAXIMUM_FILE_UPLOAD_LIMIT = 10; + + afterEach(() => { + createFlash.mockReset(); + }); + + it('does not warn when the max files are uploaded', () => { + createComponent(); + + wrapper.vm.onUploadDesign(new Array(MAXIMUM_FILE_UPLOAD_LIMIT).fill(mockDesigns[0])); + + expect(createFlash).not.toHaveBeenCalled(); + }); + + it('warns when too many files are uploaded', () => { + createComponent(); + + wrapper.vm.onUploadDesign(new Array(MAXIMUM_FILE_UPLOAD_LIMIT + 1).fill(mockDesigns[0])); + + expect(createFlash).toHaveBeenCalled(); + }); + }); + + it('flashes warning if designs are skipped', () => { + createComponent({ + mockMutate: () => + Promise.resolve({ + data: { designManagementUpload: { skippedDesigns: [{ filename: 'test.jpg' }] } }, + }), + }); + + const uploadDesign = wrapper.vm.onUploadDesign([ + { + name: 'test', + }, + ]); + + return uploadDesign.then(() => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith( + 'Upload skipped. test.jpg did not change.', + 'warning', + ); + }); + }); + + describe('dragging onto an existing design', () => { + beforeEach(() => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + }); + + it('calls onUploadDesign with valid upload', () => { + wrapper.setMethods({ + onUploadDesign: jest.fn(), + }); + + const mockUploadPayload = [ + { + name: mockDesigns[0].filename, + }, + ]; + + const designDropzone = findFirstDropzoneWithDesign(); + designDropzone.vm.$emit('change', mockUploadPayload); + + expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1); + expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith(mockUploadPayload); + }); + + it.each` + description | eventPayload | message + ${'> 1 file'} | ${[{ name: 'test' }, { name: 'test-2' }]} | ${EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE} + ${'different filename'} | ${[{ name: 'wrong-name' }]} | ${EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE} + `('calls createFlash when upload has $description', ({ eventPayload, message }) => { + const designDropzone = findFirstDropzoneWithDesign(); + designDropzone.vm.$emit('change', eventPayload); + + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith(message); + }); + }); + }); + + describe('on latest version when has designs', () => { + beforeEach(() => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + }); + + it('renders design checkboxes', () => { + expect(findDesignCheckboxes()).toHaveLength(mockDesigns.length); + }); + + it('renders toolbar buttons', () => { + expect(findToolbar().exists()).toBe(true); + expect(findToolbar().isVisible()).toBe(true); + }); + + it('adds two designs to selected designs when their checkboxes are checked', () => { + findDesignCheckboxes() + .at(0) + .trigger('click'); + + return wrapper.vm + .$nextTick() + .then(() => { + findDesignCheckboxes() + .at(1) + .trigger('click'); + + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(findDeleteButton().exists()).toBe(true); + expect(findSelectAllButton().text()).toBe('Deselect all'); + findDeleteButton().vm.$emit('deleteSelectedDesigns'); + const [{ variables }] = mutate.mock.calls[0]; + expect(variables.filenames).toStrictEqual([ + mockDesigns[0].filename, + mockDesigns[1].filename, + ]); + }); + }); + + it('adds all designs to selected designs when Select All button is clicked', () => { + findSelectAllButton().vm.$emit('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(findDeleteButton().props().hasSelectedDesigns).toBe(true); + expect(findSelectAllButton().text()).toBe('Deselect all'); + expect(wrapper.vm.selectedDesigns).toEqual(mockDesigns.map(design => design.filename)); + }); + }); + + it('removes all designs from selected designs when at least one design was selected', () => { + findDesignCheckboxes() + .at(0) + .trigger('click'); + + return wrapper.vm + .$nextTick() + .then(() => { + findSelectAllButton().vm.$emit('click'); + }) + .then(() => { + expect(findDeleteButton().props().hasSelectedDesigns).toBe(false); + expect(findSelectAllButton().text()).toBe('Select all'); + expect(wrapper.vm.selectedDesigns).toEqual([]); + }); + }); + }); + + it('on latest version when has no designs toolbar buttons are invisible', () => { + createComponent({ designs: [], allVersions: [mockVersion] }); + expect(findToolbar().isVisible()).toBe(false); + }); + + describe('on non-latest version', () => { + beforeEach(() => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + + router.replace({ + name: DESIGNS_ROUTE_NAME, + query: { + version: '2', + }, + }); + }); + + it('does not render design checkboxes', () => { + expect(findDesignCheckboxes()).toHaveLength(0); + }); + + it('does not render Delete selected button', () => { + expect(findDeleteButton().exists()).toBe(false); + }); + + it('does not render Select All button', () => { + expect(findSelectAllButton().exists()).toBe(false); + }); + }); + + describe('pasting a design', () => { + let event; + beforeEach(() => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + + wrapper.setMethods({ + onUploadDesign: jest.fn(), + }); + + event = new Event('paste'); + + router.replace({ + name: DESIGNS_ROUTE_NAME, + query: { + version: '2', + }, + }); + }); + + it('calls onUploadDesign with valid paste', () => { + event.clipboardData = { + files: [{ name: 'image.png', type: 'image/png' }], + getData: () => 'test.png', + }; + + document.dispatchEvent(event); + + expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1); + expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith([ + new File([{ name: 'image.png' }], 'test.png'), + ]); + }); + + it('renames a design if it has an image.png filename', () => { + event.clipboardData = { + files: [{ name: 'image.png', type: 'image/png' }], + getData: () => 'image.png', + }; + + document.dispatchEvent(event); + + expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1); + expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith([ + new File([{ name: 'image.png' }], `design_${Date.now()}.png`), + ]); + }); + + it('does not call onUploadDesign with invalid paste', () => { + event.clipboardData = { + items: [{ type: 'text/plain' }, { type: 'text' }], + files: [], + }; + + document.dispatchEvent(event); + + expect(wrapper.vm.onUploadDesign).not.toHaveBeenCalled(); + }); + }); + + describe('when navigating', () => { + it('ensures fullscreen layout is not applied', () => { + createComponent(true); + + wrapper.vm.$router.push('/'); + expect(mockPageEl.classList.remove).toHaveBeenCalledTimes(1); + expect(mockPageEl.classList.remove).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST); + }); + }); +}); diff --git a/spec/frontend/design_management_new/router_spec.js b/spec/frontend/design_management_new/router_spec.js new file mode 100644 index 00000000000..4d63e622724 --- /dev/null +++ b/spec/frontend/design_management_new/router_spec.js @@ -0,0 +1,70 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import VueRouter from 'vue-router'; +import App from '~/design_management_new/components/app.vue'; +import Designs from '~/design_management_new/pages/index.vue'; +import DesignDetail from '~/design_management_new/pages/design/index.vue'; +import createRouter from '~/design_management_new/router'; +import { DESIGNS_ROUTE_NAME, DESIGN_ROUTE_NAME } from '~/design_management_new/router/constants'; +import '~/commons/bootstrap'; + +function factory(routeArg) { + const localVue = createLocalVue(); + localVue.use(VueRouter); + + window.gon = { sprite_icons: '' }; + + const router = createRouter('/'); + if (routeArg !== undefined) { + router.push(routeArg); + } + + return mount(App, { + localVue, + router, + mocks: { + $apollo: { + queries: { + designs: { loading: true }, + design: { loading: true }, + permissions: { loading: true }, + }, + mutate: jest.fn(), + }, + }, + }); +} + +jest.mock('mousetrap', () => ({ + bind: jest.fn(), + unbind: jest.fn(), +})); + +describe('Design management router', () => { + afterEach(() => { + window.location.hash = ''; + }); + + describe.each([['/'], [{ name: DESIGNS_ROUTE_NAME }]])('root route', routeArg => { + it('pushes home component', () => { + const wrapper = factory(routeArg); + + expect(wrapper.find(Designs).exists()).toBe(true); + }); + }); + + describe.each([['/designs/1'], [{ name: DESIGN_ROUTE_NAME, params: { id: '1' } }]])( + 'designs detail route', + routeArg => { + it('pushes designs detail component', () => { + const wrapper = factory(routeArg); + + return nextTick().then(() => { + const detail = wrapper.find(DesignDetail); + expect(detail.exists()).toBe(true); + expect(detail.props('id')).toEqual('1'); + }); + }); + }, + ); +}); diff --git a/spec/frontend/design_management_new/utils/cache_update_spec.js b/spec/frontend/design_management_new/utils/cache_update_spec.js new file mode 100644 index 00000000000..611716d5aa7 --- /dev/null +++ b/spec/frontend/design_management_new/utils/cache_update_spec.js @@ -0,0 +1,44 @@ +import { InMemoryCache } from 'apollo-cache-inmemory'; +import { + updateStoreAfterDesignsDelete, + updateStoreAfterAddDiscussionComment, + updateStoreAfterAddImageDiffNote, + updateStoreAfterUploadDesign, + updateStoreAfterUpdateImageDiffNote, +} from '~/design_management_new/utils/cache_update'; +import { + designDeletionError, + ADD_DISCUSSION_COMMENT_ERROR, + ADD_IMAGE_DIFF_NOTE_ERROR, + UPDATE_IMAGE_DIFF_NOTE_ERROR, +} from '~/design_management_new/utils/error_messages'; +import design from '../mock_data/design'; +import createFlash from '~/flash'; + +jest.mock('~/flash.js'); + +describe('Design Management cache update', () => { + const mockErrors = ['code red!']; + + let mockStore; + + beforeEach(() => { + mockStore = new InMemoryCache(); + }); + + describe('error handling', () => { + it.each` + fnName | subject | errorMessage | extraArgs + ${'updateStoreAfterDesignsDelete'} | ${updateStoreAfterDesignsDelete} | ${designDeletionError({ singular: true })} | ${[[design]]} + ${'updateStoreAfterAddDiscussionComment'} | ${updateStoreAfterAddDiscussionComment} | ${ADD_DISCUSSION_COMMENT_ERROR} | ${[]} + ${'updateStoreAfterAddImageDiffNote'} | ${updateStoreAfterAddImageDiffNote} | ${ADD_IMAGE_DIFF_NOTE_ERROR} | ${[]} + ${'updateStoreAfterUploadDesign'} | ${updateStoreAfterUploadDesign} | ${mockErrors[0]} | ${[]} + ${'updateStoreAfterUpdateImageDiffNote'} | ${updateStoreAfterUpdateImageDiffNote} | ${UPDATE_IMAGE_DIFF_NOTE_ERROR} | ${[]} + `('$fnName handles errors in response', ({ subject, extraArgs, errorMessage }) => { + expect(createFlash).not.toHaveBeenCalled(); + expect(() => subject(mockStore, { errors: mockErrors }, {}, ...extraArgs)).toThrow(); + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith(errorMessage); + }); + }); +}); diff --git a/spec/frontend/design_management_new/utils/design_management_utils_spec.js b/spec/frontend/design_management_new/utils/design_management_utils_spec.js new file mode 100644 index 00000000000..8bc33e214be --- /dev/null +++ b/spec/frontend/design_management_new/utils/design_management_utils_spec.js @@ -0,0 +1,176 @@ +import { + extractCurrentDiscussion, + extractDiscussions, + findVersionId, + designUploadOptimisticResponse, + updateImageDiffNoteOptimisticResponse, + isValidDesignFile, + extractDesign, +} from '~/design_management_new/utils/design_management_utils'; +import mockResponseNoDesigns from '../mock_data/no_designs'; +import mockResponseWithDesigns from '../mock_data/designs'; +import mockDesign from '../mock_data/design'; + +jest.mock('lodash/uniqueId', () => () => 1); + +describe('extractCurrentDiscussion', () => { + let discussions; + + beforeEach(() => { + discussions = { + nodes: [ + { id: 101, payload: 'w' }, + { id: 102, payload: 'x' }, + { id: 103, payload: 'y' }, + { id: 104, payload: 'z' }, + ], + }; + }); + + it('finds the relevant discussion if it exists', () => { + const id = 103; + expect(extractCurrentDiscussion(discussions, id)).toEqual({ id, payload: 'y' }); + }); + + it('returns null if the relevant discussion does not exist', () => { + expect(extractCurrentDiscussion(discussions, 0)).not.toBeDefined(); + }); +}); + +describe('extractDiscussions', () => { + let discussions; + + beforeEach(() => { + discussions = { + nodes: [ + { id: 1, notes: { nodes: ['a'] } }, + { id: 2, notes: { nodes: ['b'] } }, + { id: 3, notes: { nodes: ['c'] } }, + { id: 4, notes: { nodes: ['d'] } }, + ], + }; + }); + + it('discards the edges.node artifacts of GraphQL', () => { + expect(extractDiscussions(discussions)).toEqual([ + { id: 1, notes: ['a'], index: 1 }, + { id: 2, notes: ['b'], index: 2 }, + { id: 3, notes: ['c'], index: 3 }, + { id: 4, notes: ['d'], index: 4 }, + ]); + }); +}); + +describe('version parser', () => { + it('correctly extracts version ID from a valid version string', () => { + const testVersionId = '123'; + const testVersionString = `gid://gitlab/DesignManagement::Version/${testVersionId}`; + + expect(findVersionId(testVersionString)).toEqual(testVersionId); + }); + + it('fails to extract version ID from an invalid version string', () => { + const testInvalidVersionString = `gid://gitlab/DesignManagement::Version`; + + expect(findVersionId(testInvalidVersionString)).toBeUndefined(); + }); +}); + +describe('optimistic responses', () => { + it('correctly generated for designManagementUpload', () => { + const expectedResponse = { + __typename: 'Mutation', + designManagementUpload: { + __typename: 'DesignManagementUploadPayload', + designs: [ + { + __typename: 'Design', + id: -1, + image: '', + imageV432x230: '', + filename: 'test', + fullPath: '', + notesCount: 0, + event: 'NONE', + diffRefs: { __typename: 'DiffRefs', baseSha: '', startSha: '', headSha: '' }, + discussions: { __typename: 'DesignDiscussion', nodes: [] }, + versions: { + __typename: 'DesignVersionConnection', + edges: { + __typename: 'DesignVersionEdge', + node: { __typename: 'DesignVersion', id: -1, sha: -1 }, + }, + }, + }, + ], + errors: [], + skippedDesigns: [], + }, + }; + expect(designUploadOptimisticResponse([{ name: 'test' }])).toEqual(expectedResponse); + }); + + it('correctly generated for updateImageDiffNoteOptimisticResponse', () => { + const mockNote = { + id: 'test-note-id', + }; + + const mockPosition = { + x: 10, + y: 10, + width: 10, + height: 10, + }; + + const expectedResponse = { + __typename: 'Mutation', + updateImageDiffNote: { + __typename: 'UpdateImageDiffNotePayload', + note: { + ...mockNote, + position: mockPosition, + }, + errors: [], + }, + }; + expect(updateImageDiffNoteOptimisticResponse(mockNote, { position: mockPosition })).toEqual( + expectedResponse, + ); + }); +}); + +describe('isValidDesignFile', () => { + // test every filetype that Design Management supports + // https://docs.gitlab.com/ee/user/project/issues/design_management.html#limitations + it.each` + mimetype | isValid + ${'image/svg'} | ${true} + ${'image/png'} | ${true} + ${'image/jpg'} | ${true} + ${'image/jpeg'} | ${true} + ${'image/gif'} | ${true} + ${'image/bmp'} | ${true} + ${'image/tiff'} | ${true} + ${'image/ico'} | ${true} + ${'image/svg'} | ${true} + ${'video/mpeg'} | ${false} + ${'audio/midi'} | ${false} + ${'application/octet-stream'} | ${false} + `('returns $isValid for file type $mimetype', ({ mimetype, isValid }) => { + expect(isValidDesignFile({ type: mimetype })).toBe(isValid); + }); +}); + +describe('extractDesign', () => { + describe('with no designs', () => { + it('returns undefined', () => { + expect(extractDesign(mockResponseNoDesigns)).toBeUndefined(); + }); + }); + + describe('with designs', () => { + it('returns the first design available', () => { + expect(extractDesign(mockResponseWithDesigns)).toEqual(mockDesign); + }); + }); +}); diff --git a/spec/frontend/design_management_new/utils/error_messages_spec.js b/spec/frontend/design_management_new/utils/error_messages_spec.js new file mode 100644 index 00000000000..eb5dc0fad20 --- /dev/null +++ b/spec/frontend/design_management_new/utils/error_messages_spec.js @@ -0,0 +1,62 @@ +import { + designDeletionError, + designUploadSkippedWarning, +} from '~/design_management_new/utils/error_messages'; + +const mockFilenames = n => + Array(n) + .fill(0) + .map((_, i) => ({ filename: `${i + 1}.jpg` })); + +describe('Error message', () => { + describe('designDeletionError', () => { + const singularMsg = 'Could not delete a design. Please try again.'; + const pluralMsg = 'Could not delete designs. Please try again.'; + + describe('when [singular=true]', () => { + it.each([[undefined], [true]])('uses singular grammar', singularOption => { + expect(designDeletionError({ singular: singularOption })).toEqual(singularMsg); + }); + }); + + describe('when [singular=false]', () => { + it('uses plural grammar', () => { + expect(designDeletionError({ singular: false })).toEqual(pluralMsg); + }); + }); + }); + + describe.each([ + [[], [], null], + [mockFilenames(1), mockFilenames(1), 'Upload skipped. 1.jpg did not change.'], + [ + mockFilenames(2), + mockFilenames(2), + 'Upload skipped. The designs you tried uploading did not change.', + ], + [ + mockFilenames(2), + mockFilenames(1), + 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg.', + ], + [ + mockFilenames(6), + mockFilenames(5), + 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg.', + ], + [ + mockFilenames(7), + mockFilenames(6), + 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg, and 1 more.', + ], + [ + mockFilenames(8), + mockFilenames(7), + 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg, and 2 more.', + ], + ])('designUploadSkippedWarning', (uploadedFiles, skippedFiles, expected) => { + test('returns expected warning message', () => { + expect(designUploadSkippedWarning(uploadedFiles, skippedFiles)).toBe(expected); + }); + }); +}); diff --git a/spec/frontend/design_management_new/utils/tracking_spec.js b/spec/frontend/design_management_new/utils/tracking_spec.js new file mode 100644 index 00000000000..ac7267642cb --- /dev/null +++ b/spec/frontend/design_management_new/utils/tracking_spec.js @@ -0,0 +1,59 @@ +import { mockTracking } from 'helpers/tracking_helper'; +import { trackDesignDetailView } from '~/design_management_new/utils/tracking'; + +function getTrackingSpy(key) { + return mockTracking(key, undefined, jest.spyOn); +} + +describe('Tracking Events', () => { + describe('trackDesignDetailView', () => { + const eventKey = 'projects:issues:design'; + const eventName = 'view_design'; + + it('trackDesignDetailView fires a tracking event when called', () => { + const trackingSpy = getTrackingSpy(eventKey); + + trackDesignDetailView(); + + expect(trackingSpy).toHaveBeenCalledWith( + eventKey, + eventName, + expect.objectContaining({ + label: eventName, + context: { + schema: expect.any(String), + data: { + 'design-version-number': 1, + 'design-is-current-version': false, + 'internal-object-referrer': '', + 'design-collection-owner': '', + }, + }, + }), + ); + }); + + it('trackDesignDetailView allows to customize the value payload', () => { + const trackingSpy = getTrackingSpy(eventKey); + + trackDesignDetailView('from-a-test', 'test', 100, true); + + expect(trackingSpy).toHaveBeenCalledWith( + eventKey, + eventName, + expect.objectContaining({ + label: eventName, + context: { + schema: expect.any(String), + data: { + 'design-version-number': 100, + 'design-is-current-version': true, + 'internal-object-referrer': 'from-a-test', + 'design-collection-owner': 'test', + }, + }, + }), + ); + }); + }); +}); diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index 57e3a93c6f4..b7f03f35dfb 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -56,6 +56,7 @@ describe('diffs/components/app', () => { changesEmptyStateIllustration: '', dismissEndpoint: '', showSuggestPopover: true, + viewDiffsFileByFile: false, ...props, }, provide, @@ -829,4 +830,58 @@ describe('diffs/components/app', () => { expect(toggleShowTreeList).not.toHaveBeenCalled(); }); }); + + describe('file-by-file', () => { + it('renders a single diff', () => { + createComponent({ viewDiffsFileByFile: true }, ({ state }) => { + state.diffs.diffFiles.push({ file_hash: '123' }); + state.diffs.diffFiles.push({ file_hash: '312' }); + }); + + expect(wrapper.findAll(DiffFile).length).toBe(1); + }); + + describe('pagination', () => { + it('sets previous button as disabled', () => { + createComponent({ viewDiffsFileByFile: true }, ({ state }) => { + state.diffs.diffFiles.push({ file_hash: '123' }, { file_hash: '312' }); + }); + + expect(wrapper.find('[data-testid="singleFilePrevious"]').props('disabled')).toBe(true); + expect(wrapper.find('[data-testid="singleFileNext"]').props('disabled')).toBe(false); + }); + + it('sets next button as disabled', () => { + createComponent({ viewDiffsFileByFile: true }, ({ state }) => { + state.diffs.diffFiles.push({ file_hash: '123' }, { file_hash: '312' }); + state.diffs.currentDiffFileId = '312'; + }); + + expect(wrapper.find('[data-testid="singleFilePrevious"]').props('disabled')).toBe(false); + expect(wrapper.find('[data-testid="singleFileNext"]').props('disabled')).toBe(true); + }); + + it.each` + currentDiffFileId | button | index + ${'123'} | ${'singleFileNext'} | ${1} + ${'312'} | ${'singleFilePrevious'} | ${0} + `( + 'it calls navigateToDiffFileIndex with $index when $button is clicked', + ({ currentDiffFileId, button, index }) => { + createComponent({ viewDiffsFileByFile: true }, ({ state }) => { + state.diffs.diffFiles.push({ file_hash: '123' }, { file_hash: '312' }); + state.diffs.currentDiffFileId = currentDiffFileId; + }); + + jest.spyOn(wrapper.vm, 'navigateToDiffFileIndex'); + + wrapper.find(`[data-testid="${button}"]`).vm.$emit('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.navigateToDiffFileIndex).toHaveBeenCalledWith(index); + }); + }, + ); + }); + }); }); diff --git a/spec/frontend/diffs/components/diff_expansion_cell_spec.js b/spec/frontend/diffs/components/diff_expansion_cell_spec.js index 0504f3933e0..ef2e0dfe59b 100644 --- a/spec/frontend/diffs/components/diff_expansion_cell_spec.js +++ b/spec/frontend/diffs/components/diff_expansion_cell_spec.js @@ -6,10 +6,10 @@ import DiffExpansionCell from '~/diffs/components/diff_expansion_cell.vue'; import { getPreviousLineIndex } from '~/diffs/store/utils'; import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '~/diffs/constants'; import diffFileMockData from '../mock_data/diff_file'; +import { getByText } from '@testing-library/dom'; const EXPAND_UP_CLASS = '.js-unfold'; const EXPAND_DOWN_CLASS = '.js-unfold-down'; -const EXPAND_ALL_CLASS = '.js-unfold-all'; const LINE_TO_USE = 5; const lineSources = { [INLINE_DIFF_VIEW_TYPE]: 'highlighted_diff_lines', @@ -88,7 +88,7 @@ describe('DiffExpansionCell', () => { const findExpandUp = () => vm.$el.querySelector(EXPAND_UP_CLASS); const findExpandDown = () => vm.$el.querySelector(EXPAND_DOWN_CLASS); - const findExpandAll = () => vm.$el.querySelector(EXPAND_ALL_CLASS); + const findExpandAll = () => getByText(vm.$el, 'Show unchanged lines'); describe('top row', () => { it('should have "expand up" and "show all" option', () => { diff --git a/spec/frontend/diffs/components/diff_file_header_spec.js b/spec/frontend/diffs/components/diff_file_header_spec.js index e0b7e0bc0f3..671dced080c 100644 --- a/spec/frontend/diffs/components/diff_file_header_spec.js +++ b/spec/frontend/diffs/components/diff_file_header_spec.js @@ -87,6 +87,7 @@ describe('DiffFileHeader component', () => { propsData: { diffFile, canCurrentUserFork: false, + viewDiffsFileByFile: false, ...props, }, localVue, diff --git a/spec/frontend/diffs/components/diff_file_row_spec.js b/spec/frontend/diffs/components/diff_file_row_spec.js index 856622b89cb..afdd4bfb335 100644 --- a/spec/frontend/diffs/components/diff_file_row_spec.js +++ b/spec/frontend/diffs/components/diff_file_row_spec.js @@ -73,4 +73,15 @@ describe('Diff File Row component', () => { expect(wrapper.find(FileRowStats).exists()).toEqual(value); }); }); + + it('adds is-active class when currentDiffFileId matches file_hash', () => { + createComponent({ + level: 0, + currentDiffFileId: '123', + file: { fileHash: '123' }, + hideFileStats: false, + }); + + expect(wrapper.classes('is-active')).toBe(true); + }); }); diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js index 71e975f2409..7e154d76f45 100644 --- a/spec/frontend/diffs/components/diff_file_spec.js +++ b/spec/frontend/diffs/components/diff_file_spec.js @@ -15,6 +15,7 @@ describe('DiffFile', () => { vm = createComponentWithStore(Vue.extend(DiffFileComponent), createStore(), { file: JSON.parse(JSON.stringify(diffFileMockDataReadable)), canCurrentUserFork: false, + viewDiffsFileByFile: false, }).$mount(); trackingSpy = mockTracking('_category_', vm.$el, jest.spyOn); }); @@ -113,6 +114,7 @@ describe('DiffFile', () => { vm = createComponentWithStore(Vue.extend(DiffFileComponent), createStore(), { file: JSON.parse(JSON.stringify(diffFileMockDataUnreadable)), canCurrentUserFork: false, + viewDiffsFileByFile: false, }).$mount(); vm.renderIt = false; @@ -235,6 +237,7 @@ describe('DiffFile', () => { vm = createComponentWithStore(Vue.extend(DiffFileComponent), createStore(), { file: JSON.parse(JSON.stringify(diffFileMockDataUnreadable)), canCurrentUserFork: false, + viewDiffsFileByFile: false, }).$mount(); jest.spyOn(vm, 'handleLoadCollapsedDiff').mockImplementation(() => {}); diff --git a/spec/frontend/diffs/components/diff_gutter_avatars_spec.js b/spec/frontend/diffs/components/diff_gutter_avatars_spec.js index da18d8e7894..61e110b345a 100644 --- a/spec/frontend/diffs/components/diff_gutter_avatars_spec.js +++ b/spec/frontend/diffs/components/diff_gutter_avatars_spec.js @@ -110,7 +110,7 @@ describe('DiffGutterAvatars', () => { it('returns truncated version of comment if it is longer than max length', () => { const note = wrapper.vm.discussions[0].notes[1]; - expect(wrapper.vm.getTooltipText(note)).toEqual('Fatih Acet: comment 2 is r...'); + expect(wrapper.vm.getTooltipText(note)).toEqual('Fatih Acet: comment 2 is rea…'); }); }); }); diff --git a/spec/frontend/diffs/components/diff_line_note_form_spec.js b/spec/frontend/diffs/components/diff_line_note_form_spec.js index 623df8bd55e..75ec5c202af 100644 --- a/spec/frontend/diffs/components/diff_line_note_form_spec.js +++ b/spec/frontend/diffs/components/diff_line_note_form_spec.js @@ -78,10 +78,18 @@ describe('DiffLineNoteForm', () => { .mockReturnValue(Promise.resolve()); const lineRange = { - start_line_code: wrapper.vm.commentLineStart.lineCode, - start_line_type: wrapper.vm.commentLineStart.type, - end_line_code: wrapper.vm.line.line_code, - end_line_type: wrapper.vm.line.type, + start: { + line_code: wrapper.vm.commentLineStart.line_code, + type: wrapper.vm.commentLineStart.type, + new_line: 1, + old_line: null, + }, + end: { + line_code: wrapper.vm.line.line_code, + type: wrapper.vm.line.type, + new_line: 1, + old_line: null, + }, }; const formData = { diff --git a/spec/frontend/diffs/components/diff_table_cell_spec.js b/spec/frontend/diffs/components/diff_table_cell_spec.js index e871d86d901..9693fe68b57 100644 --- a/spec/frontend/diffs/components/diff_table_cell_spec.js +++ b/spec/frontend/diffs/components/diff_table_cell_spec.js @@ -100,7 +100,11 @@ describe('DiffTableCell', () => { setWindowLocation({ href: `${TEST_HOST}?${query}` }); createComponent({ showCommentButton }); - expect(findNoteButton().exists()).toBe(expectation); + wrapper.setData({ isCommentButtonRendered: showCommentButton }); + + return wrapper.vm.$nextTick().then(() => { + expect(findNoteButton().exists()).toBe(expectation); + }); }, ); @@ -108,7 +112,6 @@ describe('DiffTableCell', () => { isHover | otherProps | discussions | expectation ${true} | ${{}} | ${[]} | ${true} ${false} | ${{}} | ${[]} | ${false} - ${true} | ${{ line: { ...line, type: 'match' } }} | ${[]} | ${false} ${true} | ${{ line: { ...line, type: 'context' } }} | ${[]} | ${false} ${true} | ${{ line: { ...line, type: 'old-nonewline' } }} | ${[]} | ${false} ${true} | ${{}} | ${[{}]} | ${false} @@ -122,7 +125,13 @@ describe('DiffTableCell', () => { ...otherProps, }); - expect(findNoteButton().isVisible()).toBe(expectation); + wrapper.setData({ + isCommentButtonRendered: true, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(findNoteButton().isVisible()).toBe(expectation); + }); }, ); }); diff --git a/spec/frontend/diffs/components/inline_diff_table_row_spec.js b/spec/frontend/diffs/components/inline_diff_table_row_spec.js index 66349727b11..f929f97b598 100644 --- a/spec/frontend/diffs/components/inline_diff_table_row_spec.js +++ b/spec/frontend/diffs/components/inline_diff_table_row_spec.js @@ -1,27 +1,31 @@ -import Vue from 'vue'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { shallowMount } from '@vue/test-utils'; import { createStore } from '~/mr_notes/stores'; import InlineDiffTableRow from '~/diffs/components/inline_diff_table_row.vue'; import diffFileMockData from '../mock_data/diff_file'; describe('InlineDiffTableRow', () => { + let wrapper; let vm; const thisLine = diffFileMockData.highlighted_diff_lines[0]; beforeEach(() => { - vm = createComponentWithStore(Vue.extend(InlineDiffTableRow), createStore(), { - line: thisLine, - fileHash: diffFileMockData.file_hash, - filePath: diffFileMockData.file_path, - contextLinesPath: 'contextLinesPath', - isHighlighted: false, - }).$mount(); + wrapper = shallowMount(InlineDiffTableRow, { + store: createStore(), + propsData: { + line: thisLine, + fileHash: diffFileMockData.file_hash, + filePath: diffFileMockData.file_path, + contextLinesPath: 'contextLinesPath', + isHighlighted: false, + }, + }); + vm = wrapper.vm; }); it('does not add hll class to line content when line does not match highlighted row', done => { vm.$nextTick() .then(() => { - expect(vm.$el.querySelector('.line_content').classList).not.toContain('hll'); + expect(wrapper.find('.line_content').classes('hll')).toBe(false); }) .then(done) .catch(done.fail); @@ -35,12 +39,19 @@ describe('InlineDiffTableRow', () => { return vm.$nextTick(); }) .then(() => { - expect(vm.$el.querySelector('.line_content').classList).toContain('hll'); + expect(wrapper.find('.line_content').classes('hll')).toBe(true); }) .then(done) .catch(done.fail); }); + it('adds hll class to lineContent when line is part of a multiline comment', () => { + wrapper.setProps({ isCommented: true }); + return vm.$nextTick().then(() => { + expect(wrapper.find('.line_content').classes('hll')).toBe(true); + }); + }); + describe('sets coverage title and class', () => { it('for lines with coverage', done => { vm.$nextTick() @@ -53,10 +64,10 @@ describe('InlineDiffTableRow', () => { return vm.$nextTick(); }) .then(() => { - const coverage = vm.$el.querySelector('.line-coverage'); + const coverage = wrapper.find('.line-coverage'); - expect(coverage.title).toContain('Test coverage: 5 hits'); - expect(coverage.classList).toContain('coverage'); + expect(coverage.attributes('title')).toContain('Test coverage: 5 hits'); + expect(coverage.classes('coverage')).toBe(true); }) .then(done) .catch(done.fail); @@ -73,10 +84,10 @@ describe('InlineDiffTableRow', () => { return vm.$nextTick(); }) .then(() => { - const coverage = vm.$el.querySelector('.line-coverage'); + const coverage = wrapper.find('.line-coverage'); - expect(coverage.title).toContain('No test coverage'); - expect(coverage.classList).toContain('no-coverage'); + expect(coverage.attributes('title')).toContain('No test coverage'); + expect(coverage.classes('no-coverage')).toBe(true); }) .then(done) .catch(done.fail); @@ -90,11 +101,11 @@ describe('InlineDiffTableRow', () => { return vm.$nextTick(); }) .then(() => { - const coverage = vm.$el.querySelector('.line-coverage'); + const coverage = wrapper.find('.line-coverage'); - expect(coverage.title).not.toContain('Coverage'); - expect(coverage.classList).not.toContain('coverage'); - expect(coverage.classList).not.toContain('no-coverage'); + expect(coverage.attributes('title')).toBeUndefined(); + expect(coverage.classes('coverage')).toBe(false); + expect(coverage.classes('no-coverage')).toBe(false); }) .then(done) .catch(done.fail); diff --git a/spec/frontend/diffs/components/no_changes_spec.js b/spec/frontend/diffs/components/no_changes_spec.js index 245651af61c..2eca97a47fd 100644 --- a/spec/frontend/diffs/components/no_changes_spec.js +++ b/spec/frontend/diffs/components/no_changes_spec.js @@ -2,6 +2,7 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; import { createStore } from '~/mr_notes/stores'; import NoChanges from '~/diffs/components/no_changes.vue'; +import { GlButton } from '@gitlab/ui'; describe('Diff no changes empty state', () => { let vm; @@ -37,4 +38,11 @@ describe('Diff no changes empty state', () => { expect(vm.contains('script')).toBe(false); }); + + describe('Renders', () => { + it('Show create commit button', () => { + createComponent(); + expect(vm.find(GlButton).exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/diffs/components/parallel_diff_table_row_spec.js b/spec/frontend/diffs/components/parallel_diff_table_row_spec.js index 6b92d448cf5..339352943a9 100644 --- a/spec/frontend/diffs/components/parallel_diff_table_row_spec.js +++ b/spec/frontend/diffs/components/parallel_diff_table_row_spec.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; import { createStore } from '~/mr_notes/stores'; import ParallelDiffTableRow from '~/diffs/components/parallel_diff_table_row.vue'; @@ -6,18 +7,24 @@ import diffFileMockData from '../mock_data/diff_file'; describe('ParallelDiffTableRow', () => { describe('when one side is empty', () => { + let wrapper; let vm; const thisLine = diffFileMockData.parallel_diff_lines[0]; const rightLine = diffFileMockData.parallel_diff_lines[0].right; beforeEach(() => { - vm = createComponentWithStore(Vue.extend(ParallelDiffTableRow), createStore(), { - line: thisLine, - fileHash: diffFileMockData.file_hash, - filePath: diffFileMockData.file_path, - contextLinesPath: 'contextLinesPath', - isHighlighted: false, - }).$mount(); + wrapper = shallowMount(ParallelDiffTableRow, { + store: createStore(), + propsData: { + line: thisLine, + fileHash: diffFileMockData.file_hash, + filePath: diffFileMockData.file_path, + contextLinesPath: 'contextLinesPath', + isHighlighted: false, + }, + }); + + vm = wrapper.vm; }); it('does not highlight non empty line content when line does not match highlighted row', done => { @@ -42,6 +49,13 @@ describe('ParallelDiffTableRow', () => { .then(done) .catch(done.fail); }); + + it('highlights nonempty line content when line is part of a multiline comment', () => { + wrapper.setProps({ isCommented: true }); + return vm.$nextTick().then(() => { + expect(vm.$el.querySelector('.line_content.right-side').classList).toContain('hll'); + }); + }); }); describe('when both sides have content', () => { diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index 7d79dcfbfe3..ec6ad031813 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -6,6 +6,8 @@ import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE, DIFFS_PER_PAGE, + DIFF_WHITESPACE_COOKIE_NAME, + SHOW_WHITESPACE, } from '~/diffs/constants'; import { setBaseConfig, @@ -44,6 +46,8 @@ import { setSuggestPopoverDismissed, changeCurrentCommit, moveToNeighboringCommit, + setCurrentDiffFileIdFromNote, + navigateToDiffFileIndex, } from '~/diffs/store/actions'; import eventHub from '~/notes/event_hub'; import * as types from '~/diffs/store/mutation_types'; @@ -55,6 +59,7 @@ import { mergeUrlParams } from '~/lib/utils/url_utility'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { diffMetadata } from '../mock_data/diff_metadata'; import createFlash from '~/flash'; +import { TEST_HOST } from 'jest/helpers/test_constants'; jest.mock('~/flash', () => jest.fn()); @@ -187,8 +192,8 @@ describe('DiffsStoreActions', () => { it('should fetch batch diff files', done => { const endpointBatch = '/fetch/diffs_batch'; - const res1 = { diff_files: [], pagination: { next_page: 2 } }; - const res2 = { diff_files: [], pagination: {} }; + const res1 = { diff_files: [{ file_hash: 'test' }], pagination: { next_page: 2 } }; + const res2 = { diff_files: [{ file_hash: 'test2' }], pagination: {} }; mock .onGet( mergeUrlParams( @@ -224,8 +229,10 @@ describe('DiffsStoreActions', () => { { type: types.SET_RETRIEVING_BATCHES, payload: true }, { type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res1.diff_files } }, { type: types.SET_BATCH_LOADING, payload: false }, - { type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: [] } }, + { type: types.UPDATE_CURRENT_DIFF_FILE_ID, payload: 'test' }, + { type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res2.diff_files } }, { type: types.SET_BATCH_LOADING, payload: false }, + { type: types.UPDATE_CURRENT_DIFF_FILE_ID, payload: 'test2' }, { type: types.SET_RETRIEVING_BATCHES, payload: false }, ], [], @@ -309,6 +316,7 @@ describe('DiffsStoreActions', () => { showWhitespace: false, diffViewType: 'inline', useSingleDiffStyle: false, + currentDiffFileId: null, }, [ { type: types.SET_LOADING, payload: true }, @@ -345,8 +353,8 @@ describe('DiffsStoreActions', () => { it('should fetch batch diff files', done => { const endpointBatch = '/fetch/diffs_batch'; - const res1 = { diff_files: [], pagination: { next_page: 2 } }; - const res2 = { diff_files: [], pagination: {} }; + const res1 = { diff_files: [{ file_hash: 'test' }], pagination: { next_page: 2 } }; + const res2 = { diff_files: [{ file_hash: 'test2' }], pagination: {} }; mock .onGet(mergeUrlParams({ per_page: DIFFS_PER_PAGE, w: '1', page: 1 }, endpointBatch)) .reply(200, res1) @@ -356,14 +364,16 @@ describe('DiffsStoreActions', () => { testAction( fetchDiffFilesBatch, {}, - { endpointBatch, useSingleDiffStyle: false }, + { endpointBatch, useSingleDiffStyle: false, currentDiffFileId: null }, [ { type: types.SET_BATCH_LOADING, payload: true }, { type: types.SET_RETRIEVING_BATCHES, payload: true }, { type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res1.diff_files } }, { type: types.SET_BATCH_LOADING, payload: false }, - { type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: [] } }, + { type: types.UPDATE_CURRENT_DIFF_FILE_ID, payload: 'test' }, + { type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res2.diff_files } }, { type: types.SET_BATCH_LOADING, payload: false }, + { type: types.UPDATE_CURRENT_DIFF_FILE_ID, payload: 'test2' }, { type: types.SET_RETRIEVING_BATCHES, payload: false }, ], [], @@ -475,6 +485,10 @@ describe('DiffsStoreActions', () => { }); describe('assignDiscussionsToDiff', () => { + afterEach(() => { + window.location.hash = ''; + }); + it('should merge discussions into diffs', done => { window.location.hash = 'ABC_123'; @@ -568,6 +582,19 @@ describe('DiffsStoreActions', () => { done, ); }); + + it('dispatches setCurrentDiffFileIdFromNote with note ID', done => { + window.location.hash = 'note_123'; + + testAction( + assignDiscussionsToDiff, + [], + { diffFiles: [], useSingleDiffStyle: true }, + [], + [{ type: 'setCurrentDiffFileIdFromNote', payload: '123' }], + done, + ); + }); }); describe('removeDiscussionsFromDiff', () => { @@ -1187,10 +1214,10 @@ describe('DiffsStoreActions', () => { ); }); - it('sets localStorage', () => { + it('sets cookie', () => { setShowWhitespace({ commit() {} }, { showWhitespace: true }); - expect(localStorage.setItem).toHaveBeenCalledWith('mr_show_whitespace', true); + expect(Cookies.get(DIFF_WHITESPACE_COOKIE_NAME)).toEqual(SHOW_WHITESPACE); }); it('calls history pushState', () => { @@ -1250,12 +1277,12 @@ describe('DiffsStoreActions', () => { describe('success', () => { beforeEach(() => { - mock.onGet(`${gl.TEST_HOST}/context`).replyOnce(200, ['test']); + mock.onGet(`${TEST_HOST}/context`).replyOnce(200, ['test']); }); it('commits the success and dispatches an action to expand the new lines', done => { const file = { - context_lines_path: `${gl.TEST_HOST}/context`, + context_lines_path: `${TEST_HOST}/context`, file_path: 'test', file_hash: 'test', }; @@ -1272,13 +1299,13 @@ describe('DiffsStoreActions', () => { describe('error', () => { beforeEach(() => { - mock.onGet(`${gl.TEST_HOST}/context`).replyOnce(500); + mock.onGet(`${TEST_HOST}/context`).replyOnce(500); }); it('dispatches receiveFullDiffError', done => { testAction( fetchFullDiff, - { context_lines_path: `${gl.TEST_HOST}/context`, file_path: 'test', file_hash: 'test' }, + { context_lines_path: `${TEST_HOST}/context`, file_path: 'test', file_hash: 'test' }, null, [], [{ type: 'receiveFullDiffError', payload: 'test' }], @@ -1442,7 +1469,7 @@ describe('DiffsStoreActions', () => { describe('setSuggestPopoverDismissed', () => { it('commits SET_SHOW_SUGGEST_POPOVER', done => { - const state = { dismissEndpoint: `${gl.TEST_HOST}/-/user_callouts` }; + const state = { dismissEndpoint: `${TEST_HOST}/-/user_callouts` }; const mock = new MockAdapter(axios); mock.onPost(state.dismissEndpoint).reply(200, {}); @@ -1563,4 +1590,31 @@ describe('DiffsStoreActions', () => { }, ); }); + + describe('setCurrentDiffFileIdFromNote', () => { + it('commits UPDATE_CURRENT_DIFF_FILE_ID', () => { + const commit = jest.fn(); + const rootGetters = { + getDiscussion: () => ({ diff_file: { file_hash: '123' } }), + notesById: { '1': { discussion_id: '2' } }, + }; + + setCurrentDiffFileIdFromNote({ commit, rootGetters }, '1'); + + expect(commit).toHaveBeenCalledWith(types.UPDATE_CURRENT_DIFF_FILE_ID, '123'); + }); + }); + + describe('navigateToDiffFileIndex', () => { + it('commits UPDATE_CURRENT_DIFF_FILE_ID', done => { + testAction( + navigateToDiffFileIndex, + 0, + { diffFiles: [{ file_hash: '123' }] }, + [{ type: types.UPDATE_CURRENT_DIFF_FILE_ID, payload: '123' }], + [], + done, + ); + }); + }); }); diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index 891de45e268..d87619e1e3c 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -1090,4 +1090,26 @@ describe('DiffsStoreUtils', () => { ]); }); }); + + describe('getDefaultWhitespace', () => { + it('defaults to true if querystring and cookie are undefined', () => { + expect(utils.getDefaultWhitespace()).toBe(true); + }); + + it('returns false if querystring is `1`', () => { + expect(utils.getDefaultWhitespace('1', '0')).toBe(false); + }); + + it('returns true if querystring is `0`', () => { + expect(utils.getDefaultWhitespace('0', undefined)).toBe(true); + }); + + it('returns false if cookie is `1`', () => { + expect(utils.getDefaultWhitespace(undefined, '1')).toBe(false); + }); + + it('returns true if cookie is `0`', () => { + expect(utils.getDefaultWhitespace(undefined, '0')).toBe(true); + }); + }); }); diff --git a/spec/frontend/editor/editor_lite_spec.js b/spec/frontend/editor/editor_lite_spec.js index cb07bcf8f28..92a136835bf 100644 --- a/spec/frontend/editor/editor_lite_spec.js +++ b/spec/frontend/editor/editor_lite_spec.js @@ -115,6 +115,76 @@ describe('Base editor', () => { }); }); + describe('extensions', () => { + const foo1 = jest.fn(); + const foo2 = jest.fn(); + const bar = jest.fn(); + const MyExt1 = { + foo: foo1, + }; + const MyExt2 = { + bar, + }; + const MyExt3 = { + foo: foo2, + }; + beforeEach(() => { + editor.createInstance({ el: editorEl, blobPath, blobContent }); + }); + + afterEach(() => { + editor.model.dispose(); + }); + + it('is extensible with the extensions', () => { + expect(editor.foo).toBeUndefined(); + + editor.use(MyExt1); + expect(editor.foo).toEqual(foo1); + }); + + it('does not fail if no extensions supplied', () => { + const spy = jest.spyOn(global.console, 'error'); + editor.use(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('is extensible with multiple extensions', () => { + expect(editor.foo).toBeUndefined(); + expect(editor.bar).toBeUndefined(); + + editor.use([MyExt1, MyExt2]); + + expect(editor.foo).toEqual(foo1); + expect(editor.bar).toEqual(bar); + }); + + it('uses the last definition of a method in case of an overlap', () => { + editor.use([MyExt1, MyExt2, MyExt3]); + expect(editor).toEqual( + expect.objectContaining({ + foo: foo2, + bar, + }), + ); + }); + + it('correctly resolves references withing extensions', () => { + const FunctionExt = { + inst() { + return this.instance; + }, + mod() { + return this.model; + }, + }; + editor.use(FunctionExt); + expect(editor.inst()).toEqual(editor.instance); + expect(editor.mod()).toEqual(editor.model); + }); + }); + describe('languages', () => { it('registers custom languages defined with Monaco', () => { expect(monacoLanguages.getLanguages()).toEqual( diff --git a/spec/frontend/editor/editor_markdown_ext_spec.js b/spec/frontend/editor/editor_markdown_ext_spec.js new file mode 100644 index 00000000000..aad2400c0f0 --- /dev/null +++ b/spec/frontend/editor/editor_markdown_ext_spec.js @@ -0,0 +1,204 @@ +import EditorLite from '~/editor/editor_lite'; +import { Range, Position } from 'monaco-editor'; +import EditorMarkdownExtension from '~/editor/editor_markdown_ext'; + +describe('Markdown Extension for Editor Lite', () => { + let editor; + let editorEl; + const firstLine = 'This is a'; + const secondLine = 'multiline'; + const thirdLine = 'string with some **markup**'; + const text = `${firstLine}\n${secondLine}\n${thirdLine}`; + const filePath = 'foo.md'; + + const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => { + const selection = new Range(startLineNumber, startColumn, endLineNumber, endColumn); + editor.instance.setSelection(selection); + }; + const selectSecondString = () => setSelection(2, 1, 2, secondLine.length + 1); // select the whole second line + const selectSecondAndThirdLines = () => setSelection(2, 1, 3, thirdLine.length + 1); // select second and third lines + + const selectionToString = () => editor.instance.getSelection().toString(); + const positionToString = () => editor.instance.getPosition().toString(); + + beforeEach(() => { + setFixtures('<div id="editor" data-editor-loading></div>'); + editorEl = document.getElementById('editor'); + editor = new EditorLite(); + editor.createInstance({ + el: editorEl, + blobPath: filePath, + blobContent: text, + }); + editor.use(EditorMarkdownExtension); + }); + + afterEach(() => { + editor.instance.dispose(); + editor.model.dispose(); + editorEl.remove(); + }); + + describe('getSelectedText', () => { + it('does not fail if there is no selection and returns the empty string', () => { + jest.spyOn(editor.instance, 'getSelection'); + const resText = editor.getSelectedText(); + + expect(editor.instance.getSelection).toHaveBeenCalled(); + expect(resText).toBe(''); + }); + + it.each` + description | selection | expectedString + ${'same-line'} | ${[1, 1, 1, firstLine.length + 1]} | ${firstLine} + ${'two-lines'} | ${[1, 1, 2, secondLine.length + 1]} | ${`${firstLine}\n${secondLine}`} + ${'multi-lines'} | ${[1, 1, 3, thirdLine.length + 1]} | ${text} + `('correctly returns selected text for $description', ({ selection, expectedString }) => { + setSelection(...selection); + + const resText = editor.getSelectedText(); + + expect(resText).toBe(expectedString); + }); + + it('accepts selection object that serves as a source instead of current selection', () => { + selectSecondString(); + const firstLineSelection = new Range(1, 1, 1, firstLine.length + 1); + + const resText = editor.getSelectedText(firstLineSelection); + + expect(resText).toBe(firstLine); + }); + }); + + describe('replaceSelectedText', () => { + const expectedStr = 'foo'; + + it('replaces selected text with the supplied one', () => { + selectSecondString(); + editor.replaceSelectedText(expectedStr); + + expect(editor.getValue()).toBe(`${firstLine}\n${expectedStr}\n${thirdLine}`); + }); + + it('prepends the supplied text if no text is selected', () => { + editor.replaceSelectedText(expectedStr); + expect(editor.getValue()).toBe(`${expectedStr}${firstLine}\n${secondLine}\n${thirdLine}`); + }); + + it('replaces selection with empty string if no text is supplied', () => { + selectSecondString(); + editor.replaceSelectedText(); + expect(editor.getValue()).toBe(`${firstLine}\n\n${thirdLine}`); + }); + + it('puts cursor at the end of the new string and collapses selection by default', () => { + selectSecondString(); + editor.replaceSelectedText(expectedStr); + + expect(positionToString()).toBe(`(2,${expectedStr.length + 1})`); + expect(selectionToString()).toBe( + `[2,${expectedStr.length + 1} -> 2,${expectedStr.length + 1}]`, + ); + }); + + it('puts cursor at the end of the new string and keeps selection if "select" is supplied', () => { + const select = 'url'; + const complexReplacementString = `[${secondLine}](${select})`; + selectSecondString(); + editor.replaceSelectedText(complexReplacementString, select); + + expect(positionToString()).toBe(`(2,${complexReplacementString.length + 1})`); + expect(selectionToString()).toBe(`[2,1 -> 2,${complexReplacementString.length + 1}]`); + }); + }); + + describe('moveCursor', () => { + const setPosition = endCol => { + const currentPos = new Position(2, endCol); + editor.instance.setPosition(currentPos); + }; + + it.each` + direction | condition | startColumn | shift | endPosition + ${'left'} | ${'negative'} | ${secondLine.length + 1} | ${-1} | ${`(2,${secondLine.length})`} + ${'left'} | ${'negative'} | ${secondLine.length} | ${secondLine.length * -1} | ${'(2,1)'} + ${'right'} | ${'positive'} | ${1} | ${1} | ${'(2,2)'} + ${'right'} | ${'positive'} | ${2} | ${secondLine.length} | ${`(2,${secondLine.length + 1})`} + ${'up'} | ${'positive'} | ${1} | ${[0, -1]} | ${'(1,1)'} + ${'top of file'} | ${'positive'} | ${1} | ${[0, -100]} | ${'(1,1)'} + ${'down'} | ${'negative'} | ${1} | ${[0, 1]} | ${'(3,1)'} + ${'end of file'} | ${'negative'} | ${1} | ${[0, 100]} | ${`(3,${thirdLine.length + 1})`} + ${'end of line'} | ${'too large'} | ${1} | ${secondLine.length + 100} | ${`(2,${secondLine.length + 1})`} + ${'start of line'} | ${'too low'} | ${1} | ${-100} | ${'(2,1)'} + `( + 'moves cursor to the $direction if $condition supplied', + ({ startColumn, shift, endPosition }) => { + setPosition(startColumn); + if (Array.isArray(shift)) { + editor.moveCursor(...shift); + } else { + editor.moveCursor(shift); + } + expect(positionToString()).toBe(endPosition); + }, + ); + }); + + describe('selectWithinSelection', () => { + it('scopes down current selection to supplied text', () => { + const selectedText = `${secondLine}\n${thirdLine}`; + const toSelect = 'string'; + selectSecondAndThirdLines(); + + expect(selectionToString()).toBe(`[2,1 -> 3,${thirdLine.length + 1}]`); + + editor.selectWithinSelection(toSelect, selectedText); + expect(selectionToString()).toBe(`[3,1 -> 3,${toSelect.length + 1}]`); + }); + + it('does not fail when only `toSelect` is supplied and fetches the text from selection', () => { + jest.spyOn(editor, 'getSelectedText'); + const toSelect = 'string'; + selectSecondAndThirdLines(); + + editor.selectWithinSelection(toSelect); + + expect(editor.getSelectedText).toHaveBeenCalled(); + expect(selectionToString()).toBe(`[3,1 -> 3,${toSelect.length + 1}]`); + }); + + it('does nothing if no `toSelect` is supplied', () => { + selectSecondAndThirdLines(); + const expectedPos = `(3,${thirdLine.length + 1})`; + const expectedSelection = `[2,1 -> 3,${thirdLine.length + 1}]`; + + expect(positionToString()).toBe(expectedPos); + expect(selectionToString()).toBe(expectedSelection); + + editor.selectWithinSelection(); + + expect(positionToString()).toBe(expectedPos); + expect(selectionToString()).toBe(expectedSelection); + }); + + it('does nothing if no selection is set in the editor', () => { + const expectedPos = '(1,1)'; + const expectedSelection = '[1,1 -> 1,1]'; + const toSelect = 'string'; + + expect(positionToString()).toBe(expectedPos); + expect(selectionToString()).toBe(expectedSelection); + + editor.selectWithinSelection(toSelect); + + expect(positionToString()).toBe(expectedPos); + expect(selectionToString()).toBe(expectedSelection); + + editor.selectWithinSelection(); + + expect(positionToString()).toBe(expectedPos); + expect(selectionToString()).toBe(expectedSelection); + }); + }); +}); diff --git a/spec/frontend/emoji_spec.js b/spec/frontend/emoji/emoji_spec.js index 25bc95e0dd6..c6a15d5976a 100644 --- a/spec/frontend/emoji_spec.js +++ b/spec/frontend/emoji/emoji_spec.js @@ -1,4 +1,6 @@ -import { glEmojiTag } from '~/emoji'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import { initEmojiMap, glEmojiTag, EMOJI_VERSION } from '~/emoji'; import isEmojiUnicodeSupported, { isFlagEmoji, isRainbowFlagEmoji, @@ -7,6 +9,7 @@ import isEmojiUnicodeSupported, { isHorceRacingSkinToneComboEmoji, isPersonZwjEmoji, } from '~/emoji/support/is_emoji_unicode_supported'; +import { trimText } from 'helpers/text_helper'; const emptySupportMap = { personZwj: false, @@ -50,77 +53,28 @@ const emojiFixtureMap = { }, }; -function markupToDomElement(markup) { - const div = document.createElement('div'); - div.innerHTML = markup; - return div.firstElementChild; -} - -function testGlEmojiImageFallback(element, name, src) { - expect(element.tagName.toLowerCase()).toBe('img'); - expect(element.getAttribute('src')).toBe(src); - expect(element.getAttribute('title')).toBe(`:${name}:`); - expect(element.getAttribute('alt')).toBe(`:${name}:`); -} - -const defaults = { - forceFallback: false, - sprite: false, -}; +describe('gl_emoji', () => { + let mock; + const emojiData = getJSONFixture('emojis/emojis.json'); -function testGlEmojiElement(element, name, unicodeVersion, unicodeMoji, options = {}) { - const opts = { ...defaults, ...options }; - expect(element.tagName.toLowerCase()).toBe('gl-emoji'); - expect(element.dataset.name).toBe(name); - expect(element.dataset.fallbackSrc.length).toBeGreaterThan(0); - expect(element.dataset.unicodeVersion).toBe(unicodeVersion); - - const fallbackSpriteClass = `emoji-${name}`; - if (opts.sprite) { - expect(element.dataset.fallbackSpriteClass).toBe(fallbackSpriteClass); - } - - if (opts.forceFallback && opts.sprite) { - expect(element.getAttribute('class')).toBe(`emoji-icon ${fallbackSpriteClass}`); - } - - if (opts.forceFallback && !opts.sprite) { - // Check for image fallback - testGlEmojiImageFallback(element.firstElementChild, name, element.dataset.fallbackSrc); - } else { - // Otherwise make sure things are still unicode text - expect(element.textContent.trim()).toBe(unicodeMoji); - } -} + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, emojiData); + + return initEmojiMap().catch(() => {}); + }); + + afterEach(() => { + mock.restore(); + }); -describe('gl_emoji', () => { describe('glEmojiTag', () => { it('bomb emoji', () => { const emojiKey = 'bomb'; const markup = glEmojiTag(emojiFixtureMap[emojiKey].name); - const glEmojiElement = markupToDomElement(markup); - testGlEmojiElement( - glEmojiElement, - emojiFixtureMap[emojiKey].name, - emojiFixtureMap[emojiKey].unicodeVersion, - emojiFixtureMap[emojiKey].moji, - ); - }); - it('bomb emoji with image fallback', () => { - const emojiKey = 'bomb'; - const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, { - forceFallback: true, - }); - const glEmojiElement = markupToDomElement(markup); - testGlEmojiElement( - glEmojiElement, - emojiFixtureMap[emojiKey].name, - emojiFixtureMap[emojiKey].unicodeVersion, - emojiFixtureMap[emojiKey].moji, - { - forceFallback: true, - }, + expect(trimText(markup)).toMatchInlineSnapshot( + `"<gl-emoji data-name=\\"bomb\\"></gl-emoji>"`, ); }); @@ -129,65 +83,8 @@ describe('gl_emoji', () => { const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, { sprite: true, }); - const glEmojiElement = markupToDomElement(markup); - testGlEmojiElement( - glEmojiElement, - emojiFixtureMap[emojiKey].name, - emojiFixtureMap[emojiKey].unicodeVersion, - emojiFixtureMap[emojiKey].moji, - { - sprite: true, - }, - ); - }); - - it('bomb emoji with sprite fallback', () => { - const emojiKey = 'bomb'; - const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, { - forceFallback: true, - sprite: true, - }); - const glEmojiElement = markupToDomElement(markup); - testGlEmojiElement( - glEmojiElement, - emojiFixtureMap[emojiKey].name, - emojiFixtureMap[emojiKey].unicodeVersion, - emojiFixtureMap[emojiKey].moji, - { - forceFallback: true, - sprite: true, - }, - ); - }); - - it('question mark when invalid emoji name given', () => { - const name = 'invalid_emoji'; - const emojiKey = 'grey_question'; - const markup = glEmojiTag(name); - const glEmojiElement = markupToDomElement(markup); - testGlEmojiElement( - glEmojiElement, - emojiFixtureMap[emojiKey].name, - emojiFixtureMap[emojiKey].unicodeVersion, - emojiFixtureMap[emojiKey].moji, - ); - }); - - it('question mark with image fallback when invalid emoji name given', () => { - const name = 'invalid_emoji'; - const emojiKey = 'grey_question'; - const markup = glEmojiTag(name, { - forceFallback: true, - }); - const glEmojiElement = markupToDomElement(markup); - testGlEmojiElement( - glEmojiElement, - emojiFixtureMap[emojiKey].name, - emojiFixtureMap[emojiKey].unicodeVersion, - emojiFixtureMap[emojiKey].moji, - { - forceFallback: true, - }, + expect(trimText(markup)).toMatchInlineSnapshot( + `"<gl-emoji data-fallback-sprite-class=\\"emoji-bomb\\" data-name=\\"bomb\\"></gl-emoji>"`, ); }); }); diff --git a/spec/frontend/behaviors/gl_emoji/unicode_support_map_spec.js b/spec/frontend/emoji/support/unicode_support_map_spec.js index aaee9c30cac..aaee9c30cac 100644 --- a/spec/frontend/behaviors/gl_emoji/unicode_support_map_spec.js +++ b/spec/frontend/emoji/support/unicode_support_map_spec.js diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js index 08da34aa27a..c9d77a34595 100644 --- a/spec/frontend/environment.js +++ b/spec/frontend/environment.js @@ -3,12 +3,14 @@ const path = require('path'); const { ErrorWithStack } = require('jest-util'); const JSDOMEnvironment = require('jest-environment-jsdom-sixteen'); +const { TEST_HOST } = require('./helpers/test_constants'); const ROOT_PATH = path.resolve(__dirname, '../..'); class CustomEnvironment extends JSDOMEnvironment { constructor(config, context) { - super(config, context); + // Setup testURL so that window.location is setup properly + super({ ...config, testURL: TEST_HOST }, context); Object.assign(context.console, { error(...args) { @@ -57,6 +59,9 @@ class CustomEnvironment extends JSDOMEnvironment { ownerDocument: this.global.document, }, }); + + // Expose the jsdom (created in super class) to the global so that we can call reconfigure({ url: '' }) to properly set `window.location` + this.global.dom = this.dom; } async teardown() { diff --git a/spec/frontend/environments/emtpy_state_spec.js b/spec/frontend/environments/emtpy_state_spec.js index ed90c13f1e1..862d90e50dc 100644 --- a/spec/frontend/environments/emtpy_state_spec.js +++ b/spec/frontend/environments/emtpy_state_spec.js @@ -7,8 +7,6 @@ describe('environments empty state', () => { beforeEach(() => { vm = shallowMount(EmptyState, { propsData: { - newPath: 'foo', - canCreateEnvironment: true, helpPath: 'bar', }, }); @@ -23,18 +21,4 @@ describe('environments empty state', () => { "You don't have any environments right now", ); }); - - it('renders the new environment button', () => { - expect(vm.find('.js-new-environment-button').attributes('href')).toEqual('foo'); - }); - - describe('without permission', () => { - beforeEach(() => { - vm.setProps({ canCreateEnvironment: false }); - }); - - it('does not render the new environment button', () => { - expect(vm.find('.js-new-environment-button').exists()).toBe(false); - }); - }); }); diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js index fd2164d05fc..6124602e038 100644 --- a/spec/frontend/error_tracking/components/error_details_spec.js +++ b/spec/frontend/error_tracking/components/error_details_spec.js @@ -3,7 +3,7 @@ import Vuex from 'vuex'; import { __ } from '~/locale'; import createFlash from '~/flash'; import { - GlDeprecatedButton, + GlButton, GlLoadingIcon, GlLink, GlBadge, @@ -48,10 +48,11 @@ describe('ErrorDetails', () => { const findUpdateResolveStatusButton = () => wrapper.find('[data-testid="update-resolve-status-btn"]'); const findExternalUrl = () => wrapper.find('[data-testid="external-url-link"]'); + const findAlert = () => wrapper.find(GlAlert); function mountComponent() { wrapper = shallowMount(ErrorDetails, { - stubs: { GlDeprecatedButton, GlSprintf }, + stubs: { GlButton, GlSprintf }, localVue, store, mocks, @@ -194,7 +195,7 @@ describe('ErrorDetails', () => { expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); expect(wrapper.find(Stacktrace).exists()).toBe(false); expect(wrapper.find(GlBadge).exists()).toBe(false); - expect(wrapper.findAll(GlDeprecatedButton).length).toBe(3); + expect(wrapper.findAll(GlButton)).toHaveLength(3); }); describe('unsafe chars for culprit field', () => { @@ -278,15 +279,17 @@ describe('ErrorDetails', () => { return wrapper.vm.$nextTick().then(() => { expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); expect(wrapper.find(Stacktrace).exists()).toBe(true); + expect(findAlert().exists()).toBe(false); }); }); - it('should NOT show stacktrace if no entries', () => { + it('should NOT show stacktrace if no entries and show Alert message', () => { store.state.details.loadingStacktrace = false; store.getters = { 'details/sentryUrl': () => 'sentry.io', 'details/stacktrace': () => [] }; return wrapper.vm.$nextTick().then(() => { expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); expect(wrapper.find(Stacktrace).exists()).toBe(false); + expect(findAlert().text()).toBe('No stack trace for this error'); }); }); }); @@ -404,7 +407,6 @@ describe('ErrorDetails', () => { }); it('should show alert with closed issueId', () => { - const findAlert = () => wrapper.find(GlAlert); const closedIssueId = 123; wrapper.setData({ isAlertVisible: true, diff --git a/spec/frontend/filtered_search/dropdown_user_spec.js b/spec/frontend/filtered_search/dropdown_user_spec.js index 8eef10290bf..c1c09ea5d3e 100644 --- a/spec/frontend/filtered_search/dropdown_user_spec.js +++ b/spec/frontend/filtered_search/dropdown_user_spec.js @@ -48,13 +48,13 @@ describe('Dropdown User', () => { }; const dropdown = new DropdownUser(); - expect(dropdown.config.AjaxFilter.endpoint).toBe('/autocomplete/users.json'); + expect(dropdown.config.AjaxFilter.endpoint).toBe('/-/autocomplete/users.json'); }); it('should return endpoint when relative_url_root is undefined', () => { const dropdown = new DropdownUser(); - expect(dropdown.config.AjaxFilter.endpoint).toBe('/autocomplete/users.json'); + expect(dropdown.config.AjaxFilter.endpoint).toBe('/-/autocomplete/users.json'); }); it('should return endpoint with relative url when available', () => { @@ -63,7 +63,9 @@ describe('Dropdown User', () => { }; const dropdown = new DropdownUser(); - expect(dropdown.config.AjaxFilter.endpoint).toBe('/gitlab_directory/autocomplete/users.json'); + expect(dropdown.config.AjaxFilter.endpoint).toBe( + '/gitlab_directory/-/autocomplete/users.json', + ); }); afterEach(() => { diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js index ef87662a1ef..70e8b339d4b 100644 --- a/spec/frontend/filtered_search/filtered_search_manager_spec.js +++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js @@ -2,7 +2,6 @@ import RecentSearchesService from '~/filtered_search/services/recent_searches_se import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; import RecentSearchesRoot from '~/filtered_search/recent_searches_root'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; -import '~/lib/utils/common_utils'; import DropdownUtils from '~/filtered_search/dropdown_utils'; import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens'; import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager'; @@ -10,6 +9,7 @@ import FilteredSearchManager from '~/filtered_search/filtered_search_manager'; import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper'; import { BACKSPACE_KEY_CODE, DELETE_KEY_CODE } from '~/lib/utils/keycodes'; import { visitUrl } from '~/lib/utils/url_utility'; +import * as commonUtils from '~/lib/utils/common_utils'; jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), @@ -83,7 +83,7 @@ describe('Filtered Search Manager', () => { jest .spyOn(FilteredSearchDropdownManager.prototype, 'updateDropdownOffset') .mockImplementation(); - jest.spyOn(gl.utils, 'getParameterByName').mockReturnValue(null); + jest.spyOn(commonUtils, 'getParameterByName').mockReturnValue(null); jest.spyOn(FilteredSearchVisualTokens, 'unselectTokens'); input = document.querySelector('.filtered-search'); diff --git a/spec/frontend/filtered_search/stores/recent_searches_store_spec.js b/spec/frontend/filtered_search/stores/recent_searches_store_spec.js index 56bb82ae941..320aaa99bcc 100644 --- a/spec/frontend/filtered_search/stores/recent_searches_store_spec.js +++ b/spec/frontend/filtered_search/stores/recent_searches_store_spec.js @@ -44,6 +44,15 @@ describe('RecentSearchesStore', () => { expect(store.state.recentSearches).toEqual(['baz', 'qux']); }); + it('handles non-string values', () => { + store.setRecentSearches(['foo ', { foo: 'bar' }, { foo: 'bar' }, ['foobar']]); + + // 1. String values will be trimmed of leading/trailing spaces + // 2. Comparison will account for objects to remove duplicates + // 3. Old behaviour of handling string values stays as it is. + expect(store.state.recentSearches).toEqual(['foo', { foo: 'bar' }, ['foobar']]); + }); + it('only keeps track of 5 items', () => { store.setRecentSearches(['1', '2', '3', '4', '5', '6', '7']); diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js index ea501423403..3a64b688c7a 100644 --- a/spec/frontend/filtered_search/visual_token_value_spec.js +++ b/spec/frontend/filtered_search/visual_token_value_spec.js @@ -4,6 +4,7 @@ import AjaxCache from '~/lib/utils/ajax_cache'; import UsersCache from '~/lib/utils/users_cache'; import DropdownUtils from '~/filtered_search//dropdown_utils'; import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper'; +import { TEST_HOST } from 'jest/helpers/test_constants'; describe('Filtered Search Visual Tokens', () => { const findElements = tokenElement => { @@ -106,7 +107,7 @@ describe('Filtered Search Visual Tokens', () => { it('escapes user name when creating token', done => { const dummyUser = { name: '<script>', - avatar_url: `${gl.TEST_HOST}/mypics/avatar.png`, + avatar_url: `${TEST_HOST}/mypics/avatar.png`, }; const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); const tokenValue = tokenValueElement.innerText; diff --git a/spec/frontend/fixtures/branches.rb b/spec/frontend/fixtures/branches.rb index 4667dfb69f8..df2d1af7ecf 100644 --- a/spec/frontend/fixtures/branches.rb +++ b/spec/frontend/fixtures/branches.rb @@ -2,33 +2,51 @@ require 'spec_helper' -RSpec.describe Projects::BranchesController, '(JavaScript fixtures)', type: :controller do +RSpec.describe 'Branches (JavaScript fixtures)' do include JavaScriptFixturesHelpers - let(:admin) { create(:admin) } - let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} - let(:project) { create(:project, :repository, namespace: namespace, path: 'branches-project') } - - render_views + let_it_be(:admin) { create(:admin) } + let_it_be(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'branches-project') } before(:all) do clean_frontend_fixtures('branches/') + clean_frontend_fixtures('api/branches/') end - before do - sign_in(admin) + after(:all) do + remove_repository(project) end - after do - remove_repository(project) + describe Projects::BranchesController, '(JavaScript fixtures)', type: :controller do + render_views + + before do + sign_in(admin) + end + + it 'branches/new_branch.html' do + get :new, params: { + namespace_id: project.namespace.to_param, + project_id: project + } + + expect(response).to be_successful + end end - it 'branches/new_branch.html' do - get :new, params: { - namespace_id: project.namespace.to_param, - project_id: project - } + describe API::Branches, '(JavaScript fixtures)', type: :request do + include ApiHelpers + + it 'api/branches/branches.json' do + # The search query "ma" matches a few branch names in the test + # repository with a variety of different properties, including: + # - "master": default, protected + # - "markdown": non-default, protected + # - "many_files": non-default, not protected + get api("/projects/#{project.id}/repository/branches?search=ma", admin) - expect(response).to be_successful + expect(response).to be_successful + end end end diff --git a/spec/frontend/fixtures/commit.rb b/spec/frontend/fixtures/commit.rb index c5c00afd4ca..9175a757b73 100644 --- a/spec/frontend/fixtures/commit.rb +++ b/spec/frontend/fixtures/commit.rb @@ -2,34 +2,55 @@ require 'spec_helper' -RSpec.describe Projects::CommitController, '(JavaScript fixtures)', type: :controller do +RSpec.describe 'Commit (JavaScript fixtures)' do include JavaScriptFixturesHelpers let_it_be(:project) { create(:project, :repository) } let_it_be(:user) { create(:user) } - let(:commit) { project.commit("master") } - - render_views + let_it_be(:commit) { project.commit("master") } before(:all) do clean_frontend_fixtures('commit/') + clean_frontend_fixtures('api/commits/') + + project.add_maintainer(user) end before do - project.add_maintainer(user) - sign_in(user) allow(SecureRandom).to receive(:hex).and_return('securerandomhex:thereisnospoon') end - it 'commit/show.html' do - params = { - namespace_id: project.namespace, - project_id: project, - id: commit.id - } + after(:all) do + remove_repository(project) + end + + describe Projects::CommitController, '(JavaScript fixtures)', type: :controller do + render_views + + before do + sign_in(user) + end + + it 'commit/show.html' do + params = { + namespace_id: project.namespace, + project_id: project, + id: commit.id + } + + get :show, params: params + + expect(response).to be_successful + end + end + + describe API::Commits, '(JavaScript fixtures)', type: :request do + include ApiHelpers - get :show, params: params + it 'api/commits/commit.json' do + get api("/projects/#{project.id}/repository/commits/#{commit.id}", user) - expect(response).to be_successful + expect(response).to be_successful + end end end diff --git a/spec/frontend/fixtures/emojis.rb b/spec/frontend/fixtures/emojis.rb new file mode 100644 index 00000000000..b95c7632917 --- /dev/null +++ b/spec/frontend/fixtures/emojis.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Emojis (JavaScript fixtures)', type: :request do + include JavaScriptFixturesHelpers + + before(:all) do + clean_frontend_fixtures('emojis/') + end + + it 'emojis/emojis.json' do |example| + get '/-/emojis/1/emojis.json' + + expect(response).to be_successful + end +end diff --git a/spec/frontend/fixtures/metrics_dashboard.rb b/spec/frontend/fixtures/metrics_dashboard.rb index b5dee7525f6..6ee730f5c3d 100644 --- a/spec/frontend/fixtures/metrics_dashboard.rb +++ b/spec/frontend/fixtures/metrics_dashboard.rb @@ -6,10 +6,11 @@ RSpec.describe MetricsDashboard, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers include MetricsDashboardHelpers - let(:user) { create(:user) } - let(:project) { project_with_dashboard('.gitlab/dashboards/test.yml') } - let(:environment) { create(:environment, project: project) } - let(:params) { { environment: environment } } + let_it_be(:user) { create(:user) } + let_it_be(:namespace) { create(:namespace, name: 'monitoring' )} + let_it_be(:project) { project_with_dashboard_namespace('.gitlab/dashboards/test.yml', namespace: namespace) } + let_it_be(:environment) { create(:environment, id: 1, project: project) } + let_it_be(:params) { { environment: environment } } before(:all) do clean_frontend_fixtures('metrics_dashboard/') @@ -24,6 +25,7 @@ RSpec.describe MetricsDashboard, '(JavaScript fixtures)', type: :controller do project.add_maintainer(user) allow(controller).to receive(:project).and_return(project) + allow(controller).to receive(:environment).and_return(environment) allow(controller) .to receive(:metrics_dashboard_params) .and_return(params) @@ -35,7 +37,9 @@ RSpec.describe MetricsDashboard, '(JavaScript fixtures)', type: :controller do it 'metrics_dashboard/environment_metrics_dashboard.json' do routes.draw { get "metrics_dashboard" => "anonymous#metrics_dashboard" } + response = get :metrics_dashboard, format: :json + expect(response).to be_successful end end diff --git a/spec/frontend/fixtures/services.rb b/spec/frontend/fixtures/services.rb index 0877998cc9d..43230301296 100644 --- a/spec/frontend/fixtures/services.rb +++ b/spec/frontend/fixtures/services.rb @@ -8,7 +8,7 @@ RSpec.describe Projects::ServicesController, '(JavaScript fixtures)', type: :con let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project_empty_repo, namespace: namespace, path: 'services-project') } - let!(:service) { create(:custom_issue_tracker_service, project: project, title: 'Custom Issue Tracker') } + let!(:service) { create(:custom_issue_tracker_service, project: project) } render_views diff --git a/spec/frontend/fixtures/static/mini_dropdown_graph.html b/spec/frontend/fixtures/static/mini_dropdown_graph.html index cd0b8dec3fc..cb55698b709 100644 --- a/spec/frontend/fixtures/static/mini_dropdown_graph.html +++ b/spec/frontend/fixtures/static/mini_dropdown_graph.html @@ -1,13 +1,13 @@ -<div class="js-builds-dropdown-tests dropdown dropdown js-mini-pipeline-graph"> -<button class="js-builds-dropdown-button" data-toggle="dropdown" data-stage-endpoint="foobar"> -Dropdown -</button> -<ul class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"> -<li class="js-builds-dropdown-list scrollable-menu"> -<ul></ul> -</li> -<li class="js-builds-dropdown-loading hidden"> -<span class="fa fa-spinner"></span> -</li> -</ul> +<div class="js-builds-dropdown-tests dropdown dropdown" data-testid="widget-mini-pipeline-graph"> + <button class="js-builds-dropdown-button" data-toggle="dropdown" data-stage-endpoint="foobar"> + Dropdown + </button> + <ul class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"> + <li class="js-builds-dropdown-list scrollable-menu"> + <ul></ul> + </li> + <li class="js-builds-dropdown-loading hidden"> + <span class="fa fa-spinner"></span> + </li> + </ul> </div> diff --git a/spec/frontend/fixtures/static/global_search_input.html b/spec/frontend/fixtures/static/search_autocomplete.html index 29db9020424..29db9020424 100644 --- a/spec/frontend/fixtures/static/global_search_input.html +++ b/spec/frontend/fixtures/static/search_autocomplete.html diff --git a/spec/frontend/fixtures/tags.rb b/spec/frontend/fixtures/tags.rb new file mode 100644 index 00000000000..b2a5429fac8 --- /dev/null +++ b/spec/frontend/fixtures/tags.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Tags (JavaScript fixtures)' do + include JavaScriptFixturesHelpers + + let_it_be(:admin) { create(:admin) } + let_it_be(:project) { create(:project, :repository, path: 'tags-project') } + + before(:all) do + clean_frontend_fixtures('api/tags/') + end + + after(:all) do + remove_repository(project) + end + + describe API::Tags, '(JavaScript fixtures)', type: :request do + include ApiHelpers + + it 'api/tags/tags.json' do + get api("/projects/#{project.id}/repository/tags", admin) + + expect(response).to be_successful + end + end +end diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index f58615000ee..869347128e5 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -312,7 +312,7 @@ describe('GfmAutoComplete', () => { title: 'My Group', search: 'my-group My Group', icon: - '<svg class="s16 vertical-align-middle prepend-left-5"><use xlink:href="undefined#notifications-off" /></svg>', + '<svg class="s16 vertical-align-middle gl-ml-2"><use xlink:href="undefined#notifications-off" /></svg>', }, ]); }); diff --git a/spec/frontend/gl_form_spec.js b/spec/frontend/gl_form_spec.js index 150d8a053d5..52e1693f8a6 100644 --- a/spec/frontend/gl_form_spec.js +++ b/spec/frontend/gl_form_spec.js @@ -9,6 +9,8 @@ describe('GLForm', () => { describe('when instantiated', () => { beforeEach(done => { + window.gl = window.gl || {}; + testContext.form = $('<form class="gfm-form"><textarea class="js-gfm-input"></form>'); testContext.textarea = testContext.form.find('textarea'); jest.spyOn($.prototype, 'off').mockReturnValue(testContext.textarea); @@ -111,5 +113,21 @@ describe('GLForm', () => { expect(autosize.destroy).not.toHaveBeenCalled(); }); }); + + describe('supportsQuickActions', () => { + it('should return false if textarea does not support quick actions', () => { + const glForm = new GLForm(testContext.form, false); + + expect(glForm.supportsQuickActions).toEqual(false); + }); + + it('should return true if textarea supports quick actions', () => { + testContext.textarea.attr('data-supports-quick-actions', true); + + const glForm = new GLForm(testContext.form, false); + + expect(glForm.supportsQuickActions).toEqual(true); + }); + }); }); }); diff --git a/spec/frontend/helpers/event_hub_factory_spec.js b/spec/frontend/helpers/event_hub_factory_spec.js index dcfec6b836a..c4f63ff6049 100644 --- a/spec/frontend/helpers/event_hub_factory_spec.js +++ b/spec/frontend/helpers/event_hub_factory_spec.js @@ -1,77 +1,72 @@ import createEventHub from '~/helpers/event_hub_factory'; +const TEST_EVENT = 'foobar'; +const TEST_EVENT_2 = 'testevent'; + describe('event bus factory', () => { let eventBus; + let handler; + let otherHandlers; beforeEach(() => { eventBus = createEventHub(); + handler = jest.fn(); + otherHandlers = [jest.fn(), jest.fn()]; }); afterEach(() => { + eventBus.dispose(); eventBus = null; }); - describe('underlying module', () => { - let mitt; + describe('instance', () => { + it.each` + method + ${'$on'} + ${'$once'} + ${'$off'} + ${'$emit'} + `('has $method method', ({ method }) => { + expect(eventBus[method]).toEqual(expect.any(Function)); + }); + }); + describe('$on', () => { beforeEach(() => { - jest.resetModules(); - jest.mock('mitt'); - - // eslint-disable-next-line global-require - mitt = require('mitt'); - mitt.mockReturnValue(() => ({})); - - const createEventHubActual = jest.requireActual('~/helpers/event_hub_factory').default; - eventBus = createEventHubActual(); + eventBus.$on(TEST_EVENT, handler); }); - it('creates an emitter', () => { - expect(mitt).toHaveBeenCalled(); + it('calls handler when event is emitted', () => { + eventBus.$emit(TEST_EVENT); + expect(handler).toHaveBeenCalledWith(); }); - }); - describe('instance', () => { - it.each` - method - ${'on'} - ${'once'} - ${'off'} - ${'emit'} - `('binds $$method to $method ', ({ method }) => { - expect(typeof eventBus[method]).toBe('function'); - expect(eventBus[method]).toBe(eventBus[`$${method}`]); + it('calls handler with multiple args', () => { + eventBus.$emit(TEST_EVENT, 'arg1', 'arg2', 'arg3'); + expect(handler).toHaveBeenCalledWith('arg1', 'arg2', 'arg3'); }); - }); - describe('once', () => { - const event = 'foobar'; - let handler; + it('calls handler multiple times', () => { + eventBus.$emit(TEST_EVENT, 'arg1', 'arg2', 'arg3'); + eventBus.$emit(TEST_EVENT, 'arg1', 'arg2', 'arg3'); - beforeEach(() => { - jest.spyOn(eventBus, 'on'); - jest.spyOn(eventBus, 'off'); - handler = jest.fn(); - eventBus.once(event, handler); + expect(handler).toHaveBeenCalledTimes(2); }); + }); - it('calls on internally', () => { - expect(eventBus.on).toHaveBeenCalled(); + describe('$once', () => { + beforeEach(() => { + eventBus.$once(TEST_EVENT, handler); }); it('calls handler when event is emitted', () => { - eventBus.emit(event); + eventBus.$emit(TEST_EVENT); expect(handler).toHaveBeenCalled(); }); - it('calls off when event is emitted', () => { - eventBus.emit(event); - expect(eventBus.off).toHaveBeenCalled(); - }); - it('calls the handler only once when event is emitted multiple times', () => { - eventBus.emit(event); - eventBus.emit(event); + eventBus.$emit(TEST_EVENT); + eventBus.$emit(TEST_EVENT); expect(handler).toHaveBeenCalledTimes(1); }); @@ -80,15 +75,70 @@ describe('event bus factory', () => { handler = jest.fn().mockImplementation(() => { throw new Error(); }); - eventBus.once(event, handler); + eventBus.$once(TEST_EVENT, handler); }); it('calls off when event is emitted', () => { expect(() => { - eventBus.emit(event); + eventBus.$emit(TEST_EVENT); }).toThrow(); - expect(eventBus.off).toHaveBeenCalled(); + expect(() => { + eventBus.$emit(TEST_EVENT); + }).not.toThrow(); + + expect(handler).toHaveBeenCalledTimes(1); }); }); }); + + describe('$off', () => { + beforeEach(() => { + otherHandlers.forEach(x => eventBus.$on(TEST_EVENT, x)); + eventBus.$on(TEST_EVENT, handler); + }); + + it('can be called on event with no handlers', () => { + expect(() => { + eventBus.$off(TEST_EVENT_2); + }).not.toThrow(); + }); + + it('can be called on event with no handlers, with a handler', () => { + expect(() => { + eventBus.$off(TEST_EVENT_2, handler); + }).not.toThrow(); + }); + + it('with a handler, will no longer call that handler', () => { + eventBus.$off(TEST_EVENT, handler); + + eventBus.$emit(TEST_EVENT); + + expect(handler).not.toHaveBeenCalled(); + expect(otherHandlers.map(x => x.mock.calls.length)).toEqual(otherHandlers.map(() => 1)); + }); + + it('without a handler, will no longer call any handlers', () => { + eventBus.$off(TEST_EVENT); + + eventBus.$emit(TEST_EVENT); + + expect(handler).not.toHaveBeenCalled(); + expect(otherHandlers.map(x => x.mock.calls.length)).toEqual(otherHandlers.map(() => 0)); + }); + }); + + describe('$emit', () => { + beforeEach(() => { + otherHandlers.forEach(x => eventBus.$on(TEST_EVENT_2, x)); + eventBus.$on(TEST_EVENT, handler); + }); + + it('only calls handlers for given type', () => { + eventBus.$emit(TEST_EVENT, 'arg1'); + + expect(handler).toHaveBeenCalledWith('arg1'); + expect(otherHandlers.map(x => x.mock.calls.length)).toEqual(otherHandlers.map(() => 0)); + }); + }); }); diff --git a/spec/frontend/helpers/fake_request_animation_frame.js b/spec/frontend/helpers/fake_request_animation_frame.js new file mode 100644 index 00000000000..b01ae5b7c5f --- /dev/null +++ b/spec/frontend/helpers/fake_request_animation_frame.js @@ -0,0 +1,13 @@ +// eslint-disable-next-line import/prefer-default-export +export const useFakeRequestAnimationFrame = () => { + let orig; + + beforeEach(() => { + orig = global.requestAnimationFrame; + global.requestAnimationFrame = cb => cb(); + }); + + afterEach(() => { + global.requestAnimationFrame = orig; + }); +}; diff --git a/spec/frontend/helpers/init_vue_mr_page_helper.js b/spec/frontend/helpers/init_vue_mr_page_helper.js new file mode 100644 index 00000000000..c1d608cc5a0 --- /dev/null +++ b/spec/frontend/helpers/init_vue_mr_page_helper.js @@ -0,0 +1,46 @@ +import MockAdapter from 'axios-mock-adapter'; +import initMRPage from '~/mr_notes'; +import axios from '~/lib/utils/axios_utils'; +import { userDataMock, notesDataMock, noteableDataMock } from '../notes/mock_data'; +import diffFileMockData from '../diffs/mock_data/diff_file'; + +export default function initVueMRPage() { + const mrTestEl = document.createElement('div'); + mrTestEl.className = 'js-merge-request-test'; + document.body.appendChild(mrTestEl); + + const diffsAppEndpoint = '/diffs/app/endpoint'; + const diffsAppProjectPath = 'testproject'; + const mrEl = document.createElement('div'); + mrEl.className = 'merge-request fixture-mr'; + mrEl.setAttribute('data-mr-action', 'diffs'); + mrTestEl.appendChild(mrEl); + + const mrDiscussionsEl = document.createElement('div'); + mrDiscussionsEl.id = 'js-vue-mr-discussions'; + mrDiscussionsEl.setAttribute('data-current-user-data', JSON.stringify(userDataMock)); + mrDiscussionsEl.setAttribute('data-noteable-data', JSON.stringify(noteableDataMock)); + mrDiscussionsEl.setAttribute('data-notes-data', JSON.stringify(notesDataMock)); + mrDiscussionsEl.setAttribute('data-noteable-type', 'merge-request'); + mrTestEl.appendChild(mrDiscussionsEl); + + const discussionCounterEl = document.createElement('div'); + discussionCounterEl.id = 'js-vue-discussion-counter'; + mrTestEl.appendChild(discussionCounterEl); + + const diffsAppEl = document.createElement('div'); + diffsAppEl.id = 'js-diffs-app'; + diffsAppEl.setAttribute('data-endpoint', diffsAppEndpoint); + diffsAppEl.setAttribute('data-project-path', diffsAppProjectPath); + diffsAppEl.setAttribute('data-current-user-data', JSON.stringify(userDataMock)); + mrTestEl.appendChild(diffsAppEl); + + const mock = new MockAdapter(axios); + mock.onGet(diffsAppEndpoint).reply(200, { + branch_name: 'foo', + diff_files: [diffFileMockData], + }); + + initMRPage(); + return mock; +} diff --git a/spec/frontend/helpers/monitor_helper_spec.js b/spec/frontend/helpers/monitor_helper_spec.js index f7163d496d2..083b6404125 100644 --- a/spec/frontend/helpers/monitor_helper_spec.js +++ b/spec/frontend/helpers/monitor_helper_spec.js @@ -33,15 +33,6 @@ describe('monitor helper', () => { ]); }); - it('excludes NaN values', () => { - expect( - monitorHelper.makeDataSeries( - data({ metric: {}, values: [[1, 1], [2, NaN]] }), - defaultConfig, - ), - ).toEqual([{ ...expectedDataSeries[0], data: [[1, 1]] }]); - }); - it('updates series name from templates', () => { const config = { ...defaultConfig, diff --git a/spec/frontend/helpers/test_constants.js b/spec/frontend/helpers/test_constants.js index c97d47a6406..69b78f556aa 100644 --- a/spec/frontend/helpers/test_constants.js +++ b/spec/frontend/helpers/test_constants.js @@ -1,7 +1,19 @@ -export const FIXTURES_PATH = `/fixtures`; -export const TEST_HOST = 'http://test.host'; +const FIXTURES_PATH = `/fixtures`; +const TEST_HOST = 'http://test.host'; -export const DUMMY_IMAGE_URL = `${FIXTURES_PATH}/static/images/one_white_pixel.png`; +const DUMMY_IMAGE_URL = `${FIXTURES_PATH}/static/images/one_white_pixel.png`; -export const GREEN_BOX_IMAGE_URL = `${FIXTURES_PATH}/static/images/green_box.png`; -export const RED_BOX_IMAGE_URL = `${FIXTURES_PATH}/static/images/red_box.png`; +const GREEN_BOX_IMAGE_URL = `${FIXTURES_PATH}/static/images/green_box.png`; +const RED_BOX_IMAGE_URL = `${FIXTURES_PATH}/static/images/red_box.png`; + +// NOTE: module.exports is needed so that this file can be used +// by environment.js +// +// eslint-disable-next-line import/no-commonjs +module.exports = { + FIXTURES_PATH, + TEST_HOST, + DUMMY_IMAGE_URL, + GREEN_BOX_IMAGE_URL, + RED_BOX_IMAGE_URL, +}; diff --git a/spec/frontend/helpers/vue_mock_directive.js b/spec/frontend/helpers/vue_mock_directive.js index 699fe3eab26..28d4708835d 100644 --- a/spec/frontend/helpers/vue_mock_directive.js +++ b/spec/frontend/helpers/vue_mock_directive.js @@ -2,13 +2,21 @@ export const getKey = name => `$_gl_jest_${name}`; export const getBinding = (el, name) => el[getKey(name)]; +const writeBindingToElement = (el, { name, value, arg, modifiers }) => { + el[getKey(name)] = { + value, + arg, + modifiers, + }; +}; + export const createMockDirective = () => ({ - bind(el, { name, value, arg, modifiers }) { - el[getKey(name)] = { - value, - arg, - modifiers, - }; + bind(el, binding) { + writeBindingToElement(el, binding); + }, + + update(el, binding) { + writeBindingToElement(el, binding); }, unbind(el, { name }) { diff --git a/spec/frontend/helpers/wait_using_real_timer.js b/spec/frontend/helpers/wait_using_real_timer.js new file mode 100644 index 00000000000..ddf23cd97b4 --- /dev/null +++ b/spec/frontend/helpers/wait_using_real_timer.js @@ -0,0 +1,7 @@ +/* useful for timing promises when jest fakeTimers are not reliable enough */ +export default timeout => + new Promise(resolve => { + jest.useRealTimers(); + setTimeout(resolve, timeout); + jest.useFakeTimers(); + }); diff --git a/spec/frontend/ide/commit_icon_spec.js b/spec/frontend/ide/commit_icon_spec.js index 90b8e34497c..e4a7394b089 100644 --- a/spec/frontend/ide/commit_icon_spec.js +++ b/spec/frontend/ide/commit_icon_spec.js @@ -11,7 +11,6 @@ const createFile = (name = 'name', id = name, type = '', parent = null) => name, path: parent ? `${parent.path}/${name}` : name, parentPath: parent ? parent.path : '', - lastCommit: {}, }); describe('getCommitIconMap', () => { diff --git a/spec/frontend/ide/components/ide_status_list_spec.js b/spec/frontend/ide/components/ide_status_list_spec.js index 847464ed806..fed61233e55 100644 --- a/spec/frontend/ide/components/ide_status_list_spec.js +++ b/spec/frontend/ide/components/ide_status_list_spec.js @@ -1,5 +1,6 @@ import Vuex from 'vuex'; import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlLink } from '@gitlab/ui'; import IdeStatusList from '~/ide/components/ide_status_list.vue'; import TerminalSyncStatusSafe from '~/ide/components/terminal_sync/terminal_sync_status_safe.vue'; @@ -9,6 +10,7 @@ const TEST_FILE = { editorColumn: 23, fileLanguage: 'markdown', content: 'abc\nndef', + permalink: '/lorem.md', }; const localVue = createLocalVue(); @@ -19,6 +21,7 @@ describe('ide/components/ide_status_list', () => { let store; let wrapper; + const findLink = () => wrapper.find(GlLink); const createComponent = (options = {}) => { store = new Vuex.Store({ getters: { @@ -51,8 +54,9 @@ describe('ide/components/ide_status_list', () => { createComponent(); }); - it('shows file name', () => { - expect(wrapper.text()).toContain(TEST_FILE.name); + it('shows a link to the file that contains the file name', () => { + expect(findLink().attributes('href')).toBe(TEST_FILE.permalink); + expect(findLink().text()).toBe(TEST_FILE.name); }); it('shows file eol', () => { diff --git a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap index bdd3d439fd4..dbfacb98813 100644 --- a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap +++ b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap @@ -2,7 +2,7 @@ exports[`IDE pipeline stage renders stage details & icon 1`] = ` <div - class="ide-stage card prepend-top-default" + class="ide-stage card gl-mt-3" > <div class="card-header" diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index 4967434dfd7..a4336b8f2eb 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -4,19 +4,25 @@ import MockAdapter from 'axios-mock-adapter'; import '~/behaviors/markdown/render_gfm'; import { Range } from 'monaco-editor'; import axios from '~/lib/utils/axios_utils'; +import service from '~/ide/services'; import { createStoreOptions } from '~/ide/stores'; import RepoEditor from '~/ide/components/repo_editor.vue'; import Editor from '~/ide/lib/editor'; -import { leftSidebarViews, FILE_VIEW_MODE_EDITOR, FILE_VIEW_MODE_PREVIEW } from '~/ide/constants'; +import { + leftSidebarViews, + FILE_VIEW_MODE_EDITOR, + FILE_VIEW_MODE_PREVIEW, + viewerTypes, +} from '~/ide/constants'; import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { file } from '../helpers'; import { exampleConfigs, exampleFiles } from '../lib/editorconfig/mock_data'; +import waitUsingRealTimer from 'helpers/wait_using_real_timer'; describe('RepoEditor', () => { let vm; let store; - let mockActions; const waitForEditorSetup = () => new Promise(resolve => { @@ -30,6 +36,10 @@ describe('RepoEditor', () => { vm = createComponentWithStore(Vue.extend(RepoEditor), store, { file: store.state.openFiles[0], }); + + jest.spyOn(vm, 'getFileData').mockResolvedValue(); + jest.spyOn(vm, 'getRawFileData').mockResolvedValue(); + vm.$mount(); }; @@ -43,21 +53,12 @@ describe('RepoEditor', () => { }; beforeEach(() => { - mockActions = { - getFileData: jest.fn().mockResolvedValue(), - getRawFileData: jest.fn().mockResolvedValue(), - }; - const f = { ...file(), viewMode: FILE_VIEW_MODE_EDITOR, }; const storeOptions = createStoreOptions(); - storeOptions.actions = { - ...storeOptions.actions, - ...mockActions, - }; store = new Vuex.Store(storeOptions); f.active = true; @@ -438,7 +439,7 @@ describe('RepoEditor', () => { vm.initEditor(); vm.$nextTick() .then(() => { - expect(mockActions.getFileData).not.toHaveBeenCalled(); + expect(vm.getFileData).not.toHaveBeenCalled(); }) .then(done) .catch(done.fail); @@ -449,10 +450,11 @@ describe('RepoEditor', () => { vm.file.raw = ''; vm.initEditor(); + vm.$nextTick() .then(() => { - expect(mockActions.getFileData).toHaveBeenCalled(); - expect(mockActions.getRawFileData).toHaveBeenCalled(); + expect(vm.getFileData).toHaveBeenCalled(); + expect(vm.getRawFileData).toHaveBeenCalled(); }) .then(done) .catch(done.fail); @@ -464,8 +466,8 @@ describe('RepoEditor', () => { vm.initEditor(); vm.$nextTick() .then(() => { - expect(mockActions.getFileData).not.toHaveBeenCalled(); - expect(mockActions.getRawFileData).not.toHaveBeenCalled(); + expect(vm.getFileData).not.toHaveBeenCalled(); + expect(vm.getRawFileData).not.toHaveBeenCalled(); expect(vm.editor.createInstance).not.toHaveBeenCalled(); }) .then(done) @@ -526,6 +528,63 @@ describe('RepoEditor', () => { }); }); + describe('populates editor with the fetched content', () => { + beforeEach(() => { + vm.getRawFileData.mockRestore(); + }); + + const createRemoteFile = name => ({ + ...file(name), + tmpFile: false, + }); + + it('after switching viewer from edit to diff', async () => { + jest.spyOn(service, 'getRawFileData').mockImplementation(async () => { + expect(vm.file.loading).toBe(true); + + // switching from edit to diff mode usually triggers editor initialization + store.state.viewer = viewerTypes.diff; + + // we delay returning the file to make sure editor doesn't initialize before we fetch file content + await waitUsingRealTimer(30); + return 'rawFileData123\n'; + }); + + const f = createRemoteFile('newFile'); + Vue.set(store.state.entries, f.path, f); + + vm.file = f; + + await waitForEditorSetup(); + expect(vm.model.getModel().getValue()).toBe('rawFileData123\n'); + }); + + it('after opening multiple files at the same time', async () => { + const fileA = createRemoteFile('fileA'); + const fileB = createRemoteFile('fileB'); + Vue.set(store.state.entries, fileA.path, fileA); + Vue.set(store.state.entries, fileB.path, fileB); + + jest + .spyOn(service, 'getRawFileData') + .mockImplementationOnce(async () => { + // opening fileB while the content of fileA is still being fetched + vm.file = fileB; + return 'fileA-rawContent\n'; + }) + .mockImplementationOnce(async () => { + // we delay returning fileB content to make sure the editor doesn't initialize prematurely + await waitUsingRealTimer(30); + return 'fileB-rawContent\n'; + }); + + vm.file = fileA; + + await waitForEditorSetup(); + expect(vm.model.getModel().getValue()).toBe('fileB-rawContent\n'); + }); + }); + describe('onPaste', () => { const setFileName = name => { Vue.set(vm, 'file', { @@ -557,6 +616,11 @@ describe('RepoEditor', () => { }); }); + // Pasting an image does a lot of things like using the FileReader API, + // so, waitForPromises isn't very reliable (and causes a flaky spec) + // Read more about state.watch: https://vuex.vuejs.org/api/#watch + const waitForFileContentChange = () => watchState(s => s.entries['foo/bar.md'].content); + beforeEach(() => { setFileName('bar.md'); @@ -573,13 +637,15 @@ describe('RepoEditor', () => { // set cursor to line 2, column 1 vm.editor.instance.setSelection(new Range(2, 1, 2, 1)); vm.editor.instance.focus(); + + jest.spyOn(vm.editor.instance, 'hasTextFocus').mockReturnValue(true); }); }); it('adds an image entry to the same folder for a pasted image in a markdown file', () => { pasteImage(); - return waitForPromises().then(() => { + return waitForFileContentChange().then(() => { expect(vm.$store.state.entries['foo/foo.png']).toMatchObject({ path: 'foo/foo.png', type: 'blob', @@ -593,10 +659,7 @@ describe('RepoEditor', () => { it("adds a markdown image tag to the file's contents", () => { pasteImage(); - // Pasting an image does a lot of things like using the FileReader API, - // so, waitForPromises isn't very reliable (and causes a flaky spec) - // Read more about state.watch: https://vuex.vuejs.org/api/#watch - return watchState(s => s.entries['foo/bar.md'].content).then(() => { + return waitForFileContentChange().then(() => { expect(vm.file.content).toBe('hello world\n![foo.png](./foo.png)'); }); }); @@ -629,8 +692,8 @@ describe('RepoEditor', () => { return waitForEditorSetup().then(() => { expect(vm.rules).toEqual(monacoRules); expect(vm.model.options).toMatchObject(monacoRules); - expect(mockActions.getFileData).not.toHaveBeenCalled(); - expect(mockActions.getRawFileData).not.toHaveBeenCalled(); + expect(vm.getFileData).not.toHaveBeenCalled(); + expect(vm.getRawFileData).not.toHaveBeenCalled(); }); }, ); @@ -646,13 +709,13 @@ describe('RepoEditor', () => { createComponent(); return waitForEditorSetup().then(() => { - expect(mockActions.getFileData.mock.calls.map(([, args]) => args)).toEqual([ + expect(vm.getFileData.mock.calls.map(([args]) => args)).toEqual([ { makeFileActive: false, path: 'foo/bar/baz/.editorconfig' }, { makeFileActive: false, path: 'foo/bar/.editorconfig' }, { makeFileActive: false, path: 'foo/.editorconfig' }, { makeFileActive: false, path: '.editorconfig' }, ]); - expect(mockActions.getRawFileData.mock.calls.map(([, args]) => args)).toEqual([ + expect(vm.getRawFileData.mock.calls.map(([args]) => args)).toEqual([ { path: 'foo/bar/baz/.editorconfig' }, { path: 'foo/bar/.editorconfig' }, { path: 'foo/.editorconfig' }, diff --git a/spec/frontend/ide/helpers.js b/spec/frontend/ide/helpers.js index de839fa99ca..a9620d26313 100644 --- a/spec/frontend/ide/helpers.js +++ b/spec/frontend/ide/helpers.js @@ -30,7 +30,6 @@ export const file = (name = 'name', id = name, type = '', parent = null) => name, path: parent ? `${parent.path}/${name}` : name, parentPath: parent ? parent.path : '', - lastCommit: {}, }); export const createEntriesFromPaths = paths => diff --git a/spec/frontend/ide/lib/editor_spec.js b/spec/frontend/ide/lib/editor_spec.js index f5815771cdf..5f28309422d 100644 --- a/spec/frontend/ide/lib/editor_spec.js +++ b/spec/frontend/ide/lib/editor_spec.js @@ -199,6 +199,28 @@ describe('Multi-file editor library', () => { }); }); + describe('schemas', () => { + let originalGon; + + beforeEach(() => { + originalGon = window.gon; + window.gon = { features: { schemaLinting: true } }; + + delete Editor.editorInstance; + instance = Editor.create(); + }); + + afterEach(() => { + window.gon = originalGon; + }); + + it('registers custom schemas defined with Monaco', () => { + expect(monacoLanguages.yaml.yamlDefaults.diagnosticsOptions).toMatchObject({ + schemas: [{ fileMatch: ['*.gitlab-ci.yml'] }], + }); + }); + }); + describe('replaceSelectedText', () => { let model; let editor; diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js index 3cb6e064aa2..bc3f86702cf 100644 --- a/spec/frontend/ide/services/index_spec.js +++ b/spec/frontend/ide/services/index_spec.js @@ -2,7 +2,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import services from '~/ide/services'; import Api from '~/api'; -import gqClient from '~/ide/services/gql'; +import { query } from '~/ide/services/gql'; import { escapeFileUrl } from '~/lib/utils/url_utility'; import getUserPermissions from '~/ide/queries/getUserPermissions.query.graphql'; import { projectData } from '../mock_data'; @@ -207,12 +207,12 @@ describe('IDE services', () => { }, }; Api.project.mockReturnValue(Promise.resolve({ data: { ...projectData } })); - gqClient.query.mockReturnValue(Promise.resolve({ data: { project: gqlProjectData } })); + query.mockReturnValue(Promise.resolve({ data: { project: gqlProjectData } })); return services.getProjectData(TEST_NAMESPACE, TEST_PROJECT).then(response => { expect(response).toEqual({ data: { ...projectData, ...gqlProjectData } }); expect(Api.project).toHaveBeenCalledWith(TEST_PROJECT_ID); - expect(gqClient.query).toHaveBeenCalledWith({ + expect(query).toHaveBeenCalledWith({ query: getUserPermissions, variables: { projectPath: TEST_PROJECT_ID, diff --git a/spec/frontend/ide/stores/actions/file_spec.js b/spec/frontend/ide/stores/actions/file_spec.js index e2dc7626c67..88e7a9fff36 100644 --- a/spec/frontend/ide/stores/actions/file_spec.js +++ b/spec/frontend/ide/stores/actions/file_spec.js @@ -51,35 +51,27 @@ describe('IDE store file actions', () => { store.state.entries[localFile.path] = localFile; }); - it('closes open files', done => { - store - .dispatch('closeFile', localFile) - .then(() => { - expect(localFile.opened).toBeFalsy(); - expect(localFile.active).toBeFalsy(); - expect(store.state.openFiles.length).toBe(0); - - done(); - }) - .catch(done.fail); + it('closes open files', () => { + return store.dispatch('closeFile', localFile).then(() => { + expect(localFile.opened).toBeFalsy(); + expect(localFile.active).toBeFalsy(); + expect(store.state.openFiles.length).toBe(0); + }); }); - it('closes file even if file has changes', done => { + it('closes file even if file has changes', () => { store.state.changedFiles.push(localFile); - store + return store .dispatch('closeFile', localFile) .then(Vue.nextTick) .then(() => { expect(store.state.openFiles.length).toBe(0); expect(store.state.changedFiles.length).toBe(1); - - done(); - }) - .catch(done.fail); + }); }); - it('closes file & opens next available file', done => { + it('closes file & opens next available file', () => { const f = { ...file('newOpenFile'), url: '/newOpenFile', @@ -88,31 +80,23 @@ describe('IDE store file actions', () => { store.state.openFiles.push(f); store.state.entries[f.path] = f; - store + return store .dispatch('closeFile', localFile) .then(Vue.nextTick) .then(() => { expect(router.push).toHaveBeenCalledWith(`/project${f.url}`); - - done(); - }) - .catch(done.fail); + }); }); - it('removes file if it pending', done => { + it('removes file if it pending', () => { store.state.openFiles.push({ ...localFile, pending: true, }); - store - .dispatch('closeFile', localFile) - .then(() => { - expect(store.state.openFiles.length).toBe(0); - - done(); - }) - .catch(done.fail); + return store.dispatch('closeFile', localFile).then(() => { + expect(store.state.openFiles.length).toBe(0); + }); }); }); @@ -264,61 +248,48 @@ describe('IDE store file actions', () => { ); }); - it('calls the service', done => { - store - .dispatch('getFileData', { path: localFile.path }) - .then(() => { - expect(service.getFileData).toHaveBeenCalledWith( - `${RELATIVE_URL_ROOT}/test/test/-/7297abc/${localFile.path}`, - ); - - done(); - }) - .catch(done.fail); + it('calls the service', () => { + return store.dispatch('getFileData', { path: localFile.path }).then(() => { + expect(service.getFileData).toHaveBeenCalledWith( + `${RELATIVE_URL_ROOT}/test/test/-/7297abc/${localFile.path}`, + ); + }); }); - it('sets document title with the branchId', done => { - store - .dispatch('getFileData', { path: localFile.path }) - .then(() => { - expect(document.title).toBe(`${localFile.path} · master · test/test · GitLab`); - done(); - }) - .catch(done.fail); + it('sets document title with the branchId', () => { + return store.dispatch('getFileData', { path: localFile.path }).then(() => { + expect(document.title).toBe(`${localFile.path} · master · test/test · GitLab`); + }); }); - it('sets the file as active', done => { - store - .dispatch('getFileData', { path: localFile.path }) - .then(() => { - expect(localFile.active).toBeTruthy(); - - done(); - }) - .catch(done.fail); + it('sets the file as active', () => { + return store.dispatch('getFileData', { path: localFile.path }).then(() => { + expect(localFile.active).toBeTruthy(); + }); }); - it('sets the file not as active if we pass makeFileActive false', done => { - store + it('sets the file not as active if we pass makeFileActive false', () => { + return store .dispatch('getFileData', { path: localFile.path, makeFileActive: false }) .then(() => { expect(localFile.active).toBeFalsy(); - - done(); - }) - .catch(done.fail); + }); }); - it('adds the file to open files', done => { - store - .dispatch('getFileData', { path: localFile.path }) + it('does not update the page title with the path of the file if makeFileActive is false', () => { + document.title = 'dummy title'; + return store + .dispatch('getFileData', { path: localFile.path, makeFileActive: false }) .then(() => { - expect(store.state.openFiles.length).toBe(1); - expect(store.state.openFiles[0].name).toBe(localFile.name); + expect(document.title).toBe(`dummy title`); + }); + }); - done(); - }) - .catch(done.fail); + it('adds the file to open files', () => { + return store.dispatch('getFileData', { path: localFile.path }).then(() => { + expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles[0].name).toBe(localFile.name); + }); }); }); @@ -342,15 +313,10 @@ describe('IDE store file actions', () => { ); }); - it('sets document title considering `prevPath` on a file', done => { - store - .dispatch('getFileData', { path: localFile.path }) - .then(() => { - expect(document.title).toBe(`new-shiny-file · master · test/test · GitLab`); - - done(); - }) - .catch(done.fail); + it('sets document title considering `prevPath` on a file', () => { + return store.dispatch('getFileData', { path: localFile.path }).then(() => { + expect(document.title).toBe(`new-shiny-file · master · test/test · GitLab`); + }); }); }); @@ -397,29 +363,19 @@ describe('IDE store file actions', () => { mock.onGet(/(.*)/).replyOnce(200, 'raw'); }); - it('calls getRawFileData service method', done => { - store - .dispatch('getRawFileData', { path: tmpFile.path }) - .then(() => { - expect(service.getRawFileData).toHaveBeenCalledWith(tmpFile); - - done(); - }) - .catch(done.fail); + it('calls getRawFileData service method', () => { + return store.dispatch('getRawFileData', { path: tmpFile.path }).then(() => { + expect(service.getRawFileData).toHaveBeenCalledWith(tmpFile); + }); }); - it('updates file raw data', done => { - store - .dispatch('getRawFileData', { path: tmpFile.path }) - .then(() => { - expect(tmpFile.raw).toBe('raw'); - - done(); - }) - .catch(done.fail); + it('updates file raw data', () => { + return store.dispatch('getRawFileData', { path: tmpFile.path }).then(() => { + expect(tmpFile.raw).toBe('raw'); + }); }); - it('calls also getBaseRawFileData service method', done => { + it('calls also getBaseRawFileData service method', () => { jest.spyOn(service, 'getBaseRawFileData').mockReturnValue(Promise.resolve('baseraw')); store.state.currentProjectId = 'gitlab-org/gitlab-ce'; @@ -436,15 +392,58 @@ describe('IDE store file actions', () => { tmpFile.mrChange = { new_file: false }; - store - .dispatch('getRawFileData', { path: tmpFile.path }) - .then(() => { - expect(service.getBaseRawFileData).toHaveBeenCalledWith(tmpFile, 'SHA'); - expect(tmpFile.baseRaw).toBe('baseraw'); + return store.dispatch('getRawFileData', { path: tmpFile.path }).then(() => { + expect(service.getBaseRawFileData).toHaveBeenCalledWith(tmpFile, 'SHA'); + expect(tmpFile.baseRaw).toBe('baseraw'); + }); + }); + + describe('sets file loading to true', () => { + let loadingWhenGettingRawData; + let loadingWhenGettingBaseRawData; + + beforeEach(() => { + loadingWhenGettingRawData = undefined; + loadingWhenGettingBaseRawData = undefined; + + jest.spyOn(service, 'getRawFileData').mockImplementation(f => { + loadingWhenGettingRawData = f.loading; + return Promise.resolve('raw'); + }); + jest.spyOn(service, 'getBaseRawFileData').mockImplementation(f => { + loadingWhenGettingBaseRawData = f.loading; + return Promise.resolve('rawBase'); + }); + }); - done(); - }) - .catch(done.fail); + it('when getting raw file data', async () => { + expect(tmpFile.loading).toBe(false); + + await store.dispatch('getRawFileData', { path: tmpFile.path }); + + expect(loadingWhenGettingRawData).toBe(true); + expect(tmpFile.loading).toBe(false); + }); + + it('when getting base raw file data', async () => { + tmpFile.mrChange = { new_file: false }; + + expect(tmpFile.loading).toBe(false); + + await store.dispatch('getRawFileData', { path: tmpFile.path }); + + expect(loadingWhenGettingBaseRawData).toBe(true); + expect(tmpFile.loading).toBe(false); + }); + + it('when file was already loading', async () => { + tmpFile.loading = true; + + await store.dispatch('getRawFileData', { path: tmpFile.path }); + + expect(loadingWhenGettingRawData).toBe(true); + expect(tmpFile.loading).toBe(false); + }); }); }); @@ -453,15 +452,10 @@ describe('IDE store file actions', () => { mock.onGet(/(.*)/).replyOnce(200, JSON.stringify({ test: '123' })); }); - it('does not parse returned JSON', done => { - store - .dispatch('getRawFileData', { path: tmpFile.path }) - .then(() => { - expect(tmpFile.raw).toEqual('{"test":"123"}'); - - done(); - }) - .catch(done.fail); + it('does not parse returned JSON', () => { + return store.dispatch('getRawFileData', { path: tmpFile.path }).then(() => { + expect(tmpFile.raw).toEqual('{"test":"123"}'); + }); }); }); @@ -489,6 +483,12 @@ describe('IDE store file actions', () => { }); }); }); + + it('toggles loading off after error', async () => { + await expect(store.dispatch('getRawFileData', { path: tmpFile.path })).rejects.toThrow(); + + expect(tmpFile.loading).toBe(false); + }); }); }); @@ -504,32 +504,25 @@ describe('IDE store file actions', () => { store.state.entries[tmpFile.path] = tmpFile; }); - it('updates file content', done => { - callAction() - .then(() => { - expect(tmpFile.content).toBe('content\n'); - - done(); - }) - .catch(done.fail); + it('updates file content', () => { + return callAction().then(() => { + expect(tmpFile.content).toBe('content\n'); + }); }); - it('adds file into stagedFiles array', done => { - store + it('adds file into stagedFiles array', () => { + return store .dispatch('changeFileContent', { path: tmpFile.path, content: 'content', }) .then(() => { expect(store.state.stagedFiles.length).toBe(1); - - done(); - }) - .catch(done.fail); + }); }); - it('adds file not more than once into stagedFiles array', done => { - store + it('adds file not more than once into stagedFiles array', () => { + return store .dispatch('changeFileContent', { path: tmpFile.path, content: 'content', @@ -542,14 +535,11 @@ describe('IDE store file actions', () => { ) .then(() => { expect(store.state.stagedFiles.length).toBe(1); - - done(); - }) - .catch(done.fail); + }); }); - it('removes file from changedFiles array if not changed', done => { - store + it('removes file from changedFiles array if not changed', () => { + return store .dispatch('changeFileContent', { path: tmpFile.path, content: 'content\n', @@ -562,10 +552,7 @@ describe('IDE store file actions', () => { ) .then(() => { expect(store.state.changedFiles.length).toBe(0); - - done(); - }) - .catch(done.fail); + }); }); }); @@ -723,52 +710,36 @@ describe('IDE store file actions', () => { store.state.entries[f.path] = f; }); - it('makes file pending in openFiles', done => { - store - .dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }) - .then(() => { - expect(store.state.openFiles[0].pending).toBe(true); - }) - .then(done) - .catch(done.fail); + it('makes file pending in openFiles', () => { + return store.dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }).then(() => { + expect(store.state.openFiles[0].pending).toBe(true); + }); }); - it('returns true when opened', done => { - store - .dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }) - .then(added => { - expect(added).toBe(true); - }) - .then(done) - .catch(done.fail); + it('returns true when opened', () => { + return store.dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }).then(added => { + expect(added).toBe(true); + }); }); - it('returns false when already opened', done => { + it('returns false when already opened', () => { store.state.openFiles.push({ ...f, active: true, key: `pending-${f.key}`, }); - store - .dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }) - .then(added => { - expect(added).toBe(false); - }) - .then(done) - .catch(done.fail); + return store.dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }).then(added => { + expect(added).toBe(false); + }); }); - it('pushes router URL when added', done => { + it('pushes router URL when added', () => { store.state.currentBranchId = 'master'; - store - .dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }) - .then(() => { - expect(router.push).toHaveBeenCalledWith('/project/123/tree/master/'); - }) - .then(done) - .catch(done.fail); + return store.dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }).then(() => { + expect(router.push).toHaveBeenCalledWith('/project/123/tree/master/'); + }); }); }); @@ -784,26 +755,18 @@ describe('IDE store file actions', () => { }; }); - it('removes pending file from open files', done => { + it('removes pending file from open files', () => { store.state.openFiles.push(f); - store - .dispatch('removePendingTab', f) - .then(() => { - expect(store.state.openFiles.length).toBe(0); - }) - .then(done) - .catch(done.fail); + return store.dispatch('removePendingTab', f).then(() => { + expect(store.state.openFiles.length).toBe(0); + }); }); - it('emits event to dispose model', done => { - store - .dispatch('removePendingTab', f) - .then(() => { - expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.dispose.${f.key}`); - }) - .then(done) - .catch(done.fail); + it('emits event to dispose model', () => { + return store.dispatch('removePendingTab', f).then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.dispose.${f.key}`); + }); }); }); @@ -812,14 +775,10 @@ describe('IDE store file actions', () => { jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); }); - it('emits event that files have changed', done => { - store - .dispatch('triggerFilesChange') - .then(() => { - expect(eventHub.$emit).toHaveBeenCalledWith('ide.files.change'); - }) - .then(done) - .catch(done.fail); + it('emits event that files have changed', () => { + return store.dispatch('triggerFilesChange').then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('ide.files.change'); + }); }); }); }); diff --git a/spec/frontend/ide/stores/actions/merge_request_spec.js b/spec/frontend/ide/stores/actions/merge_request_spec.js index cb4eebd97d9..e5c4f346459 100644 --- a/spec/frontend/ide/stores/actions/merge_request_spec.js +++ b/spec/frontend/ide/stores/actions/merge_request_spec.js @@ -55,6 +55,7 @@ describe('IDE store merge request actions', () => { expect(service.getProjectMergeRequests).toHaveBeenCalledWith(TEST_PROJECT, { source_branch: 'bar', source_project_id: TEST_PROJECT_ID, + state: 'opened', order_by: 'created_at', per_page: 1, }); diff --git a/spec/frontend/ide/stores/actions/tree_spec.js b/spec/frontend/ide/stores/actions/tree_spec.js index 44e2fcab436..c20941843c4 100644 --- a/spec/frontend/ide/stores/actions/tree_spec.js +++ b/spec/frontend/ide/stores/actions/tree_spec.js @@ -7,6 +7,7 @@ import { createStore } from '~/ide/stores'; import service from '~/ide/services'; import { createRouter } from '~/ide/ide_router'; import { file, createEntriesFromPaths } from '../../helpers'; +import { TEST_HOST } from 'jest/helpers/test_constants'; describe('Multi-file store tree actions', () => { let projectTree; @@ -97,7 +98,7 @@ describe('Multi-file store tree actions', () => { store.state.projects = { 'abc/def': { - web_url: `${gl.TEST_HOST}/files`, + web_url: `${TEST_HOST}/files`, branches: { 'master-testing': { commit: { diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js index 15baeca7f36..b6de576a0a4 100644 --- a/spec/frontend/ide/utils_spec.js +++ b/spec/frontend/ide/utils_spec.js @@ -1,6 +1,7 @@ import { isTextFile, registerLanguages, + registerSchemas, trimPathComponents, insertFinalNewline, trimTrailingWhitespace, @@ -158,6 +159,57 @@ describe('WebIDE utils', () => { }); }); + describe('registerSchemas', () => { + let options; + + beforeEach(() => { + options = { + validate: true, + enableSchemaRequest: true, + hover: true, + completion: true, + schemas: [ + { + uri: 'http://myserver/foo-schema.json', + fileMatch: ['*'], + schema: { + id: 'http://myserver/foo-schema.json', + type: 'object', + properties: { + p1: { enum: ['v1', 'v2'] }, + p2: { $ref: 'http://myserver/bar-schema.json' }, + }, + }, + }, + { + uri: 'http://myserver/bar-schema.json', + schema: { + id: 'http://myserver/bar-schema.json', + type: 'object', + properties: { q1: { enum: ['x1', 'x2'] } }, + }, + }, + ], + }; + + jest.spyOn(languages.json.jsonDefaults, 'setDiagnosticsOptions'); + jest.spyOn(languages.yaml.yamlDefaults, 'setDiagnosticsOptions'); + }); + + it.each` + language | defaultsObj + ${'json'} | ${languages.json.jsonDefaults} + ${'yaml'} | ${languages.yaml.yamlDefaults} + `( + 'registers the given schemas with monaco for lang: $language', + ({ language, defaultsObj }) => { + registerSchemas({ language, options }); + + expect(defaultsObj.setDiagnosticsOptions).toHaveBeenCalledWith(options); + }, + ); + }); + describe('trimTrailingWhitespace', () => { it.each` input | output diff --git a/spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js b/spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js index 395bb7de362..2deb4be2b91 100644 --- a/spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js +++ b/spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js @@ -1,5 +1,6 @@ import * as commentIndicatorHelper from '~/image_diff/helpers/comment_indicator_helper'; import * as mockData from '../mock_data'; +import { TEST_HOST } from 'jest/helpers/test_constants'; describe('commentIndicatorHelper', () => { const { coordinate } = mockData; @@ -52,7 +53,7 @@ describe('commentIndicatorHelper', () => { beforeEach(() => { containerEl.innerHTML = ` <div class="comment-indicator" style="left:${coordinate.x}px; top: ${coordinate.y}px;"> - <img src="${gl.TEST_HOST}/image.png"> + <img src="${TEST_HOST}/image.png"> </div> `; result = commentIndicatorHelper.removeCommentIndicator(containerEl); diff --git a/spec/frontend/image_diff/helpers/utils_helper_spec.js b/spec/frontend/image_diff/helpers/utils_helper_spec.js index 3b6378be883..a47c681e775 100644 --- a/spec/frontend/image_diff/helpers/utils_helper_spec.js +++ b/spec/frontend/image_diff/helpers/utils_helper_spec.js @@ -1,6 +1,7 @@ import * as utilsHelper from '~/image_diff/helpers/utils_helper'; import ImageBadge from '~/image_diff/image_badge'; import * as mockData from '../mock_data'; +import { TEST_HOST } from 'jest/helpers/test_constants'; describe('utilsHelper', () => { const { noteId, discussionId, image, imageProperties, imageMeta } = mockData; @@ -36,7 +37,7 @@ describe('utilsHelper', () => { beforeEach(() => { const imageFrameEl = document.createElement('div'); imageFrameEl.innerHTML = ` - <img src="${gl.TEST_HOST}/image.png"> + <img src="${TEST_HOST}/image.png"> `; discussionEl = document.createElement('div'); discussionEl.dataset.discussionId = discussionId; diff --git a/spec/frontend/image_diff/image_diff_spec.js b/spec/frontend/image_diff/image_diff_spec.js index c15718b5106..2b29a522193 100644 --- a/spec/frontend/image_diff/image_diff_spec.js +++ b/spec/frontend/image_diff/image_diff_spec.js @@ -2,6 +2,7 @@ import ImageDiff from '~/image_diff/image_diff'; import * as imageUtility from '~/lib/utils/image_utility'; import imageDiffHelper from '~/image_diff/helpers/index'; import * as mockData from './mock_data'; +import { TEST_HOST } from 'jest/helpers/test_constants'; describe('ImageDiff', () => { let element; @@ -12,7 +13,7 @@ describe('ImageDiff', () => { <div id="element"> <div class="diff-file"> <div class="js-image-frame"> - <img src="${gl.TEST_HOST}/image.png"> + <img src="${TEST_HOST}/image.png"> <div class="comment-indicator"></div> <div id="badge-1" class="badge">1</div> <div id="badge-2" class="badge">2</div> diff --git a/spec/frontend/image_diff/replaced_image_diff_spec.js b/spec/frontend/image_diff/replaced_image_diff_spec.js index f2a7b7f8406..38a43bfa858 100644 --- a/spec/frontend/image_diff/replaced_image_diff_spec.js +++ b/spec/frontend/image_diff/replaced_image_diff_spec.js @@ -2,6 +2,7 @@ import ReplacedImageDiff from '~/image_diff/replaced_image_diff'; import ImageDiff from '~/image_diff/image_diff'; import { viewTypes } from '~/image_diff/view_types'; import imageDiffHelper from '~/image_diff/helpers/index'; +import { TEST_HOST } from 'jest/helpers/test_constants'; describe('ReplacedImageDiff', () => { let element; @@ -12,17 +13,17 @@ describe('ReplacedImageDiff', () => { <div id="element"> <div class="two-up"> <div class="js-image-frame"> - <img src="${gl.TEST_HOST}/image.png"> + <img src="${TEST_HOST}/image.png"> </div> </div> <div class="swipe"> <div class="js-image-frame"> - <img src="${gl.TEST_HOST}/image.png"> + <img src="${TEST_HOST}/image.png"> </div> </div> <div class="onion-skin"> <div class="js-image-frame"> - <img src="${gl.TEST_HOST}/image.png"> + <img src="${TEST_HOST}/image.png"> </div> </div> <div class="view-modes-menu"> diff --git a/spec/frontend/import_projects/store/actions_spec.js b/spec/frontend/import_projects/store/actions_spec.js index 1f2882a2532..fd6fbcbfce0 100644 --- a/spec/frontend/import_projects/store/actions_spec.js +++ b/spec/frontend/import_projects/store/actions_spec.js @@ -1,4 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; +import createFlash from '~/flash'; import testAction from 'helpers/vuex_action_helper'; import { TEST_HOST } from 'helpers/test_constants'; import axios from '~/lib/utils/axios_utils'; @@ -22,6 +23,8 @@ import { } from '~/import_projects/store/actions'; import state from '~/import_projects/store/state'; +jest.mock('~/flash'); + describe('import_projects store actions', () => { let localState; const repos = [{ id: 1 }, { id: 2 }]; @@ -130,10 +133,28 @@ describe('import_projects store actions', () => { ); }); - it('commits REQUEST_IMPORT and RECEIVE_IMPORT_ERROR on an unsuccessful request', () => { + it('commits REQUEST_IMPORT and RECEIVE_IMPORT_ERROR and shows generic error message on an unsuccessful request', async () => { mock.onPost(`${TEST_HOST}/endpoint.json`).reply(500); - return testAction( + await testAction( + fetchImport, + importPayload, + localState, + [ + { type: REQUEST_IMPORT, payload: importPayload.repo.id }, + { type: RECEIVE_IMPORT_ERROR, payload: importPayload.repo.id }, + ], + [], + ); + + expect(createFlash).toHaveBeenCalledWith('Importing the project failed'); + }); + + it('commits REQUEST_IMPORT and RECEIVE_IMPORT_ERROR and shows detailed error message on an unsuccessful request with errors fields in response', async () => { + const ERROR_MESSAGE = 'dummy'; + mock.onPost(`${TEST_HOST}/endpoint.json`).reply(500, { errors: ERROR_MESSAGE }); + + await testAction( fetchImport, importPayload, localState, @@ -143,6 +164,8 @@ describe('import_projects store actions', () => { ], [], ); + + expect(createFlash).toHaveBeenCalledWith(`Importing the project failed: ${ERROR_MESSAGE}`); }); }); diff --git a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap new file mode 100644 index 00000000000..dd3589e2951 --- /dev/null +++ b/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap @@ -0,0 +1,99 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Alert integration settings form default state should match the default snapshot 1`] = ` +<div> + <p> + <gl-sprintf-stub + message="Action to take when receiving an alert. %{docsLink}" + /> + </p> + + <form> + <gl-form-group-stub + class="gl-pl-0" + > + <gl-form-checkbox-stub + checked="true" + data-qa-selector="create_issue_checkbox" + > + <span> + Create an issue. Issues are created for each alert triggered. + </span> + </gl-form-checkbox-stub> + </gl-form-group-stub> + + <gl-form-group-stub + class="col-8 col-md-9 gl-px-6" + label-for="alert-integration-settings-issue-template" + label-size="sm" + > + <label + class="gl-display-inline-flex" + for="alert-integration-settings-issue-template" + > + + Issue template (optional) + + <gl-link-stub + href="/help/user/project/description_templates#creating-issue-templates" + target="_blank" + > + <gl-icon-stub + name="question" + size="12" + /> + </gl-link-stub> + </label> + + <gl-new-dropdown-stub + block="true" + category="tertiary" + data-qa-selector="incident_templates_dropdown" + headertext="" + id="alert-integration-settings-issue-template" + size="medium" + text="selecte_tmpl" + variant="default" + > + <gl-new-dropdown-item-stub + avatarurl="" + data-qa-selector="incident_templates_item" + iconcolor="" + iconname="" + iconrightname="" + ischeckitem="true" + secondarytext="" + > + + No template selected + + </gl-new-dropdown-item-stub> + </gl-new-dropdown-stub> + </gl-form-group-stub> + + <gl-form-group-stub + class="gl-pl-0 gl-mb-5" + > + <gl-form-checkbox-stub> + <span> + Send a separate email notification to Developers. + </span> + </gl-form-checkbox-stub> + </gl-form-group-stub> + + <gl-button-stub + category="tertiary" + class="js-no-auto-disable" + data-qa-selector="save_changes_button" + icon="" + size="medium" + type="submit" + variant="success" + > + + Save changes + + </gl-button-stub> + </form> +</div> +`; diff --git a/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap new file mode 100644 index 00000000000..5f355ee8261 --- /dev/null +++ b/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`IncidentsSettingTabs should render the component 1`] = ` +<section + class="settings no-animate qa-incident-management-settings" + data-qa-selector="incidents_settings_content" + id="incident-management-settings" +> + <div + class="settings-header" + > + <h3 + class="h4" + > + + Incidents + + </h3> + + <gl-button-stub + category="tertiary" + class="js-settings-toggle" + icon="" + size="medium" + variant="default" + > + Expand + </gl-button-stub> + + <p> + + Set up integrations with external tools to help better manage incidents. + + </p> + </div> + + <div + class="settings-content" + > + <gl-tabs-stub + theme="indigo" + > + <gl-tab-stub + title="Alert integration" + > + <alertssettingsform-stub + class="gl-pt-3" + data-testid="AlertsSettingsForm-tab" + /> + </gl-tab-stub> + <gl-tab-stub + title="PagerDuty integration" + > + <pagerdutysettingsform-stub + class="gl-pt-3" + data-testid="PagerDutySettingsForm-tab" + /> + </gl-tab-stub> + <!----> + </gl-tabs-stub> + </div> +</section> +`; diff --git a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap new file mode 100644 index 00000000000..17ada722034 --- /dev/null +++ b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap @@ -0,0 +1,89 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Alert integration settings form should match the default snapshot 1`] = ` +<div> + <!----> + + <p> + Setting up a webhook with PagerDuty will automatically create a GitLab issue for each PagerDuty incident. + </p> + + <form> + <gl-form-group-stub + class="col-8 col-md-9 gl-p-0" + > + <gl-toggle-stub + id="active" + label="Active" + labelposition="top" + value="true" + /> + </gl-form-group-stub> + + <gl-form-group-stub + class="col-8 col-md-9 gl-p-0" + label="Webhook URL" + label-class="label-bold" + label-for="url" + > + <gl-form-input-group-stub + data-testid="webhook-url" + id="url" + predefinedoptions="[object Object]" + readonly="" + value="pagerduty.webhook.com" + /> + + <div + class="gl-text-gray-400 gl-pt-2" + > + <gl-sprintf-stub + message="Create a GitLab issue for each PagerDuty incident by %{docsLink}" + /> + </div> + + <gl-button-stub + category="tertiary" + class="gl-mt-3" + data-testid="webhook-reset-btn" + icon="" + role="button" + size="medium" + tabindex="0" + variant="default" + > + + Reset webhook URL + + </gl-button-stub> + + <gl-modal-stub + modalclass="" + modalid="resetWebhookModal" + ok-title="Reset webhook URL" + ok-variant="danger" + size="md" + title="Reset webhook URL" + titletag="h4" + > + + Resetting the webhook URL for this project will require updating this integration's settings in PagerDuty. + + </gl-modal-stub> + </gl-form-group-stub> + + <gl-button-stub + category="tertiary" + class="js-no-auto-disable" + icon="" + size="medium" + type="submit" + variant="success" + > + + Save changes + + </gl-button-stub> + </form> +</div> +`; diff --git a/spec/frontend/incidents_settings/components/alerts_form_spec.js b/spec/frontend/incidents_settings/components/alerts_form_spec.js new file mode 100644 index 00000000000..04832f31e58 --- /dev/null +++ b/spec/frontend/incidents_settings/components/alerts_form_spec.js @@ -0,0 +1,49 @@ +import { shallowMount } from '@vue/test-utils'; +import AlertsSettingsForm from '~/incidents_settings/components/alerts_form.vue'; + +describe('Alert integration settings form', () => { + let wrapper; + const service = { updateSettings: jest.fn().mockResolvedValue() }; + + const findForm = () => wrapper.find({ ref: 'settingsForm' }); + + beforeEach(() => { + wrapper = shallowMount(AlertsSettingsForm, { + provide: { + service, + alertSettings: { + issueTemplateKey: 'selecte_tmpl', + createIssue: true, + sendEmail: false, + templates: [], + }, + }, + }); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('default state', () => { + it('should match the default snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('form', () => { + it('should call service `updateSettings` on submit', () => { + findForm().trigger('submit'); + expect(service.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + create_issue: wrapper.vm.createIssueEnabled, + issue_template_key: wrapper.vm.issueTemplate, + send_email: wrapper.vm.sendEmailEnabled, + }), + ); + }); + }); +}); diff --git a/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js b/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js new file mode 100644 index 00000000000..58f9a318808 --- /dev/null +++ b/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js @@ -0,0 +1,55 @@ +import axios from '~/lib/utils/axios_utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import httpStatusCodes from '~/lib/utils/http_status'; +import IncidentsSettingsService from '~/incidents_settings/incidents_settings_service'; +import { ERROR_MSG } from '~/incidents_settings/constants'; +import createFlash from '~/flash'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; + +jest.mock('~/flash'); +jest.mock('~/lib/utils/url_utility'); + +describe('IncidentsSettingsService', () => { + const settingsEndpoint = 'operations/settings'; + const webhookUpdateEndpoint = 'webhook/update'; + let mock; + let service; + + beforeEach(() => { + mock = new AxiosMockAdapter(axios); + service = new IncidentsSettingsService(settingsEndpoint, webhookUpdateEndpoint); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('updateSettings', () => { + it('should refresh the page on successful update', () => { + mock.onPatch().reply(httpStatusCodes.OK); + + return service.updateSettings({}).then(() => { + expect(refreshCurrentPage).toHaveBeenCalled(); + }); + }); + + it('should display a flash message on update error', () => { + mock.onPatch().reply(httpStatusCodes.BAD_REQUEST); + + return service.updateSettings({}).then(() => { + expect(createFlash).toHaveBeenCalledWith(expect.stringContaining(ERROR_MSG), 'alert'); + }); + }); + }); + + describe('resetWebhookUrl', () => { + it('should make a call for webhook update', () => { + jest.spyOn(axios, 'post'); + mock.onPost().reply(httpStatusCodes.OK); + + return service.resetWebhookUrl().then(() => { + expect(axios.post).toHaveBeenCalledWith(webhookUpdateEndpoint); + }); + }); + }); +}); diff --git a/spec/frontend/incidents_settings/components/incidents_settings_tabs_spec.js b/spec/frontend/incidents_settings/components/incidents_settings_tabs_spec.js new file mode 100644 index 00000000000..47e2aecc108 --- /dev/null +++ b/spec/frontend/incidents_settings/components/incidents_settings_tabs_spec.js @@ -0,0 +1,55 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlTab } from '@gitlab/ui'; +import IncidentsSettingTabs from '~/incidents_settings/components/incidents_settings_tabs.vue'; + +describe('IncidentsSettingTabs', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallowMount(IncidentsSettingTabs, { + provide: { glFeatures: { pagerdutyWebhook: true } }, + }); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + const findToggleButton = () => wrapper.find({ ref: 'toggleBtn' }); + const findSectionHeader = () => wrapper.find({ ref: 'sectionHeader' }); + + const findIntegrationTabs = () => wrapper.findAll(GlTab); + it('renders header text', () => { + expect(findSectionHeader().text()).toBe('Incidents'); + }); + + describe('expand/collapse button', () => { + it('renders as an expand button by default', () => { + expect(findToggleButton().text()).toBe('Expand'); + }); + }); + + it('should render the component', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('should render the tab for each active integration', () => { + const activeTabs = wrapper.vm.$options.tabs.filter(tab => tab.active); + expect(findIntegrationTabs().length).toBe(activeTabs.length); + activeTabs.forEach((tab, index) => { + expect( + findIntegrationTabs() + .at(index) + .attributes('title'), + ).toBe(tab.title); + expect( + findIntegrationTabs() + .at(index) + .find(`[data-testid="${tab.component}-tab"]`) + .exists(), + ).toBe(true); + }); + }); +}); diff --git a/spec/frontend/incidents_settings/components/pagerduty_form_spec.js b/spec/frontend/incidents_settings/components/pagerduty_form_spec.js new file mode 100644 index 00000000000..521094ad54c --- /dev/null +++ b/spec/frontend/incidents_settings/components/pagerduty_form_spec.js @@ -0,0 +1,67 @@ +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import PagerDutySettingsForm from '~/incidents_settings/components/pagerduty_form.vue'; +import { GlAlert, GlModal } from '@gitlab/ui'; + +describe('Alert integration settings form', () => { + let wrapper; + const resetWebhookUrl = jest.fn(); + const service = { updateSettings: jest.fn().mockResolvedValue(), resetWebhookUrl }; + + const findForm = () => wrapper.find({ ref: 'settingsForm' }); + const findWebhookInput = () => wrapper.find('[data-testid="webhook-url"]'); + const findModal = () => wrapper.find(GlModal); + const findAlert = () => wrapper.find(GlAlert); + + beforeEach(() => { + wrapper = shallowMount(PagerDutySettingsForm, { + provide: { + service, + pagerDutySettings: { + active: true, + webhookUrl: 'pagerduty.webhook.com', + webhookUpdateEndpoint: 'webhook/update', + }, + }, + }); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + it('should match the default snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('should call service `updateSettings` on form submit', () => { + findForm().trigger('submit'); + expect(service.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ pagerduty_active: wrapper.vm.active }), + ); + }); + + describe('Webhook reset', () => { + it('should make a call for webhook reset and reset form values', async () => { + const newWebhookUrl = 'new.webhook.url?token=token'; + resetWebhookUrl.mockResolvedValueOnce({ + data: { pagerduty_webhook_url: newWebhookUrl }, + }); + findModal().vm.$emit('ok'); + await waitForPromises(); + expect(resetWebhookUrl).toHaveBeenCalled(); + expect(findWebhookInput().attributes('value')).toBe(newWebhookUrl); + expect(findAlert().attributes('variant')).toBe('success'); + }); + + it('should show error message and NOT reset webhook url', async () => { + resetWebhookUrl.mockRejectedValueOnce(); + findModal().vm.$emit('ok'); + await waitForPromises(); + expect(findAlert().attributes('variant')).toBe('danger'); + }); + }); +}); diff --git a/spec/frontend/integrations/edit/components/active_toggle_spec.js b/spec/frontend/integrations/edit/components/active_toggle_spec.js index 5469b45f708..228d8f5fc30 100644 --- a/spec/frontend/integrations/edit/components/active_toggle_spec.js +++ b/spec/frontend/integrations/edit/components/active_toggle_spec.js @@ -1,8 +1,10 @@ import { mount } from '@vue/test-utils'; -import ActiveToggle from '~/integrations/edit/components/active_toggle.vue'; import { GlToggle } from '@gitlab/ui'; +import ActiveToggle from '~/integrations/edit/components/active_toggle.vue'; + const GL_TOGGLE_ACTIVE_CLASS = 'is-checked'; +const GL_TOGGLE_DISABLED_CLASS = 'is-disabled'; describe('ActiveToggle', () => { let wrapper; @@ -11,9 +13,12 @@ describe('ActiveToggle', () => { initialActivated: true, }; - const createComponent = props => { + const createComponent = (props = {}, isInheriting = false) => { wrapper = mount(ActiveToggle, { propsData: { ...defaultProps, ...props }, + computed: { + isInheriting: () => isInheriting, + }, }); }; @@ -29,6 +34,15 @@ describe('ActiveToggle', () => { const findInputInToggle = () => findGlToggle().find('input'); describe('template', () => { + describe('is inheriting adminSettings', () => { + it('renders GlToggle as disabled', () => { + createComponent({}, true); + + expect(findGlToggle().exists()).toBe(true); + expect(findButtonInToggle().classes()).toContain(GL_TOGGLE_DISABLED_CLASS); + }); + }); + describe('initialActivated is false', () => { it('renders GlToggle as inactive', () => { createComponent({ diff --git a/spec/frontend/integrations/edit/components/dynamic_field_spec.js b/spec/frontend/integrations/edit/components/dynamic_field_spec.js index e5710641f81..3a7a0efcab7 100644 --- a/spec/frontend/integrations/edit/components/dynamic_field_spec.js +++ b/spec/frontend/integrations/edit/components/dynamic_field_spec.js @@ -14,9 +14,12 @@ describe('DynamicField', () => { value: '1', }; - const createComponent = props => { + const createComponent = (props, isInheriting = false) => { wrapper = mount(DynamicField, { propsData: { ...defaultProps, ...props }, + computed: { + isInheriting: () => isInheriting, + }, }); }; @@ -34,108 +37,143 @@ describe('DynamicField', () => { const findGlFormTextarea = () => wrapper.find(GlFormTextarea); describe('template', () => { - describe('dynamic field', () => { - describe('type is checkbox', () => { - beforeEach(() => { - createComponent({ - type: 'checkbox', + describe.each([[true, 'disabled', 'readonly'], [false, undefined, undefined]])( + 'dynamic field, when isInheriting = `%p`', + (isInheriting, disabled, readonly) => { + describe('type is checkbox', () => { + beforeEach(() => { + createComponent( + { + type: 'checkbox', + }, + isInheriting, + ); }); - }); - it('renders GlFormCheckbox', () => { - expect(findGlFormCheckbox().exists()).toBe(true); - }); + it(`renders GlFormCheckbox, which ${isInheriting ? 'is' : 'is not'} disabled`, () => { + expect(findGlFormCheckbox().exists()).toBe(true); + expect( + findGlFormCheckbox() + .find('[type=checkbox]') + .attributes('disabled'), + ).toBe(disabled); + }); - it('does not render other types of input', () => { - expect(findGlFormSelect().exists()).toBe(false); - expect(findGlFormTextarea().exists()).toBe(false); - expect(findGlFormInput().exists()).toBe(false); + it('does not render other types of input', () => { + expect(findGlFormSelect().exists()).toBe(false); + expect(findGlFormTextarea().exists()).toBe(false); + expect(findGlFormInput().exists()).toBe(false); + }); }); - }); - describe('type is select', () => { - beforeEach(() => { - createComponent({ - type: 'select', - choices: [['all', 'All details'], ['standard', 'Standard']], + describe('type is select', () => { + beforeEach(() => { + createComponent( + { + type: 'select', + choices: [['all', 'All details'], ['standard', 'Standard']], + }, + isInheriting, + ); }); - }); - it('renders findGlFormSelect', () => { - expect(findGlFormSelect().exists()).toBe(true); - expect(findGlFormSelect().findAll('option')).toHaveLength(2); - }); + it(`renders GlFormSelect, which ${isInheriting ? 'is' : 'is not'} disabled`, () => { + expect(findGlFormSelect().exists()).toBe(true); + expect(findGlFormSelect().findAll('option')).toHaveLength(2); + expect( + findGlFormSelect() + .find('select') + .attributes('disabled'), + ).toBe(disabled); + }); - it('does not render other types of input', () => { - expect(findGlFormCheckbox().exists()).toBe(false); - expect(findGlFormTextarea().exists()).toBe(false); - expect(findGlFormInput().exists()).toBe(false); + it('does not render other types of input', () => { + expect(findGlFormCheckbox().exists()).toBe(false); + expect(findGlFormTextarea().exists()).toBe(false); + expect(findGlFormInput().exists()).toBe(false); + }); }); - }); - describe('type is textarea', () => { - beforeEach(() => { - createComponent({ - type: 'textarea', + describe('type is textarea', () => { + beforeEach(() => { + createComponent( + { + type: 'textarea', + }, + isInheriting, + ); }); - }); - it('renders findGlFormTextarea', () => { - expect(findGlFormTextarea().exists()).toBe(true); - }); + it(`renders GlFormTextarea, which ${isInheriting ? 'is' : 'is not'} readonly`, () => { + expect(findGlFormTextarea().exists()).toBe(true); + expect( + findGlFormTextarea() + .find('textarea') + .attributes('readonly'), + ).toBe(readonly); + }); - it('does not render other types of input', () => { - expect(findGlFormCheckbox().exists()).toBe(false); - expect(findGlFormSelect().exists()).toBe(false); - expect(findGlFormInput().exists()).toBe(false); + it('does not render other types of input', () => { + expect(findGlFormCheckbox().exists()).toBe(false); + expect(findGlFormSelect().exists()).toBe(false); + expect(findGlFormInput().exists()).toBe(false); + }); }); - }); - describe('type is password', () => { - beforeEach(() => { - createComponent({ - type: 'password', + describe('type is password', () => { + beforeEach(() => { + createComponent( + { + type: 'password', + }, + isInheriting, + ); }); - }); - it('renders GlFormInput', () => { - expect(findGlFormInput().exists()).toBe(true); - expect(findGlFormInput().attributes('type')).toBe('password'); - }); + it(`renders GlFormInput, which ${isInheriting ? 'is' : 'is not'} readonly`, () => { + expect(findGlFormInput().exists()).toBe(true); + expect(findGlFormInput().attributes('type')).toBe('password'); + expect(findGlFormInput().attributes('readonly')).toBe(readonly); + }); - it('does not render other types of input', () => { - expect(findGlFormCheckbox().exists()).toBe(false); - expect(findGlFormSelect().exists()).toBe(false); - expect(findGlFormTextarea().exists()).toBe(false); + it('does not render other types of input', () => { + expect(findGlFormCheckbox().exists()).toBe(false); + expect(findGlFormSelect().exists()).toBe(false); + expect(findGlFormTextarea().exists()).toBe(false); + }); }); - }); - describe('type is text', () => { - beforeEach(() => { - createComponent({ - type: 'text', - required: true, + describe('type is text', () => { + beforeEach(() => { + createComponent( + { + type: 'text', + required: true, + }, + isInheriting, + ); }); - }); - it('renders GlFormInput', () => { - expect(findGlFormInput().exists()).toBe(true); - expect(findGlFormInput().attributes()).toMatchObject({ - type: 'text', - id: 'service_project_url', - name: 'service[project_url]', - placeholder: defaultProps.placeholder, - required: 'required', + it(`renders GlFormInput, which ${isInheriting ? 'is' : 'is not'} readonly`, () => { + expect(findGlFormInput().exists()).toBe(true); + expect(findGlFormInput().attributes()).toMatchObject({ + type: 'text', + id: 'service_project_url', + name: 'service[project_url]', + placeholder: defaultProps.placeholder, + required: 'required', + }); + expect(findGlFormInput().attributes('readonly')).toBe(readonly); }); - }); - it('does not render other types of input', () => { - expect(findGlFormCheckbox().exists()).toBe(false); - expect(findGlFormSelect().exists()).toBe(false); - expect(findGlFormTextarea().exists()).toBe(false); + it('does not render other types of input', () => { + expect(findGlFormCheckbox().exists()).toBe(false); + expect(findGlFormSelect().exists()).toBe(false); + expect(findGlFormTextarea().exists()).toBe(false); + }); }); - }); - }); + }, + ); describe('help text', () => { it('renders description with help text', () => { @@ -147,6 +185,20 @@ describe('DynamicField', () => { .text(), ).toBe(defaultProps.help); }); + + it('renders description with help text as HTML', () => { + const helpHTML = 'The <strong>URL</strong> of the project'; + + createComponent({ + help: helpHTML, + }); + + expect( + findGlFormGroup() + .find('small') + .html(), + ).toContain(helpHTML); + }); }); describe('label text', () => { @@ -175,5 +227,39 @@ describe('DynamicField', () => { }); }); }); + + describe('validations', () => { + describe('password field', () => { + beforeEach(() => { + createComponent({ + type: 'password', + required: true, + value: null, + }); + + wrapper.vm.validated = true; + }); + + describe('without value', () => { + it('requires validation', () => { + expect(wrapper.vm.valid).toBe(false); + expect(findGlFormGroup().classes('is-invalid')).toBe(true); + expect(findGlFormInput().classes('is-invalid')).toBe(true); + }); + }); + + describe('with value', () => { + beforeEach(() => { + wrapper.setProps({ value: 'true' }); + }); + + it('does not require validation', () => { + expect(wrapper.vm.valid).toBe(true); + expect(findGlFormGroup().classes('is-valid')).toBe(true); + expect(findGlFormInput().classes('is-valid')).toBe(true); + }); + }); + }); + }); }); }); diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index b598a71cea8..482c6a439f2 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -1,32 +1,32 @@ import { shallowMount } from '@vue/test-utils'; +import { createStore } from '~/integrations/edit/store'; import IntegrationForm from '~/integrations/edit/components/integration_form.vue'; +import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue'; import ActiveToggle from '~/integrations/edit/components/active_toggle.vue'; import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue'; +import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue'; import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; import DynamicField from '~/integrations/edit/components/dynamic_field.vue'; +import { mockIntegrationProps } from 'jest/integrations/edit/mock_data'; describe('IntegrationForm', () => { let wrapper; - const defaultProps = { - activeToggleProps: { - initialActivated: true, - }, - showActive: true, - triggerFieldsProps: { - initialTriggerCommit: false, - initialTriggerMergeRequest: false, - initialEnableComments: false, - }, - type: '', - }; - - const createComponent = props => { + const createComponent = (customStateProps = {}, featureFlags = {}, initialState = {}) => { wrapper = shallowMount(IntegrationForm, { - propsData: { ...defaultProps, ...props }, + propsData: {}, + store: createStore({ + customState: { ...mockIntegrationProps, ...customStateProps }, + ...initialState, + }), stubs: { + OverrideDropdown, ActiveToggle, JiraTriggerFields, + TriggerFields, + }, + provide: { + glFeatures: featureFlags, }, }); }; @@ -38,8 +38,10 @@ describe('IntegrationForm', () => { } }); + const findOverrideDropdown = () => wrapper.find(OverrideDropdown); const findActiveToggle = () => wrapper.find(ActiveToggle); const findJiraTriggerFields = () => wrapper.find(JiraTriggerFields); + const findJiraIssuesFields = () => wrapper.find(JiraIssuesFields); const findTriggerFields = () => wrapper.find(TriggerFields); describe('template', () => { @@ -62,23 +64,41 @@ describe('IntegrationForm', () => { }); describe('type is "slack"', () => { - it('does not render JiraTriggerFields', () => { - createComponent({ - type: 'slack', - }); + beforeEach(() => { + createComponent({ type: 'slack' }); + }); + it('does not render JiraTriggerFields', () => { expect(findJiraTriggerFields().exists()).toBe(false); }); + + it('does not render JiraIssuesFields', () => { + expect(findJiraIssuesFields().exists()).toBe(false); + }); }); describe('type is "jira"', () => { it('renders JiraTriggerFields', () => { - createComponent({ - type: 'jira', - }); + createComponent({ type: 'jira' }); expect(findJiraTriggerFields().exists()).toBe(true); }); + + describe('featureFlag jiraIssuesIntegration is false', () => { + it('does not render JiraIssuesFields', () => { + createComponent({ type: 'jira' }, { jiraIssuesIntegration: false }); + + expect(findJiraIssuesFields().exists()).toBe(false); + }); + }); + + describe('featureFlag jiraIssuesIntegration is true', () => { + it('renders JiraIssuesFields', () => { + createComponent({ type: 'jira' }, { jiraIssuesIntegration: true }); + + expect(findJiraIssuesFields().exists()).toBe(true); + }); + }); }); describe('triggerEvents is present', () => { @@ -116,5 +136,35 @@ describe('IntegrationForm', () => { }); }); }); + + describe('adminState state is null', () => { + it('does not render OverrideDropdown', () => { + createComponent( + {}, + {}, + { + adminState: null, + }, + ); + + expect(findOverrideDropdown().exists()).toBe(false); + }); + }); + + describe('adminState state is an object', () => { + it('renders OverrideDropdown', () => { + createComponent( + {}, + {}, + { + adminState: { + ...mockIntegrationProps, + }, + }, + ); + + expect(findOverrideDropdown().exists()).toBe(true); + }); + }); }); }); diff --git a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js new file mode 100644 index 00000000000..f58825f6297 --- /dev/null +++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js @@ -0,0 +1,96 @@ +import { mount } from '@vue/test-utils'; + +import { GlFormCheckbox, GlFormInput } from '@gitlab/ui'; + +import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue'; + +describe('JiraIssuesFields', () => { + let wrapper; + + const defaultProps = { + showJiraIssuesIntegration: true, + editProjectPath: '/edit', + }; + + const createComponent = props => { + wrapper = mount(JiraIssuesFields, { + propsData: { ...defaultProps, ...props }, + }); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + const findEnableCheckbox = () => wrapper.find(GlFormCheckbox); + const findProjectKey = () => wrapper.find(GlFormInput); + const expectedBannerText = 'This is a Premium feature'; + + describe('template', () => { + describe('upgrade banner for non-Premium user', () => { + beforeEach(() => { + createComponent({ initialProjectKey: '', showJiraIssuesIntegration: false }); + }); + + it('shows upgrade banner', () => { + expect(wrapper.text()).toContain(expectedBannerText); + }); + + it('does not show checkbox and input field', () => { + expect(findEnableCheckbox().exists()).toBe(false); + expect(findProjectKey().exists()).toBe(false); + }); + }); + + describe('Enable Jira issues checkbox', () => { + beforeEach(() => { + createComponent({ initialProjectKey: '' }); + }); + + it('does not show upgrade banner', () => { + expect(wrapper.text()).not.toContain(expectedBannerText); + }); + + // As per https://vuejs.org/v2/guide/forms.html#Checkbox-1, + // browsers don't include unchecked boxes in form submissions. + it('includes issues_enabled as false even if unchecked', () => { + expect(wrapper.contains('input[name="service[issues_enabled]"]')).toBe(true); + }); + + it('disables project_key input', () => { + expect(findProjectKey().attributes('disabled')).toBe('disabled'); + }); + + it('does not require project_key', () => { + expect(findProjectKey().attributes('required')).toBeUndefined(); + }); + + describe('on enable issues', () => { + it('enables project_key input', () => { + findEnableCheckbox().vm.$emit('input', true); + + return wrapper.vm.$nextTick().then(() => { + expect(findProjectKey().attributes('disabled')).toBeUndefined(); + }); + }); + + it('requires project_key input', () => { + findEnableCheckbox().vm.$emit('input', true); + + return wrapper.vm.$nextTick().then(() => { + expect(findProjectKey().attributes('required')).toBe('required'); + }); + }); + }); + }); + + it('contains link to editProjectPath', () => { + createComponent(); + + expect(wrapper.contains(`a[href="${defaultProps.editProjectPath}"]`)).toBe(true); + }); + }); +}); diff --git a/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js index e4c2a0be6a3..782930eb6a2 100644 --- a/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; -import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue'; import { GlFormCheckbox } from '@gitlab/ui'; +import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue'; describe('JiraTriggerFields', () => { let wrapper; @@ -11,9 +11,12 @@ describe('JiraTriggerFields', () => { initialEnableComments: false, }; - const createComponent = props => { + const createComponent = (props, isInheriting = false) => { wrapper = mount(JiraTriggerFields, { propsData: { ...defaultProps, ...props }, + computed: { + isInheriting: () => isInheriting, + }, }); }; @@ -93,5 +96,23 @@ describe('JiraTriggerFields', () => { expect(findCommentDetail().isVisible()).toBe(true); }); }); + + it('disables checkboxes and radios if inheriting', () => { + createComponent( + { + initialTriggerCommit: true, + initialEnableComments: true, + }, + true, + ); + + wrapper.findAll('[type=checkbox]').wrappers.forEach(checkbox => { + expect(checkbox.attributes('disabled')).toBe('disabled'); + }); + + wrapper.findAll('[type=radio]').wrappers.forEach(radio => { + expect(radio.attributes('disabled')).toBe('disabled'); + }); + }); }); }); diff --git a/spec/frontend/integrations/edit/components/trigger_fields_spec.js b/spec/frontend/integrations/edit/components/trigger_fields_spec.js index 337876c6d16..41bccb8ada0 100644 --- a/spec/frontend/integrations/edit/components/trigger_fields_spec.js +++ b/spec/frontend/integrations/edit/components/trigger_fields_spec.js @@ -9,9 +9,12 @@ describe('TriggerFields', () => { type: 'slack', }; - const createComponent = props => { + const createComponent = (props, isInheriting = false) => { wrapper = mount(TriggerFields, { propsData: { ...defaultProps, ...props }, + computed: { + isInheriting: () => isInheriting, + }, }); }; @@ -22,10 +25,11 @@ describe('TriggerFields', () => { } }); + const findAllGlFormGroups = () => wrapper.find('#trigger-fields').findAll(GlFormGroup); const findAllGlFormCheckboxes = () => wrapper.findAll(GlFormCheckbox); const findAllGlFormInputs = () => wrapper.findAll(GlFormInput); - describe('template', () => { + describe.each([true, false])('template, isInheriting = `%p`', isInheriting => { it('renders a label with text "Trigger"', () => { createComponent(); @@ -51,9 +55,12 @@ describe('TriggerFields', () => { ]; beforeEach(() => { - createComponent({ - events, - }); + createComponent( + { + events, + }, + isInheriting, + ); }); it('does not render GlFormInput for each event', () => { @@ -69,8 +76,10 @@ describe('TriggerFields', () => { }); }); - it('renders GlFormCheckbox for each event', () => { - const checkboxes = findAllGlFormCheckboxes(); + it(`renders GlFormCheckbox and corresponding hidden input for each event, which ${ + isInheriting ? 'is' : 'is not' + } disabled`, () => { + const checkboxes = findAllGlFormGroups(); const expectedResults = [ { labelText: 'Push', inputName: 'service[push_event]' }, { labelText: 'Merge Request', inputName: 'service[merge_requests_event]' }, @@ -78,14 +87,22 @@ describe('TriggerFields', () => { expect(checkboxes).toHaveLength(2); checkboxes.wrappers.forEach((checkbox, index) => { + const checkBox = checkbox.find(GlFormCheckbox); + expect(checkbox.find('label').text()).toBe(expectedResults[index].labelText); - expect(checkbox.find('input').attributes('name')).toBe(expectedResults[index].inputName); - expect(checkbox.vm.$attrs.checked).toBe(events[index].value); + expect(checkbox.find('[type=hidden]').attributes('name')).toBe( + expectedResults[index].inputName, + ); + expect(checkbox.find('[type=hidden]').attributes('value')).toBe( + events[index].value.toString(), + ); + expect(checkBox.vm.$attrs.disabled).toBe(isInheriting); + expect(checkBox.vm.$attrs.checked).toBe(events[index].value); }); }); }); - describe('events with field property', () => { + describe('events with field property, isInheriting = `%p`', () => { const events = [ { field: { @@ -102,16 +119,21 @@ describe('TriggerFields', () => { ]; beforeEach(() => { - createComponent({ - events, - }); + createComponent( + { + events, + }, + isInheriting, + ); }); it('renders GlFormCheckbox for each event', () => { expect(findAllGlFormCheckboxes()).toHaveLength(2); }); - it('renders GlFormInput for each event', () => { + it(`renders GlFormInput for each event, which ${ + isInheriting ? 'is' : 'is not' + } readonly`, () => { const fields = findAllGlFormInputs(); const expectedResults = [ { @@ -128,6 +150,7 @@ describe('TriggerFields', () => { fields.wrappers.forEach((field, index) => { expect(field.attributes()).toMatchObject(expectedResults[index]); + expect(field.vm.$attrs.readonly).toBe(isInheriting); expect(field.vm.$attrs.value).toBe(events[index].field.value); }); }); diff --git a/spec/frontend/integrations/edit/mock_data.js b/spec/frontend/integrations/edit/mock_data.js new file mode 100644 index 00000000000..da2758ec15c --- /dev/null +++ b/spec/frontend/integrations/edit/mock_data.js @@ -0,0 +1,18 @@ +// eslint-disable-next-line import/prefer-default-export +export const mockIntegrationProps = { + id: 25, + activeToggleProps: { + initialActivated: true, + }, + showActive: true, + triggerFieldsProps: { + initialTriggerCommit: false, + initialTriggerMergeRequest: false, + initialEnableComments: false, + }, + jiraIssuesProps: {}, + triggerEvents: [], + fields: [], + type: '', + inheritFromId: 25, +}; diff --git a/spec/frontend/integrations/edit/store/actions_spec.js b/spec/frontend/integrations/edit/store/actions_spec.js new file mode 100644 index 00000000000..c3ce6e51a3d --- /dev/null +++ b/spec/frontend/integrations/edit/store/actions_spec.js @@ -0,0 +1,19 @@ +import createState from '~/integrations/edit/store/state'; +import { setOverride } from '~/integrations/edit/store/actions'; +import * as types from '~/integrations/edit/store/mutation_types'; + +import testAction from 'helpers/vuex_action_helper'; + +describe('Integration form store actions', () => { + let state; + + beforeEach(() => { + state = createState(); + }); + + describe('setOverride', () => { + it('should commit override mutation', () => { + return testAction(setOverride, true, state, [{ type: types.SET_OVERRIDE, payload: true }]); + }); + }); +}); diff --git a/spec/frontend/integrations/edit/store/getters_spec.js b/spec/frontend/integrations/edit/store/getters_spec.js new file mode 100644 index 00000000000..700d36edaad --- /dev/null +++ b/spec/frontend/integrations/edit/store/getters_spec.js @@ -0,0 +1,71 @@ +import { currentKey, isInheriting, propsSource } from '~/integrations/edit/store/getters'; +import createState from '~/integrations/edit/store/state'; +import { mockIntegrationProps } from '../mock_data'; + +describe('Integration form store getters', () => { + let state; + const customState = { ...mockIntegrationProps, type: 'CustomState' }; + const adminState = { ...mockIntegrationProps, type: 'AdminState' }; + + beforeEach(() => { + state = createState({ customState }); + }); + + describe('isInheriting', () => { + describe('when adminState is null', () => { + it('returns false', () => { + expect(isInheriting(state)).toBe(false); + }); + }); + + describe('when adminState is an object', () => { + beforeEach(() => { + state.adminState = adminState; + }); + + describe('when override is false', () => { + beforeEach(() => { + state.override = false; + }); + + it('returns false', () => { + expect(isInheriting(state)).toBe(true); + }); + }); + + describe('when override is true', () => { + beforeEach(() => { + state.override = true; + }); + + it('returns true', () => { + expect(isInheriting(state)).toBe(false); + }); + }); + }); + }); + + describe('propsSource', () => { + beforeEach(() => { + state.adminState = adminState; + }); + + it('equals adminState if inheriting', () => { + expect(propsSource(state, { isInheriting: true })).toEqual(adminState); + }); + + it('equals customState if not inheriting', () => { + expect(propsSource(state, { isInheriting: false })).toEqual(customState); + }); + }); + + describe('currentKey', () => { + it('equals `admin` if inheriting', () => { + expect(currentKey(state, { isInheriting: true })).toEqual('admin'); + }); + + it('equals `custom` if not inheriting', () => { + expect(currentKey(state, { isInheriting: false })).toEqual('custom'); + }); + }); +}); diff --git a/spec/frontend/integrations/edit/store/mutations_spec.js b/spec/frontend/integrations/edit/store/mutations_spec.js new file mode 100644 index 00000000000..4b733726d44 --- /dev/null +++ b/spec/frontend/integrations/edit/store/mutations_spec.js @@ -0,0 +1,19 @@ +import mutations from '~/integrations/edit/store/mutations'; +import createState from '~/integrations/edit/store/state'; +import * as types from '~/integrations/edit/store/mutation_types'; + +describe('Integration form store mutations', () => { + let state; + + beforeEach(() => { + state = createState(); + }); + + describe(`${types.SET_OVERRIDE}`, () => { + it('sets override', () => { + mutations[types.SET_OVERRIDE](state, true); + + expect(state.override).toBe(true); + }); + }); +}); diff --git a/spec/frontend/integrations/edit/store/state_spec.js b/spec/frontend/integrations/edit/store/state_spec.js new file mode 100644 index 00000000000..a8b431aa310 --- /dev/null +++ b/spec/frontend/integrations/edit/store/state_spec.js @@ -0,0 +1,26 @@ +import createState from '~/integrations/edit/store/state'; + +describe('Integration form state factory', () => { + it('states default to null', () => { + expect(createState()).toEqual({ + adminState: null, + customState: {}, + override: false, + }); + }); + + describe('override is initialized correctly', () => { + it.each([ + [{ id: 25 }, { inheritFromId: null }, true], + [{ id: 25 }, { inheritFromId: 27 }, true], + [{ id: 25 }, { inheritFromId: 25 }, false], + [null, { inheritFromId: null }, false], + [null, { inheritFromId: 25 }, false], + ])( + 'for adminState: %p, customState: %p: override = `%p`', + (adminState, customState, expected) => { + expect(createState({ adminState, customState }).override).toEqual(expected); + }, + ); + }); +}); diff --git a/spec/frontend/issuable_suggestions/components/app_spec.js b/spec/frontend/issuable_suggestions/components/app_spec.js index 20930be8667..d51c89807be 100644 --- a/spec/frontend/issuable_suggestions/components/app_spec.js +++ b/spec/frontend/issuable_suggestions/components/app_spec.js @@ -89,7 +89,7 @@ describe('Issuable suggestions app component', () => { wrapper .findAll('li') .at(0) - .is('.append-bottom-default'), + .is('.gl-mb-3'), ).toBe(true); }); }); @@ -102,7 +102,7 @@ describe('Issuable suggestions app component', () => { wrapper .findAll('li') .at(1) - .is('.append-bottom-default'), + .is('.gl-mb-3'), ).toBe(false); }); }); diff --git a/spec/frontend/issuable_suggestions/components/item_spec.js b/spec/frontend/issuable_suggestions/components/item_spec.js index 6c3c30fcbb0..36799f4ee9f 100644 --- a/spec/frontend/issuable_suggestions/components/item_spec.js +++ b/spec/frontend/issuable_suggestions/components/item_spec.js @@ -4,6 +4,7 @@ import Icon from '~/vue_shared/components/icon.vue'; import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; import Suggestion from '~/issuable_suggestions/components/item.vue'; import mockData from '../mock_data'; +import { TEST_HOST } from 'jest/helpers/test_constants'; describe('Issuable suggestions suggestion component', () => { let vm; @@ -34,7 +35,7 @@ describe('Issuable suggestions suggestion component', () => { const link = vm.find(GlLink); - expect(link.attributes('href')).toBe(`${gl.TEST_HOST}/test/issue/1`); + expect(link.attributes('href')).toBe(`${TEST_HOST}/test/issue/1`); }); it('renders IID', () => { @@ -100,7 +101,7 @@ describe('Issuable suggestions suggestion component', () => { const image = vm.find(UserAvatarImage); - expect(image.props('imgSrc')).toBe(`${gl.TEST_HOST}/avatar`); + expect(image.props('imgSrc')).toBe(`${TEST_HOST}/avatar`); }); }); diff --git a/spec/frontend/issuable_suggestions/mock_data.js b/spec/frontend/issuable_suggestions/mock_data.js index 4f0f9ef8d62..4ce35ad5196 100644 --- a/spec/frontend/issuable_suggestions/mock_data.js +++ b/spec/frontend/issuable_suggestions/mock_data.js @@ -1,3 +1,5 @@ +import { TEST_HOST } from 'jest/helpers/test_constants'; + function getDate(daysMinus) { const today = new Date(); today.setDate(today.getDate() - daysMinus); @@ -15,12 +17,12 @@ export default () => ({ createdAt: getDate(3), updatedAt: getDate(2), confidential: false, - webUrl: `${gl.TEST_HOST}/test/issue/1`, + webUrl: `${TEST_HOST}/test/issue/1`, title: 'Test issue', author: { - avatarUrl: `${gl.TEST_HOST}/avatar`, + avatarUrl: `${TEST_HOST}/avatar`, name: 'Author Name', username: 'author.username', - webUrl: `${gl.TEST_HOST}/author`, + webUrl: `${TEST_HOST}/author`, }, }); diff --git a/spec/frontend/issuables_list/components/issuable_list_root_app_spec.js b/spec/frontend/issuables_list/components/issuable_list_root_app_spec.js index 899010bdb0f..aee49076b5d 100644 --- a/spec/frontend/issuables_list/components/issuable_list_root_app_spec.js +++ b/spec/frontend/issuables_list/components/issuable_list_root_app_spec.js @@ -16,10 +16,8 @@ describe('IssuableListRootApp', () => { const findAlertLabel = () => wrapper.find(GlAlert).find(GlLabel); const mountComponent = ({ - isFinishedAlertShowing = false, - isInProgressAlertShowing = false, - isInProgress = false, - isFinished = false, + shouldShowFinishedAlert = false, + shouldShowInProgressAlert = false, } = {}) => shallowMount(IssuableListRootApp, { propsData: { @@ -30,12 +28,11 @@ describe('IssuableListRootApp', () => { }, data() { return { - isFinishedAlertShowing, - isInProgressAlertShowing, jiraImport: { - isInProgress, - isFinished, + importedIssuesCount: 1, label, + shouldShowFinishedAlert, + shouldShowInProgressAlert, }, }; }, @@ -57,8 +54,7 @@ describe('IssuableListRootApp', () => { describe('when Jira import is in progress', () => { it('shows an alert that tells the user a Jira import is in progress', () => { wrapper = mountComponent({ - isInProgressAlertShowing: true, - isInProgress: true, + shouldShowInProgressAlert: true, }); expect(findAlert().text()).toBe( @@ -70,14 +66,13 @@ describe('IssuableListRootApp', () => { describe('when Jira import has finished', () => { beforeEach(() => { wrapper = mountComponent({ - isFinishedAlertShowing: true, - isFinished: true, + shouldShowFinishedAlert: true, }); }); describe('shows an alert', () => { it('tells the user the Jira import has finished', () => { - expect(findAlert().text()).toBe('Issues successfully imported with the label'); + expect(findAlert().text()).toBe('1 issue successfully imported with the label'); }); it('contains the label title associated with the Jira import', () => { @@ -105,8 +100,7 @@ describe('IssuableListRootApp', () => { describe('alert message', () => { it('is hidden when dismissed', () => { wrapper = mountComponent({ - isInProgressAlertShowing: true, - isInProgress: true, + shouldShowInProgressAlert: true, }); expect(wrapper.contains(GlAlert)).toBe(true); diff --git a/spec/frontend/issuables_list/components/issuable_spec.js b/spec/frontend/issuables_list/components/issuable_spec.js index 834d18246a5..87868b7eeff 100644 --- a/spec/frontend/issuables_list/components/issuable_spec.js +++ b/spec/frontend/issuables_list/components/issuable_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlSprintf } from '@gitlab/ui'; +import { GlSprintf, GlLabel, GlIcon } from '@gitlab/ui'; import { TEST_HOST } from 'helpers/test_constants'; import { trimText } from 'helpers/text_helper'; import initUserPopovers from '~/user_popovers'; @@ -8,6 +8,7 @@ import { mergeUrlParams } from '~/lib/utils/url_utility'; import Issuable from '~/issuables_list/components/issuable.vue'; import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; import { simpleIssue, testAssignees, testLabels } from '../issuable_list_test_data'; +import { isScopedLabel } from '~/lib/utils/common_utils'; jest.mock('~/user_popovers'); @@ -37,13 +38,18 @@ describe('Issuable component', () => { let DateOrig; let wrapper; - const factory = (props = {}) => { + const factory = (props = {}, scopedLabels = false) => { wrapper = shallowMount(Issuable, { propsData: { issuable: simpleIssue, baseUrl: TEST_BASE_URL, ...props, }, + provide: { + glFeatures: { + scopedLabels, + }, + }, stubs: { 'gl-sprintf': GlSprintf, 'gl-link': '<a><slot></slot></a>', @@ -57,6 +63,7 @@ describe('Issuable component', () => { afterEach(() => { wrapper.destroy(); + wrapper = null; }); beforeAll(() => { @@ -68,14 +75,16 @@ describe('Issuable component', () => { window.Date = DateOrig; }); - const findConfidentialIcon = () => wrapper.find('.fa-eye-slash'); + const checkExists = findFn => () => findFn().exists(); + const hasConfidentialIcon = () => + wrapper.findAll(GlIcon).wrappers.some(iconWrapper => iconWrapper.props('name') === 'eye-slash'); const findTaskStatus = () => wrapper.find('.task-status'); const findOpenedAgoContainer = () => wrapper.find('[data-testid="openedByMessage"]'); + const findAuthor = () => wrapper.find({ ref: 'openedAgoByContainer' }); const findMilestone = () => wrapper.find('.js-milestone'); const findMilestoneTooltip = () => findMilestone().attributes('title'); const findDueDate = () => wrapper.find('.js-due-date'); - const findLabelContainer = () => wrapper.find('.js-labels'); - const findLabelLinks = () => findLabelContainer().findAll('a'); + const findLabels = () => wrapper.findAll(GlLabel); const findWeight = () => wrapper.find('.js-weight'); const findAssignees = () => wrapper.find(IssueAssignees); const findMergeRequestsCount = () => wrapper.find('.js-merge-requests'); @@ -83,6 +92,11 @@ describe('Issuable component', () => { const findDownvotes = () => wrapper.find('.js-downvotes'); const findNotes = () => wrapper.find('.js-notes'); const findBulkCheckbox = () => wrapper.find('input.selected-issuable'); + const findScopedLabels = () => findLabels().filter(w => isScopedLabel({ title: w.text() })); + const findUnscopedLabels = () => findLabels().filter(w => !isScopedLabel({ title: w.text() })); + const findIssuableTitle = () => wrapper.find('[data-testid="issuable-title"]'); + const findIssuableStatus = () => wrapper.find('[data-testid="issuable-status"]'); + const containsJiraLogo = () => wrapper.contains('[data-testid="jira-logo"]'); describe('when mounted', () => { it('initializes user popovers', () => { @@ -94,6 +108,54 @@ describe('Issuable component', () => { }); }); + describe('when scopedLabels feature is available', () => { + beforeEach(() => { + issuable.labels = [...testLabels]; + + factory({ issuable }, true); + }); + + describe('when label is scoped', () => { + it('returns label with correct props', () => { + const scopedLabel = findScopedLabels().at(0); + + expect(scopedLabel.props('scoped')).toBe(true); + }); + }); + + describe('when label is not scoped', () => { + it('returns label with correct props', () => { + const notScopedLabel = findUnscopedLabels().at(0); + + expect(notScopedLabel.props('scoped')).toBe(false); + }); + }); + }); + + describe('when scopedLabels feature is not available', () => { + beforeEach(() => { + issuable.labels = [...testLabels]; + + factory({ issuable }); + }); + + describe('when label is scoped', () => { + it('label scoped props is false', () => { + const scopedLabel = findScopedLabels().at(0); + + expect(scopedLabel.props('scoped')).toBe(false); + }); + }); + + describe('when label is not scoped', () => { + it('label scoped props is false', () => { + const notScopedLabel = findUnscopedLabels().at(0); + + expect(notScopedLabel.props('scoped')).toBe(false); + }); + }); + }); + describe('with simple issuable', () => { beforeEach(() => { Object.assign(issuable, { @@ -111,19 +173,19 @@ describe('Issuable component', () => { }); it.each` - desc | finder - ${'bulk editing checkbox'} | ${findBulkCheckbox} - ${'confidential icon'} | ${findConfidentialIcon} - ${'task status'} | ${findTaskStatus} - ${'milestone'} | ${findMilestone} - ${'due date'} | ${findDueDate} - ${'labels'} | ${findLabelContainer} - ${'weight'} | ${findWeight} - ${'merge request count'} | ${findMergeRequestsCount} - ${'upvotes'} | ${findUpvotes} - ${'downvotes'} | ${findDownvotes} - `('does not render $desc', ({ finder }) => { - expect(finder().exists()).toBe(false); + desc | check + ${'bulk editing checkbox'} | ${checkExists(findBulkCheckbox)} + ${'confidential icon'} | ${hasConfidentialIcon} + ${'task status'} | ${checkExists(findTaskStatus)} + ${'milestone'} | ${checkExists(findMilestone)} + ${'due date'} | ${checkExists(findDueDate)} + ${'labels'} | ${checkExists(findLabels)} + ${'weight'} | ${checkExists(findWeight)} + ${'merge request count'} | ${checkExists(findMergeRequestsCount)} + ${'upvotes'} | ${checkExists(findUpvotes)} + ${'downvotes'} | ${checkExists(findDownvotes)} + `('does not render $desc', ({ check }) => { + expect(check()).toBe(false); }); it('show relative reference path', () => { @@ -157,7 +219,41 @@ describe('Issuable component', () => { }); it('renders the confidential icon', () => { - expect(findConfidentialIcon().exists()).toBe(true); + expect(hasConfidentialIcon()).toBe(true); + }); + }); + + describe('with Jira issuable', () => { + beforeEach(() => { + issuable.external_tracker = 'jira'; + + factory({ issuable }); + }); + + it('renders the Jira icon', () => { + expect(containsJiraLogo()).toBe(true); + }); + + it('opens issuable in a new tab', () => { + expect(findIssuableTitle().props('target')).toBe('_blank'); + }); + + it('opens author in a new tab', () => { + expect(findAuthor().props('target')).toBe('_blank'); + }); + + describe('with Jira status', () => { + const expectedStatus = 'In Progress'; + + beforeEach(() => { + issuable.status = expectedStatus; + + factory({ issuable }); + }); + + it('renders the Jira status', () => { + expect(findIssuableStatus().text()).toBe(expectedStatus); + }); }); }); @@ -243,10 +339,10 @@ describe('Issuable component', () => { it('renders labels', () => { factory({ issuable }); - const labels = findLabelLinks().wrappers.map(label => ({ - href: label.attributes('href'), + const labels = findLabels().wrappers.map(label => ({ + href: label.props('target'), text: label.text(), - tooltip: label.find('span').attributes('title'), + tooltip: label.attributes('description'), })); const expected = testLabels.map(label => ({ @@ -259,6 +355,33 @@ describe('Issuable component', () => { }); }); + describe('with labels for Jira issuable', () => { + beforeEach(() => { + issuable.labels = [...testLabels]; + issuable.external_tracker = 'jira'; + + factory({ issuable }); + }); + + it('renders labels', () => { + factory({ issuable }); + + const labels = findLabels().wrappers.map(label => ({ + href: label.props('target'), + text: label.text(), + tooltip: label.attributes('description'), + })); + + const expected = testLabels.map(label => ({ + href: mergeUrlParams({ 'labels[]': label.name }, TEST_BASE_URL), + text: label.name, + tooltip: label.description, + })); + + expect(labels).toEqual(expected); + }); + }); + describe.each` weight ${0} diff --git a/spec/frontend/issuables_list/components/issuables_list_app_spec.js b/spec/frontend/issuables_list/components/issuables_list_app_spec.js index 6b680af354e..9f4995a54ee 100644 --- a/spec/frontend/issuables_list/components/issuables_list_app_spec.js +++ b/spec/frontend/issuables_list/components/issuables_list_app_spec.js @@ -7,6 +7,7 @@ import { TEST_HOST } from 'helpers/test_constants'; import flash from '~/flash'; import IssuablesListApp from '~/issuables_list/components/issuables_list_app.vue'; import Issuable from '~/issuables_list/components/issuable.vue'; +import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import issueablesEventBus from '~/issuables_list/eventhub'; import { PAGE_SIZE, PAGE_SIZE_MANUAL, RELATIVE_POSITION } from '~/issuables_list/constants'; @@ -59,6 +60,7 @@ describe('Issuables list component', () => { const findLoading = () => wrapper.find(GlSkeletonLoading); const findIssuables = () => wrapper.findAll(Issuable); + const findFilteredSearchBar = () => wrapper.find(FilteredSearchBar); const findFirstIssuable = () => findIssuables().wrappers[0]; const findEmptyState = () => wrapper.find(GlEmptyState); @@ -75,6 +77,7 @@ describe('Issuables list component', () => { afterEach(() => { wrapper.destroy(); + wrapper = null; mockAxios.restore(); window.location = oldLocation; }); @@ -131,6 +134,7 @@ describe('Issuables list component', () => { }); it('does not call API until mounted', () => { + factory(); expect(apiSpy).not.toHaveBeenCalled(); }); @@ -173,6 +177,12 @@ describe('Issuables list component', () => { expect(wrapper.find(GlPagination).exists()).toBe(true); }); }); + + it('does not render FilteredSearchBar', () => { + factory(); + + expect(findFilteredSearchBar().exists()).toBe(false); + }); }); describe('with bulk editing enabled', () => { @@ -293,7 +303,7 @@ describe('Issuables list component', () => { describe('when page is not present in params', () => { const query = - '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&utf8=%E2%9C%93&weight=0'; + '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&utf8=%E2%9C%93&weight=0¬[label_name][]=Afterpod¬[milestone_title][]=13'; beforeEach(() => { setUrl(query); @@ -310,7 +320,11 @@ describe('Issuables list component', () => { it('applies filters and sorts', () => { expect(wrapper.vm.hasFilters).toBe(true); - expect(wrapper.vm.filters).toEqual(expectedFilters); + expect(wrapper.vm.filters).toEqual({ + ...expectedFilters, + 'not[milestone]': ['13'], + 'not[labels]': ['Afterpod'], + }); expect(apiSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -319,6 +333,8 @@ describe('Issuables list component', () => { with_labels_details: true, page: 1, per_page: PAGE_SIZE, + 'not[milestone]': ['13'], + 'not[labels]': ['Afterpod'], }, }), ); @@ -454,43 +470,117 @@ describe('Issuables list component', () => { describe('when paginates', () => { const newPage = 3; - beforeEach(() => { - window.history.pushState = jest.fn(); - setupApiMock(() => [ - 200, - MOCK_ISSUES.slice(0, PAGE_SIZE), - { - 'x-total': 100, - 'x-page': 2, - }, - ]); + describe('when total-items is defined in response headers', () => { + beforeEach(() => { + window.history.pushState = jest.fn(); + setupApiMock(() => [ + 200, + MOCK_ISSUES.slice(0, PAGE_SIZE), + { + 'x-total': 100, + 'x-page': 2, + }, + ]); - factory(); + factory(); - return waitForPromises(); + return waitForPromises(); + }); + + afterEach(() => { + // reset to original value + window.history.pushState.mockRestore(); + }); + + it('calls window.history.pushState one time', () => { + // Trigger pagination + wrapper.find(GlPagination).vm.$emit('input', newPage); + + expect(window.history.pushState).toHaveBeenCalledTimes(1); + }); + + it('sets params in the url', () => { + // Trigger pagination + wrapper.find(GlPagination).vm.$emit('input', newPage); + + expect(window.history.pushState).toHaveBeenCalledWith( + {}, + '', + `${TEST_LOCATION}?state=opened&order_by=priority&sort=asc&page=${newPage}`, + ); + }); }); - afterEach(() => { - // reset to original value - window.history.pushState.mockRestore(); + describe('when total-items is not defined in the headers', () => { + const page = 2; + const prevPage = page - 1; + const nextPage = page + 1; + + beforeEach(() => { + setupApiMock(() => [ + 200, + MOCK_ISSUES.slice(0, PAGE_SIZE), + { + 'x-page': page, + }, + ]); + + factory(); + + return waitForPromises(); + }); + + it('finds the correct props applied to GlPagination', () => { + expect(wrapper.find(GlPagination).props()).toMatchObject({ + nextPage, + prevPage, + value: page, + }); + }); }); + }); - it('calls window.history.pushState one time', () => { - // Trigger pagination - wrapper.find(GlPagination).vm.$emit('input', newPage); + describe('when type is "jira"', () => { + it('renders FilteredSearchBar', () => { + factory({ type: 'jira' }); - expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(findFilteredSearchBar().exists()).toBe(true); }); - it('sets params in the url', () => { - // Trigger pagination - wrapper.find(GlPagination).vm.$emit('input', newPage); + describe('initialSortBy', () => { + const query = '?sort=updated_asc'; - expect(window.history.pushState).toHaveBeenCalledWith( - {}, - '', - `${TEST_LOCATION}?state=opened&order_by=priority&sort=asc&page=${newPage}`, - ); + it('sets default value', () => { + factory({ type: 'jira' }); + + expect(findFilteredSearchBar().props('initialSortBy')).toBe('created_desc'); + }); + + it('sets value according to query', () => { + setUrl(query); + + factory({ type: 'jira' }); + + expect(findFilteredSearchBar().props('initialSortBy')).toBe('updated_asc'); + }); + }); + + describe('initialFilterValue', () => { + it('does not set value when no query', () => { + factory({ type: 'jira' }); + + expect(findFilteredSearchBar().props('initialFilterValue')).toEqual([]); + }); + + it('sets value according to query', () => { + const query = '?search=free+text'; + + setUrl(query); + + factory({ type: 'jira' }); + + expect(findFilteredSearchBar().props('initialFilterValue')).toEqual(['free text']); + }); }); }); }); diff --git a/spec/frontend/issue_show/components/issuable_header_warnings_spec.js b/spec/frontend/issue_show/components/issuable_header_warnings_spec.js new file mode 100644 index 00000000000..5a166812d84 --- /dev/null +++ b/spec/frontend/issue_show/components/issuable_header_warnings_spec.js @@ -0,0 +1,79 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import IssuableHeaderWarnings from '~/issue_show/components/issuable_header_warnings.vue'; +import createStore from '~/notes/stores'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('IssuableHeaderWarnings', () => { + let wrapper; + let store; + + const findConfidential = () => wrapper.find('[data-testid="confidential"]'); + const findLocked = () => wrapper.find('[data-testid="locked"]'); + const confidentialIconName = () => findConfidential().attributes('name'); + const lockedIconName = () => findLocked().attributes('name'); + + const createComponent = () => { + wrapper = shallowMount(IssuableHeaderWarnings, { store, localVue }); + }; + + beforeEach(() => { + store = createStore(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + store = null; + }); + + describe('when confidential is on', () => { + beforeEach(() => { + store.state.noteableData.confidential = true; + + createComponent(); + }); + + it('renders the confidential icon', () => { + expect(confidentialIconName()).toBe('eye-slash'); + }); + }); + + describe('when confidential is off', () => { + beforeEach(() => { + store.state.noteableData.confidential = false; + + createComponent(); + }); + + it('does not find the component', () => { + expect(findConfidential().exists()).toBe(false); + }); + }); + + describe('when discussion locked is on', () => { + beforeEach(() => { + store.state.noteableData.discussion_locked = true; + + createComponent(); + }); + + it('renders the locked icon', () => { + expect(lockedIconName()).toBe('lock'); + }); + }); + + describe('when discussion locked is off', () => { + beforeEach(() => { + store.state.noteableData.discussion_locked = false; + + createComponent(); + }); + + it('does not find the component', () => { + expect(findLocked().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/issue_show/components/pinned_links_spec.js b/spec/frontend/issue_show/components/pinned_links_spec.js index 007ad4c9a1b..bb67feee601 100644 --- a/spec/frontend/issue_show/components/pinned_links_spec.js +++ b/spec/frontend/issue_show/components/pinned_links_spec.js @@ -1,6 +1,7 @@ import { shallowMount } from '@vue/test-utils'; -import { GlLink } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import PinnedLinks from '~/issue_show/components/pinned_links.vue'; +import { STATUS_PAGE_PUBLISHED, JOIN_ZOOM_MEETING } from '~/issue_show/constants'; const plainZoomUrl = 'https://zoom.us/j/123456789'; const plainStatusUrl = 'https://status.com'; @@ -8,7 +9,7 @@ const plainStatusUrl = 'https://status.com'; describe('PinnedLinks', () => { let wrapper; - const findLinks = () => wrapper.findAll(GlLink); + const findButtons = () => wrapper.findAll(GlButton); const createComponent = props => { wrapper = shallowMount(PinnedLinks, { @@ -26,10 +27,10 @@ describe('PinnedLinks', () => { }); expect( - findLinks() + findButtons() .at(0) .text(), - ).toBe('Join Zoom meeting'); + ).toBe(JOIN_ZOOM_MEETING); }); it('displays Status link', () => { @@ -38,10 +39,10 @@ describe('PinnedLinks', () => { }); expect( - findLinks() + findButtons() .at(0) .text(), - ).toBe('Published on status page'); + ).toBe(STATUS_PAGE_PUBLISHED); }); it('does not render if there are no links', () => { @@ -50,6 +51,6 @@ describe('PinnedLinks', () => { publishedIncidentUrl: '', }); - expect(wrapper.find(GlLink).exists()).toBe(false); + expect(findButtons()).toHaveLength(0); }); }); diff --git a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap new file mode 100644 index 00000000000..975c31bb59c --- /dev/null +++ b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap @@ -0,0 +1,277 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`JiraImportForm table body shows correct information in each cell 1`] = ` +<table + aria-busy="false" + aria-colcount="3" + class="table b-table gl-table b-table-fixed" + role="table" +> + <!----> + <!----> + <thead + class="" + role="rowgroup" + > + <!----> + <tr + class="" + role="row" + > + <th + aria-colindex="1" + class="" + role="columnheader" + scope="col" + > + Jira display name + </th> + <th + aria-colindex="2" + aria-label="Arrow" + class="" + role="columnheader" + scope="col" + /> + <th + aria-colindex="3" + class="" + role="columnheader" + scope="col" + > + GitLab username + </th> + </tr> + </thead> + <tbody + role="rowgroup" + > + <!----> + <tr + class="" + role="row" + > + <td + aria-colindex="1" + class="" + role="cell" + > + Jane Doe + </td> + <td + aria-colindex="2" + class="" + role="cell" + > + <svg + aria-label="Will be mapped to" + class="gl-icon s16" + data-testid="arrow-right-icon" + > + <use + href="#arrow-right" + /> + </svg> + </td> + <td + aria-colindex="3" + class="" + role="cell" + > + <div + aria-label="The GitLab user to which the Jira user Jane Doe will be mapped" + class="dropdown b-dropdown gl-new-dropdown w-100 btn-group" + > + <!----> + <button + aria-expanded="false" + aria-haspopup="true" + class="btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle" + type="button" + > + <!----> + + <span + class="gl-new-dropdown-button-text" + > + janedoe + </span> + + <svg + class="dropdown-chevron gl-icon s16" + data-testid="chevron-down-icon" + > + <use + href="#chevron-down" + /> + </svg> + </button> + <ul + class="dropdown-menu" + role="menu" + tabindex="-1" + > + <!----> + + <div + class="gl-search-box-by-type m-2" + > + <svg + class="gl-search-box-by-type-search-icon gl-icon s16" + data-testid="search-icon" + > + <use + href="#search" + /> + </svg> + + <input + aria-label="Search" + class="gl-form-input gl-search-box-by-type-input form-control" + placeholder="Search" + type="text" + /> + + <div + class="gl-search-box-by-type-right-icons" + > + <!----> + + <!----> + </div> + </div> + + <li + class="gl-new-dropdown-text text-secondary" + role="presentation" + > + <p + class="b-dropdown-text" + > + + No matches found + + </p> + </li> + + </ul> + </div> + </td> + </tr> + <tr + class="" + role="row" + > + <td + aria-colindex="1" + class="" + role="cell" + > + Fred Chopin + </td> + <td + aria-colindex="2" + class="" + role="cell" + > + <svg + aria-label="Will be mapped to" + class="gl-icon s16" + data-testid="arrow-right-icon" + > + <use + href="#arrow-right" + /> + </svg> + </td> + <td + aria-colindex="3" + class="" + role="cell" + > + <div + aria-label="The GitLab user to which the Jira user Fred Chopin will be mapped" + class="dropdown b-dropdown gl-new-dropdown w-100 btn-group" + > + <!----> + <button + aria-expanded="false" + aria-haspopup="true" + class="btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle" + type="button" + > + <!----> + + <span + class="gl-new-dropdown-button-text" + > + mrgitlab + </span> + + <svg + class="dropdown-chevron gl-icon s16" + data-testid="chevron-down-icon" + > + <use + href="#chevron-down" + /> + </svg> + </button> + <ul + class="dropdown-menu" + role="menu" + tabindex="-1" + > + <!----> + + <div + class="gl-search-box-by-type m-2" + > + <svg + class="gl-search-box-by-type-search-icon gl-icon s16" + data-testid="search-icon" + > + <use + href="#search" + /> + </svg> + + <input + aria-label="Search" + class="gl-form-input gl-search-box-by-type-input form-control" + placeholder="Search" + type="text" + /> + + <div + class="gl-search-box-by-type-right-icons" + > + <!----> + + <!----> + </div> + </div> + + <li + class="gl-new-dropdown-text text-secondary" + role="presentation" + > + <p + class="b-dropdown-text" + > + + No matches found + + </p> + </li> + + </ul> + </div> + </td> + </tr> + <!----> + <!----> + </tbody> + <!----> +</table> +`; diff --git a/spec/frontend/jira_import/components/jira_import_app_spec.js b/spec/frontend/jira_import/components/jira_import_app_spec.js index a21b89f6517..074f9842512 100644 --- a/spec/frontend/jira_import/components/jira_import_app_spec.js +++ b/spec/frontend/jira_import/components/jira_import_app_spec.js @@ -1,88 +1,19 @@ import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; import Vue from 'vue'; +import axios from '~/lib/utils/axios_utils'; import JiraImportApp from '~/jira_import/components/jira_import_app.vue'; import JiraImportForm from '~/jira_import/components/jira_import_form.vue'; import JiraImportProgress from '~/jira_import/components/jira_import_progress.vue'; import JiraImportSetup from '~/jira_import/components/jira_import_setup.vue'; import initiateJiraImportMutation from '~/jira_import/queries/initiate_jira_import.mutation.graphql'; - -const mountComponent = ({ - isJiraConfigured = true, - errorMessage = '', - selectedProject = 'MTG', - showAlert = false, - isInProgress = false, - loading = false, - mutate = jest.fn(() => Promise.resolve()), - mountType, -} = {}) => { - const mountFunction = mountType === 'mount' ? mount : shallowMount; - - return mountFunction(JiraImportApp, { - propsData: { - inProgressIllustration: 'in-progress-illustration.svg', - isJiraConfigured, - issuesPath: 'gitlab-org/gitlab-test/-/issues', - jiraIntegrationPath: 'gitlab-org/gitlab-test/-/services/jira/edit', - projectPath: 'gitlab-org/gitlab-test', - setupIllustration: 'setup-illustration.svg', - }, - data() { - return { - errorMessage, - showAlert, - selectedProject, - jiraImportDetails: { - isInProgress, - imports: [ - { - jiraProjectKey: 'MTG', - scheduledAt: '2020-04-08T10:11:12+00:00', - scheduledBy: { - name: 'John Doe', - }, - }, - { - jiraProjectKey: 'MSJP', - scheduledAt: '2020-04-09T13:14:15+00:00', - scheduledBy: { - name: 'Jimmy Doe', - }, - }, - { - jiraProjectKey: 'MTG', - scheduledAt: '2020-04-09T16:17:18+00:00', - scheduledBy: { - name: 'Jane Doe', - }, - }, - ], - mostRecentImport: { - jiraProjectKey: 'MTG', - scheduledAt: '2020-04-09T16:17:18+00:00', - scheduledBy: { - name: 'Jane Doe', - }, - }, - projects: [ - { text: 'My Jira Project (MJP)', value: 'MJP' }, - { text: 'My Second Jira Project (MSJP)', value: 'MSJP' }, - { text: 'Migrate to GitLab (MTG)', value: 'MTG' }, - ], - }, - }; - }, - mocks: { - $apollo: { - loading, - mutate, - }, - }, - }); -}; +import getJiraUserMappingMutation from '~/jira_import/queries/get_jira_user_mapping.mutation.graphql'; +import { imports, issuesPath, jiraIntegrationPath, jiraProjects, userMappings } from '../mock_data'; describe('JiraImportApp', () => { + let axiosMock; + let mutateSpy; let wrapper; const getFormComponent = () => wrapper.find(JiraImportForm); @@ -95,7 +26,64 @@ describe('JiraImportApp', () => { const getLoadingIcon = () => wrapper.find(GlLoadingIcon); + const mountComponent = ({ + isJiraConfigured = true, + errorMessage = '', + selectedProject = 'MTG', + showAlert = false, + isInProgress = false, + loading = false, + mutate = mutateSpy, + mountFunction = shallowMount, + } = {}) => + mountFunction(JiraImportApp, { + propsData: { + inProgressIllustration: 'in-progress-illustration.svg', + isJiraConfigured, + issuesPath, + jiraIntegrationPath, + projectId: '5', + projectPath: 'gitlab-org/gitlab-test', + setupIllustration: 'setup-illustration.svg', + }, + data() { + return { + isSubmitting: false, + selectedProject, + userMappings, + errorMessage, + showAlert, + jiraImportDetails: { + isInProgress, + imports, + mostRecentImport: imports[imports.length - 1], + projects: jiraProjects, + }, + }; + }, + mocks: { + $apollo: { + loading, + mutate, + }, + }, + }); + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + mutateSpy = jest.fn(() => + Promise.resolve({ + data: { + jiraImportStart: { errors: [] }, + jiraImportUsers: { jiraUsers: [], errors: [] }, + }, + }), + ); + }); + afterEach(() => { + axiosMock.restore(); + mutateSpy.mockRestore(); wrapper.destroy(); wrapper = null; }); @@ -223,7 +211,7 @@ describe('JiraImportApp', () => { }); it('shows warning alert to explain project MTG has been imported 2 times before', () => { - wrapper = mountComponent({ mountType: 'mount' }); + wrapper = mountComponent({ mountFunction: mount }); expect(getAlert().text()).toBe( 'You have imported from this project 2 times before. Each new import will create duplicate issues.', @@ -248,9 +236,7 @@ describe('JiraImportApp', () => { describe('initiating a Jira import', () => { it('calls the mutation with the expected arguments', () => { - const mutate = jest.fn(() => Promise.resolve()); - - wrapper = mountComponent({ mutate }); + wrapper = mountComponent(); const mutationArguments = { mutation: initiateJiraImportMutation, @@ -258,13 +244,23 @@ describe('JiraImportApp', () => { input: { jiraProjectKey: 'MTG', projectPath: 'gitlab-org/gitlab-test', + usersMapping: [ + { + jiraAccountId: 'aei23f98f-q23fj98qfj', + gitlabId: 15, + }, + { + jiraAccountId: 'fu39y8t34w-rq3u289t3h4i', + gitlabId: undefined, + }, + ], }, }, }; getFormComponent().vm.$emit('initiateJiraImport', 'MTG'); - expect(mutate).toHaveBeenCalledWith(expect.objectContaining(mutationArguments)); + expect(mutateSpy).toHaveBeenCalledWith(expect.objectContaining(mutationArguments)); }); it('shows alert message with error message on error', () => { @@ -283,19 +279,53 @@ describe('JiraImportApp', () => { }); }); - it('can dismiss alert message', () => { - wrapper = mountComponent({ - errorMessage: 'There was an error importing the Jira project.', - showAlert: true, - selectedProject: null, + describe('alert', () => { + it('can be dismissed', () => { + wrapper = mountComponent({ + errorMessage: 'There was an error importing the Jira project.', + showAlert: true, + selectedProject: null, + }); + + expect(getAlert().exists()).toBe(true); + + getAlert().vm.$emit('dismiss'); + + return Vue.nextTick().then(() => { + expect(getAlert().exists()).toBe(false); + }); }); + }); - expect(getAlert().exists()).toBe(true); + describe('on mount', () => { + it('makes a GraphQL mutation call to get user mappings', () => { + wrapper = mountComponent(); - getAlert().vm.$emit('dismiss'); + const mutationArguments = { + mutation: getJiraUserMappingMutation, + variables: { + input: { + projectPath: 'gitlab-org/gitlab-test', + startAt: 1, + }, + }, + }; + + expect(mutateSpy).toHaveBeenCalledWith(expect.objectContaining(mutationArguments)); + }); + + it('does not make a GraphQL mutation call to get user mappings when Jira is not configured', () => { + wrapper = mountComponent({ isJiraConfigured: false }); + + expect(mutateSpy).not.toHaveBeenCalled(); + }); + + it('shows error message when there is an error with the GraphQL mutation call', () => { + const mutate = jest.fn(() => Promise.reject()); + + wrapper = mountComponent({ mutate }); - return Vue.nextTick().then(() => { - expect(getAlert().exists()).toBe(false); + expect(getAlert().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/jira_import/components/jira_import_form_spec.js b/spec/frontend/jira_import/components/jira_import_form_spec.js index dea94e7bf1f..685b0288e92 100644 --- a/spec/frontend/jira_import/components/jira_import_form_spec.js +++ b/spec/frontend/jira_import/components/jira_import_form_spec.js @@ -1,44 +1,51 @@ -import { GlAvatar, GlButton, GlFormSelect, GlLabel } from '@gitlab/ui'; +import { GlButton, GlFormSelect, GlLabel, GlTable } from '@gitlab/ui'; +import { getByRole } from '@testing-library/dom'; import { mount, shallowMount } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; import JiraImportForm from '~/jira_import/components/jira_import_form.vue'; - -const importLabel = 'jira-import::MTG-1'; -const value = 'MTG'; - -const mountComponent = ({ mountType } = {}) => { - const mountFunction = mountType === 'mount' ? mount : shallowMount; - - return mountFunction(JiraImportForm, { - propsData: { - importLabel, - issuesPath: 'gitlab-org/gitlab-test/-/issues', - jiraProjects: [ - { - text: 'My Jira Project', - value: 'MJP', - }, - { - text: 'My Second Jira Project', - value: 'MSJP', - }, - { - text: 'Migrate to GitLab', - value: 'MTG', - }, - ], - value, - }, - }); -}; +import { issuesPath, jiraProjects, userMappings } from '../mock_data'; describe('JiraImportForm', () => { + let axiosMock; let wrapper; + const currentUsername = 'mrgitlab'; + const importLabel = 'jira-import::MTG-1'; + const value = 'MTG'; + const getSelectDropdown = () => wrapper.find(GlFormSelect); const getCancelButton = () => wrapper.findAll(GlButton).at(1); + const getHeader = name => getByRole(wrapper.element, 'columnheader', { name }); + + const mountComponent = ({ isSubmitting = false, mountFunction = shallowMount } = {}) => + mountFunction(JiraImportForm, { + propsData: { + importLabel, + isSubmitting, + issuesPath, + jiraProjects, + projectId: '5', + userMappings, + value, + }, + data: () => ({ + isFetching: false, + searchTerm: '', + selectState: null, + users: [], + }), + currentUsername, + }); + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + }); + afterEach(() => { + axiosMock.restore(); wrapper.destroy(); wrapper = null; }); @@ -51,16 +58,22 @@ describe('JiraImportForm', () => { }); it('contains a list of Jira projects to select from', () => { - wrapper = mountComponent({ mountType: 'mount' }); - - const optionItems = ['My Jira Project', 'My Second Jira Project', 'Migrate to GitLab']; + wrapper = mountComponent({ mountFunction: mount }); getSelectDropdown() .findAll('option') .wrappers.forEach((optionEl, index) => { - expect(optionEl.text()).toBe(optionItems[index]); + expect(optionEl.text()).toBe(jiraProjects[index].text); }); }); + + it('emits an "input" event when the input select value changes', () => { + wrapper = mountComponent(); + + getSelectDropdown().vm.$emit('change', value); + + expect(wrapper.emitted('input')[0]).toEqual([value]); + }); }); describe('form information', () => { @@ -72,64 +85,90 @@ describe('JiraImportForm', () => { expect(wrapper.find(GlLabel).props('title')).toBe(importLabel); }); + it('shows a heading for the user mapping section', () => { + expect( + getByRole(wrapper.element, 'heading', { name: 'Jira-GitLab user mapping template' }), + ).toBeTruthy(); + }); + it('shows information to the user', () => { expect(wrapper.find('p').text()).toBe( - "For each Jira issue successfully imported, we'll create a new GitLab issue with the following data:", + 'Jira users have been matched with similar GitLab users. This can be overwritten by selecting a GitLab user from the dropdown in the "GitLab username" column. If it wasn\'t possible to match a Jira user with a GitLab user, the dropdown defaults to the user conducting the import.', ); }); + }); - it('shows jira.issue.summary for the Title', () => { - expect(wrapper.find('[id="jira-project-title"]').text()).toBe('jira.issue.summary'); - }); + describe('table', () => { + describe('headers', () => { + beforeEach(() => { + wrapper = mountComponent({ mountFunction: mount }); + }); - it('shows an avatar for the Reporter', () => { - expect(wrapper.contains(GlAvatar)).toBe(true); - }); + it('has a "Jira display name" column', () => { + expect(getHeader('Jira display name')).toBeTruthy(); + }); - it('shows jira.issue.description.content for the Description', () => { - expect(wrapper.find('[id="jira-project-description"]').text()).toBe( - 'jira.issue.description.content', - ); - }); - }); + it('has an "arrow" column', () => { + expect(getHeader('Arrow')).toBeTruthy(); + }); - describe('Next button', () => { - beforeEach(() => { - wrapper = mountComponent(); + it('has a "GitLab username" column', () => { + expect(getHeader('GitLab username')).toBeTruthy(); + }); }); - it('is shown', () => { - expect(wrapper.find(GlButton).text()).toBe('Next'); + describe('body', () => { + it('shows all user mappings', () => { + wrapper = mountComponent({ mountFunction: mount }); + + expect(wrapper.find(GlTable).findAll('tbody tr').length).toBe(userMappings.length); + }); + + it('shows correct information in each cell', () => { + wrapper = mountComponent({ mountFunction: mount }); + + expect(wrapper.find(GlTable).element).toMatchSnapshot(); + }); }); }); - describe('Cancel button', () => { - beforeEach(() => { - wrapper = mountComponent(); - }); + describe('buttons', () => { + describe('"Continue" button', () => { + it('is shown', () => { + wrapper = mountComponent(); - it('is shown', () => { - expect(getCancelButton().text()).toBe('Cancel'); - }); + expect(wrapper.find(GlButton).text()).toBe('Continue'); + }); + + it('is in loading state when the form is submitting', async () => { + wrapper = mountComponent({ isSubmitting: true }); - it('links to the Issues page', () => { - expect(getCancelButton().attributes('href')).toBe('gitlab-org/gitlab-test/-/issues'); + expect(wrapper.find(GlButton).props('loading')).toBe(true); + }); }); - }); - it('emits an "input" event when the input select value changes', () => { - wrapper = mountComponent({ mountType: 'mount' }); + describe('"Cancel" button', () => { + beforeEach(() => { + wrapper = mountComponent(); + }); - getSelectDropdown().vm.$emit('change', value); + it('is shown', () => { + expect(getCancelButton().text()).toBe('Cancel'); + }); - expect(wrapper.emitted('input')[0]).toEqual([value]); + it('links to the Issues page', () => { + expect(getCancelButton().attributes('href')).toBe(issuesPath); + }); + }); }); - it('emits an "initiateJiraImport" event with the selected dropdown value when submitted', () => { - wrapper = mountComponent(); + describe('form', () => { + it('emits an "initiateJiraImport" event with the selected dropdown value when submitted', () => { + wrapper = mountComponent(); - wrapper.find('form').trigger('submit'); + wrapper.find('form').trigger('submit'); - expect(wrapper.emitted('initiateJiraImport')[0]).toEqual([value]); + expect(wrapper.emitted('initiateJiraImport')[0]).toEqual([value]); + }); }); }); diff --git a/spec/frontend/jira_import/components/jira_import_progress_spec.js b/spec/frontend/jira_import/components/jira_import_progress_spec.js index 3ccf14554e1..ed7e1824fa3 100644 --- a/spec/frontend/jira_import/components/jira_import_progress_spec.js +++ b/spec/frontend/jira_import/components/jira_import_progress_spec.js @@ -1,14 +1,13 @@ import { GlEmptyState } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import JiraImportProgress from '~/jira_import/components/jira_import_progress.vue'; - -const illustration = 'illustration.svg'; -const importProject = 'JIRAPROJECT'; -const issuesPath = 'gitlab-org/gitlab-test/-/issues'; +import { illustration, issuesPath } from '../mock_data'; describe('JiraImportProgress', () => { let wrapper; + const importProject = 'JIRAPROJECT'; + const getGlEmptyStateProp = attribute => wrapper.find(GlEmptyState).props(attribute); const getParagraphText = () => wrapper.find('p').text(); diff --git a/spec/frontend/jira_import/components/jira_import_setup_spec.js b/spec/frontend/jira_import/components/jira_import_setup_spec.js index aa94dc4f503..7c84d4a166a 100644 --- a/spec/frontend/jira_import/components/jira_import_setup_spec.js +++ b/spec/frontend/jira_import/components/jira_import_setup_spec.js @@ -1,9 +1,7 @@ import { GlEmptyState } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import JiraImportSetup from '~/jira_import/components/jira_import_setup.vue'; - -const illustration = 'illustration.svg'; -const jiraIntegrationPath = 'gitlab-org/gitlab-test/-/services/jira/edit'; +import { illustration, jiraIntegrationPath } from '../mock_data'; describe('JiraImportSetup', () => { let wrapper; diff --git a/spec/frontend/jira_import/mock_data.js b/spec/frontend/jira_import/mock_data.js index e82ab53cb6f..a7447221b15 100644 --- a/spec/frontend/jira_import/mock_data.js +++ b/spec/frontend/jira_import/mock_data.js @@ -70,3 +70,56 @@ export const jiraImportMutationResponse = { __typename: 'JiraImportStartPayload', }, }; + +export const issuesPath = 'gitlab-org/gitlab-test/-/issues'; + +export const jiraIntegrationPath = 'gitlab-org/gitlab-test/-/services/jira/edit'; + +export const illustration = 'illustration.svg'; + +export const jiraProjects = [ + { text: 'My Jira Project (MJP)', value: 'MJP' }, + { text: 'My Second Jira Project (MSJP)', value: 'MSJP' }, + { text: 'Migrate to GitLab (MTG)', value: 'MTG' }, +]; + +export const imports = [ + { + jiraProjectKey: 'MTG', + scheduledAt: '2020-04-08T10:11:12+00:00', + scheduledBy: { + name: 'John Doe', + }, + }, + { + jiraProjectKey: 'MSJP', + scheduledAt: '2020-04-09T13:14:15+00:00', + scheduledBy: { + name: 'Jimmy Doe', + }, + }, + { + jiraProjectKey: 'MTG', + scheduledAt: '2020-04-09T16:17:18+00:00', + scheduledBy: { + name: 'Jane Doe', + }, + }, +]; + +export const userMappings = [ + { + jiraAccountId: 'aei23f98f-q23fj98qfj', + jiraDisplayName: 'Jane Doe', + jiraEmail: 'janedoe@example.com', + gitlabId: 15, + gitlabUsername: 'janedoe', + }, + { + jiraAccountId: 'fu39y8t34w-rq3u289t3h4i', + jiraDisplayName: 'Fred Chopin', + jiraEmail: 'fredchopin@example.com', + gitlabId: undefined, + gitlabUsername: undefined, + }, +]; diff --git a/spec/frontend/jira_import/utils/jira_import_utils_spec.js b/spec/frontend/jira_import/utils/jira_import_utils_spec.js index 504d399217a..8ae1fc3535a 100644 --- a/spec/frontend/jira_import/utils/jira_import_utils_spec.js +++ b/spec/frontend/jira_import/utils/jira_import_utils_spec.js @@ -1,10 +1,16 @@ +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { calculateJiraImportLabel, extractJiraProjectsOptions, IMPORT_STATE, isFinished, isInProgress, + setFinishedAlertHideMap, + shouldShowFinishedAlert, } from '~/jira_import/utils/jira_import_utils'; +import { JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY } from '~/issuables_list/constants'; + +useLocalStorageSpy(); describe('isInProgress', () => { it.each` @@ -89,3 +95,56 @@ describe('calculateJiraImportLabel', () => { expect(label.color).toBe('#333'); }); }); + +describe('shouldShowFinishedAlert', () => { + const labelTitle = 'jira-import::JCP-1'; + + afterEach(() => { + localStorage.clear(); + }); + + it('checks localStorage value', () => { + jest.spyOn(localStorage, 'getItem').mockReturnValue(JSON.stringify({})); + + shouldShowFinishedAlert(labelTitle, IMPORT_STATE.FINISHED); + + expect(localStorage.getItem).toHaveBeenCalledWith(JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY); + }); + + it('returns true when an import has finished', () => { + jest.spyOn(localStorage, 'getItem').mockReturnValue(JSON.stringify({})); + + expect(shouldShowFinishedAlert(labelTitle, IMPORT_STATE.FINISHED)).toBe(true); + }); + + it('returns false when an import has finished but the user chose to hide the alert', () => { + jest.spyOn(localStorage, 'getItem').mockReturnValue(JSON.stringify({ [labelTitle]: true })); + + expect(shouldShowFinishedAlert(labelTitle, IMPORT_STATE.FINISHED)).toBe(false); + }); + + it('returns false when an import has not finished', () => { + jest.spyOn(localStorage, 'getItem').mockReturnValue(JSON.stringify({})); + + expect(shouldShowFinishedAlert(labelTitle, IMPORT_STATE.SCHEDULED)).toBe(false); + }); +}); + +describe('setFinishedAlertHideMap', () => { + const labelTitle = 'jira-import::ABC-1'; + const newLabelTitle = 'jira-import::JCP-1'; + + it('sets item to localStorage correctly', () => { + jest.spyOn(localStorage, 'getItem').mockReturnValue(JSON.stringify({ [labelTitle]: true })); + + setFinishedAlertHideMap(newLabelTitle); + + expect(localStorage.setItem).toHaveBeenCalledWith( + JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY, + JSON.stringify({ + [labelTitle]: true, + [newLabelTitle]: true, + }), + ); + }); +}); diff --git a/spec/frontend/jobs/components/job_app_spec.js b/spec/frontend/jobs/components/job_app_spec.js index 8fa289bbe4d..d0b3d4f6247 100644 --- a/spec/frontend/jobs/components/job_app_spec.js +++ b/spec/frontend/jobs/components/job_app_spec.js @@ -6,6 +6,7 @@ import axios from '~/lib/utils/axios_utils'; import JobApp from '~/jobs/components/job_app.vue'; import createStore from '~/jobs/store'; import job from '../mock_data'; +import { TEST_HOST } from 'jest/helpers/test_constants'; describe('Job App', () => { const localVue = createLocalVue(); @@ -18,8 +19,8 @@ describe('Job App', () => { let mock; const initSettings = { - endpoint: `${gl.TEST_HOST}jobs/123.json`, - pagePath: `${gl.TEST_HOST}jobs/123`, + endpoint: `${TEST_HOST}jobs/123.json`, + pagePath: `${TEST_HOST}jobs/123`, logState: 'eyJvZmZzZXQiOjE3NDUxLCJuX29wZW5fdGFncyI6MCwiZmdfY29sb3IiOm51bGwsImJnX2NvbG9yIjpudWxsLCJzdHlsZV9tYXNrIjowfQ%3D%3D', }; @@ -397,132 +398,31 @@ describe('Job App', () => { }); }); - describe('trace output', () => { - describe('with append flag', () => { - it('appends the log content to the existing one', () => - setupAndMount({ - traceData: { - html: '<span>More<span>', - status: 'running', - state: 'newstate', - append: true, - complete: true, - }, - }) - .then(() => { - store.state.trace = 'Update'; - - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect( - wrapper - .find('.js-build-trace') - .text() - .trim(), - ).toEqual('Update'); - })); + describe('trace controls', () => { + beforeEach(() => + setupAndMount({ + traceData: { + html: '<span>Update</span>', + status: 'success', + append: false, + size: 50, + total: 100, + complete: true, + }, + }), + ); + + it('should render scroll buttons', () => { + expect(wrapper.find('.js-scroll-top').exists()).toBe(true); + expect(wrapper.find('.js-scroll-bottom').exists()).toBe(true); }); - describe('without append flag', () => { - it('replaces the trace', () => - setupAndMount({ - traceData: { - html: '<span>Different<span>', - status: 'running', - append: false, - complete: true, - }, - }).then(() => { - expect( - wrapper - .find('.js-build-trace') - .text() - .trim(), - ).toEqual('Different'); - })); - }); - - describe('truncated information', () => { - describe('when size is less than total', () => { - it('shows information about truncated log', () => { - mock.onGet(`${props.pagePath}/trace.json`).reply(200, { - html: '<span>Update</span>', - status: 'success', - append: false, - size: 50, - total: 100, - complete: true, - }); - - return setupAndMount({ - traceData: { - html: '<span>Update</span>', - status: 'success', - append: false, - size: 50, - total: 100, - complete: true, - }, - }).then(() => { - expect( - wrapper - .find('.js-truncated-info') - .text() - .trim(), - ).toContain('Showing last 50 bytes'); - }); - }); - }); - - describe('when size is equal than total', () => { - it('does not show the truncated information', () => - setupAndMount({ - traceData: { - html: '<span>Update</span>', - status: 'success', - append: false, - size: 100, - total: 100, - complete: true, - }, - }).then(() => { - expect( - wrapper - .find('.js-truncated-info') - .text() - .trim(), - ).toEqual(''); - })); - }); + it('should render link to raw ouput', () => { + expect(wrapper.find('.js-raw-link-controller').exists()).toBe(true); }); - describe('trace controls', () => { - beforeEach(() => - setupAndMount({ - traceData: { - html: '<span>Update</span>', - status: 'success', - append: false, - size: 50, - total: 100, - complete: true, - }, - }), - ); - - it('should render scroll buttons', () => { - expect(wrapper.find('.js-scroll-top').exists()).toBe(true); - expect(wrapper.find('.js-scroll-bottom').exists()).toBe(true); - }); - - it('should render link to raw ouput', () => { - expect(wrapper.find('.js-raw-link-controller').exists()).toBe(true); - }); - - it('should render link to erase job', () => { - expect(wrapper.find('.js-erase-link').exists()).toBe(true); - }); + it('should render link to erase job', () => { + expect(wrapper.find('.js-erase-link').exists()).toBe(true); }); }); }); diff --git a/spec/frontend/jobs/components/job_log_spec.js b/spec/frontend/jobs/components/job_log_spec.js deleted file mode 100644 index a167fe8a134..00000000000 --- a/spec/frontend/jobs/components/job_log_spec.js +++ /dev/null @@ -1,65 +0,0 @@ -import Vue from 'vue'; -import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; -import component from '~/jobs/components/job_log.vue'; -import createStore from '~/jobs/store'; -import { resetStore } from '../store/helpers'; - -describe('Job Log', () => { - const Component = Vue.extend(component); - let store; - let vm; - - const trace = - '<span>Running with gitlab-runner 12.1.0 (de7731dd)<br/></span><span> on docker-auto-scale-com d5ae8d25<br/></span><div class="gl-mr-3" data-timestamp="1565502765" data-section="prepare-executor" role="button"></div><span class="section section-header js-s-prepare-executor">Using Docker executor with image ruby:2.6 ...<br/></span>'; - - beforeEach(() => { - store = createStore(); - }); - - afterEach(() => { - resetStore(store); - vm.$destroy(); - }); - - it('renders provided trace', () => { - vm = mountComponentWithStore(Component, { - props: { - trace, - isComplete: true, - }, - store, - }); - - expect(vm.$el.querySelector('code').textContent).toContain( - 'Running with gitlab-runner 12.1.0 (de7731dd)', - ); - }); - - describe('while receiving trace', () => { - it('renders animation', () => { - vm = mountComponentWithStore(Component, { - props: { - trace, - isComplete: false, - }, - store, - }); - - expect(vm.$el.querySelector('.js-log-animation')).not.toBeNull(); - }); - }); - - describe('when build trace has finishes', () => { - it('does not render animation', () => { - vm = mountComponentWithStore(Component, { - props: { - trace, - isComplete: true, - }, - store, - }); - - expect(vm.$el.querySelector('.js-log-animation')).toBeNull(); - }); - }); -}); diff --git a/spec/frontend/jobs/components/log/collapsible_section_spec.js b/spec/frontend/jobs/components/log/collapsible_section_spec.js index 3a16521a986..bf2f8c05806 100644 --- a/spec/frontend/jobs/components/log/collapsible_section_spec.js +++ b/spec/frontend/jobs/components/log/collapsible_section_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import CollpasibleSection from '~/jobs/components/log/collapsible_section.vue'; +import CollapsibleSection from '~/jobs/components/log/collapsible_section.vue'; import { collapsibleSectionClosed, collapsibleSectionOpened } from './mock_data'; describe('Job Log Collapsible Section', () => { @@ -11,7 +11,7 @@ describe('Job Log Collapsible Section', () => { const findCollapsibleLineSvg = () => wrapper.find('.collapsible-line svg'); const createComponent = (props = {}) => { - wrapper = mount(CollpasibleSection, { + wrapper = mount(CollapsibleSection, { propsData: { ...props, }, diff --git a/spec/frontend/jobs/store/mutations_spec.js b/spec/frontend/jobs/store/mutations_spec.js index 3557d3b94b6..608abc8f7c4 100644 --- a/spec/frontend/jobs/store/mutations_spec.js +++ b/spec/frontend/jobs/store/mutations_spec.js @@ -76,28 +76,15 @@ describe('Jobs Store Mutations', () => { lines: [], }); - expect(stateCopy.trace).toEqual(html); expect(stateCopy.traceSize).toEqual(511846); expect(stateCopy.isTraceComplete).toEqual(true); }); describe('with new job log', () => { - let stateWithNewLog; - beforeEach(() => { - gon.features = gon.features || {}; - gon.features.jobLogJson = true; - - stateWithNewLog = state(); - }); - - afterEach(() => { - gon.features.jobLogJson = false; - }); - describe('log.lines', () => { describe('when append is true', () => { it('sets the parsed log ', () => { - mutations[types.RECEIVE_TRACE_SUCCESS](stateWithNewLog, { + mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { append: true, size: 511846, complete: true, @@ -109,7 +96,7 @@ describe('Jobs Store Mutations', () => { ], }); - expect(stateWithNewLog.trace).toEqual([ + expect(stateCopy.trace).toEqual([ { offset: 1, content: [{ text: 'Running with gitlab-runner 11.12.1 (5a147c92)' }], @@ -121,7 +108,7 @@ describe('Jobs Store Mutations', () => { describe('when it is defined', () => { it('sets the parsed log ', () => { - mutations[types.RECEIVE_TRACE_SUCCESS](stateWithNewLog, { + mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { append: false, size: 511846, complete: true, @@ -130,7 +117,7 @@ describe('Jobs Store Mutations', () => { ], }); - expect(stateWithNewLog.trace).toEqual([ + expect(stateCopy.trace).toEqual([ { offset: 0, content: [{ text: 'Running with gitlab-runner 11.11.1 (5a147c92)' }], @@ -142,7 +129,7 @@ describe('Jobs Store Mutations', () => { describe('when it is null', () => { it('sets the default value', () => { - mutations[types.RECEIVE_TRACE_SUCCESS](stateWithNewLog, { + mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { append: true, html, size: 511846, @@ -150,7 +137,7 @@ describe('Jobs Store Mutations', () => { lines: null, }); - expect(stateWithNewLog.trace).toEqual([]); + expect(stateCopy.trace).toEqual([]); }); }); }); diff --git a/spec/frontend/jobs/store/utils_spec.js b/spec/frontend/jobs/store/utils_spec.js index 8819f39dee0..294f88bbc74 100644 --- a/spec/frontend/jobs/store/utils_spec.js +++ b/spec/frontend/jobs/store/utils_spec.js @@ -181,7 +181,7 @@ describe('Jobs Store Utils', () => { }); }); - describe('collpasible section', () => { + describe('collapsible section', () => { it('adds a `isClosed` property', () => { expect(result[1].isClosed).toEqual(false); }); @@ -190,7 +190,7 @@ describe('Jobs Store Utils', () => { expect(result[1].isHeader).toEqual(true); }); - it('creates a lines array property with the content of the collpasible section', () => { + it('creates a lines array property with the content of the collapsible section', () => { expect(result[1].lines.length).toEqual(2); expect(result[1].lines[0].content).toEqual(utilsMockData[2].content); expect(result[1].lines[1].content).toEqual(utilsMockData[3].content); diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js index f597255538c..585f0de9cc3 100644 --- a/spec/frontend/lib/utils/common_utils_spec.js +++ b/spec/frontend/lib/utils/common_utils_spec.js @@ -330,32 +330,6 @@ describe('common_utils', () => { }); }); - describe('normalizeCRLFHeaders', () => { - const testContext = {}; - beforeEach(() => { - testContext.CLRFHeaders = - 'a-header: a-value\nAnother-Header: ANOTHER-VALUE\nLaSt-HeAdEr: last-VALUE'; - jest.spyOn(String.prototype, 'split'); - testContext.normalizeCRLFHeaders = commonUtils.normalizeCRLFHeaders(testContext.CLRFHeaders); - }); - - it('should split by newline', () => { - expect(String.prototype.split).toHaveBeenCalledWith('\n'); - }); - - it('should split by colon+space for each header', () => { - expect(String.prototype.split.mock.calls.filter(args => args[0] === ': ').length).toBe(3); - }); - - it('should return a normalized headers object', () => { - expect(testContext.normalizeCRLFHeaders).toEqual({ - 'A-HEADER': 'a-value', - 'ANOTHER-HEADER': 'ANOTHER-VALUE', - 'LAST-HEADER': 'last-VALUE', - }); - }); - }); - describe('parseIntPagination', () => { it('should parse to integers all string values and return pagination object', () => { const pagination = { @@ -510,27 +484,6 @@ describe('common_utils', () => { }); }); - describe('setFavicon', () => { - beforeEach(() => { - const favicon = document.createElement('link'); - favicon.setAttribute('id', 'favicon'); - favicon.setAttribute('href', 'default/favicon'); - favicon.setAttribute('data-default-href', 'default/favicon'); - document.body.appendChild(favicon); - }); - - afterEach(() => { - document.body.removeChild(document.getElementById('favicon')); - }); - - it('should set page favicon to provided favicon', () => { - const faviconPath = '//custom_favicon'; - commonUtils.setFavicon(faviconPath); - - expect(document.getElementById('favicon').getAttribute('href')).toEqual(faviconPath); - }); - }); - describe('resetFavicon', () => { beforeEach(() => { const favicon = document.createElement('link'); diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js index f6878c7c920..adf5c312149 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -1,5 +1,6 @@ import { __, s__ } from '~/locale'; import $ from 'jquery'; +import timezoneMock from 'timezone-mock'; import '~/commons/bootstrap'; import * as datetimeUtility from '~/lib/utils/datetime_utility'; @@ -86,6 +87,31 @@ describe('Date time utils', () => { datetimeUtility.formatDate('2016-07-23 00:00:00 UTC'); }).toThrow(new Error('Invalid date')); }); + + describe('convert local timezone to UTC with utc parameter', () => { + const midnightUTC = '2020-07-09'; + const format = 'mmm d, yyyy'; + + beforeEach(() => { + timezoneMock.register('US/Pacific'); + }); + + afterEach(() => { + timezoneMock.unregister(); + }); + + it('defaults to false', () => { + const formattedDate = datetimeUtility.formatDate(midnightUTC, format); + + expect(formattedDate).toBe('Jul 8, 2020'); + }); + + it('converts local time to UTC if utc flag is true', () => { + const formattedDate = datetimeUtility.formatDate(midnightUTC, format, true); + + expect(formattedDate).toBe('Jul 9, 2020'); + }); + }); }); describe('get day difference', () => { diff --git a/spec/frontend/lib/utils/dom_utils_spec.js b/spec/frontend/lib/utils/dom_utils_spec.js index 10b4a10a8ff..d918016a5f4 100644 --- a/spec/frontend/lib/utils/dom_utils_spec.js +++ b/spec/frontend/lib/utils/dom_utils_spec.js @@ -1,4 +1,9 @@ -import { addClassIfElementExists, canScrollUp, canScrollDown } from '~/lib/utils/dom_utils'; +import { + addClassIfElementExists, + canScrollUp, + canScrollDown, + parseBooleanDataAttributes, +} from '~/lib/utils/dom_utils'; const TEST_MARGIN = 5; @@ -112,4 +117,47 @@ describe('DOM Utils', () => { expect(canScrollDown(element, TEST_MARGIN)).toBe(false); }); }); + + describe('parseBooleanDataAttributes', () => { + let element; + + beforeEach(() => { + setFixtures('<div data-foo-bar data-baz data-qux="">'); + element = document.querySelector('[data-foo-bar]'); + }); + + it('throws if not given an element', () => { + expect(() => parseBooleanDataAttributes(null, ['baz'])).toThrow(); + }); + + it('throws if not given an array of dataset names', () => { + expect(() => parseBooleanDataAttributes(element)).toThrow(); + }); + + it('returns an empty object if given an empty array of names', () => { + expect(parseBooleanDataAttributes(element, [])).toEqual({}); + }); + + it('correctly parses boolean-like data attributes', () => { + expect( + parseBooleanDataAttributes(element, [ + 'fooBar', + 'foobar', + 'baz', + 'qux', + 'doesNotExist', + 'toString', + ]), + ).toEqual({ + fooBar: true, + foobar: false, + baz: true, + qux: true, + doesNotExist: false, + + // Ensure prototype properties aren't false positives + toString: false, + }); + }); + }); }); diff --git a/spec/frontend/lib/utils/grammar_spec.js b/spec/frontend/lib/utils/grammar_spec.js index 377b2ffb48c..7f2431af7ed 100644 --- a/spec/frontend/lib/utils/grammar_spec.js +++ b/spec/frontend/lib/utils/grammar_spec.js @@ -7,27 +7,27 @@ describe('utils/grammar', () => { }); it('with single item returns item', () => { - const items = ['Lorem Ipsum']; + const items = ['Lorem & Ipsum']; expect(grammar.toNounSeriesText(items)).toBe(items[0]); }); it('with 2 items returns item1 and item2', () => { - const items = ['Dolar', 'Sit Amit']; + const items = ['Dolar', 'Sit & Amit']; expect(grammar.toNounSeriesText(items)).toBe(`${items[0]} and ${items[1]}`); }); it('with 3 items returns comma separated series', () => { - const items = ['Lorem', 'Ipsum', 'dolar']; - const expected = 'Lorem, Ipsum, and dolar'; + const items = ['Lorem', 'Ipsum', 'Sit & Amit']; + const expected = 'Lorem, Ipsum, and Sit & Amit'; expect(grammar.toNounSeriesText(items)).toBe(expected); }); it('with 6 items returns comma separated series', () => { - const items = ['Lorem', 'ipsum', 'dolar', 'sit', 'amit', 'consectetur']; - const expected = 'Lorem, ipsum, dolar, sit, amit, and consectetur'; + const items = ['Lorem', 'ipsum', 'dolar', 'sit', 'amit', 'consectetur & adipiscing']; + const expected = 'Lorem, ipsum, dolar, sit, amit, and consectetur & adipiscing'; expect(grammar.toNounSeriesText(items)).toBe(expected); }); diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js index aca299aea0f..2e52958a828 100644 --- a/spec/frontend/lib/utils/text_markdown_spec.js +++ b/spec/frontend/lib/utils/text_markdown_spec.js @@ -232,19 +232,17 @@ describe('init markdown', () => { beforeEach(() => { editor = { - getSelectionRange: () => ({ + getSelectionRange: jest.fn().mockReturnValue({ start: 0, end: 0, }), - getValue: () => 'this is text \n in two lines', - insert: () => {}, - navigateLeft: () => {}, + getValue: jest.fn().mockReturnValue('this is text \n in two lines'), + insert: jest.fn(), + navigateLeft: jest.fn(), }; }); it('uses ace editor insert text when editor is passed in', () => { - jest.spyOn(editor, 'insert').mockReturnValue(); - insertMarkdownText({ text: editor.getValue, tag: '*', @@ -258,8 +256,6 @@ describe('init markdown', () => { }); it('adds block tags on line above and below selection', () => { - jest.spyOn(editor, 'insert').mockReturnValue(); - const selected = 'this text \n is multiple \n lines'; const text = `before \n ${selected} \n after`; @@ -276,8 +272,6 @@ describe('init markdown', () => { }); it('uses ace editor to navigate back tag length when nothing is selected', () => { - jest.spyOn(editor, 'navigateLeft').mockReturnValue(); - insertMarkdownText({ text: editor.getValue, tag: '*', @@ -291,8 +285,6 @@ describe('init markdown', () => { }); it('ace editor does not navigate back when there is selected text', () => { - jest.spyOn(editor, 'navigateLeft').mockReturnValue(); - insertMarkdownText({ text: editor.getValue, tag: '*', @@ -305,4 +297,96 @@ describe('init markdown', () => { expect(editor.navigateLeft).not.toHaveBeenCalled(); }); }); + + describe('Editor Lite', () => { + let editor; + let origGon; + + beforeEach(() => { + origGon = window.gon; + window.gon = { + features: { + monacoBlobs: true, + }, + }; + editor = { + getSelection: jest.fn().mockReturnValue({ + startLineNumber: 1, + startColumn: 1, + endLineNumber: 2, + endColumn: 2, + }), + getValue: jest.fn().mockReturnValue('this is text \n in two lines'), + selectWithinSelection: jest.fn(), + replaceSelectedText: jest.fn(), + moveCursor: jest.fn(), + }; + }); + + afterEach(() => { + window.gon = origGon; + }); + + it('replaces selected text', () => { + insertMarkdownText({ + text: editor.getValue, + tag: '*', + blockTag: null, + selected: '', + wrap: false, + editor, + }); + + expect(editor.replaceSelectedText).toHaveBeenCalled(); + }); + + it('adds block tags on line above and below selection', () => { + const selected = 'this text \n is multiple \n lines'; + const text = `before \n ${selected} \n after`; + + insertMarkdownText({ + text, + tag: '', + blockTag: '***', + selected, + wrap: true, + editor, + }); + + expect(editor.replaceSelectedText).toHaveBeenCalledWith(`***\n${selected}\n***\n`, undefined); + }); + + it('uses ace editor to navigate back tag length when nothing is selected', () => { + editor.getSelection = jest.fn().mockReturnValue({ + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 1, + }); + + insertMarkdownText({ + text: editor.getValue, + tag: '*', + blockTag: null, + selected: '', + wrap: true, + editor, + }); + + expect(editor.moveCursor).toHaveBeenCalledWith(-1); + }); + + it('ace editor does not navigate back when there is selected text', () => { + insertMarkdownText({ + text: editor.getValue, + tag: '*', + blockTag: null, + selected: 'foobar', + wrap: true, + editor, + }); + + expect(editor.selectWithinSelection).not.toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js index 76e0e435860..285f7d04c3b 100644 --- a/spec/frontend/lib/utils/text_utility_spec.js +++ b/spec/frontend/lib/utils/text_utility_spec.js @@ -145,6 +145,56 @@ describe('text_utility', () => { }); }); + describe('truncate', () => { + it('returns the original string when str length is less than maxLength', () => { + const str = 'less than 20 chars'; + expect(textUtils.truncate(str, 20)).toEqual(str); + }); + + it('returns truncated string when str length is more than maxLength', () => { + const str = 'more than 10 chars'; + expect(textUtils.truncate(str, 10)).toEqual(`${str.substring(0, 10 - 1)}…`); + }); + + it('returns the original string when rendered width is exactly equal to maxWidth', () => { + const str = 'Exactly 16 chars'; + expect(textUtils.truncate(str, 16)).toEqual(str); + }); + }); + + describe('truncateWidth', () => { + const clientWidthDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'clientWidth'); + + beforeAll(() => { + // Mock measured width of ' ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + Object.defineProperty(Element.prototype, 'clientWidth', { + value: 431, + writable: false, + }); + }); + + afterAll(() => { + Object.defineProperty(Element.prototype, 'clientWidth', clientWidthDescriptor); + }); + + it('returns the original string when rendered width is less than maxWidth', () => { + const str = '< 80px'; + expect(textUtils.truncateWidth(str)).toEqual(str); + }); + + it('returns truncated string when rendered width is more than maxWidth', () => { + const str = 'This is wider than 80px'; + expect(textUtils.truncateWidth(str)).toEqual(`${str.substring(0, 10)}…`); + }); + + it('returns the original string when rendered width is exactly equal to maxWidth', () => { + const str = 'Exactly 159.62962962962965px'; + expect(textUtils.truncateWidth(str, { maxWidth: 159.62962962962965, fontSize: 10 })).toEqual( + str, + ); + }); + }); + describe('truncateSha', () => { it('shortens SHAs to 8 characters', () => { expect(textUtils.truncateSha('verylongsha')).toBe('verylong'); diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index 85e680fe216..e769580b587 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -595,6 +595,14 @@ describe('URL utility', () => { ); }); + it('handles arrays properly when railsArraySyntax=true', () => { + const url = 'https://gitlab.com/test'; + + expect(urlUtils.setUrlParams({ labels: ['foo', 'bar'] }, url, false, true)).toEqual( + 'https://gitlab.com/test?labels%5B%5D=foo&labels%5B%5D=bar', + ); + }); + it('removes all existing URL params and sets a new param when cleanParams=true', () => { const url = 'https://gitlab.com/test?group_id=gitlab-org&project_id=my-project'; diff --git a/spec/frontend/logs/components/environment_logs_spec.js b/spec/frontend/logs/components/environment_logs_spec.js index 62f3e8a755d..dee62709d81 100644 --- a/spec/frontend/logs/components/environment_logs_spec.js +++ b/spec/frontend/logs/components/environment_logs_spec.js @@ -12,6 +12,7 @@ import { mockTrace, mockEnvironmentsEndpoint, mockDocumentationPath, + mockManagedAppsEndpoint, } from '../mock_data'; jest.mock('~/lib/utils/scroll_utils'); @@ -34,6 +35,7 @@ describe('EnvironmentLogs', () => { environmentName: mockEnvName, environmentsPath: mockEnvironmentsEndpoint, clusterApplicationsDocumentationPath: mockDocumentationPath, + clustersPath: mockManagedAppsEndpoint, }; const updateControlBtnsMock = jest.fn(); diff --git a/spec/frontend/logs/components/log_control_buttons_spec.js b/spec/frontend/logs/components/log_control_buttons_spec.js index 85fc5a040d6..38e568f569f 100644 --- a/spec/frontend/logs/components/log_control_buttons_spec.js +++ b/spec/frontend/logs/components/log_control_buttons_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import LogControlButtons from '~/logs/components/log_control_buttons.vue'; describe('LogControlButtons', () => { @@ -31,9 +31,9 @@ describe('LogControlButtons', () => { expect(wrapper.isVueInstance()).toBe(true); expect(wrapper.isEmpty()).toBe(false); - expect(findScrollToTop().is(GlDeprecatedButton)).toBe(true); - expect(findScrollToBottom().is(GlDeprecatedButton)).toBe(true); - expect(findRefreshBtn().is(GlDeprecatedButton)).toBe(true); + expect(findScrollToTop().is(GlButton)).toBe(true); + expect(findScrollToBottom().is(GlButton)).toBe(true); + expect(findRefreshBtn().is(GlButton)).toBe(true); }); it('emits a `refresh` event on click on `refresh` button', () => { diff --git a/spec/frontend/logs/mock_data.js b/spec/frontend/logs/mock_data.js index 14c8f7a2ba2..f9b3508e01c 100644 --- a/spec/frontend/logs/mock_data.js +++ b/spec/frontend/logs/mock_data.js @@ -7,6 +7,8 @@ export const mockDocumentationPath = '/documentation.md'; export const mockLogsEndpoint = '/dummy_logs_path.json'; export const mockCursor = 'MOCK_CURSOR'; export const mockNextCursor = 'MOCK_NEXT_CURSOR'; +export const mockManagedAppName = 'kubernetes-cluster-1'; +export const mockManagedAppsEndpoint = `${mockProjectPath}/clusters.json`; const makeMockEnvironment = (id, name, advancedQuerying) => ({ id, @@ -23,6 +25,19 @@ export const mockEnvironments = [ makeMockEnvironment(102, 'review/a-feature', false), ]; +export const mockManagedApps = [ + { + cluster_type: 'project_type', + enabled: true, + environment_scope: '*', + name: 'kubernetes-cluster-1', + provider_type: 'user', + status: 'connected', + path: '/root/autodevops-deploy/-/clusters/15', + gitlab_managed_apps_logs_path: '/root/autodevops-deploy/-/logs?cluster_id=15', + }, +]; + export const mockPodName = 'production-764c58d697-aaaaa'; export const mockPods = [ mockPodName, diff --git a/spec/frontend/logs/stores/actions_spec.js b/spec/frontend/logs/stores/actions_spec.js index e2e3c3d23c6..acd9536a682 100644 --- a/spec/frontend/logs/stores/actions_spec.js +++ b/spec/frontend/logs/stores/actions_spec.js @@ -11,6 +11,7 @@ import { fetchEnvironments, fetchLogs, fetchMoreLogsPrepend, + fetchManagedApps, } from '~/logs/stores/actions'; import { defaultTimeRange } from '~/vue_shared/constants'; @@ -30,6 +31,8 @@ import { mockResponse, mockCursor, mockNextCursor, + mockManagedApps, + mockManagedAppsEndpoint, } from '../mock_data'; import { TOKEN_TYPE_POD_NAME } from '~/logs/constants'; @@ -217,6 +220,30 @@ describe('Logs Store actions', () => { }); }); + describe('fetchManagedApps', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + it('should commit RECEIVE_MANAGED_APPS_DATA_SUCCESS mutation on succesful fetch', () => { + mock.onGet(mockManagedAppsEndpoint).replyOnce(200, { clusters: mockManagedApps }); + return testAction(fetchManagedApps, mockManagedAppsEndpoint, state, [ + { type: types.RECEIVE_MANAGED_APPS_DATA_SUCCESS, payload: mockManagedApps }, + ]); + }); + + it('should commit RECEIVE_MANAGED_APPS_DATA_ERROR on wrong data', () => { + mock.onGet(mockManagedAppsEndpoint).replyOnce(500); + return testAction( + fetchManagedApps, + mockManagedAppsEndpoint, + state, + [{ type: types.RECEIVE_MANAGED_APPS_DATA_ERROR }], + [], + ); + }); + }); + describe('when the backend responds succesfully', () => { let expectedMutations; let expectedActions; diff --git a/spec/frontend/logs/stores/mutations_spec.js b/spec/frontend/logs/stores/mutations_spec.js index 46561055a4a..137533f02d7 100644 --- a/spec/frontend/logs/stores/mutations_spec.js +++ b/spec/frontend/logs/stores/mutations_spec.js @@ -11,6 +11,8 @@ import { mockSearch, mockCursor, mockNextCursor, + mockManagedApps, + mockManagedAppName, } from '../mock_data'; describe('Logs Store Mutations', () => { @@ -30,6 +32,15 @@ describe('Logs Store Mutations', () => { it('sets the environment', () => { mutations[types.SET_PROJECT_ENVIRONMENT](state, mockEnvName); expect(state.environments.current).toEqual(mockEnvName); + expect(state.managedApps.current).toBe(null); + }); + }); + + describe('SET_MANAGED_APP', () => { + it('sets the managed app', () => { + mutations[types.SET_MANAGED_APP](state, mockManagedAppName); + expect(state.managedApps.current).toBe(mockManagedAppName); + expect(state.environments.current).toBe(null); }); }); @@ -254,4 +265,28 @@ describe('Logs Store Mutations', () => { ); }); }); + + describe('RECEIVE_MANAGED_APPS_DATA_SUCCESS', () => { + it('receives managed apps data success', () => { + expect(state.managedApps.options).toEqual([]); + + mutations[types.RECEIVE_MANAGED_APPS_DATA_SUCCESS](state, mockManagedApps); + + expect(state.managedApps.options).toEqual(mockManagedApps); + expect(state.managedApps.isLoading).toBe(false); + }); + }); + + describe('RECEIVE_MANAGED_APPS_DATA_ERROR', () => { + it('received managed apps data error', () => { + mutations[types.RECEIVE_MANAGED_APPS_DATA_ERROR](state); + + expect(state.managedApps).toEqual({ + options: [], + isLoading: false, + current: null, + fetchError: true, + }); + }); + }); }); diff --git a/spec/frontend/merge_request_tabs_spec.js b/spec/frontend/merge_request_tabs_spec.js index 3d3be647d12..ad373d04ec0 100644 --- a/spec/frontend/merge_request_tabs_spec.js +++ b/spec/frontend/merge_request_tabs_spec.js @@ -5,7 +5,7 @@ import MergeRequestTabs from '~/merge_request_tabs'; import '~/commit/pipelines/pipelines_bundle'; import '~/lib/utils/common_utils'; import 'vendor/jquery.scrollTo'; -import initMrPage from '../javascripts/helpers/init_vue_mr_page_helper'; +import initMrPage from 'helpers/init_vue_mr_page_helper'; jest.mock('~/lib/utils/webpack', () => ({ resetServiceWorkersPublicPath: jest.fn(), diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap index 4b08163f30a..e7c51d82cd2 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -4,22 +4,32 @@ exports[`Dashboard template matches the default snapshot 1`] = ` <div class="prometheus-graphs" data-qa-selector="prometheus_graphs" + environmentstate="available" + metricsdashboardbasepath="/monitoring/monitor-project/-/environments/1/metrics" + metricsendpoint="/monitoring/monitor-project/-/environments/1/additional_metrics.json" + prometheusstatus="" > <div class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light" > <div - class="mb-2 pr-2 d-flex d-sm-block" + class="mb-2 mr-2 d-flex d-sm-block" > <dashboards-dropdown-stub class="flex-grow-1" data-qa-selector="dashboards_filter_dropdown" defaultbranch="master" id="monitor-dashboards-dropdown" + modalid="duplicateDashboard" toggle-class="dropdown-menu-toggle" /> </div> + <span + aria-hidden="true" + class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block" + /> + <div class="mb-2 pr-2 d-flex d-sm-block" > @@ -80,17 +90,7 @@ exports[`Dashboard template matches the default snapshot 1`] = ` <div class="mb-2 pr-2 d-flex d-sm-block" > - <gl-deprecated-button-stub - class="flex-grow-1" - size="md" - title="Refresh dashboard" - variant="default" - > - <icon-stub - name="retry" - size="16" - /> - </gl-deprecated-button-stub> + <refresh-button-stub /> </div> <div @@ -127,23 +127,30 @@ exports[`Dashboard template matches the default snapshot 1`] = ` <!----> <!----> + + <!----> + + <!----> + + <!----> </div> + + <duplicate-dashboard-modal-stub + defaultbranch="master" + modalid="duplicateDashboard" + /> </div> - <!----> - - <!----> - <empty-state-stub - clusterspath="/path/to/clusters" - documentationpath="/path/to/docs" - emptygettingstartedsvgpath="/path/to/getting-started.svg" - emptyloadingsvgpath="/path/to/loading.svg" - emptynodatasmallsvgpath="/path/to/no-data-small.svg" - emptynodatasvgpath="/path/to/no-data.svg" - emptyunabletoconnectsvgpath="/path/to/unable-to-connect.svg" + clusterspath="/monitoring/monitor-project/-/clusters" + documentationpath="/help/administration/monitoring/prometheus/index.md" + emptygettingstartedsvgpath="/images/illustrations/monitoring/getting_started.svg" + emptyloadingsvgpath="/images/illustrations/monitoring/loading.svg" + emptynodatasmallsvgpath="/images/illustrations/chart-empty-state-small.svg" + emptynodatasvgpath="/images/illustrations/monitoring/no_data.svg" + emptyunabletoconnectsvgpath="/images/illustrations/monitoring/unable_to_connect.svg" selectedstate="gettingStarted" - settingspath="/path/to/settings" + settingspath="/monitoring/monitor-project/-/services/prometheus/edit" /> </div> `; diff --git a/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap index 31b3ad1bd76..4f8a82692b8 100644 --- a/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap @@ -1,37 +1,49 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`EmptyState shows gettingStarted state 1`] = ` -<gl-empty-state-stub - description="Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments." - primarybuttonlink="/clustersPath" - primarybuttontext="Install on clusters" - secondarybuttonlink="/settingsPath" - secondarybuttontext="Configure existing installation" - svgpath="/path/to/getting-started.svg" - title="Get started with performance monitoring" -/> +<div> + <!----> + + <gl-empty-state-stub + description="Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments." + primarybuttonlink="/clustersPath" + primarybuttontext="Install on clusters" + secondarybuttonlink="/settingsPath" + secondarybuttontext="Configure existing installation" + svgpath="/path/to/getting-started.svg" + title="Get started with performance monitoring" + /> +</div> `; -exports[`EmptyState shows loading state 1`] = ` -<gl-empty-state-stub - description="Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available." - primarybuttonlink="/documentationPath" - primarybuttontext="View documentation" - secondarybuttonlink="" - secondarybuttontext="" - svgpath="/path/to/loading.svg" - title="Waiting for performance data" -/> +exports[`EmptyState shows noData state 1`] = ` +<div> + <!----> + + <gl-empty-state-stub + description="You are connected to the Prometheus server, but there is currently no data to display." + primarybuttonlink="/settingsPath" + primarybuttontext="Configure Prometheus" + secondarybuttonlink="" + secondarybuttontext="" + svgpath="/path/to/no-data.svg" + title="No data found" + /> +</div> `; exports[`EmptyState shows unableToConnect state 1`] = ` -<gl-empty-state-stub - description="Ensure connectivity is available from the GitLab server to the Prometheus server" - primarybuttonlink="/documentationPath" - primarybuttontext="View documentation" - secondarybuttonlink="/settingsPath" - secondarybuttontext="Configure Prometheus" - svgpath="/path/to/unable-to-connect.svg" - title="Unable to connect to Prometheus server" -/> +<div> + <!----> + + <gl-empty-state-stub + description="Ensure connectivity is available from the GitLab server to the Prometheus server" + primarybuttonlink="/documentationPath" + primarybuttontext="View documentation" + secondarybuttonlink="/settingsPath" + secondarybuttontext="Configure Prometheus" + svgpath="/path/to/unable-to-connect.svg" + title="Unable to connect to Prometheus server" + /> +</div> `; diff --git a/spec/frontend/monitoring/components/charts/anomaly_spec.js b/spec/frontend/monitoring/components/charts/anomaly_spec.js index 4178d3f0d2d..15a52d03bcd 100644 --- a/spec/frontend/monitoring/components/charts/anomaly_spec.js +++ b/spec/frontend/monitoring/components/charts/anomaly_spec.js @@ -3,28 +3,14 @@ import { TEST_HOST } from 'helpers/test_constants'; import Anomaly from '~/monitoring/components/charts/anomaly.vue'; import { colorValues } from '~/monitoring/constants'; -import { - anomalyDeploymentData, - mockProjectDir, - anomalyMockGraphData, - anomalyMockResultValues, -} from '../../mock_data'; +import { anomalyDeploymentData, mockProjectDir } from '../../mock_data'; +import { anomalyGraphData } from '../../graph_data'; import MonitorTimeSeriesChart from '~/monitoring/components/charts/time_series.vue'; const mockProjectPath = `${TEST_HOST}${mockProjectDir}`; -const makeAnomalyGraphData = (datasetName, template = anomalyMockGraphData) => { - const metrics = anomalyMockResultValues[datasetName].map((values, index) => ({ - ...template.metrics[index], - result: [ - { - metrics: {}, - values, - }, - ], - })); - return { ...template, metrics }; -}; +const TEST_UPPER = 11; +const TEST_LOWER = 9; describe('Anomaly chart component', () => { let wrapper; @@ -38,13 +24,22 @@ describe('Anomaly chart component', () => { const getTimeSeriesProps = () => findTimeSeries().props(); describe('wrapped monitor-time-series-chart component', () => { - const dataSetName = 'noAnomaly'; - const dataSet = anomalyMockResultValues[dataSetName]; + const mockValues = ['10', '10', '10']; + + const mockGraphData = anomalyGraphData( + {}, + { + upper: mockValues.map(() => String(TEST_UPPER)), + values: mockValues, + lower: mockValues.map(() => String(TEST_LOWER)), + }, + ); + const inputThresholds = ['some threshold']; beforeEach(() => { setupAnomalyChart({ - graphData: makeAnomalyGraphData(dataSetName), + graphData: mockGraphData, deploymentData: anomalyDeploymentData, thresholds: inputThresholds, projectPath: mockProjectPath, @@ -65,21 +60,21 @@ describe('Anomaly chart component', () => { it('receives "metric" with all data', () => { const { graphData } = getTimeSeriesProps(); - const query = graphData.metrics[0]; - const expectedQuery = makeAnomalyGraphData(dataSetName).metrics[0]; - expect(query).toEqual(expectedQuery); + const metric = graphData.metrics[0]; + const expectedMetric = mockGraphData.metrics[0]; + expect(metric).toEqual(expectedMetric); }); it('receives the "metric" results', () => { const { graphData } = getTimeSeriesProps(); const { result } = graphData.metrics[0]; const { values } = result[0]; - const [metricDataset] = dataSet; - expect(values).toEqual(expect.any(Array)); - values.forEach(([, y], index) => { - expect(y).toBeCloseTo(metricDataset[index][1]); - }); + expect(values).toEqual([ + [expect.any(String), 10], + [expect.any(String), 10], + [expect.any(String), 10], + ]); }); }); @@ -108,14 +103,13 @@ describe('Anomaly chart component', () => { it('upper boundary values are stacked on top of lower boundary', () => { const [lowerSeries, upperSeries] = series; - const [, upperDataset, lowerDataset] = dataSet; - lowerSeries.data.forEach(([, y], i) => { - expect(y).toBeCloseTo(lowerDataset[i][1]); + lowerSeries.data.forEach(([, y]) => { + expect(y).toBeCloseTo(TEST_LOWER); }); - upperSeries.data.forEach(([, y], i) => { - expect(y).toBeCloseTo(upperDataset[i][1] - lowerDataset[i][1]); + upperSeries.data.forEach(([, y]) => { + expect(y).toBeCloseTo(TEST_UPPER - TEST_LOWER); }); }); }); @@ -140,11 +134,10 @@ describe('Anomaly chart component', () => { }), ); }); + it('does not display anomalies', () => { const { symbolSize, itemStyle } = seriesConfig; - const [metricDataset] = dataSet; - - metricDataset.forEach((v, dataIndex) => { + mockValues.forEach((v, dataIndex) => { const size = symbolSize(null, { dataIndex }); const color = itemStyle.color({ dataIndex }); @@ -155,9 +148,10 @@ describe('Anomaly chart component', () => { }); it('can format y values (to use in tooltips)', () => { - expect(parseFloat(wrapper.vm.yValueFormatted(0, 0))).toEqual(dataSet[0][0][1]); - expect(parseFloat(wrapper.vm.yValueFormatted(1, 0))).toEqual(dataSet[1][0][1]); - expect(parseFloat(wrapper.vm.yValueFormatted(2, 0))).toEqual(dataSet[2][0][1]); + mockValues.forEach((v, dataIndex) => { + const formatted = wrapper.vm.yValueFormatted(0, dataIndex); + expect(parseFloat(formatted)).toEqual(parseFloat(v)); + }); }); }); @@ -179,12 +173,18 @@ describe('Anomaly chart component', () => { }); describe('with no boundary data', () => { - const dataSetName = 'noBoundary'; - const dataSet = anomalyMockResultValues[dataSetName]; + const noBoundaryData = anomalyGraphData( + {}, + { + upper: [], + values: ['10', '10', '10'], + lower: [], + }, + ); beforeEach(() => { setupAnomalyChart({ - graphData: makeAnomalyGraphData(dataSetName), + graphData: noBoundaryData, deploymentData: anomalyDeploymentData, }); }); @@ -204,7 +204,7 @@ describe('Anomaly chart component', () => { }); it('can format y values (to use in tooltips)', () => { - expect(parseFloat(wrapper.vm.yValueFormatted(0, 0))).toEqual(dataSet[0][0][1]); + expect(parseFloat(wrapper.vm.yValueFormatted(0, 0))).toEqual(10); expect(wrapper.vm.yValueFormatted(1, 0)).toBe(''); // missing boundary expect(wrapper.vm.yValueFormatted(2, 0)).toBe(''); // missing boundary }); @@ -212,12 +212,20 @@ describe('Anomaly chart component', () => { }); describe('with one anomaly', () => { - const dataSetName = 'oneAnomaly'; - const dataSet = anomalyMockResultValues[dataSetName]; + const mockValues = ['10', '20', '10']; + + const oneAnomalyData = anomalyGraphData( + {}, + { + upper: mockValues.map(() => TEST_UPPER), + values: mockValues, + lower: mockValues.map(() => TEST_LOWER), + }, + ); beforeEach(() => { setupAnomalyChart({ - graphData: makeAnomalyGraphData(dataSetName), + graphData: oneAnomalyData, deploymentData: anomalyDeploymentData, }); }); @@ -226,13 +234,12 @@ describe('Anomaly chart component', () => { it('displays one anomaly', () => { const { seriesConfig } = getTimeSeriesProps(); const { symbolSize, itemStyle } = seriesConfig; - const [metricDataset] = dataSet; - const bigDots = metricDataset.filter((v, dataIndex) => { + const bigDots = mockValues.filter((v, dataIndex) => { const size = symbolSize(null, { dataIndex }); return size > 0.1; }); - const redDots = metricDataset.filter((v, dataIndex) => { + const redDots = mockValues.filter((v, dataIndex) => { const color = itemStyle.color({ dataIndex }); return color === colorValues.anomalySymbol; }); @@ -244,13 +251,21 @@ describe('Anomaly chart component', () => { }); describe('with offset', () => { - const dataSetName = 'negativeBoundary'; - const dataSet = anomalyMockResultValues[dataSetName]; - const expectedOffset = 4; // Lowst point in mock data is -3.70, it gets rounded + const mockValues = ['10', '11', '12']; + const mockUpper = ['20', '20', '20']; + const mockLower = ['-1', '-2', '-3.70']; + const expectedOffset = 4; // Lowest point in mock data is -3.70, it gets rounded beforeEach(() => { setupAnomalyChart({ - graphData: makeAnomalyGraphData(dataSetName), + graphData: anomalyGraphData( + {}, + { + upper: mockUpper, + values: mockValues, + lower: mockLower, + }, + ), deploymentData: anomalyDeploymentData, }); }); @@ -266,11 +281,11 @@ describe('Anomaly chart component', () => { const { graphData } = getTimeSeriesProps(); const { result } = graphData.metrics[0]; const { values } = result[0]; - const [metricDataset] = dataSet; + expect(values).toEqual(expect.any(Array)); values.forEach(([, y], index) => { - expect(y).toBeCloseTo(metricDataset[index][1] + expectedOffset); + expect(y).toBeCloseTo(parseFloat(mockValues[index]) + expectedOffset); }); }); }); @@ -281,14 +296,12 @@ describe('Anomaly chart component', () => { const { option } = getTimeSeriesProps(); const { series } = option; const [lowerSeries, upperSeries] = series; - const [, upperDataset, lowerDataset] = dataSet; - lowerSeries.data.forEach(([, y], i) => { - expect(y).toBeCloseTo(lowerDataset[i][1] + expectedOffset); + expect(y).toBeCloseTo(parseFloat(mockLower[i]) + expectedOffset); }); upperSeries.data.forEach(([, y], i) => { - expect(y).toBeCloseTo(upperDataset[i][1] - lowerDataset[i][1]); + expect(y).toBeCloseTo(parseFloat(mockUpper[i] - mockLower[i])); }); }); }); diff --git a/spec/frontend/monitoring/components/charts/column_spec.js b/spec/frontend/monitoring/components/charts/column_spec.js index 89739a7485d..a2056d96dcf 100644 --- a/spec/frontend/monitoring/components/charts/column_spec.js +++ b/spec/frontend/monitoring/components/charts/column_spec.js @@ -63,8 +63,8 @@ describe('Column component', () => { return formatter(date); }; - it('x-axis is formatted correctly in AM/PM format', () => { - expect(useXAxisFormatter(mockDate)).toEqual('8:00 PM'); + it('x-axis is formatted correctly in m/d h:MM TT format', () => { + expect(useXAxisFormatter(mockDate)).toEqual('5/26 8:00 PM'); }); describe('when in PT timezone', () => { @@ -78,17 +78,17 @@ describe('Column component', () => { it('by default, values are formatted in PT', () => { createWrapper(); - expect(useXAxisFormatter(mockDate)).toEqual('1:00 PM'); + expect(useXAxisFormatter(mockDate)).toEqual('5/26 1:00 PM'); }); it('when the chart uses local timezone, y-axis is formatted in PT', () => { createWrapper({ timezone: 'LOCAL' }); - expect(useXAxisFormatter(mockDate)).toEqual('1:00 PM'); + expect(useXAxisFormatter(mockDate)).toEqual('5/26 1:00 PM'); }); it('when the chart uses UTC, y-axis is formatted in UTC', () => { createWrapper({ timezone: 'UTC' }); - expect(useXAxisFormatter(mockDate)).toEqual('8:00 PM'); + expect(useXAxisFormatter(mockDate)).toEqual('5/26 8:00 PM'); }); }); }); diff --git a/spec/frontend/monitoring/components/charts/single_stat_spec.js b/spec/frontend/monitoring/components/charts/single_stat_spec.js index 9cc5970da82..3783b1eebd2 100644 --- a/spec/frontend/monitoring/components/charts/single_stat_spec.js +++ b/spec/frontend/monitoring/components/charts/single_stat_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import SingleStatChart from '~/monitoring/components/charts/single_stat.vue'; -import { singleStatMetricsResult } from '../../mock_data'; +import { singleStatGraphData } from '../../graph_data'; describe('Single Stat Chart component', () => { let singleStatChart; @@ -8,7 +8,7 @@ describe('Single Stat Chart component', () => { beforeEach(() => { singleStatChart = shallowMount(SingleStatChart, { propsData: { - graphData: singleStatMetricsResult, + graphData: singleStatGraphData({}, { unit: 'MB' }), }, }); }); @@ -20,15 +20,12 @@ describe('Single Stat Chart component', () => { describe('computed', () => { describe('statValue', () => { it('should interpolate the value and unit props', () => { - expect(singleStatChart.vm.statValue).toBe('91.00MB'); + expect(singleStatChart.vm.statValue).toBe('1.00MB'); }); it('should change the value representation to a percentile one', () => { singleStatChart.setProps({ - graphData: { - ...singleStatMetricsResult, - maxValue: 120, - }, + graphData: singleStatGraphData({ max_value: 120 }, { value: 91 }), }); expect(singleStatChart.vm.statValue).toContain('75.83%'); @@ -36,10 +33,7 @@ describe('Single Stat Chart component', () => { it('should display NaN for non numeric maxValue values', () => { singleStatChart.setProps({ - graphData: { - ...singleStatMetricsResult, - maxValue: 'not a number', - }, + graphData: singleStatGraphData({ max_value: 'not a number' }), }); expect(singleStatChart.vm.statValue).toContain('NaN'); @@ -47,25 +41,33 @@ describe('Single Stat Chart component', () => { it('should display NaN for missing query values', () => { singleStatChart.setProps({ - graphData: { - ...singleStatMetricsResult, - metrics: [ - { - ...singleStatMetricsResult.metrics[0], - result: [ - { - ...singleStatMetricsResult.metrics[0].result[0], - value: [''], - }, - ], - }, - ], - maxValue: 120, - }, + graphData: singleStatGraphData({ max_value: 120 }, { value: 'NaN' }), }); expect(singleStatChart.vm.statValue).toContain('NaN'); }); + + describe('field attribute', () => { + it('displays a label value instead of metric value when field attribute is used', () => { + singleStatChart.setProps({ + graphData: singleStatGraphData({ field: 'job' }, { isVector: true }), + }); + + return singleStatChart.vm.$nextTick(() => { + expect(singleStatChart.vm.statValue).toContain('prometheus'); + }); + }); + + it('displays No data to display if field attribute is not present', () => { + singleStatChart.setProps({ + graphData: singleStatGraphData({ field: 'this-does-not-exist' }), + }); + + return singleStatChart.vm.$nextTick(() => { + expect(singleStatChart.vm.statValue).toContain('No data to display'); + }); + }); + }); }); }); }); diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js index 50d2c9c80b2..97386be9e32 100644 --- a/spec/frontend/monitoring/components/charts/time_series_spec.js +++ b/spec/frontend/monitoring/components/charts/time_series_spec.js @@ -9,18 +9,12 @@ import { GlChartSeriesLabel, GlChartLegend, } from '@gitlab/ui/dist/charts'; -import { cloneDeep } from 'lodash'; import { shallowWrapperContainsSlotText } from 'helpers/vue_test_utils_helper'; -import { createStore } from '~/monitoring/stores'; import { panelTypes, chartHeight } from '~/monitoring/constants'; import TimeSeries from '~/monitoring/components/charts/time_series.vue'; -import * as types from '~/monitoring/stores/mutation_types'; import { deploymentData, mockProjectDir, annotationsData } from '../../mock_data'; -import { - metricsDashboardPayload, - metricsDashboardViewModel, - metricResultStatus, -} from '../../fixture_data'; + +import { timeSeriesGraphData } from '../../graph_data'; jest.mock('lodash/throttle', () => // this throttle mock executes immediately @@ -35,23 +29,21 @@ jest.mock('~/lib/utils/icon_utils', () => ({ })); describe('Time series component', () => { - let mockGraphData; - let store; + const defaultGraphData = timeSeriesGraphData(); let wrapper; const createWrapper = ( - { graphData = mockGraphData, ...props } = {}, + { graphData = defaultGraphData, ...props } = {}, mountingMethod = shallowMount, ) => { wrapper = mountingMethod(TimeSeries, { propsData: { graphData, - deploymentData: store.state.monitoringDashboard.deploymentData, - annotations: store.state.monitoringDashboard.annotations, + deploymentData, + annotations: annotationsData, projectPath: `${TEST_HOST}${mockProjectDir}`, ...props, }, - store, stubs: { GlPopover: true, }, @@ -59,27 +51,15 @@ describe('Time series component', () => { }); }; - describe('With a single time series', () => { - beforeEach(() => { - setTestTimeout(1000); - - store = createStore(); - - store.commit( - `monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`, - metricsDashboardPayload, - ); - - store.commit(`monitoringDashboard/${types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS}`, deploymentData); + beforeEach(() => { + setTestTimeout(1000); + }); - store.commit( - `monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, - metricResultStatus, - ); - // dashboard is a dynamically generated fixture and stored at environment_metrics_dashboard.json - [mockGraphData] = store.state.monitoringDashboard.dashboard.panelGroups[1].panels; - }); + afterEach(() => { + wrapper.destroy(); + }); + describe('With a single time series', () => { describe('general functions', () => { const findChart = () => wrapper.find({ ref: 'chart' }); @@ -88,10 +68,6 @@ describe('Time series component', () => { return wrapper.vm.$nextTick(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('allows user to override legend label texts using props', () => { const legendRelatedProps = { legendMinText: 'legendMinText', @@ -231,19 +207,20 @@ describe('Time series component', () => { }); it('formats tooltip content', () => { - const name = 'Status Code'; + const name = 'Metric 1'; const value = '5.556'; const dataIndex = 0; const seriesLabel = wrapper.find(GlChartSeriesLabel); expect(seriesLabel.vm.color).toBe(''); + expect(shallowWrapperContainsSlotText(seriesLabel, 'default', name)).toBe(true); expect(wrapper.vm.tooltip.content).toEqual([ { name, value, dataIndex, color: undefined }, ]); expect( - shallowWrapperContainsSlotText(wrapper.find(GlAreaChart), 'tooltipContent', value), + shallowWrapperContainsSlotText(wrapper.find(GlLineChart), 'tooltipContent', value), ).toBe(true); }); @@ -385,10 +362,8 @@ describe('Time series component', () => { }); it('utilizes all data points', () => { - const { values } = mockGraphData.metrics[0].result[0]; - expect(chartData.length).toBe(1); - expect(seriesData().data.length).toBe(values.length); + expect(seriesData().data.length).toBe(3); }); it('creates valid data', () => { @@ -552,8 +527,8 @@ describe('Time series component', () => { return formatter(date); }; - it('x-axis is formatted correctly in AM/PM format', () => { - expect(useXAxisFormatter(mockDate)).toEqual('8:00 PM'); + it('x-axis is formatted correctly in m/d h:MM TT format', () => { + expect(useXAxisFormatter(mockDate)).toEqual('5/26 8:00 PM'); }); describe('when in PT timezone', () => { @@ -567,17 +542,17 @@ describe('Time series component', () => { it('by default, values are formatted in PT', () => { createWrapper(); - expect(useXAxisFormatter(mockDate)).toEqual('1:00 PM'); + expect(useXAxisFormatter(mockDate)).toEqual('5/26 1:00 PM'); }); it('when the chart uses local timezone, y-axis is formatted in PT', () => { createWrapper({ timezone: 'LOCAL' }); - expect(useXAxisFormatter(mockDate)).toEqual('1:00 PM'); + expect(useXAxisFormatter(mockDate)).toEqual('5/26 1:00 PM'); }); it('when the chart uses UTC, y-axis is formatted in UTC', () => { createWrapper({ timezone: 'UTC' }); - expect(useXAxisFormatter(mockDate)).toEqual('8:00 PM'); + expect(useXAxisFormatter(mockDate)).toEqual('5/26 8:00 PM'); }); }); }); @@ -602,14 +577,10 @@ describe('Time series component', () => { it('constructs a label for the chart y-axis', () => { const { yAxis } = getChartOptions(); - expect(yAxis[0].name).toBe('Requests / Sec'); + expect(yAxis[0].name).toBe('Y Axis'); }); }); }); - - afterEach(() => { - wrapper.destroy(); - }); }); describe('wrapped components', () => { @@ -630,7 +601,7 @@ describe('Time series component', () => { beforeEach(() => { createWrapper( - { graphData: { ...mockGraphData, type: dynamicComponent.chartType } }, + { graphData: timeSeriesGraphData({ type: dynamicComponent.chartType }) }, mount, ); return wrapper.vm.$nextTick(); @@ -700,20 +671,12 @@ describe('Time series component', () => { describe('with multiple time series', () => { describe('General functions', () => { beforeEach(() => { - store = createStore(); - const graphData = cloneDeep(metricsDashboardViewModel.panelGroups[0].panels[3]); - graphData.metrics.forEach(metric => - Object.assign(metric, { result: metricResultStatus.result }), - ); + const graphData = timeSeriesGraphData({ type: panelTypes.AREA_CHART, multiMetric: true }); - createWrapper({ graphData: { ...graphData, type: 'area-chart' } }, mount); + createWrapper({ graphData }, mount); return wrapper.vm.$nextTick(); }); - afterEach(() => { - wrapper.destroy(); - }); - describe('Color match', () => { let lineColors; @@ -754,14 +717,10 @@ describe('Time series component', () => { const findLegend = () => wrapper.find(GlChartLegend); beforeEach(() => { - createWrapper(mockGraphData, mount); + createWrapper({}, mount); return wrapper.vm.$nextTick(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('should render a tabular legend layout by default', () => { expect(findLegend().props('layout')).toBe('table'); }); diff --git a/spec/frontend/monitoring/components/create_dashboard_modal_spec.js b/spec/frontend/monitoring/components/create_dashboard_modal_spec.js new file mode 100644 index 00000000000..d1028445638 --- /dev/null +++ b/spec/frontend/monitoring/components/create_dashboard_modal_spec.js @@ -0,0 +1,48 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; +import CreateDashboardModal from '~/monitoring/components/create_dashboard_modal.vue'; + +describe('Create dashboard modal', () => { + let wrapper; + + const defaultProps = { + modalId: 'id', + projectPath: 'https://localhost/', + addDashboardDocumentationPath: 'https://link/to/docs', + }; + + const findDocsButton = () => wrapper.find('[data-testid="create-dashboard-modal-docs-button"]'); + const findRepoButton = () => wrapper.find('[data-testid="create-dashboard-modal-repo-button"]'); + + const createWrapper = (props = {}, options = {}) => { + wrapper = shallowMount(CreateDashboardModal, { + propsData: { ...defaultProps, ...props }, + stubs: { + GlModal, + }, + ...options, + }); + }; + + beforeEach(() => { + createWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('has button that links to the project url', () => { + findRepoButton().trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(findRepoButton().exists()).toBe(true); + expect(findRepoButton().attributes('href')).toBe(defaultProps.projectPath); + }); + }); + + it('has button that links to the docs', () => { + expect(findDocsButton().exists()).toBe(true); + expect(findDocsButton().attributes('href')).toBe(defaultProps.addDashboardDocumentationPath); + }); +}); diff --git a/spec/frontend/monitoring/components/dashboard_header_spec.js b/spec/frontend/monitoring/components/dashboard_header_spec.js new file mode 100644 index 00000000000..5a1a615c703 --- /dev/null +++ b/spec/frontend/monitoring/components/dashboard_header_spec.js @@ -0,0 +1,232 @@ +import { shallowMount } from '@vue/test-utils'; +import { createStore } from '~/monitoring/stores'; +import DashboardHeader from '~/monitoring/components/dashboard_header.vue'; +import DuplicateDashboardModal from '~/monitoring/components/duplicate_dashboard_modal.vue'; +import CreateDashboardModal from '~/monitoring/components/create_dashboard_modal.vue'; +import { setupAllDashboards } from '../store_utils'; +import { + dashboardGitResponse, + selfMonitoringDashboardGitResponse, + dashboardHeaderProps, +} from '../mock_data'; +import { redirectTo } from '~/lib/utils/url_utility'; + +jest.mock('~/lib/utils/url_utility', () => ({ + redirectTo: jest.fn(), + queryToObject: jest.fn(), + mergeUrlParams: jest.requireActual('~/lib/utils/url_utility').mergeUrlParams, +})); + +describe('Dashboard header', () => { + let store; + let wrapper; + + const findActionsMenu = () => wrapper.find('[data-testid="actions-menu"]'); + const findCreateDashboardMenuItem = () => + findActionsMenu().find('[data-testid="action-create-dashboard"]'); + const findCreateDashboardDuplicateItem = () => + findActionsMenu().find('[data-testid="action-duplicate-dashboard"]'); + const findDuplicateDashboardModal = () => wrapper.find(DuplicateDashboardModal); + const findCreateDashboardModal = () => wrapper.find('[data-testid="create-dashboard-modal"]'); + + const createShallowWrapper = (props = {}, options = {}) => { + wrapper = shallowMount(DashboardHeader, { + propsData: { ...dashboardHeaderProps, ...props }, + store, + ...options, + }); + }; + + beforeEach(() => { + store = createStore(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when a dashboard has been duplicated in the duplicate dashboard modal', () => { + beforeEach(() => { + store.state.monitoringDashboard.projectPath = 'root/sandbox'; + }); + /** + * The duplicate dashboard modal gets called both by a menu item from the + * dashboards dropdown and by an item from the actions menu. + * + * This spec is context agnostic, so it addresses all cases where the + * duplicate dashboard modal gets called. + */ + it('redirects to the newly created dashboard', () => { + delete window.location; + window.location = new URL('https://localhost'); + + const newDashboard = dashboardGitResponse[1]; + + createShallowWrapper(); + + const newDashboardUrl = 'root/sandbox/-/metrics/dashboard.yml'; + findDuplicateDashboardModal().vm.$emit('dashboardDuplicated', newDashboard); + + return wrapper.vm.$nextTick().then(() => { + expect(redirectTo).toHaveBeenCalled(); + expect(redirectTo).toHaveBeenCalledWith(newDashboardUrl); + }); + }); + }); + + describe('actions menu', () => { + beforeEach(() => { + store.state.monitoringDashboard.projectPath = ''; + createShallowWrapper(); + }); + + it('is rendered if projectPath is set in store', () => { + store.state.monitoringDashboard.projectPath = 'https://path/to/project'; + + return wrapper.vm.$nextTick().then(() => { + expect(findActionsMenu().exists()).toBe(true); + }); + }); + + it('is not rendered if projectPath is not set in store', () => { + expect(findActionsMenu().exists()).toBe(false); + }); + + it('contains a modal', () => { + store.state.monitoringDashboard.projectPath = 'https://path/to/project'; + + return wrapper.vm.$nextTick().then(() => { + expect(findActionsMenu().contains(CreateDashboardModal)).toBe(true); + }); + }); + + const duplicableCases = [ + null, // When no path is specified, it uses the default dashboard path. + dashboardGitResponse[0].path, + dashboardGitResponse[2].path, + selfMonitoringDashboardGitResponse[0].path, + ]; + + describe.each(duplicableCases)( + 'when the selected dashboard can be duplicated', + dashboardPath => { + it('contains a "Create New" menu item and a "Duplicate Dashboard" menu item', () => { + store.state.monitoringDashboard.projectPath = 'https://path/to/project'; + setupAllDashboards(store, dashboardPath); + + return wrapper.vm.$nextTick().then(() => { + expect(findCreateDashboardMenuItem().exists()).toBe(true); + expect(findCreateDashboardDuplicateItem().exists()).toBe(true); + }); + }); + }, + ); + + const nonDuplicableCases = [ + dashboardGitResponse[1].path, + selfMonitoringDashboardGitResponse[1].path, + ]; + + describe.each(nonDuplicableCases)( + 'when the selected dashboard cannot be duplicated', + dashboardPath => { + it('contains a "Create New" menu item and no "Duplicate Dashboard" menu item', () => { + store.state.monitoringDashboard.projectPath = 'https://path/to/project'; + setupAllDashboards(store, dashboardPath); + + return wrapper.vm.$nextTick().then(() => { + expect(findCreateDashboardMenuItem().exists()).toBe(true); + expect(findCreateDashboardDuplicateItem().exists()).toBe(false); + }); + }); + }, + ); + }); + + describe('actions menu modals', () => { + const url = 'https://path/to/project'; + + beforeEach(() => { + store.state.monitoringDashboard.projectPath = url; + setupAllDashboards(store); + + createShallowWrapper(); + }); + + it('Clicking on "Create New" opens up a modal', () => { + const modalId = 'createDashboard'; + const modalTrigger = findCreateDashboardMenuItem(); + const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit'); + + modalTrigger.trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(rootEmit.mock.calls[0]).toContainEqual(modalId); + }); + }); + + it('"Create new dashboard" modal contains correct buttons', () => { + expect(findCreateDashboardModal().props('projectPath')).toBe(url); + }); + + it('"Duplicate Dashboard" opens up a modal', () => { + const modalId = 'duplicateDashboard'; + const modalTrigger = findCreateDashboardDuplicateItem(); + const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit'); + + modalTrigger.trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(rootEmit.mock.calls[0]).toContainEqual(modalId); + }); + }); + }); + + describe('metrics settings button', () => { + const findSettingsButton = () => wrapper.find('[data-testid="metrics-settings-button"]'); + const url = 'https://path/to/project/settings'; + + beforeEach(() => { + createShallowWrapper(); + + store.state.monitoringDashboard.canAccessOperationsSettings = false; + store.state.monitoringDashboard.operationsSettingsPath = ''; + }); + + it('is rendered when the user can access the project settings and path to settings is available', () => { + store.state.monitoringDashboard.canAccessOperationsSettings = true; + store.state.monitoringDashboard.operationsSettingsPath = url; + + return wrapper.vm.$nextTick(() => { + expect(findSettingsButton().exists()).toBe(true); + }); + }); + + it('is not rendered when the user can not access the project settings', () => { + store.state.monitoringDashboard.canAccessOperationsSettings = false; + store.state.monitoringDashboard.operationsSettingsPath = url; + + return wrapper.vm.$nextTick(() => { + expect(findSettingsButton().exists()).toBe(false); + }); + }); + + it('is not rendered when the path to settings is unavailable', () => { + store.state.monitoringDashboard.canAccessOperationsSettings = false; + store.state.monitoringDashboard.operationsSettingsPath = ''; + + return wrapper.vm.$nextTick(() => { + expect(findSettingsButton().exists()).toBe(false); + }); + }); + + it('leads to the project settings page', () => { + store.state.monitoringDashboard.canAccessOperationsSettings = true; + store.state.monitoringDashboard.operationsSettingsPath = url; + + return wrapper.vm.$nextTick(() => { + expect(findSettingsButton().attributes('href')).toBe(url); + }); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js index 0ad6e04588f..693818aa55a 100644 --- a/spec/frontend/monitoring/components/dashboard_panel_spec.js +++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js @@ -9,17 +9,16 @@ import AlertWidget from '~/monitoring/components/alert_widget.vue'; import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; import { - anomalyMockGraphData, mockLogsHref, mockLogsPath, mockNamespace, mockNamespacedData, mockTimeRange, - singleStatMetricsResult, graphDataPrometheusQueryRangeMultiTrack, barMockData, - propsData, } from '../mock_data'; +import { dashboardProps, graphData, graphDataEmpty } from '../fixture_data'; +import { anomalyGraphData, singleStatGraphData } from '../graph_data'; import { panelTypes } from '~/monitoring/constants'; @@ -32,7 +31,6 @@ import MonitorColumnChart from '~/monitoring/components/charts/column.vue'; import MonitorBarChart from '~/monitoring/components/charts/bar.vue'; import MonitorStackedColumnChart from '~/monitoring/components/charts/stacked_column.vue'; -import { graphData, graphDataEmpty } from '../fixture_data'; import { createStore, monitoringDashboard } from '~/monitoring/stores'; import { createStore as createEmbedGroupStore } from '~/monitoring/stores/embed_group'; @@ -63,7 +61,7 @@ describe('Dashboard Panel', () => { wrapper = shallowMount(DashboardPanel, { propsData: { graphData, - settingsPath: propsData.settingsPath, + settingsPath: dashboardProps.settingsPath, ...props, }, store, @@ -137,10 +135,6 @@ describe('Dashboard Panel', () => { expect(wrapper.find(MonitorEmptyChart).exists()).toBe(true); expect(wrapper.find(MonitorEmptyChart).isVueInstance()).toBe(true); }); - - it('does not contain a tabindex attribute', () => { - expect(wrapper.find(MonitorEmptyChart).contains('[tabindex]')).toBe(false); - }); }); describe('When graphData is null', () => { @@ -233,23 +227,32 @@ describe('Dashboard Panel', () => { expect(wrapper.find(MonitorTimeSeriesChart).isVueInstance()).toBe(true); }); - it.each` - data | component - ${dataWithType(panelTypes.AREA_CHART)} | ${MonitorTimeSeriesChart} - ${dataWithType(panelTypes.LINE_CHART)} | ${MonitorTimeSeriesChart} - ${anomalyMockGraphData} | ${MonitorAnomalyChart} - ${dataWithType(panelTypes.COLUMN)} | ${MonitorColumnChart} - ${dataWithType(panelTypes.STACKED_COLUMN)} | ${MonitorStackedColumnChart} - ${singleStatMetricsResult} | ${MonitorSingleStatChart} - ${graphDataPrometheusQueryRangeMultiTrack} | ${MonitorHeatmapChart} - ${barMockData} | ${MonitorBarChart} - `('wrapps a $data.type component binding attributes', ({ data, component }) => { + describe.each` + data | component | hasCtxMenu + ${dataWithType(panelTypes.AREA_CHART)} | ${MonitorTimeSeriesChart} | ${true} + ${dataWithType(panelTypes.LINE_CHART)} | ${MonitorTimeSeriesChart} | ${true} + ${singleStatGraphData()} | ${MonitorSingleStatChart} | ${true} + ${anomalyGraphData()} | ${MonitorAnomalyChart} | ${false} + ${dataWithType(panelTypes.COLUMN)} | ${MonitorColumnChart} | ${false} + ${dataWithType(panelTypes.STACKED_COLUMN)} | ${MonitorStackedColumnChart} | ${false} + ${graphDataPrometheusQueryRangeMultiTrack} | ${MonitorHeatmapChart} | ${false} + ${barMockData} | ${MonitorBarChart} | ${false} + `('when $data.type data is provided', ({ data, component, hasCtxMenu }) => { const attrs = { attr1: 'attr1Value', attr2: 'attr2Value' }; - createWrapper({ graphData: data }, { attrs }); - expect(wrapper.find(component).exists()).toBe(true); - expect(wrapper.find(component).isVueInstance()).toBe(true); - expect(wrapper.find(component).attributes()).toMatchObject(attrs); + beforeEach(() => { + createWrapper({ graphData: data }, { attrs }); + }); + + it(`renders the chart component and binds attributes`, () => { + expect(wrapper.find(component).exists()).toBe(true); + expect(wrapper.find(component).isVueInstance()).toBe(true); + expect(wrapper.find(component).attributes()).toMatchObject(attrs); + }); + + it(`contextual menu is ${hasCtxMenu ? '' : 'not '}shown`, () => { + expect(findCtxMenu().exists()).toBe(hasCtxMenu); + }); }); }); }); @@ -307,7 +310,7 @@ describe('Dashboard Panel', () => { return wrapper.vm.$nextTick(() => { expect(findEditCustomMetricLink().text()).toBe('Edit metrics'); - expect(findEditCustomMetricLink().attributes('href')).toBe(propsData.settingsPath); + expect(findEditCustomMetricLink().attributes('href')).toBe(dashboardProps.settingsPath); }); }); }); @@ -361,7 +364,7 @@ describe('Dashboard Panel', () => { }); }); - it('it is overriden when a datazoom event is received', () => { + it('it is overridden when a datazoom event is received', () => { state.logsPath = mockLogsPath; state.timeRange = mockTimeRange; @@ -424,7 +427,7 @@ describe('Dashboard Panel', () => { wrapper = shallowMount(DashboardPanel, { propsData: { clipboardText: exampleText, - settingsPath: propsData.settingsPath, + settingsPath: dashboardProps.settingsPath, graphData: { y_label: 'metric', ...graphData, @@ -474,7 +477,7 @@ describe('Dashboard Panel', () => { wrapper = shallowMount(DashboardPanel, { propsData: { graphData, - settingsPath: propsData.settingsPath, + settingsPath: dashboardProps.settingsPath, namespace: mockNamespace, }, store, diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index 7bb4c68b4cd..4b7f7a9ddb3 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -6,16 +6,18 @@ import { objectToQuery } from '~/lib/utils/url_utility'; import VueDraggable from 'vuedraggable'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; -import { metricStates } from '~/monitoring/constants'; +import { dashboardEmptyStates, metricStates } from '~/monitoring/constants'; import Dashboard from '~/monitoring/components/dashboard.vue'; import DashboardHeader from '~/monitoring/components/dashboard_header.vue'; +import RefreshButton from '~/monitoring/components/refresh_button.vue'; import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue'; import EmptyState from '~/monitoring/components/empty_state.vue'; import GroupEmptyState from '~/monitoring/components/group_empty_state.vue'; import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; +import GraphGroup from '~/monitoring/components/graph_group.vue'; import LinksSection from '~/monitoring/components/links_section.vue'; import { createStore } from '~/monitoring/stores'; import * as types from '~/monitoring/stores/mutation_types'; @@ -24,12 +26,17 @@ import { setupStoreWithDashboard, setMetricResult, setupStoreWithData, - setupStoreWithVariable, + setupStoreWithDataForPanelCount, setupStoreWithLinks, } from '../store_utils'; -import { environmentData, dashboardGitResponse, propsData } from '../mock_data'; -import { metricsDashboardViewModel, metricsDashboardPanelCount } from '../fixture_data'; +import { environmentData, dashboardGitResponse, storeVariables } from '../mock_data'; +import { + metricsDashboardViewModel, + metricsDashboardPanelCount, + dashboardProps, +} from '../fixture_data'; import createFlash from '~/flash'; +import { TEST_HOST } from 'helpers/test_constants'; jest.mock('~/flash'); @@ -48,7 +55,7 @@ describe('Dashboard', () => { const createShallowWrapper = (props = {}, options = {}) => { wrapper = shallowMount(Dashboard, { - propsData: { ...propsData, ...props }, + propsData: { ...dashboardProps, ...props }, store, stubs: { DashboardHeader, @@ -59,7 +66,7 @@ describe('Dashboard', () => { const createMountedWrapper = (props = {}, options = {}) => { wrapper = mount(Dashboard, { - propsData: { ...propsData, ...props }, + propsData: { ...dashboardProps, ...props }, store, stubs: { 'graph-group': true, @@ -120,13 +127,13 @@ describe('Dashboard', () => { }); it('shows up a loading state', () => { - store.state.monitoringDashboard.emptyState = 'loading'; + store.state.monitoringDashboard.emptyState = dashboardEmptyStates.LOADING; createShallowWrapper({ hasMetrics: true }); return wrapper.vm.$nextTick().then(() => { expect(wrapper.find(EmptyState).exists()).toBe(true); - expect(wrapper.find(EmptyState).props('selectedState')).toBe('loading'); + expect(wrapper.find(EmptyState).props('selectedState')).toBe(dashboardEmptyStates.LOADING); }); }); @@ -136,7 +143,7 @@ describe('Dashboard', () => { setupStoreWithData(store); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.showEmptyState).toEqual(false); + expect(wrapper.vm.emptyState).toBeNull(); expect(wrapper.findAll('.prometheus-panel')).toHaveLength(0); }); }); @@ -157,6 +164,103 @@ describe('Dashboard', () => { }); }); + describe('panel containers layout', () => { + const findPanelLayoutWrapperAt = index => { + return wrapper + .find(GraphGroup) + .findAll('[data-testid="dashboard-panel-layout-wrapper"]') + .at(index); + }; + + beforeEach(() => { + createMountedWrapper({ hasMetrics: true }); + + return wrapper.vm.$nextTick(); + }); + + describe('when the graph group has an even number of panels', () => { + it('2 panels - all panel wrappers take half width of their parent', () => { + setupStoreWithDataForPanelCount(store, 2); + + wrapper.vm.$nextTick(() => { + expect(findPanelLayoutWrapperAt(0).classes('col-lg-6')).toBe(true); + expect(findPanelLayoutWrapperAt(1).classes('col-lg-6')).toBe(true); + }); + }); + + it('4 panels - all panel wrappers take half width of their parent', () => { + setupStoreWithDataForPanelCount(store, 4); + + wrapper.vm.$nextTick(() => { + expect(findPanelLayoutWrapperAt(0).classes('col-lg-6')).toBe(true); + expect(findPanelLayoutWrapperAt(1).classes('col-lg-6')).toBe(true); + expect(findPanelLayoutWrapperAt(2).classes('col-lg-6')).toBe(true); + expect(findPanelLayoutWrapperAt(3).classes('col-lg-6')).toBe(true); + }); + }); + }); + + describe('when the graph group has an odd number of panels', () => { + it('1 panel - panel wrapper does not take half width of its parent', () => { + setupStoreWithDataForPanelCount(store, 1); + + wrapper.vm.$nextTick(() => { + expect(findPanelLayoutWrapperAt(0).classes('col-lg-6')).toBe(false); + }); + }); + + it('3 panels - all panels but last take half width of their parents', () => { + setupStoreWithDataForPanelCount(store, 3); + + wrapper.vm.$nextTick(() => { + expect(findPanelLayoutWrapperAt(0).classes('col-lg-6')).toBe(true); + expect(findPanelLayoutWrapperAt(1).classes('col-lg-6')).toBe(true); + expect(findPanelLayoutWrapperAt(2).classes('col-lg-6')).toBe(false); + }); + }); + + it('5 panels - all panels but last take half width of their parents', () => { + setupStoreWithDataForPanelCount(store, 5); + + wrapper.vm.$nextTick(() => { + expect(findPanelLayoutWrapperAt(0).classes('col-lg-6')).toBe(true); + expect(findPanelLayoutWrapperAt(1).classes('col-lg-6')).toBe(true); + expect(findPanelLayoutWrapperAt(2).classes('col-lg-6')).toBe(true); + expect(findPanelLayoutWrapperAt(3).classes('col-lg-6')).toBe(true); + expect(findPanelLayoutWrapperAt(4).classes('col-lg-6')).toBe(false); + }); + }); + }); + }); + + describe('dashboard validation warning', () => { + it('displays a warning if there are validation warnings', () => { + createMountedWrapper({ hasMetrics: true }); + + store.commit( + `monitoringDashboard/${types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS}`, + true, + ); + + return wrapper.vm.$nextTick().then(() => { + expect(createFlash).toHaveBeenCalled(); + }); + }); + + it('does not display a warning if there are no validation warnings', () => { + createMountedWrapper({ hasMetrics: true }); + + store.commit( + `monitoringDashboard/${types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS}`, + false, + ); + + return wrapper.vm.$nextTick().then(() => { + expect(createFlash).not.toHaveBeenCalled(); + }); + }); + }); + describe('when the URL contains a reference to a panel', () => { let location; @@ -323,12 +427,72 @@ describe('Dashboard', () => { ); }); }); + + describe('when custom dashboard is selected', () => { + const windowLocation = window.location; + const findDashboardDropdown = () => wrapper.find(DashboardHeader).find(DashboardsDropdown); + + beforeEach(() => { + store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { + projectPath: TEST_HOST, + }); + + delete window.location; + window.location = { ...windowLocation, assign: jest.fn() }; + createMountedWrapper(); + + return wrapper.vm.$nextTick(); + }); + + afterEach(() => { + window.location = windowLocation; + }); + + it('encodes dashboard param', () => { + findDashboardDropdown().vm.$emit('selectDashboard', { + path: '.gitlab/dashboards/dashboard©.yml', + display_name: 'dashboard©.yml', + }); + expect(window.location.assign).toHaveBeenCalledWith( + `${TEST_HOST}/-/metrics/dashboard%26copy.yml`, + ); + }); + }); + }); + + describe('when all panels in the first group are loading', () => { + const findGroupAt = i => wrapper.findAll(GraphGroup).at(i); + + beforeEach(() => { + setupStoreWithDashboard(store); + + const { panels } = store.state.monitoringDashboard.dashboard.panelGroups[0]; + panels.forEach(({ metrics }) => { + store.commit(`monitoringDashboard/${types.REQUEST_METRIC_RESULT}`, { + metricId: metrics[0].metricId, + }); + }); + + createShallowWrapper(); + + return wrapper.vm.$nextTick(); + }); + + it('a loading icon appears in the first group', () => { + expect(findGroupAt(0).props('isLoading')).toBe(true); + }); + + it('a loading icon does not appear in the second group', () => { + expect(findGroupAt(1).props('isLoading')).toBe(false); + }); }); describe('when all requests have been commited by the store', () => { beforeEach(() => { store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { currentEnvironmentName: 'production', + currentDashboard: dashboardGitResponse[0].path, + projectPath: TEST_HOST, }); createMountedWrapper({ hasMetrics: true }); setupStoreWithData(store); @@ -341,13 +505,26 @@ describe('Dashboard', () => { findAllEnvironmentsDropdownItems().wrappers.forEach((itemWrapper, index) => { const anchorEl = itemWrapper.find('a'); - if (anchorEl.exists() && environmentData[index].metrics_path) { + if (anchorEl.exists()) { const href = anchorEl.attributes('href'); - expect(href).toBe(environmentData[index].metrics_path); + const currentDashboard = encodeURIComponent(dashboardGitResponse[0].path); + const environmentId = encodeURIComponent(environmentData[index].id); + const url = `${TEST_HOST}/-/metrics/${currentDashboard}?environment=${environmentId}`; + expect(href).toBe(url); } }); }); + it('it does not show loading icons in any group', () => { + setupStoreWithData(store); + + wrapper.vm.$nextTick(() => { + wrapper.findAll(GraphGroup).wrappers.forEach(groupWrapper => { + expect(groupWrapper.props('isLoading')).toBe(false); + }); + }); + }); + // Note: This test is not working, .active does not show the active environment // eslint-disable-next-line jest/no-disabled-tests it.skip('renders the environments dropdown with a single active element', () => { @@ -464,10 +641,9 @@ describe('Dashboard', () => { setupStoreWithData(store); return wrapper.vm.$nextTick().then(() => { - const refreshBtn = wrapper.find(DashboardHeader).findAll({ ref: 'refreshDashboardBtn' }); + const refreshBtn = wrapper.find(DashboardHeader).find(RefreshButton); - expect(refreshBtn).toHaveLength(1); - expect(refreshBtn.is(GlDeprecatedButton)).toBe(true); + expect(refreshBtn.exists()).toBe(true); }); }); @@ -475,8 +651,7 @@ describe('Dashboard', () => { beforeEach(() => { createShallowWrapper({ hasMetrics: true }); setupStoreWithData(store); - setupStoreWithVariable(store); - + store.state.monitoringDashboard.variables = storeVariables; return wrapper.vm.$nextTick(); }); @@ -1041,6 +1216,34 @@ describe('Dashboard', () => { }); }); + describe('keyboard shortcuts', () => { + const currentDashboard = dashboardGitResponse[1].path; + const panelRef = 'dashboard-panel-response-metrics-aws-elb-4-1'; // skip expanded panel + + // While the recommendation in the documentation is to test + // with a data-testid attribute, I want to make sure that + // the dashboard panels have a ref attribute set. + const getDashboardPanel = () => wrapper.find({ ref: panelRef }); + + beforeEach(() => { + setupStoreWithData(store); + store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { + currentDashboard, + }); + createShallowWrapper({ hasMetrics: true }); + + wrapper.setData({ hoveredPanel: panelRef }); + + return wrapper.vm.$nextTick(); + }); + + it('contains a ref attribute inside a DashboardPanel component', () => { + const dashboardPanel = getDashboardPanel(); + + expect(dashboardPanel.exists()).toBe(true); + }); + }); + describe('add custom metrics', () => { const findAddMetricButton = () => wrapper.find(DashboardHeader).find({ ref: 'addMetricBtn' }); @@ -1082,7 +1285,7 @@ describe('Dashboard', () => { it('uses modal for custom metrics form', () => { expect(wrapper.find(GlModal).exists()).toBe(true); - expect(wrapper.find(GlModal).attributes().modalid).toBe('add-metric'); + expect(wrapper.find(GlModal).attributes().modalid).toBe('addMetric'); }); it('adding new metric is tracked', done => { const submitButton = wrapper diff --git a/spec/frontend/monitoring/components/dashboard_template_spec.js b/spec/frontend/monitoring/components/dashboard_template_spec.js index a1a450d4abe..8941e57c4ce 100644 --- a/spec/frontend/monitoring/components/dashboard_template_spec.js +++ b/spec/frontend/monitoring/components/dashboard_template_spec.js @@ -5,7 +5,7 @@ import Dashboard from '~/monitoring/components/dashboard.vue'; import DashboardHeader from '~/monitoring/components/dashboard_header.vue'; import { createStore } from '~/monitoring/stores'; import { setupAllDashboards } from '../store_utils'; -import { propsData } from '../mock_data'; +import { dashboardProps } from '../fixture_data'; jest.mock('~/lib/utils/url_utility'); @@ -29,7 +29,7 @@ describe('Dashboard template', () => { it('matches the default snapshot', () => { wrapper = shallowMount(Dashboard, { - propsData: { ...propsData }, + propsData: { ...dashboardProps }, store, stubs: { DashboardHeader, diff --git a/spec/frontend/monitoring/components/dashboard_url_time_spec.js b/spec/frontend/monitoring/components/dashboard_url_time_spec.js index a74c621db9b..276e20bae6a 100644 --- a/spec/frontend/monitoring/components/dashboard_url_time_spec.js +++ b/spec/frontend/monitoring/components/dashboard_url_time_spec.js @@ -9,7 +9,8 @@ import { updateHistory, } from '~/lib/utils/url_utility'; import axios from '~/lib/utils/axios_utils'; -import { mockProjectDir, propsData } from '../mock_data'; +import { mockProjectDir } from '../mock_data'; +import { dashboardProps } from '../fixture_data'; import Dashboard from '~/monitoring/components/dashboard.vue'; import DashboardHeader from '~/monitoring/components/dashboard_header.vue'; @@ -26,7 +27,7 @@ describe('dashboard invalid url parameters', () => { const createMountedWrapper = (props = { hasMetrics: true }, options = {}) => { wrapper = mount(Dashboard, { - propsData: { ...propsData, ...props }, + propsData: { ...dashboardProps, ...props }, store, stubs: { 'graph-group': true, 'dashboard-panel': true, 'dashboard-header': DashboardHeader }, ...options, diff --git a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js index b29d86cbc5b..d09fcc92ee7 100644 --- a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js +++ b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js @@ -1,14 +1,12 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDropdownItem, GlModal, GlLoadingIcon, GlAlert, GlIcon } from '@gitlab/ui'; -import waitForPromises from 'helpers/wait_for_promises'; +import { GlDropdownItem, GlIcon } from '@gitlab/ui'; import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue'; -import DuplicateDashboardForm from '~/monitoring/components/duplicate_dashboard_form.vue'; -import { dashboardGitResponse } from '../mock_data'; +import { dashboardGitResponse, selfMonitoringDashboardGitResponse } from '../mock_data'; const defaultBranch = 'master'; - +const modalId = 'duplicateDashboardModalId'; const starredDashboards = dashboardGitResponse.filter(({ starred }) => starred); const notStarredDashboards = dashboardGitResponse.filter(({ starred }) => !starred); @@ -32,6 +30,7 @@ describe('DashboardsDropdown', () => { propsData: { ...props, defaultBranch, + modalId, }, sync: false, ...storeOpts, @@ -82,7 +81,7 @@ describe('DashboardsDropdown', () => { const searchTerm = 'Default'; setSearchTerm(searchTerm); - return wrapper.vm.$nextTick(() => { + return wrapper.vm.$nextTick().then(() => { expect(findItems()).toHaveLength(1); }); }); @@ -91,7 +90,7 @@ describe('DashboardsDropdown', () => { const searchTerm = 'does-not-exist'; setSearchTerm(searchTerm); - return wrapper.vm.$nextTick(() => { + return wrapper.vm.$nextTick().then(() => { expect(findNoItemsMsg().isVisible()).toBe(true); }); }); @@ -151,12 +150,18 @@ describe('DashboardsDropdown', () => { }); }); - describe('when a system dashboard is selected', () => { + const duplicableCases = [ + dashboardGitResponse[0], + dashboardGitResponse[2], + selfMonitoringDashboardGitResponse[0], + ]; + + describe.each(duplicableCases)('when the selected dashboard can be duplicated', dashboard => { let duplicateDashboardAction; let modalDirective; beforeEach(() => { - [mockSelectedDashboard] = dashboardGitResponse; + mockSelectedDashboard = dashboard; modalDirective = jest.fn(); duplicateDashboardAction = jest.fn().mockResolvedValue(); @@ -172,152 +177,59 @@ describe('DashboardsDropdown', () => { }, }, ); - - wrapper.vm.$refs.duplicateDashboardModal.hide = jest.fn(); }); - it('displays an item for each dashboard plus a "duplicate dashboard" item', () => { - const item = wrapper.findAll({ ref: 'duplicateDashboardItem' }); - + it('displays a dropdown item for each dashboard', () => { expect(findItems().length).toEqual(dashboardGitResponse.length + 1); - expect(item.length).toBe(1); }); - describe('modal form', () => { - let okEvent; - - const findModal = () => wrapper.find(GlModal); - const findAlert = () => wrapper.find(GlAlert); - - beforeEach(() => { - okEvent = { - preventDefault: jest.fn(), - }; - }); - - it('exists and contains a form to duplicate a dashboard', () => { - expect(findModal().exists()).toBe(true); - expect(findModal().contains(DuplicateDashboardForm)).toBe(true); - }); - - it('saves a new dashboard', () => { - findModal().vm.$emit('ok', okEvent); - - return waitForPromises().then(() => { - expect(okEvent.preventDefault).toHaveBeenCalled(); - - expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); - expect(wrapper.vm.$refs.duplicateDashboardModal.hide).toHaveBeenCalled(); - expect(wrapper.emitted().selectDashboard).toBeTruthy(); - expect(findAlert().exists()).toBe(false); - }); - }); - - describe('when a new dashboard is saved succesfully', () => { - const newDashboard = { - can_edit: true, - default: false, - display_name: 'A new dashboard', - system_dashboard: false, - }; - - const submitForm = formVals => { - duplicateDashboardAction.mockResolvedValueOnce(newDashboard); - findModal() - .find(DuplicateDashboardForm) - .vm.$emit('change', { - dashboard: 'common_metrics.yml', - commitMessage: 'A commit message', - ...formVals, - }); - findModal().vm.$emit('ok', okEvent); - }; - - it('to the default branch, redirects to the new dashboard', () => { - submitForm({ - branch: defaultBranch, - }); - - return waitForPromises().then(() => { - expect(wrapper.emitted().selectDashboard[0][0]).toEqual(newDashboard); - }); - }); - - it('to a new branch refreshes in the current dashboard', () => { - submitForm({ - branch: 'another-branch', - }); - - return waitForPromises().then(() => { - expect(wrapper.emitted().selectDashboard[0][0]).toEqual(dashboardGitResponse[0]); - }); - }); - }); - - it('handles error when a new dashboard is not saved', () => { - const errMsg = 'An error occurred'; - - duplicateDashboardAction.mockRejectedValueOnce(errMsg); - findModal().vm.$emit('ok', okEvent); + it('displays one "duplicate dashboard" dropdown item with a directive attached', () => { + const item = wrapper.findAll('[data-testid="duplicateDashboardItem"]'); - return waitForPromises().then(() => { - expect(okEvent.preventDefault).toHaveBeenCalled(); + expect(item.length).toBe(1); + }); - expect(findAlert().exists()).toBe(true); - expect(findAlert().text()).toBe(errMsg); + it('"duplicate dashboard" dropdown item directive works', () => { + const item = wrapper.find('[data-testid="duplicateDashboardItem"]'); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); - expect(wrapper.vm.$refs.duplicateDashboardModal.hide).not.toHaveBeenCalled(); - }); - }); + item.trigger('click'); - it('id is correct, as the value of modal directive binding matches modal id', () => { - expect(modalDirective).toHaveBeenCalledTimes(1); - - // Binding's second argument contains the modal id - expect(modalDirective.mock.calls[0][1]).toEqual( - expect.objectContaining({ - value: findModal().props('modalId'), - }), - ); + return wrapper.vm.$nextTick().then(() => { + expect(modalDirective).toHaveBeenCalled(); }); + }); - it('updates the form on changes', () => { - const formVals = { - dashboard: 'common_metrics.yml', - commitMessage: 'A commit message', - }; - - findModal() - .find(DuplicateDashboardForm) - .vm.$emit('change', formVals); + it('id is correct, as the value of modal directive binding matches modal id', () => { + expect(modalDirective).toHaveBeenCalledTimes(1); - // Binding's second argument contains the modal id - expect(wrapper.vm.form).toEqual(formVals); - }); + // Binding's second argument contains the modal id + expect(modalDirective.mock.calls[0][1]).toEqual( + expect.objectContaining({ + value: modalId, + }), + ); }); }); - describe('when a custom dashboard is selected', () => { - const findModal = () => wrapper.find(GlModal); + const nonDuplicableCases = [dashboardGitResponse[1], selfMonitoringDashboardGitResponse[1]]; - beforeEach(() => { - wrapper = createComponent({ - selectedDashboard: dashboardGitResponse[1], + describe.each(nonDuplicableCases)( + 'when the selected dashboard can not be duplicated', + dashboard => { + beforeEach(() => { + mockSelectedDashboard = dashboard; + + wrapper = createComponent(); }); - }); - it('displays an item for each dashboard', () => { - const item = wrapper.findAll({ ref: 'duplicateDashboardItem' }); + it('displays a dropdown list item for each dashboard, but no list item for "duplicate dashboard"', () => { + const item = wrapper.findAll('[data-testid="duplicateDashboardItem"]'); - expect(findItems()).toHaveLength(dashboardGitResponse.length); - expect(item.length).toBe(0); - }); - - it('modal form does not exist and contains a form to duplicate a dashboard', () => { - expect(findModal().exists()).toBe(false); - }); - }); + expect(findItems()).toHaveLength(dashboardGitResponse.length); + expect(item.length).toBe(0); + }); + }, + ); describe('when a dashboard gets selected by the user', () => { beforeEach(() => { diff --git a/spec/frontend/monitoring/components/duplicate_dashboard_modal_spec.js b/spec/frontend/monitoring/components/duplicate_dashboard_modal_spec.js new file mode 100644 index 00000000000..d8ffb4443ac --- /dev/null +++ b/spec/frontend/monitoring/components/duplicate_dashboard_modal_spec.js @@ -0,0 +1,111 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlAlert, GlLoadingIcon, GlModal } from '@gitlab/ui'; + +import waitForPromises from 'helpers/wait_for_promises'; + +import DuplicateDashboardModal from '~/monitoring/components/duplicate_dashboard_modal.vue'; +import DuplicateDashboardForm from '~/monitoring/components/duplicate_dashboard_form.vue'; + +import { dashboardGitResponse } from '../mock_data'; + +describe('duplicate dashboard modal', () => { + let wrapper; + let mockDashboards; + let mockSelectedDashboard; + let duplicateDashboardAction; + let okEvent; + + function createComponent(opts = {}) { + const storeOpts = { + methods: { + duplicateSystemDashboard: jest.fn(), + }, + computed: { + allDashboards: () => mockDashboards, + selectedDashboard: () => mockSelectedDashboard, + }, + }; + + return shallowMount(DuplicateDashboardModal, { + propsData: { + defaultBranch: 'master', + modalId: 'id', + }, + sync: false, + ...storeOpts, + ...opts, + }); + } + + const findAlert = () => wrapper.find(GlAlert); + const findModal = () => wrapper.find(GlModal); + const findDuplicateDashboardForm = () => wrapper.find(DuplicateDashboardForm); + + beforeEach(() => { + mockDashboards = dashboardGitResponse; + [mockSelectedDashboard] = dashboardGitResponse; + + duplicateDashboardAction = jest.fn().mockResolvedValue(); + + okEvent = { + preventDefault: jest.fn(), + }; + + wrapper = createComponent({ + methods: { + // Mock vuex actions + duplicateSystemDashboard: duplicateDashboardAction, + }, + }); + + wrapper.vm.$refs.duplicateDashboardModal.hide = jest.fn(); + }); + + it('contains a form to duplicate a dashboard', () => { + expect(findDuplicateDashboardForm().exists()).toBe(true); + }); + + it('saves a new dashboard', () => { + findModal().vm.$emit('ok', okEvent); + + return waitForPromises().then(() => { + expect(okEvent.preventDefault).toHaveBeenCalled(); + expect(wrapper.emitted().dashboardDuplicated).toBeTruthy(); + expect(wrapper.emitted().dashboardDuplicated[0]).toEqual([dashboardGitResponse[0]]); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.vm.$refs.duplicateDashboardModal.hide).toHaveBeenCalled(); + expect(findAlert().exists()).toBe(false); + }); + }); + + it('handles error when a new dashboard is not saved', () => { + const errMsg = 'An error occurred'; + + duplicateDashboardAction.mockRejectedValueOnce(errMsg); + findModal().vm.$emit('ok', okEvent); + + return waitForPromises().then(() => { + expect(okEvent.preventDefault).toHaveBeenCalled(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(errMsg); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.vm.$refs.duplicateDashboardModal.hide).not.toHaveBeenCalled(); + }); + }); + + it('updates the form on changes', () => { + const formVals = { + dashboard: 'common_metrics.yml', + commitMessage: 'A commit message', + }; + + findModal() + .find(DuplicateDashboardForm) + .vm.$emit('change', formVals); + + // Binding's second argument contains the modal id + expect(wrapper.vm.form).toEqual(formVals); + }); +}); diff --git a/spec/frontend/monitoring/components/empty_state_spec.js b/spec/frontend/monitoring/components/empty_state_spec.js index e985e5fb443..abb8b21e9f4 100644 --- a/spec/frontend/monitoring/components/empty_state_spec.js +++ b/spec/frontend/monitoring/components/empty_state_spec.js @@ -1,10 +1,11 @@ import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; +import { dashboardEmptyStates } from '~/monitoring/constants'; import EmptyState from '~/monitoring/components/empty_state.vue'; function createComponent(props) { return shallowMount(EmptyState, { propsData: { - ...props, settingsPath: '/settingsPath', clustersPath: '/clustersPath', documentationPath: '/documentationPath', @@ -13,30 +14,40 @@ function createComponent(props) { emptyNoDataSvgPath: '/path/to/no-data.svg', emptyNoDataSmallSvgPath: '/path/to/no-data-small.svg', emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg', + ...props, }, }); } describe('EmptyState', () => { + it('shows loading state with a loading icon', () => { + const wrapper = createComponent({ + selectedState: dashboardEmptyStates.LOADING, + }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.find(GlEmptyState).exists()).toBe(false); + }); + it('shows gettingStarted state', () => { const wrapper = createComponent({ - selectedState: 'gettingStarted', + selectedState: dashboardEmptyStates.GETTING_STARTED, }); expect(wrapper.element).toMatchSnapshot(); }); - it('shows loading state', () => { + it('shows unableToConnect state', () => { const wrapper = createComponent({ - selectedState: 'loading', + selectedState: dashboardEmptyStates.UNABLE_TO_CONNECT, }); expect(wrapper.element).toMatchSnapshot(); }); - it('shows unableToConnect state', () => { + it('shows noData state', () => { const wrapper = createComponent({ - selectedState: 'unableToConnect', + selectedState: dashboardEmptyStates.NO_DATA, }); expect(wrapper.element).toMatchSnapshot(); diff --git a/spec/frontend/monitoring/components/graph_group_spec.js b/spec/frontend/monitoring/components/graph_group_spec.js index 92829135c0f..81f5d90c310 100644 --- a/spec/frontend/monitoring/components/graph_group_spec.js +++ b/spec/frontend/monitoring/components/graph_group_spec.js @@ -1,13 +1,14 @@ import { shallowMount } from '@vue/test-utils'; import GraphGroup from '~/monitoring/components/graph_group.vue'; -import Icon from '~/vue_shared/components/icon.vue'; +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; describe('Graph group component', () => { let wrapper; const findGroup = () => wrapper.find({ ref: 'graph-group' }); const findContent = () => wrapper.find({ ref: 'graph-group-content' }); - const findCaretIcon = () => wrapper.find(Icon); + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findCaretIcon = () => wrapper.find(GlIcon); const findToggleButton = () => wrapper.find('[data-testid="group-toggle-button"]'); const createComponent = propsData => { @@ -28,28 +29,28 @@ describe('Graph group component', () => { }); }); + it('should not show a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + it('should show the angle-down caret icon', () => { expect(findContent().isVisible()).toBe(true); expect(findCaretIcon().props('name')).toBe('angle-down'); }); it('should show the angle-right caret icon when the user collapses the group', () => { - wrapper.vm.collapse(); + findToggleButton().trigger('click'); - return wrapper.vm.$nextTick(() => { + return wrapper.vm.$nextTick().then(() => { expect(findContent().isVisible()).toBe(false); expect(findCaretIcon().props('name')).toBe('angle-right'); }); }); - it('should contain a tabindex', () => { - expect(findGroup().contains('[tabindex]')).toBe(true); - }); - it('should contain a tab index for the collapse button', () => { const groupToggle = findToggleButton(); - expect(groupToggle.contains('[tabindex]')).toBe(true); + expect(groupToggle.is('[tabindex]')).toBe(true); }); it('should show the open the group when collapseGroup is set to true', () => { @@ -57,77 +58,94 @@ describe('Graph group component', () => { collapseGroup: true, }); - return wrapper.vm.$nextTick(() => { + return wrapper.vm.$nextTick().then(() => { expect(findContent().isVisible()).toBe(true); expect(findCaretIcon().props('name')).toBe('angle-down'); }); }); + }); - describe('When group is collapsed', () => { - beforeEach(() => { - createComponent({ - name: 'panel', - collapseGroup: true, - }); + describe('When group is collapsed', () => { + beforeEach(() => { + createComponent({ + name: 'panel', + collapseGroup: true, }); + }); - it('should show the angle-down caret icon when collapseGroup is true', () => { - expect(wrapper.vm.caretIcon).toBe('angle-right'); - }); + it('should show the angle-down caret icon when collapseGroup is true', () => { + expect(findCaretIcon().props('name')).toBe('angle-right'); + }); - it('should show the angle-right caret icon when collapseGroup is false', () => { - wrapper.vm.collapse(); + it('should show the angle-right caret icon when collapseGroup is false', () => { + findToggleButton().trigger('click'); - expect(wrapper.vm.caretIcon).toBe('angle-down'); + return wrapper.vm.$nextTick().then(() => { + expect(findCaretIcon().props('name')).toBe('angle-down'); }); + }); - it('should call collapse the graph group content when enter is pressed on the caret icon', () => { - const graphGroupContent = findContent(); - const button = findToggleButton(); + it('should call collapse the graph group content when enter is pressed on the caret icon', () => { + const graphGroupContent = findContent(); + const button = findToggleButton(); - button.trigger('keyup.enter'); + button.trigger('keyup.enter'); + + expect(graphGroupContent.isVisible()).toBe(false); + }); + }); - expect(graphGroupContent.isVisible()).toBe(false); + describe('When groups can not be collapsed', () => { + beforeEach(() => { + createComponent({ + name: 'panel', + showPanels: false, + collapseGroup: false, }); }); - describe('When groups can not be collapsed', () => { - beforeEach(() => { - createComponent({ - name: 'panel', - showPanels: false, - collapseGroup: false, - }); + it('should not have a container when showPanels is false', () => { + expect(findGroup().exists()).toBe(false); + expect(findContent().exists()).toBe(true); + }); + }); + + describe('When group is loading', () => { + beforeEach(() => { + createComponent({ + name: 'panel', + isLoading: true, }); + }); - it('should not have a container when showPanels is false', () => { - expect(findGroup().exists()).toBe(false); - expect(findContent().exists()).toBe(true); + it('should show a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('When group does not show a panel heading', () => { + beforeEach(() => { + createComponent({ + name: 'panel', + showPanels: false, + collapseGroup: false, }); }); - describe('When group does not show a panel heading', () => { - beforeEach(() => { - createComponent({ - name: 'panel', - showPanels: false, - collapseGroup: false, - }); + it('should collapse the panel content', () => { + expect(findContent().isVisible()).toBe(true); + expect(findCaretIcon().exists()).toBe(false); + }); + + it('should show the panel content when collapse is set to false', () => { + wrapper.setProps({ + collapseGroup: false, }); - it('should collapse the panel content', () => { + return wrapper.vm.$nextTick().then(() => { expect(findContent().isVisible()).toBe(true); expect(findCaretIcon().exists()).toBe(false); }); - - it('should show the panel content when clicked', () => { - wrapper.vm.collapse(); - - return wrapper.vm.$nextTick(() => { - expect(findContent().isVisible()).toBe(true); - expect(findCaretIcon().exists()).toBe(false); - }); - }); }); }); }); diff --git a/spec/frontend/monitoring/components/links_section_spec.js b/spec/frontend/monitoring/components/links_section_spec.js index 3b5b72d84ee..b771d63d51f 100644 --- a/spec/frontend/monitoring/components/links_section_spec.js +++ b/spec/frontend/monitoring/components/links_section_spec.js @@ -15,7 +15,7 @@ describe('Links Section component', () => { const setState = links => { store.state.monitoringDashboard = { ...store.state.monitoringDashboard, - showEmptyState: false, + emptyState: null, links, }; }; diff --git a/spec/frontend/monitoring/components/refresh_button_spec.js b/spec/frontend/monitoring/components/refresh_button_spec.js new file mode 100644 index 00000000000..29615638453 --- /dev/null +++ b/spec/frontend/monitoring/components/refresh_button_spec.js @@ -0,0 +1,143 @@ +import { shallowMount } from '@vue/test-utils'; +import { createStore } from '~/monitoring/stores'; +import { GlNewDropdown, GlNewDropdownItem, GlButton } from '@gitlab/ui'; + +import RefreshButton from '~/monitoring/components/refresh_button.vue'; + +describe('RefreshButton', () => { + let wrapper; + let store; + let dispatch; + let documentHidden; + + const createWrapper = () => { + wrapper = shallowMount(RefreshButton, { store }); + }; + + const findRefreshBtn = () => wrapper.find(GlButton); + const findDropdown = () => wrapper.find(GlNewDropdown); + const findOptions = () => findDropdown().findAll(GlNewDropdownItem); + const findOptionAt = index => findOptions().at(index); + + const expectFetchDataToHaveBeenCalledTimes = times => { + const refreshCalls = dispatch.mock.calls.filter(([action, payload]) => { + return action === 'monitoringDashboard/fetchDashboardData' && payload === undefined; + }); + expect(refreshCalls).toHaveLength(times); + }; + + beforeEach(() => { + store = createStore(); + jest.spyOn(store, 'dispatch').mockResolvedValue(); + dispatch = store.dispatch; + + // Document can be mock hidden by overriding the `hidden` property + documentHidden = false; + Object.defineProperty(document, 'hidden', { + configurable: true, + get() { + return documentHidden; + }, + }); + + createWrapper(); + }); + + afterEach(() => { + dispatch.mockReset(); + wrapper.destroy(); + }); + + it('refreshes data when "refresh" is clicked', () => { + findRefreshBtn().vm.$emit('click'); + expectFetchDataToHaveBeenCalledTimes(1); + }); + + it('refresh rate is "Off" in the dropdown', () => { + expect(findDropdown().props('text')).toBe('Off'); + }); + + describe('refresh rate options', () => { + it('presents multiple options', () => { + expect(findOptions().length).toBeGreaterThan(1); + }); + + it('presents an "Off" option as the default option', () => { + expect(findOptionAt(0).text()).toBe('Off'); + expect(findOptionAt(0).props('isChecked')).toBe(true); + }); + }); + + describe('when a refresh rate is chosen', () => { + const optIndex = 2; // Other option than "Off" + + beforeEach(() => { + findOptionAt(optIndex).vm.$emit('click'); + return wrapper.vm.$nextTick; + }); + + it('refresh rate appears in the dropdown', () => { + expect(findDropdown().props('text')).toBe('10s'); + }); + + it('refresh rate option is checked', () => { + expect(findOptionAt(0).props('isChecked')).toBe(false); + expect(findOptionAt(optIndex).props('isChecked')).toBe(true); + }); + + it('refreshes data when a new refresh rate is chosen', () => { + expectFetchDataToHaveBeenCalledTimes(1); + }); + + it('refreshes data after two intervals of time have passed', async () => { + jest.runOnlyPendingTimers(); + expectFetchDataToHaveBeenCalledTimes(2); + + await wrapper.vm.$nextTick(); + + jest.runOnlyPendingTimers(); + expectFetchDataToHaveBeenCalledTimes(3); + }); + + it('does not refresh data if the document is hidden', async () => { + documentHidden = true; + + jest.runOnlyPendingTimers(); + expectFetchDataToHaveBeenCalledTimes(1); + + await wrapper.vm.$nextTick(); + + jest.runOnlyPendingTimers(); + expectFetchDataToHaveBeenCalledTimes(1); + }); + + it('data is not refreshed anymore after component is destroyed', () => { + expect(jest.getTimerCount()).toBe(1); + + wrapper.destroy(); + + expect(jest.getTimerCount()).toBe(0); + }); + + describe('when "Off" refresh rate is chosen', () => { + beforeEach(() => { + findOptionAt(0).vm.$emit('click'); + return wrapper.vm.$nextTick; + }); + + it('refresh rate is "Off" in the dropdown', () => { + expect(findDropdown().props('text')).toBe('Off'); + }); + + it('refresh rate option is appears selected', () => { + expect(findOptionAt(0).props('isChecked')).toBe(true); + expect(findOptionAt(optIndex).props('isChecked')).toBe(false); + }); + + it('stops refreshing data', () => { + jest.runOnlyPendingTimers(); + expectFetchDataToHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/variables/custom_variable_spec.js b/spec/frontend/monitoring/components/variables/dropdown_field_spec.js index 5a2b26219b6..cc384aef231 100644 --- a/spec/frontend/monitoring/components/variables/custom_variable_spec.js +++ b/spec/frontend/monitoring/components/variables/dropdown_field_spec.js @@ -1,18 +1,25 @@ import { shallowMount } from '@vue/test-utils'; import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import CustomVariable from '~/monitoring/components/variables/custom_variable.vue'; +import DropdownField from '~/monitoring/components/variables/dropdown_field.vue'; describe('Custom variable component', () => { let wrapper; - const propsData = { + + const defaultProps = { name: 'env', label: 'Select environment', value: 'Production', - options: [{ text: 'Production', value: 'prod' }, { text: 'Canary', value: 'canary' }], + options: { + values: [{ text: 'Production', value: 'prod' }, { text: 'Canary', value: 'canary' }], + }, }; - const createShallowWrapper = () => { - wrapper = shallowMount(CustomVariable, { - propsData, + + const createShallowWrapper = props => { + wrapper = shallowMount(DropdownField, { + propsData: { + ...defaultProps, + ...props, + }, }); }; @@ -22,19 +29,25 @@ describe('Custom variable component', () => { it('renders dropdown element when all necessary props are passed', () => { createShallowWrapper(); - expect(findDropdown()).toExist(); + expect(findDropdown().exists()).toBe(true); }); it('renders dropdown element with a text', () => { createShallowWrapper(); - expect(findDropdown().attributes('text')).toBe(propsData.value); + expect(findDropdown().attributes('text')).toBe(defaultProps.value); }); it('renders all the dropdown items', () => { createShallowWrapper(); - expect(findDropdownItems()).toHaveLength(propsData.options.length); + expect(findDropdownItems()).toHaveLength(defaultProps.options.values.length); + }); + + it('renders dropdown when values are missing', () => { + createShallowWrapper({ options: {} }); + + expect(findDropdown().exists()).toBe(true); }); it('changing dropdown items triggers update', () => { @@ -46,7 +59,7 @@ describe('Custom variable component', () => { .vm.$emit('click'); return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.$emit).toHaveBeenCalledWith('onUpdate', 'env', 'canary'); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('input', 'canary'); }); }); }); diff --git a/spec/frontend/monitoring/components/variables/text_variable_spec.js b/spec/frontend/monitoring/components/variables/text_field_spec.js index f01584ae8bc..99c6facac38 100644 --- a/spec/frontend/monitoring/components/variables/text_variable_spec.js +++ b/spec/frontend/monitoring/components/variables/text_field_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { GlFormInput } from '@gitlab/ui'; -import TextVariable from '~/monitoring/components/variables/text_variable.vue'; +import TextField from '~/monitoring/components/variables/text_field.vue'; describe('Text variable component', () => { let wrapper; @@ -10,7 +10,7 @@ describe('Text variable component', () => { value: 'test-pod', }; const createShallowWrapper = () => { - wrapper = shallowMount(TextVariable, { + wrapper = shallowMount(TextField, { propsData, }); }; @@ -40,7 +40,7 @@ describe('Text variable component', () => { findInput().trigger('keyup.enter'); return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.$emit).toHaveBeenCalledWith('onUpdate', 'pod', 'prod-pod'); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('input', 'prod-pod'); }); }); @@ -53,7 +53,7 @@ describe('Text variable component', () => { findInput().trigger('blur'); return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.$emit).toHaveBeenCalledWith('onUpdate', 'pod', 'canary-pod'); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('input', 'canary-pod'); }); }); }); diff --git a/spec/frontend/monitoring/components/variables_section_spec.js b/spec/frontend/monitoring/components/variables_section_spec.js index fd814e81c8f..3097906ee68 100644 --- a/spec/frontend/monitoring/components/variables_section_spec.js +++ b/spec/frontend/monitoring/components/variables_section_spec.js @@ -1,13 +1,12 @@ import { shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; import VariablesSection from '~/monitoring/components/variables_section.vue'; -import CustomVariable from '~/monitoring/components/variables/custom_variable.vue'; -import TextVariable from '~/monitoring/components/variables/text_variable.vue'; +import DropdownField from '~/monitoring/components/variables/dropdown_field.vue'; +import TextField from '~/monitoring/components/variables/text_field.vue'; import { updateHistory, mergeUrlParams } from '~/lib/utils/url_utility'; import { createStore } from '~/monitoring/stores'; import { convertVariablesForURL } from '~/monitoring/utils'; -import * as types from '~/monitoring/stores/mutation_types'; -import { mockTemplatingDataResponses } from '../mock_data'; +import { storeVariables } from '../mock_data'; jest.mock('~/lib/utils/url_utility', () => ({ updateHistory: jest.fn(), @@ -17,11 +16,6 @@ jest.mock('~/lib/utils/url_utility', () => ({ describe('Metrics dashboard/variables section component', () => { let store; let wrapper; - const sampleVariables = { - label1: mockTemplatingDataResponses.simpleText.simpleText, - label2: mockTemplatingDataResponses.advText.advText, - label3: mockTemplatingDataResponses.simpleCustom.simpleCustom, - }; const createShallowWrapper = () => { wrapper = shallowMount(VariablesSection, { @@ -29,30 +23,41 @@ describe('Metrics dashboard/variables section component', () => { }); }; - const findTextInput = () => wrapper.findAll(TextVariable); - const findCustomInput = () => wrapper.findAll(CustomVariable); + const findTextInputs = () => wrapper.findAll(TextField); + const findCustomInputs = () => wrapper.findAll(DropdownField); beforeEach(() => { store = createStore(); - store.state.monitoringDashboard.showEmptyState = false; + store.state.monitoringDashboard.emptyState = null; }); it('does not show the variables section', () => { createShallowWrapper(); - const allInputs = findTextInput().length + findCustomInput().length; + const allInputs = findTextInputs().length + findCustomInputs().length; expect(allInputs).toBe(0); }); - it('shows the variables section', () => { - createShallowWrapper(); - store.commit(`monitoringDashboard/${types.SET_VARIABLES}`, sampleVariables); + describe('when variables are set', () => { + beforeEach(() => { + store.state.monitoringDashboard.variables = storeVariables; + createShallowWrapper(); + + return wrapper.vm.$nextTick; + }); + + it('shows the variables section', () => { + const allInputs = findTextInputs().length + findCustomInputs().length; + + expect(allInputs).toBe(storeVariables.length); + }); - return wrapper.vm.$nextTick(() => { - const allInputs = findTextInput().length + findCustomInput().length; + it('shows the right custom variable inputs', () => { + const customInputs = findCustomInputs(); - expect(allInputs).toBe(Object.keys(sampleVariables).length); + expect(customInputs.at(0).props('name')).toBe('customSimple'); + expect(customInputs.at(1).props('name')).toBe('customAdvanced'); }); }); @@ -65,8 +70,8 @@ describe('Metrics dashboard/variables section component', () => { monitoringDashboard: { namespaced: true, state: { - showEmptyState: false, - variables: sampleVariables, + emptyState: null, + variables: storeVariables, }, actions: { updateVariablesAndFetchData, @@ -79,14 +84,14 @@ describe('Metrics dashboard/variables section component', () => { }); it('merges the url params and refreshes the dashboard when a text-based variables inputs are updated', () => { - const firstInput = findTextInput().at(0); + const firstInput = findTextInputs().at(0); - firstInput.vm.$emit('onUpdate', 'label1', 'test'); + firstInput.vm.$emit('input', 'test'); return wrapper.vm.$nextTick(() => { expect(updateVariablesAndFetchData).toHaveBeenCalled(); expect(mergeUrlParams).toHaveBeenCalledWith( - convertVariablesForURL(sampleVariables), + convertVariablesForURL(storeVariables), window.location.href, ); expect(updateHistory).toHaveBeenCalled(); @@ -94,14 +99,14 @@ describe('Metrics dashboard/variables section component', () => { }); it('merges the url params and refreshes the dashboard when a custom-based variables inputs are updated', () => { - const firstInput = findCustomInput().at(0); + const firstInput = findCustomInputs().at(0); - firstInput.vm.$emit('onUpdate', 'label1', 'test'); + firstInput.vm.$emit('input', 'test'); return wrapper.vm.$nextTick(() => { expect(updateVariablesAndFetchData).toHaveBeenCalled(); expect(mergeUrlParams).toHaveBeenCalledWith( - convertVariablesForURL(sampleVariables), + convertVariablesForURL(storeVariables), window.location.href, ); expect(updateHistory).toHaveBeenCalled(); @@ -109,9 +114,9 @@ describe('Metrics dashboard/variables section component', () => { }); it('does not merge the url params and refreshes the dashboard if the value entered is not different that is what currently stored', () => { - const firstInput = findTextInput().at(0); + const firstInput = findTextInputs().at(0); - firstInput.vm.$emit('onUpdate', 'label1', 'Simple text'); + firstInput.vm.$emit('input', 'My default value'); expect(updateVariablesAndFetchData).not.toHaveBeenCalled(); expect(mergeUrlParams).not.toHaveBeenCalled(); diff --git a/spec/frontend/monitoring/fixture_data.js b/spec/frontend/monitoring/fixture_data.js index b7b72a15992..97edf7bda74 100644 --- a/spec/frontend/monitoring/fixture_data.js +++ b/spec/frontend/monitoring/fixture_data.js @@ -1,5 +1,8 @@ +import { stateAndPropsFromDataset } from '~/monitoring/utils'; import { mapToDashboardViewModel } from '~/monitoring/stores/utils'; import { metricStates } from '~/monitoring/constants'; +import { convertObjectProps } from '~/lib/utils/common_utils'; +import { convertToCamelCase } from '~/lib/utils/text_utility'; import { metricsResult } from './mock_data'; @@ -7,23 +10,54 @@ import { metricsResult } from './mock_data'; export const metricsDashboardResponse = getJSONFixture( 'metrics_dashboard/environment_metrics_dashboard.json', ); + export const metricsDashboardPayload = metricsDashboardResponse.dashboard; + +const datasetState = stateAndPropsFromDataset( + // It's preferable to have props in snake_case, this will be addressed at: + // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/33574 + convertObjectProps( + // Some props use kebab-case, convert to snake_case first + key => convertToCamelCase(key.replace(/-/g, '_')), + metricsDashboardResponse.metrics_data, + ), +); + +// new properties like addDashboardDocumentationPath prop and alertsEndpoint +// was recently added to dashboard.vue component this needs to be +// added to fixtures data +// https://gitlab.com/gitlab-org/gitlab/-/issues/229256 +export const dashboardProps = { + ...datasetState.dataProps, + addDashboardDocumentationPath: 'https://path/to/docs', + alertsEndpoint: null, +}; + export const metricsDashboardViewModel = mapToDashboardViewModel(metricsDashboardPayload); export const metricsDashboardPanelCount = 22; export const metricResultStatus = { // First metric in fixture `metrics_dashboard/environment_metrics_dashboard.json` metricId: 'NO_DB_response_metrics_nginx_ingress_throughput_status_code', - result: metricsResult, + data: { + resultType: 'matrix', + result: metricsResult, + }, }; export const metricResultPods = { // Second metric in fixture `metrics_dashboard/environment_metrics_dashboard.json` metricId: 'NO_DB_response_metrics_nginx_ingress_latency_pod_average', - result: metricsResult, + data: { + resultType: 'matrix', + result: metricsResult, + }, }; export const metricResultEmpty = { metricId: 'NO_DB_response_metrics_nginx_ingress_16_throughput_status_code', - result: [], + data: { + resultType: 'matrix', + result: [], + }, }; // Graph data diff --git a/spec/frontend/monitoring/graph_data.js b/spec/frontend/monitoring/graph_data.js new file mode 100644 index 00000000000..e1b95723f3d --- /dev/null +++ b/spec/frontend/monitoring/graph_data.js @@ -0,0 +1,164 @@ +import { mapPanelToViewModel, normalizeQueryResponseData } from '~/monitoring/stores/utils'; +import { panelTypes, metricStates } from '~/monitoring/constants'; + +const initTime = 1435781451.781; + +const makeValue = val => [initTime, val]; +const makeValues = vals => vals.map((val, i) => [initTime + 15 * i, val]); + +// Normalized Prometheus Responses + +const scalarResult = ({ value = '1' } = {}) => + normalizeQueryResponseData({ + resultType: 'scalar', + result: makeValue(value), + }); + +const vectorResult = ({ value1 = '1', value2 = '2' } = {}) => + normalizeQueryResponseData({ + resultType: 'vector', + result: [ + { + metric: { + __name__: 'up', + job: 'prometheus', + instance: 'localhost:9090', + }, + value: makeValue(value1), + }, + { + metric: { + __name__: 'up', + job: 'node', + instance: 'localhost:9100', + }, + value: makeValue(value2), + }, + ], + }); + +const matrixSingleResult = ({ values = ['1', '2', '3'] } = {}) => + normalizeQueryResponseData({ + resultType: 'matrix', + result: [ + { + metric: {}, + values: makeValues(values), + }, + ], + }); + +const matrixMultiResult = ({ values1 = ['1', '2', '3'], values2 = ['4', '5', '6'] } = {}) => + normalizeQueryResponseData({ + resultType: 'matrix', + result: [ + { + metric: { + __name__: 'up', + job: 'prometheus', + instance: 'localhost:9090', + }, + values: makeValues(values1), + }, + { + metric: { + __name__: 'up', + job: 'node', + instance: 'localhost:9091', + }, + values: makeValues(values2), + }, + ], + }); + +// GraphData factory + +/** + * Generate mock graph data according to options + * + * @param {Object} panelOptions - Panel options as in YML. + * @param {Object} dataOptions + * @param {Object} dataOptions.metricCount + * @param {Object} dataOptions.isMultiSeries + */ +export const timeSeriesGraphData = (panelOptions = {}, dataOptions = {}) => { + const { metricCount = 1, isMultiSeries = false } = dataOptions; + + return mapPanelToViewModel({ + title: 'Time Series Panel', + type: panelTypes.LINE_CHART, + x_label: 'X Axis', + y_label: 'Y Axis', + metrics: Array.from(Array(metricCount), (_, i) => ({ + label: `Metric ${i + 1}`, + state: metricStates.OK, + result: isMultiSeries ? matrixMultiResult() : matrixSingleResult(), + })), + ...panelOptions, + }); +}; + +/** + * Generate mock graph data according to options + * + * @param {Object} panelOptions - Panel options as in YML. + * @param {Object} dataOptions + * @param {Object} dataOptions.unit + * @param {Object} dataOptions.value + * @param {Object} dataOptions.isVector + */ +export const singleStatGraphData = (panelOptions = {}, dataOptions = {}) => { + const { unit, value = '1', isVector = false } = dataOptions; + + return mapPanelToViewModel({ + title: 'Single Stat Panel', + type: panelTypes.SINGLE_STAT, + metrics: [ + { + label: 'Metric Label', + state: metricStates.OK, + result: isVector ? vectorResult({ value }) : scalarResult({ value }), + unit, + }, + ], + ...panelOptions, + }); +}; + +/** + * Generate mock graph data according to options + * + * @param {Object} panelOptions - Panel options as in YML. + * @param {Object} dataOptions + * @param {Array} dataOptions.values - Metric values + * @param {Array} dataOptions.upper - Upper boundary values + * @param {Array} dataOptions.lower - Lower boundary values + */ +export const anomalyGraphData = (panelOptions = {}, dataOptions = {}) => { + const { values, upper, lower } = dataOptions; + + return mapPanelToViewModel({ + title: 'Anomaly Panel', + type: panelTypes.ANOMALY_CHART, + x_label: 'X Axis', + y_label: 'Y Axis', + metrics: [ + { + label: `Metric`, + state: metricStates.OK, + result: matrixSingleResult({ values }), + }, + { + label: `Upper boundary`, + state: metricStates.OK, + result: matrixSingleResult({ values: upper }), + }, + { + label: `Lower boundary`, + state: metricStates.OK, + result: matrixSingleResult({ values: lower }), + }, + ], + ...panelOptions, + }); +}; diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js index 05b29e78ecd..49ad33402c6 100644 --- a/spec/frontend/monitoring/mock_data.js +++ b/spec/frontend/monitoring/mock_data.js @@ -5,28 +5,14 @@ import { TEST_HOST } from '../helpers/test_constants'; export const mockProjectDir = '/frontend-fixtures/environments-project'; export const mockApiEndpoint = `${TEST_HOST}/monitoring/mock`; -export const propsData = { - hasMetrics: false, - documentationPath: '/path/to/docs', - settingsPath: '/path/to/settings', - clustersPath: '/path/to/clusters', - tagsPath: '/path/to/tags', - defaultBranch: 'master', - emptyGettingStartedSvgPath: '/path/to/getting-started.svg', - emptyLoadingSvgPath: '/path/to/loading.svg', - emptyNoDataSvgPath: '/path/to/no-data.svg', - emptyNoDataSmallSvgPath: '/path/to/no-data-small.svg', - emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg', - customMetricsAvailable: false, - customMetricsPath: '', - validateQueryPath: '', -}; +export const customDashboardBasePath = '.gitlab/dashboards'; const customDashboardsData = new Array(30).fill(null).map((_, idx) => ({ default: false, display_name: `Custom Dashboard ${idx}`, can_edit: true, system_dashboard: false, + out_of_the_box_dashboard: false, project_blob_path: `${mockProjectDir}/blob/master/dashboards/.gitlab/dashboards/dashboard_${idx}.yml`, path: `.gitlab/dashboards/dashboard_${idx}.yml`, starred: false, @@ -65,136 +51,6 @@ export const anomalyDeploymentData = [ }, ]; -export const anomalyMockResultValues = { - noAnomaly: [ - [ - ['2019-08-19T19:00:00.000Z', 1.25], - ['2019-08-19T20:00:00.000Z', 1.45], - ['2019-08-19T21:00:00.000Z', 1.55], - ['2019-08-19T22:00:00.000Z', 1.48], - ], - [ - // upper boundary - ['2019-08-19T19:00:00.000Z', 2], - ['2019-08-19T20:00:00.000Z', 2.55], - ['2019-08-19T21:00:00.000Z', 2.65], - ['2019-08-19T22:00:00.000Z', 3.0], - ], - [ - // lower boundary - ['2019-08-19T19:00:00.000Z', 0.45], - ['2019-08-19T20:00:00.000Z', 0.65], - ['2019-08-19T21:00:00.000Z', 0.7], - ['2019-08-19T22:00:00.000Z', 0.8], - ], - ], - noBoundary: [ - [ - ['2019-08-19T19:00:00.000Z', 1.25], - ['2019-08-19T20:00:00.000Z', 1.45], - ['2019-08-19T21:00:00.000Z', 1.55], - ['2019-08-19T22:00:00.000Z', 1.48], - ], - [ - // empty upper boundary - ], - [ - // empty lower boundary - ], - ], - oneAnomaly: [ - [ - ['2019-08-19T19:00:00.000Z', 1.25], - ['2019-08-19T20:00:00.000Z', 3.45], // anomaly - ['2019-08-19T21:00:00.000Z', 1.55], - ], - [ - // upper boundary - ['2019-08-19T19:00:00.000Z', 2], - ['2019-08-19T20:00:00.000Z', 2.55], - ['2019-08-19T21:00:00.000Z', 2.65], - ], - [ - // lower boundary - ['2019-08-19T19:00:00.000Z', 0.45], - ['2019-08-19T20:00:00.000Z', 0.65], - ['2019-08-19T21:00:00.000Z', 0.7], - ], - ], - negativeBoundary: [ - [ - ['2019-08-19T19:00:00.000Z', 1.25], - ['2019-08-19T20:00:00.000Z', 3.45], // anomaly - ['2019-08-19T21:00:00.000Z', 1.55], - ], - [ - // upper boundary - ['2019-08-19T19:00:00.000Z', 2], - ['2019-08-19T20:00:00.000Z', 2.55], - ['2019-08-19T21:00:00.000Z', 2.65], - ], - [ - // lower boundary - ['2019-08-19T19:00:00.000Z', -1.25], - ['2019-08-19T20:00:00.000Z', -2.65], - ['2019-08-19T21:00:00.000Z', -3.7], // lowest point - ], - ], -}; - -export const anomalyMockGraphData = { - title: 'Requests Per Second Mock Data', - type: 'anomaly-chart', - weight: 3, - metrics: [ - { - metricId: '90', - id: 'metric', - query_range: 'MOCK_PROMETHEUS_METRIC_QUERY_RANGE', - unit: 'RPS', - label: 'Metrics RPS', - metric_id: 90, - prometheus_endpoint_path: 'MOCK_METRIC_PEP', - result: [ - { - metric: {}, - values: [['2019-08-19T19:00:00.000Z', 0]], - }, - ], - }, - { - metricId: '91', - id: 'upper', - query_range: '...', - unit: 'RPS', - label: 'Upper Limit Metrics RPS', - metric_id: 91, - prometheus_endpoint_path: 'MOCK_UPPER_PEP', - result: [ - { - metric: {}, - values: [['2019-08-19T19:00:00.000Z', 0]], - }, - ], - }, - { - metricId: '92', - id: 'lower', - query_range: '...', - unit: 'RPS', - label: 'Lower Limit Metrics RPS', - metric_id: 92, - prometheus_endpoint_path: 'MOCK_LOWER_PEP', - result: [ - { - metric: {}, - values: [['2019-08-19T19:00:00.000Z', 0]], - }, - ], - }, - ], -}; - export const deploymentData = [ { id: 111, @@ -317,6 +173,7 @@ export const dashboardGitResponse = [ display_name: 'Default', can_edit: false, system_dashboard: true, + out_of_the_box_dashboard: true, project_blob_path: null, path: 'config/prometheus/common_metrics.yml', starred: false, @@ -327,6 +184,44 @@ export const dashboardGitResponse = [ display_name: 'dashboard.yml', can_edit: true, system_dashboard: false, + out_of_the_box_dashboard: false, + project_blob_path: `${mockProjectDir}/-/blob/master/.gitlab/dashboards/dashboard.yml`, + path: '.gitlab/dashboards/dashboard.yml', + starred: true, + user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=.gitlab/dashboards/dashboard.yml`, + }, + { + default: false, + display_name: 'Pod Health', + can_edit: false, + system_dashboard: false, + out_of_the_box_dashboard: true, + project_blob_path: null, + path: 'config/prometheus/pod_metrics.yml', + starred: false, + user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=config/prometheus/pod_metrics.yml`, + }, + ...customDashboardsData, +]; + +export const selfMonitoringDashboardGitResponse = [ + { + default: true, + display_name: 'Default', + can_edit: false, + system_dashboard: false, + out_of_the_box_dashboard: true, + project_blob_path: null, + path: 'config/prometheus/self_monitoring_default.yml', + starred: false, + user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=config/prometheus/self_monitoring_default.yml`, + }, + { + default: false, + display_name: 'dashboard.yml', + can_edit: true, + system_dashboard: false, + out_of_the_box_dashboard: false, project_blob_path: `${mockProjectDir}/-/blob/master/.gitlab/dashboards/dashboard.yml`, path: '.gitlab/dashboards/dashboard.yml', starred: true, @@ -349,30 +244,6 @@ export const metricsResult = [ }, ]; -export const singleStatMetricsResult = { - title: 'Super Chart A2', - type: 'single-stat', - weight: 2, - metrics: [ - { - id: 'metric_a1', - metricId: '2', - query: 'max(go_memstats_alloc_bytes{job="prometheus"}) by (job) /1024/1024', - unit: 'MB', - label: 'Total Consumption', - metric_id: 2, - prometheus_endpoint_path: - '/root/kubernetes-gke-project/environments/35/prometheus/api/v1/query?query=max%28go_memstats_alloc_bytes%7Bjob%3D%22prometheus%22%7D%29+by+%28job%29+%2F1024%2F1024', - result: [ - { - metric: { job: 'prometheus' }, - value: ['2019-06-26T21:03:20.881Z', 91], - }, - ], - }, - ], -}; - export const graphDataPrometheusQueryRangeMultiTrack = { title: 'Super Chart A3', type: 'heatmap', @@ -641,253 +512,186 @@ export const mockLinks = [ }, ]; -const templatingVariableTypes = { +export const templatingVariablesExamples = { text: { - simple: 'Simple text', - advanced: { - label: 'Variable 4', + textSimple: 'My default value', + textAdvanced: { + label: 'Advanced text variable', type: 'text', options: { - default_value: 'default', + default_value: 'A default value', }, }, }, custom: { - simple: ['value1', 'value2', 'value3'], - advanced: { - normal: { - label: 'Advanced Var', - type: 'custom', - options: { - values: [ - { value: 'value1', text: 'Var 1 Option 1' }, - { - value: 'value2', - text: 'Var 1 Option 2', - default: true, - }, - ], - }, - }, - withoutOpts: { - type: 'custom', - options: {}, + customSimple: ['value1', 'value2', 'value3'], + customAdvanced: { + label: 'Advanced Var', + type: 'custom', + options: { + values: [ + { value: 'value1', text: 'Var 1 Option 1' }, + { + value: 'value2', + text: 'Var 1 Option 2', + default: true, + }, + ], }, - withoutLabel: { - type: 'custom', - options: { - values: [ - { value: 'value1', text: 'Var 1 Option 1' }, - { - value: 'value2', - text: 'Var 1 Option 2', - default: true, - }, - ], - }, + }, + customAdvancedWithoutOpts: { + type: 'custom', + options: {}, + }, + customAdvancedWithoutLabel: { + type: 'custom', + options: { + values: [ + { value: 'value1', text: 'Var 1 Option 1' }, + { + value: 'value2', + text: 'Var 1 Option 2', + default: true, + }, + ], }, - withoutType: { - label: 'Variable 2', - options: { - values: [ - { value: 'value1', text: 'Var 1 Option 1' }, - { - value: 'value2', - text: 'Var 1 Option 2', - default: true, - }, - ], - }, + }, + customAdvancedWithoutType: { + label: 'Variable 2', + options: { + values: [ + { value: 'value1', text: 'Var 1 Option 1' }, + { + value: 'value2', + text: 'Var 1 Option 2', + default: true, + }, + ], }, - withoutOptText: { - label: 'Options without text', - type: 'custom', - options: { - values: [ - { value: 'value1' }, - { - value: 'value2', - default: true, - }, - ], - }, + }, + customAdvancedWithoutOptText: { + label: 'Options without text', + type: 'custom', + options: { + values: [ + { value: 'value1' }, + { + value: 'value2', + default: true, + }, + ], }, }, }, -}; - -const generateMockTemplatingData = data => { - const vars = data - ? { - variables: { - ...data, - }, - } - : {}; - return { - dashboard: { - templating: vars, + metricLabelValues: { + metricLabelValuesSimple: { + label: 'Metric Label Values', + type: 'metric_label_values', + options: { + prometheus_endpoint_path: '/series', + series_selector: 'backend:haproxy_backend_availability:ratio{env="{{env}}"}', + label: 'backend', + }, }, - }; + }, }; -const responseForSimpleTextVariable = { - simpleText: { - label: 'simpleText', +export const storeTextVariables = [ + { type: 'text', - value: 'Simple text', + name: 'textSimple', + label: 'textSimple', + value: 'My default value', }, -}; - -const responseForAdvTextVariable = { - advText: { - label: 'Variable 4', + { type: 'text', - value: 'default', + name: 'textAdvanced', + label: 'Advanced text variable', + value: 'A default value', }, -}; +]; -const responseForSimpleCustomVariable = { - simpleCustom: { - label: 'simpleCustom', +export const storeCustomVariables = [ + { + type: 'custom', + name: 'customSimple', + label: 'customSimple', + options: { + values: [ + { default: false, text: 'value1', value: 'value1' }, + { default: false, text: 'value2', value: 'value2' }, + { default: false, text: 'value3', value: 'value3' }, + ], + }, value: 'value1', - options: [ - { - default: false, - text: 'value1', - value: 'value1', - }, - { - default: false, - text: 'value2', - value: 'value2', - }, - { - default: false, - text: 'value3', - value: 'value3', - }, - ], + }, + { type: 'custom', + name: 'customAdvanced', + label: 'Advanced Var', + options: { + values: [ + { default: false, text: 'Var 1 Option 1', value: 'value1' }, + { default: true, text: 'Var 1 Option 2', value: 'value2' }, + ], + }, + value: 'value2', }, -}; - -const responseForAdvancedCustomVariableWithoutOptions = { - advCustomWithoutOpts: { - label: 'advCustomWithoutOpts', - options: [], + { type: 'custom', + name: 'customAdvancedWithoutOpts', + label: 'customAdvancedWithoutOpts', + options: { values: [] }, + value: null, }, -}; - -const responseForAdvancedCustomVariableWithoutLabel = { - advCustomWithoutLabel: { - label: 'advCustomWithoutLabel', - value: 'value2', - options: [ - { - default: false, - text: 'Var 1 Option 1', - value: 'value1', - }, - { - default: true, - text: 'Var 1 Option 2', - value: 'value2', - }, - ], + { type: 'custom', + name: 'customAdvancedWithoutLabel', + label: 'customAdvancedWithoutLabel', + value: 'value2', + options: { + values: [ + { default: false, text: 'Var 1 Option 1', value: 'value1' }, + { default: true, text: 'Var 1 Option 2', value: 'value2' }, + ], + }, }, -}; - -const responseForAdvancedCustomVariableWithoutOptText = { - advCustomWithoutOptText: { + { + type: 'custom', + name: 'customAdvancedWithoutOptText', label: 'Options without text', + options: { + values: [ + { default: false, text: 'value1', value: 'value1' }, + { default: true, text: 'value2', value: 'value2' }, + ], + }, value: 'value2', - options: [ - { - default: false, - text: 'value1', - value: 'value1', - }, - { - default: true, - text: 'value2', - value: 'value2', - }, - ], - type: 'custom', }, -}; +]; -const responseForAdvancedCustomVariable = { - ...responseForSimpleCustomVariable, - advCustomNormal: { - label: 'Advanced Var', - value: 'value2', - options: [ - { - default: false, - text: 'Var 1 Option 1', - value: 'value1', - }, - { - default: true, - text: 'Var 1 Option 2', - value: 'value2', - }, - ], - type: 'custom', +export const storeMetricLabelValuesVariables = [ + { + type: 'metric_label_values', + name: 'metricLabelValuesSimple', + label: 'Metric Label Values', + options: { prometheusEndpointPath: '/series', label: 'backend', values: [] }, + value: null, }, -}; - -const responsesForAllVariableTypes = { - ...responseForSimpleTextVariable, - ...responseForAdvTextVariable, - ...responseForSimpleCustomVariable, - ...responseForAdvancedCustomVariable, -}; +]; -export const mockTemplatingData = { - emptyTemplatingProp: generateMockTemplatingData(), - emptyVariablesProp: generateMockTemplatingData({}), - simpleText: generateMockTemplatingData({ simpleText: templatingVariableTypes.text.simple }), - advText: generateMockTemplatingData({ advText: templatingVariableTypes.text.advanced }), - simpleCustom: generateMockTemplatingData({ simpleCustom: templatingVariableTypes.custom.simple }), - advCustomWithoutOpts: generateMockTemplatingData({ - advCustomWithoutOpts: templatingVariableTypes.custom.advanced.withoutOpts, - }), - advCustomWithoutType: generateMockTemplatingData({ - advCustomWithoutType: templatingVariableTypes.custom.advanced.withoutType, - }), - advCustomWithoutLabel: generateMockTemplatingData({ - advCustomWithoutLabel: templatingVariableTypes.custom.advanced.withoutLabel, - }), - advCustomWithoutOptText: generateMockTemplatingData({ - advCustomWithoutOptText: templatingVariableTypes.custom.advanced.withoutOptText, - }), - simpleAndAdv: generateMockTemplatingData({ - simpleCustom: templatingVariableTypes.custom.simple, - advCustomNormal: templatingVariableTypes.custom.advanced.normal, - }), - allVariableTypes: generateMockTemplatingData({ - simpleText: templatingVariableTypes.text.simple, - advText: templatingVariableTypes.text.advanced, - simpleCustom: templatingVariableTypes.custom.simple, - advCustomNormal: templatingVariableTypes.custom.advanced.normal, - }), -}; +export const storeVariables = [ + ...storeTextVariables, + ...storeCustomVariables, + ...storeMetricLabelValuesVariables, +]; -export const mockTemplatingDataResponses = { - emptyTemplatingProp: {}, - emptyVariablesProp: {}, - simpleText: responseForSimpleTextVariable, - advText: responseForAdvTextVariable, - simpleCustom: responseForSimpleCustomVariable, - advCustomWithoutOpts: responseForAdvancedCustomVariableWithoutOptions, - advCustomWithoutType: {}, - advCustomWithoutLabel: responseForAdvancedCustomVariableWithoutLabel, - advCustomWithoutOptText: responseForAdvancedCustomVariableWithoutOptText, - simpleAndAdv: responseForAdvancedCustomVariable, - allVariableTypes: responsesForAllVariableTypes, +export const dashboardHeaderProps = { + defaultBranch: 'master', + addDashboardDocumentationPath: 'https://path/to/docs', + isRearrangingPanels: false, + selectedTimeRange: { + start: '2020-01-01T00:00:00.000Z', + end: '2020-01-01T01:00:00.000Z', + }, }; diff --git a/spec/frontend/monitoring/pages/dashboard_page_spec.js b/spec/frontend/monitoring/pages/dashboard_page_spec.js index e3c56ef4cbf..675165e9e56 100644 --- a/spec/frontend/monitoring/pages/dashboard_page_spec.js +++ b/spec/frontend/monitoring/pages/dashboard_page_spec.js @@ -1,21 +1,42 @@ import { shallowMount } from '@vue/test-utils'; +import { createStore } from '~/monitoring/stores'; import DashboardPage from '~/monitoring/pages/dashboard_page.vue'; import Dashboard from '~/monitoring/components/dashboard.vue'; -import { propsData } from '../mock_data'; +import { dashboardProps } from '../fixture_data'; describe('monitoring/pages/dashboard_page', () => { let wrapper; + let store; + let $route; + + const buildRouter = () => { + const dashboard = {}; + $route = { + params: { dashboard }, + query: { dashboard }, + }; + }; const buildWrapper = (props = {}) => { wrapper = shallowMount(DashboardPage, { + store, propsData: { ...props, }, + mocks: { + $route, + }, }); }; const findDashboardComponent = () => wrapper.find(Dashboard); + beforeEach(() => { + buildRouter(); + store = createStore(); + jest.spyOn(store, 'dispatch').mockResolvedValue(); + }); + afterEach(() => { if (wrapper) { wrapper.destroy(); @@ -28,9 +49,18 @@ describe('monitoring/pages/dashboard_page', () => { }); it('renders the dashboard page with dashboard component', () => { - buildWrapper({ dashboardProps: propsData }); + buildWrapper({ dashboardProps }); + + const allProps = { + ...dashboardProps, + // default props values + rearrangePanelsAvailable: false, + showHeader: true, + showPanels: true, + smallEmptyState: false, + }; - expect(findDashboardComponent().props()).toMatchObject(propsData); expect(findDashboardComponent()).toExist(); + expect(allProps).toMatchObject(findDashboardComponent().props()); }); }); diff --git a/spec/frontend/monitoring/router_spec.js b/spec/frontend/monitoring/router_spec.js new file mode 100644 index 00000000000..5b8f4b3c83e --- /dev/null +++ b/spec/frontend/monitoring/router_spec.js @@ -0,0 +1,81 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import VueRouter from 'vue-router'; +import DashboardPage from '~/monitoring/pages/dashboard_page.vue'; +import Dashboard from '~/monitoring/components/dashboard.vue'; +import { createStore } from '~/monitoring/stores'; +import createRouter from '~/monitoring/router'; +import { dashboardProps } from './fixture_data'; +import { dashboardHeaderProps } from './mock_data'; + +describe('Monitoring router', () => { + let router; + let store; + const propsData = { dashboardProps: { ...dashboardProps, ...dashboardHeaderProps } }; + const NEW_BASE_PATH = '/project/my-group/test-project/-/metrics'; + const OLD_BASE_PATH = '/project/my-group/test-project/-/environments/71146/metrics'; + + const createWrapper = (basePath, routeArg) => { + const localVue = createLocalVue(); + localVue.use(VueRouter); + + router = createRouter(basePath); + if (routeArg !== undefined) { + router.push(routeArg); + } + + return mount(DashboardPage, { + localVue, + store, + router, + propsData, + }); + }; + + beforeEach(() => { + store = createStore(); + jest.spyOn(store, 'dispatch').mockResolvedValue(); + }); + + afterEach(() => { + window.location.hash = ''; + }); + + describe('support old URL with full dashboard path', () => { + it.each` + route | currentDashboard + ${'/dashboard.yml'} | ${'dashboard.yml'} + ${'/folder1/dashboard.yml'} | ${'folder1/dashboard.yml'} + ${'/?dashboard=dashboard.yml'} | ${'dashboard.yml'} + `('sets component as $componentName for path "$route"', ({ route, currentDashboard }) => { + const wrapper = createWrapper(OLD_BASE_PATH, route); + + expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setCurrentDashboard', { + currentDashboard, + }); + + expect(wrapper.find(Dashboard)).toExist(); + }); + }); + + describe('supports new URL with short dashboard path', () => { + it.each` + route | currentDashboard + ${'/'} | ${null} + ${'/dashboard.yml'} | ${'dashboard.yml'} + ${'/folder1/dashboard.yml'} | ${'folder1/dashboard.yml'} + ${'/folder1%2Fdashboard.yml'} | ${'folder1/dashboard.yml'} + ${'/dashboard.yml'} | ${'dashboard.yml'} + ${'/config/prometheus/common_metrics.yml'} | ${'config/prometheus/common_metrics.yml'} + ${'/config/prometheus/pod_metrics.yml'} | ${'config/prometheus/pod_metrics.yml'} + ${'/config%2Fprometheus%2Fpod_metrics.yml'} | ${'config/prometheus/pod_metrics.yml'} + `('sets component as $componentName for path "$route"', ({ route, currentDashboard }) => { + const wrapper = createWrapper(NEW_BASE_PATH, route); + + expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setCurrentDashboard', { + currentDashboard, + }); + + expect(wrapper.find(Dashboard)).toExist(); + }); + }); +}); diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index d0290386f12..22f2b2e3c77 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -6,27 +6,30 @@ import statusCodes from '~/lib/utils/http_status'; import * as commonUtils from '~/lib/utils/common_utils'; import createFlash from '~/flash'; import { defaultTimeRange } from '~/vue_shared/constants'; +import * as getters from '~/monitoring/stores/getters'; import { ENVIRONMENT_AVAILABLE_STATE } from '~/monitoring/constants'; import { createStore } from '~/monitoring/stores'; import * as types from '~/monitoring/stores/mutation_types'; import { + setGettingStartedEmptyState, + setInitialState, + setExpandedPanel, + clearExpandedPanel, + filterEnvironments, fetchData, fetchDashboard, receiveMetricsDashboardSuccess, + fetchDashboardData, + fetchPrometheusMetric, fetchDeploymentsData, fetchEnvironmentsData, - fetchDashboardData, fetchAnnotations, + fetchDashboardValidationWarnings, toggleStarredValue, - fetchPrometheusMetric, - setInitialState, - filterEnvironments, - setExpandedPanel, - clearExpandedPanel, - setGettingStartedEmptyState, duplicateSystemDashboard, updateVariablesAndFetchData, + fetchVariableMetricLabelValues, } from '~/monitoring/stores/actions'; import { gqClient, @@ -35,12 +38,12 @@ import { } from '~/monitoring/stores/utils'; import getEnvironments from '~/monitoring/queries/getEnvironments.query.graphql'; import getAnnotations from '~/monitoring/queries/getAnnotations.query.graphql'; +import getDashboardValidationWarnings from '~/monitoring/queries/getDashboardValidationWarnings.query.graphql'; import storeState from '~/monitoring/stores/state'; import { deploymentData, environmentData, annotationsData, - mockTemplatingData, dashboardGitResponse, mockDashboardsErrorResponse, } from '../mock_data'; @@ -59,11 +62,17 @@ describe('Monitoring store actions', () => { let store; let state; + let dispatch; + let commit; + beforeEach(() => { - store = createStore(); + store = createStore({ getters }); state = store.state.monitoringDashboard; mock = new MockAdapter(axios); + commit = jest.fn(); + dispatch = jest.fn(); + jest.spyOn(commonUtils, 'backOff').mockImplementation(callback => { const q = new Promise((resolve, reject) => { const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg)); @@ -78,6 +87,7 @@ describe('Monitoring store actions', () => { return q; }); }); + afterEach(() => { mock.reset(); @@ -85,377 +95,122 @@ describe('Monitoring store actions', () => { createFlash.mockReset(); }); - describe('fetchData', () => { - it('dispatches fetchEnvironmentsData and fetchEnvironmentsData', () => { - return testAction( - fetchData, - null, - state, - [], - [ - { type: 'fetchEnvironmentsData' }, - { type: 'fetchDashboard' }, - { type: 'fetchAnnotations' }, - ], - ); - }); + // Setup - it('dispatches when feature metricsDashboardAnnotations is on', () => { - const origGon = window.gon; - window.gon = { features: { metricsDashboardAnnotations: true } }; - - return testAction( - fetchData, + describe('setGettingStartedEmptyState', () => { + it('should commit SET_GETTING_STARTED_EMPTY_STATE mutation', done => { + testAction( + setGettingStartedEmptyState, null, state, - [], [ - { type: 'fetchEnvironmentsData' }, - { type: 'fetchDashboard' }, - { type: 'fetchAnnotations' }, + { + type: types.SET_GETTING_STARTED_EMPTY_STATE, + }, ], - ).then(() => { - window.gon = origGon; - }); - }); - }); - - describe('fetchDeploymentsData', () => { - it('dispatches receiveDeploymentsDataSuccess on success', () => { - state.deploymentsEndpoint = '/success'; - mock.onGet(state.deploymentsEndpoint).reply(200, { - deployments: deploymentData, - }); - - return testAction( - fetchDeploymentsData, - null, - state, - [], - [{ type: 'receiveDeploymentsDataSuccess', payload: deploymentData }], - ); - }); - it('dispatches receiveDeploymentsDataFailure on error', () => { - state.deploymentsEndpoint = '/error'; - mock.onGet(state.deploymentsEndpoint).reply(500); - - return testAction( - fetchDeploymentsData, - null, - state, [], - [{ type: 'receiveDeploymentsDataFailure' }], - () => { - expect(createFlash).toHaveBeenCalled(); - }, + done, ); }); }); - describe('fetchEnvironmentsData', () => { - beforeEach(() => { - state.projectPath = 'gitlab-org/gitlab-test'; - }); - - it('setting SET_ENVIRONMENTS_FILTER should dispatch fetchEnvironmentsData', () => { - jest.spyOn(gqClient, 'mutate').mockReturnValue({ - data: { - project: { - data: { - environments: [], - }, - }, + describe('setInitialState', () => { + it('should commit SET_INITIAL_STATE mutation', done => { + testAction( + setInitialState, + { + currentDashboard: '.gitlab/dashboards/dashboard.yml', + deploymentsEndpoint: 'deployments.json', }, - }); - - return testAction( - filterEnvironments, - {}, state, [ { - type: 'SET_ENVIRONMENTS_FILTER', - payload: {}, - }, - ], - [ - { - type: 'fetchEnvironmentsData', + type: types.SET_INITIAL_STATE, + payload: { + currentDashboard: '.gitlab/dashboards/dashboard.yml', + deploymentsEndpoint: 'deployments.json', + }, }, ], - ); - }); - - it('fetch environments data call takes in search param', () => { - const mockMutate = jest.spyOn(gqClient, 'mutate'); - const searchTerm = 'Something'; - const mutationVariables = { - mutation: getEnvironments, - variables: { - projectPath: state.projectPath, - search: searchTerm, - states: [ENVIRONMENT_AVAILABLE_STATE], - }, - }; - state.environmentsSearchTerm = searchTerm; - mockMutate.mockResolvedValue({}); - - return testAction( - fetchEnvironmentsData, - null, - state, [], - [ - { type: 'requestEnvironmentsData' }, - { type: 'receiveEnvironmentsDataSuccess', payload: [] }, - ], - () => { - expect(mockMutate).toHaveBeenCalledWith(mutationVariables); - }, + done, ); }); + }); - it('dispatches receiveEnvironmentsDataSuccess on success', () => { - jest.spyOn(gqClient, 'mutate').mockResolvedValue({ - data: { - project: { - data: { - environments: environmentData, - }, - }, - }, - }); + describe('setExpandedPanel', () => { + it('Sets a panel as expanded', () => { + const group = 'group_1'; + const panel = { title: 'A Panel' }; return testAction( - fetchEnvironmentsData, - null, + setExpandedPanel, + { group, panel }, state, + [{ type: types.SET_EXPANDED_PANEL, payload: { group, panel } }], [], - [ - { type: 'requestEnvironmentsData' }, - { - type: 'receiveEnvironmentsDataSuccess', - payload: parseEnvironmentsResponse(environmentData, state.projectPath), - }, - ], ); }); + }); - it('dispatches receiveEnvironmentsDataFailure on error', () => { - jest.spyOn(gqClient, 'mutate').mockRejectedValue({}); - + describe('clearExpandedPanel', () => { + it('Clears a panel as expanded', () => { return testAction( - fetchEnvironmentsData, - null, + clearExpandedPanel, + undefined, state, + [{ type: types.SET_EXPANDED_PANEL, payload: { group: null, panel: null } }], [], - [{ type: 'requestEnvironmentsData' }, { type: 'receiveEnvironmentsDataFailure' }], ); }); }); - describe('fetchAnnotations', () => { - beforeEach(() => { - state.timeRange = { - start: '2020-04-15T12:54:32.137Z', - end: '2020-08-15T12:54:32.137Z', - }; - state.projectPath = 'gitlab-org/gitlab-test'; - state.currentEnvironmentName = 'production'; - state.currentDashboard = '.gitlab/dashboards/custom_dashboard.yml'; - }); - - it('fetches annotations data and dispatches receiveAnnotationsSuccess', () => { - const mockMutate = jest.spyOn(gqClient, 'mutate'); - const mutationVariables = { - mutation: getAnnotations, - variables: { - projectPath: state.projectPath, - environmentName: state.currentEnvironmentName, - dashboardPath: state.currentDashboard, - startingFrom: state.timeRange.start, - }, - }; - const parsedResponse = parseAnnotationsResponse(annotationsData); - - mockMutate.mockResolvedValue({ - data: { - project: { - environments: { - nodes: [ - { - metricsDashboard: { - annotations: { - nodes: parsedResponse, - }, - }, - }, - ], - }, - }, - }, - }); + // All Data + describe('fetchData', () => { + it('dispatches fetchEnvironmentsData and fetchEnvironmentsData', () => { return testAction( - fetchAnnotations, + fetchData, null, state, [], - [{ type: 'receiveAnnotationsSuccess', payload: parsedResponse }], - () => { - expect(mockMutate).toHaveBeenCalledWith(mutationVariables); - }, + [ + { type: 'fetchEnvironmentsData' }, + { type: 'fetchDashboard' }, + { type: 'fetchAnnotations' }, + ], ); }); - it('dispatches receiveAnnotationsFailure if the annotations API call fails', () => { - const mockMutate = jest.spyOn(gqClient, 'mutate'); - const mutationVariables = { - mutation: getAnnotations, - variables: { - projectPath: state.projectPath, - environmentName: state.currentEnvironmentName, - dashboardPath: state.currentDashboard, - startingFrom: state.timeRange.start, - }, - }; - - mockMutate.mockRejectedValue({}); + it('dispatches when feature metricsDashboardAnnotations is on', () => { + const origGon = window.gon; + window.gon = { features: { metricsDashboardAnnotations: true } }; return testAction( - fetchAnnotations, + fetchData, null, state, [], - [{ type: 'receiveAnnotationsFailure' }], - () => { - expect(mockMutate).toHaveBeenCalledWith(mutationVariables); - }, - ); - }); - }); - - describe('Toggles starred value of current dashboard', () => { - let unstarredDashboard; - let starredDashboard; - - beforeEach(() => { - state.isUpdatingStarredValue = false; - [unstarredDashboard, starredDashboard] = dashboardGitResponse; - }); - - describe('toggleStarredValue', () => { - it('performs no changes if no dashboard is selected', () => { - return testAction(toggleStarredValue, null, state, [], []); - }); - - it('performs no changes if already changing starred value', () => { - state.selectedDashboard = unstarredDashboard; - state.isUpdatingStarredValue = true; - return testAction(toggleStarredValue, null, state, [], []); - }); - - it('stars dashboard if it is not starred', () => { - state.selectedDashboard = unstarredDashboard; - mock.onPost(unstarredDashboard.user_starred_path).reply(200); - - return testAction(toggleStarredValue, null, state, [ - { type: types.REQUEST_DASHBOARD_STARRING }, - { - type: types.RECEIVE_DASHBOARD_STARRING_SUCCESS, - payload: { - newStarredValue: true, - selectedDashboard: unstarredDashboard, - }, - }, - ]); - }); - - it('unstars dashboard if it is starred', () => { - state.selectedDashboard = starredDashboard; - mock.onPost(starredDashboard.user_starred_path).reply(200); - - return testAction(toggleStarredValue, null, state, [ - { type: types.REQUEST_DASHBOARD_STARRING }, - { type: types.RECEIVE_DASHBOARD_STARRING_FAILURE }, - ]); - }); - }); - }); - - describe('Set initial state', () => { - it('should commit SET_INITIAL_STATE mutation', done => { - testAction( - setInitialState, - { - currentDashboard: '.gitlab/dashboards/dashboard.yml', - deploymentsEndpoint: 'deployments.json', - }, - state, - [ - { - type: types.SET_INITIAL_STATE, - payload: { - currentDashboard: '.gitlab/dashboards/dashboard.yml', - deploymentsEndpoint: 'deployments.json', - }, - }, - ], - [], - done, - ); - }); - }); - describe('Set empty states', () => { - it('should commit SET_METRICS_ENDPOINT mutation', done => { - testAction( - setGettingStartedEmptyState, - null, - state, [ - { - type: types.SET_GETTING_STARTED_EMPTY_STATE, - }, + { type: 'fetchEnvironmentsData' }, + { type: 'fetchDashboard' }, + { type: 'fetchAnnotations' }, ], - [], - done, - ); + ).then(() => { + window.gon = origGon; + }); }); }); - describe('updateVariablesAndFetchData', () => { - it('should commit UPDATE_VARIABLES mutation and fetch data', done => { - testAction( - updateVariablesAndFetchData, - { pod: 'POD' }, - state, - [ - { - type: types.UPDATE_VARIABLES, - payload: { pod: 'POD' }, - }, - ], - [ - { - type: 'fetchDashboardData', - }, - ], - done, - ); - }); - }); + // Metrics dashboard describe('fetchDashboard', () => { - let dispatch; - let commit; const response = metricsDashboardResponse; beforeEach(() => { - dispatch = jest.fn(); - commit = jest.fn(); state.dashboardEndpoint = '/dashboard'; }); - it('on success, dispatches receive and success actions', () => { + it('on success, dispatches receive and success actions, then fetches dashboard warnings', () => { document.body.dataset.page = 'projects:environments:metrics'; mock.onGet(state.dashboardEndpoint).reply(200, response); @@ -470,6 +225,7 @@ describe('Monitoring store actions', () => { type: 'receiveMetricsDashboardSuccess', payload: { response }, }, + { type: 'fetchDashboardValidationWarnings' }, ], ); }); @@ -478,9 +234,12 @@ describe('Monitoring store actions', () => { let result; beforeEach(() => { const params = {}; + const localGetters = { + fullDashboardPath: store.getters['monitoringDashboard/fullDashboardPath'], + }; result = () => { mock.onGet(state.dashboardEndpoint).replyOnce(500, mockDashboardsErrorResponse); - return fetchDashboard({ state, commit, dispatch }, params); + return fetchDashboard({ state, commit, dispatch, getters: localGetters }, params); }; }); @@ -532,15 +291,8 @@ describe('Monitoring store actions', () => { }); }); }); - describe('receiveMetricsDashboardSuccess', () => { - let commit; - let dispatch; - - beforeEach(() => { - commit = jest.fn(); - dispatch = jest.fn(); - }); + describe('receiveMetricsDashboardSuccess', () => { it('stores groups', () => { const response = metricsDashboardResponse; receiveMetricsDashboardSuccess({ state, commit, dispatch }, { response }); @@ -552,32 +304,6 @@ describe('Monitoring store actions', () => { expect(dispatch).toHaveBeenCalledWith('fetchDashboardData'); }); - it('stores templating variables', () => { - const response = { - ...metricsDashboardResponse.dashboard, - ...mockTemplatingData.allVariableTypes.dashboard, - }; - - receiveMetricsDashboardSuccess( - { state, commit, dispatch }, - { - response: { - ...metricsDashboardResponse, - dashboard: { - ...metricsDashboardResponse.dashboard, - ...mockTemplatingData.allVariableTypes.dashboard, - }, - }, - }, - ); - - expect(commit).toHaveBeenCalledWith( - types.RECEIVE_METRICS_DASHBOARD_SUCCESS, - - response, - ); - }); - it('sets the dashboards loaded from the repository', () => { const params = {}; const response = metricsDashboardResponse; @@ -596,23 +322,21 @@ describe('Monitoring store actions', () => { expect(commit).toHaveBeenCalledWith(types.SET_ALL_DASHBOARDS, dashboardGitResponse); }); }); - describe('fetchDashboardData', () => { - let commit; - let dispatch; + // Metrics + + describe('fetchDashboardData', () => { beforeEach(() => { jest.spyOn(Tracking, 'event'); - commit = jest.fn(); - dispatch = jest.fn(); state.timeRange = defaultTimeRange; }); it('commits empty state when state.groups is empty', done => { - const getters = { + const localGetters = { metricsWithData: () => [], }; - fetchDashboardData({ state, commit, dispatch, getters }) + fetchDashboardData({ state, commit, dispatch, getters: localGetters }) .then(() => { expect(Tracking.event).toHaveBeenCalledWith( document.body.dataset.page, @@ -623,25 +347,33 @@ describe('Monitoring store actions', () => { value: 0, }, ); - expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData'); + expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', { + defaultQueryParams: { + start_time: expect.any(String), + end_time: expect.any(String), + step: expect.any(Number), + }, + }); expect(createFlash).not.toHaveBeenCalled(); done(); }) .catch(done.fail); }); + it('dispatches fetchPrometheusMetric for each panel query', done => { state.dashboard.panelGroups = convertObjectPropsToCamelCase( metricsDashboardResponse.dashboard.panel_groups, ); const [metric] = state.dashboard.panelGroups[0].panels[0].metrics; - const getters = { + const localGetters = { metricsWithData: () => [metric.id], }; - fetchDashboardData({ state, commit, dispatch, getters }) + fetchDashboardData({ state, commit, dispatch, getters: localGetters }) .then(() => { expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { metric, @@ -673,21 +405,27 @@ describe('Monitoring store actions', () => { const metric = state.dashboard.panelGroups[0].panels[0].metrics[0]; dispatch.mockResolvedValueOnce(); // fetchDeploymentsData + dispatch.mockResolvedValueOnce(); // fetchVariableMetricLabelValues // Mock having one out of four metrics failing dispatch.mockRejectedValueOnce(new Error('Error fetching this metric')); dispatch.mockResolvedValue(); fetchDashboardData({ state, commit, dispatch }) .then(() => { - expect(dispatch).toHaveBeenCalledTimes(metricsDashboardPanelCount + 1); // plus 1 for deployments + const defaultQueryParams = { + start_time: expect.any(String), + end_time: expect.any(String), + step: expect.any(Number), + }; + + expect(dispatch).toHaveBeenCalledTimes(metricsDashboardPanelCount + 2); // plus 1 for deployments expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData'); + expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', { + defaultQueryParams, + }); expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { metric, - defaultQueryParams: { - start_time: expect.any(String), - end_time: expect.any(String), - step: expect.any(Number), - }, + defaultQueryParams, }); expect(createFlash).toHaveBeenCalledTimes(1); @@ -698,6 +436,7 @@ describe('Monitoring store actions', () => { done(); }); }); + describe('fetchPrometheusMetric', () => { const defaultQueryParams = { start_time: '2019-08-06T12:40:02.184Z', @@ -738,7 +477,7 @@ describe('Monitoring store actions', () => { type: types.RECEIVE_METRIC_RESULT_SUCCESS, payload: { metricId: metric.metricId, - result: data.result, + data, }, }, ], @@ -775,7 +514,7 @@ describe('Monitoring store actions', () => { type: types.RECEIVE_METRIC_RESULT_SUCCESS, payload: { metricId: metric.metricId, - result: data.result, + data, }, }, ], @@ -817,7 +556,7 @@ describe('Monitoring store actions', () => { type: types.RECEIVE_METRIC_RESULT_SUCCESS, payload: { metricId: metric.metricId, - result: data.result, + data, }, }, ], @@ -852,7 +591,7 @@ describe('Monitoring store actions', () => { type: types.RECEIVE_METRIC_RESULT_SUCCESS, payload: { metricId: metric.metricId, - result: data.result, + data, }, }, ], @@ -901,6 +640,402 @@ describe('Monitoring store actions', () => { }); }); + // Deployments + + describe('fetchDeploymentsData', () => { + it('dispatches receiveDeploymentsDataSuccess on success', () => { + state.deploymentsEndpoint = '/success'; + mock.onGet(state.deploymentsEndpoint).reply(200, { + deployments: deploymentData, + }); + + return testAction( + fetchDeploymentsData, + null, + state, + [], + [{ type: 'receiveDeploymentsDataSuccess', payload: deploymentData }], + ); + }); + it('dispatches receiveDeploymentsDataFailure on error', () => { + state.deploymentsEndpoint = '/error'; + mock.onGet(state.deploymentsEndpoint).reply(500); + + return testAction( + fetchDeploymentsData, + null, + state, + [], + [{ type: 'receiveDeploymentsDataFailure' }], + () => { + expect(createFlash).toHaveBeenCalled(); + }, + ); + }); + }); + + // Environments + + describe('fetchEnvironmentsData', () => { + beforeEach(() => { + state.projectPath = 'gitlab-org/gitlab-test'; + }); + + it('setting SET_ENVIRONMENTS_FILTER should dispatch fetchEnvironmentsData', () => { + jest.spyOn(gqClient, 'mutate').mockReturnValue({ + data: { + project: { + data: { + environments: [], + }, + }, + }, + }); + + return testAction( + filterEnvironments, + {}, + state, + [ + { + type: 'SET_ENVIRONMENTS_FILTER', + payload: {}, + }, + ], + [ + { + type: 'fetchEnvironmentsData', + }, + ], + ); + }); + + it('fetch environments data call takes in search param', () => { + const mockMutate = jest.spyOn(gqClient, 'mutate'); + const searchTerm = 'Something'; + const mutationVariables = { + mutation: getEnvironments, + variables: { + projectPath: state.projectPath, + search: searchTerm, + states: [ENVIRONMENT_AVAILABLE_STATE], + }, + }; + state.environmentsSearchTerm = searchTerm; + mockMutate.mockResolvedValue({}); + + return testAction( + fetchEnvironmentsData, + null, + state, + [], + [ + { type: 'requestEnvironmentsData' }, + { type: 'receiveEnvironmentsDataSuccess', payload: [] }, + ], + () => { + expect(mockMutate).toHaveBeenCalledWith(mutationVariables); + }, + ); + }); + + it('dispatches receiveEnvironmentsDataSuccess on success', () => { + jest.spyOn(gqClient, 'mutate').mockResolvedValue({ + data: { + project: { + data: { + environments: environmentData, + }, + }, + }, + }); + + return testAction( + fetchEnvironmentsData, + null, + state, + [], + [ + { type: 'requestEnvironmentsData' }, + { + type: 'receiveEnvironmentsDataSuccess', + payload: parseEnvironmentsResponse(environmentData, state.projectPath), + }, + ], + ); + }); + + it('dispatches receiveEnvironmentsDataFailure on error', () => { + jest.spyOn(gqClient, 'mutate').mockRejectedValue({}); + + return testAction( + fetchEnvironmentsData, + null, + state, + [], + [{ type: 'requestEnvironmentsData' }, { type: 'receiveEnvironmentsDataFailure' }], + ); + }); + }); + + describe('fetchAnnotations', () => { + beforeEach(() => { + state.timeRange = { + start: '2020-04-15T12:54:32.137Z', + end: '2020-08-15T12:54:32.137Z', + }; + state.projectPath = 'gitlab-org/gitlab-test'; + state.currentEnvironmentName = 'production'; + state.currentDashboard = '.gitlab/dashboards/custom_dashboard.yml'; + // testAction doesn't have access to getters. The state is passed in as getters + // instead of the actual getters inside the testAction method implementation. + // All methods downstream that needs access to getters will throw and error. + // For that reason, the result of the getter is set as a state variable. + state.fullDashboardPath = store.getters['monitoringDashboard/fullDashboardPath']; + }); + + it('fetches annotations data and dispatches receiveAnnotationsSuccess', () => { + const mockMutate = jest.spyOn(gqClient, 'mutate'); + const mutationVariables = { + mutation: getAnnotations, + variables: { + projectPath: state.projectPath, + environmentName: state.currentEnvironmentName, + dashboardPath: state.currentDashboard, + startingFrom: state.timeRange.start, + }, + }; + const parsedResponse = parseAnnotationsResponse(annotationsData); + + mockMutate.mockResolvedValue({ + data: { + project: { + environments: { + nodes: [ + { + metricsDashboard: { + annotations: { + nodes: parsedResponse, + }, + }, + }, + ], + }, + }, + }, + }); + + return testAction( + fetchAnnotations, + null, + state, + [], + [{ type: 'receiveAnnotationsSuccess', payload: parsedResponse }], + () => { + expect(mockMutate).toHaveBeenCalledWith(mutationVariables); + }, + ); + }); + + it('dispatches receiveAnnotationsFailure if the annotations API call fails', () => { + const mockMutate = jest.spyOn(gqClient, 'mutate'); + const mutationVariables = { + mutation: getAnnotations, + variables: { + projectPath: state.projectPath, + environmentName: state.currentEnvironmentName, + dashboardPath: state.currentDashboard, + startingFrom: state.timeRange.start, + }, + }; + + mockMutate.mockRejectedValue({}); + + return testAction( + fetchAnnotations, + null, + state, + [], + [{ type: 'receiveAnnotationsFailure' }], + () => { + expect(mockMutate).toHaveBeenCalledWith(mutationVariables); + }, + ); + }); + }); + + describe('fetchDashboardValidationWarnings', () => { + let mockMutate; + let mutationVariables; + + beforeEach(() => { + state.projectPath = 'gitlab-org/gitlab-test'; + state.currentEnvironmentName = 'production'; + state.currentDashboard = '.gitlab/dashboards/dashboard_with_warnings.yml'; + // testAction doesn't have access to getters. The state is passed in as getters + // instead of the actual getters inside the testAction method implementation. + // All methods downstream that needs access to getters will throw and error. + // For that reason, the result of the getter is set as a state variable. + state.fullDashboardPath = store.getters['monitoringDashboard/fullDashboardPath']; + + mockMutate = jest.spyOn(gqClient, 'mutate'); + mutationVariables = { + mutation: getDashboardValidationWarnings, + variables: { + projectPath: state.projectPath, + environmentName: state.currentEnvironmentName, + dashboardPath: state.fullDashboardPath, + }, + }; + }); + + it('dispatches receiveDashboardValidationWarningsSuccess with true payload when there are warnings', () => { + mockMutate.mockResolvedValue({ + data: { + project: { + id: 'gid://gitlab/Project/29', + environments: { + nodes: [ + { + name: 'production', + metricsDashboard: { + path: '.gitlab/dashboards/dashboard_errors_test.yml', + schemaValidationWarnings: ["unit: can't be blank"], + }, + }, + ], + }, + }, + }, + }); + + return testAction( + fetchDashboardValidationWarnings, + null, + state, + [], + [{ type: 'receiveDashboardValidationWarningsSuccess', payload: true }], + () => { + expect(mockMutate).toHaveBeenCalledWith(mutationVariables); + }, + ); + }); + + it('dispatches receiveDashboardValidationWarningsSuccess with false payload when there are no warnings', () => { + mockMutate.mockResolvedValue({ + data: { + project: { + id: 'gid://gitlab/Project/29', + environments: { + nodes: [ + { + name: 'production', + metricsDashboard: { + path: '.gitlab/dashboards/dashboard_errors_test.yml', + schemaValidationWarnings: [], + }, + }, + ], + }, + }, + }, + }); + + return testAction( + fetchDashboardValidationWarnings, + null, + state, + [], + [{ type: 'receiveDashboardValidationWarningsSuccess', payload: false }], + () => { + expect(mockMutate).toHaveBeenCalledWith(mutationVariables); + }, + ); + }); + + it('dispatches receiveDashboardValidationWarningsSuccess with false payload when the response is empty ', () => { + mockMutate.mockResolvedValue({ + data: { + project: null, + }, + }); + + return testAction( + fetchDashboardValidationWarnings, + null, + state, + [], + [{ type: 'receiveDashboardValidationWarningsSuccess', payload: false }], + () => { + expect(mockMutate).toHaveBeenCalledWith(mutationVariables); + }, + ); + }); + + it('dispatches receiveDashboardValidationWarningsFailure if the warnings API call fails', () => { + mockMutate.mockRejectedValue({}); + + return testAction( + fetchDashboardValidationWarnings, + null, + state, + [], + [{ type: 'receiveDashboardValidationWarningsFailure' }], + () => { + expect(mockMutate).toHaveBeenCalledWith(mutationVariables); + }, + ); + }); + }); + + // Dashboard manipulation + + describe('toggleStarredValue', () => { + let unstarredDashboard; + let starredDashboard; + + beforeEach(() => { + state.isUpdatingStarredValue = false; + [unstarredDashboard, starredDashboard] = dashboardGitResponse; + }); + + it('performs no changes if no dashboard is selected', () => { + return testAction(toggleStarredValue, null, state, [], []); + }); + + it('performs no changes if already changing starred value', () => { + state.selectedDashboard = unstarredDashboard; + state.isUpdatingStarredValue = true; + return testAction(toggleStarredValue, null, state, [], []); + }); + + it('stars dashboard if it is not starred', () => { + state.selectedDashboard = unstarredDashboard; + mock.onPost(unstarredDashboard.user_starred_path).reply(200); + + return testAction(toggleStarredValue, null, state, [ + { type: types.REQUEST_DASHBOARD_STARRING }, + { + type: types.RECEIVE_DASHBOARD_STARRING_SUCCESS, + payload: { + newStarredValue: true, + selectedDashboard: unstarredDashboard, + }, + }, + ]); + }); + + it('unstars dashboard if it is starred', () => { + state.selectedDashboard = starredDashboard; + mock.onPost(starredDashboard.user_starred_path).reply(200); + + return testAction(toggleStarredValue, null, state, [ + { type: types.REQUEST_DASHBOARD_STARRING }, + { type: types.RECEIVE_DASHBOARD_STARRING_FAILURE }, + ]); + }); + }); + describe('duplicateSystemDashboard', () => { beforeEach(() => { state.dashboardsEndpoint = '/dashboards.json'; @@ -979,30 +1114,95 @@ describe('Monitoring store actions', () => { }); }); - describe('setExpandedPanel', () => { - it('Sets a panel as expanded', () => { - const group = 'group_1'; - const panel = { title: 'A Panel' }; + // Variables manipulation - return testAction( - setExpandedPanel, - { group, panel }, + describe('updateVariablesAndFetchData', () => { + it('should commit UPDATE_VARIABLE_VALUE mutation and fetch data', done => { + testAction( + updateVariablesAndFetchData, + { pod: 'POD' }, state, - [{ type: types.SET_EXPANDED_PANEL, payload: { group, panel } }], - [], + [ + { + type: types.UPDATE_VARIABLE_VALUE, + payload: { pod: 'POD' }, + }, + ], + [ + { + type: 'fetchDashboardData', + }, + ], + done, ); }); }); - describe('clearExpandedPanel', () => { - it('Clears a panel as expanded', () => { + describe('fetchVariableMetricLabelValues', () => { + const variable = { + type: 'metric_label_values', + name: 'label1', + options: { + prometheusEndpointPath: '/series?match[]=metric_name', + label: 'job', + }, + }; + + const defaultQueryParams = { + start_time: '2019-08-06T12:40:02.184Z', + end_time: '2019-08-06T20:40:02.184Z', + }; + + beforeEach(() => { + state = { + ...state, + timeRange: defaultTimeRange, + variables: [variable], + }; + }); + + it('should commit UPDATE_VARIABLE_METRIC_LABEL_VALUES mutation and fetch data', () => { + const data = [ + { + __name__: 'up', + job: 'prometheus', + }, + { + __name__: 'up', + job: 'POD', + }, + ]; + + mock.onGet('/series?match[]=metric_name').reply(200, { + status: 'success', + data, + }); + return testAction( - clearExpandedPanel, - undefined, + fetchVariableMetricLabelValues, + { defaultQueryParams }, state, - [{ type: types.SET_EXPANDED_PANEL, payload: { group: null, panel: null } }], + [ + { + type: types.UPDATE_VARIABLE_METRIC_LABEL_VALUES, + payload: { variable, label: 'job', data }, + }, + ], [], ); }); + + it('should notify the user that dynamic options were not loaded', () => { + mock.onGet('/series?match[]=metric_name').reply(500); + + return testAction(fetchVariableMetricLabelValues, { defaultQueryParams }, state, [], []).then( + () => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith( + expect.stringContaining('error getting options for variable "label1"'), + ); + }, + ); + }); }); }); diff --git a/spec/frontend/monitoring/store/getters_spec.js b/spec/frontend/monitoring/store/getters_spec.js index 933ccb1e46c..a69f5265ea7 100644 --- a/spec/frontend/monitoring/store/getters_spec.js +++ b/spec/frontend/monitoring/store/getters_spec.js @@ -4,10 +4,11 @@ import mutations from '~/monitoring/stores/mutations'; import * as types from '~/monitoring/stores/mutation_types'; import { metricStates } from '~/monitoring/constants'; import { + customDashboardBasePath, environmentData, metricsResult, dashboardGitResponse, - mockTemplatingDataResponses, + storeVariables, mockLinks, } from '../mock_data'; import { @@ -27,7 +28,10 @@ describe('Monitoring store Getters', () => { const { metricId } = state.dashboard.panelGroups[group].panels[panel].metrics[metric]; mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, - result, + data: { + resultType: 'matrix', + result, + }, }); }; @@ -340,19 +344,21 @@ describe('Monitoring store Getters', () => { }); it('transforms the variables object to an array in the [variable, variable_value] format for all variable types', () => { - mutations[types.SET_VARIABLES](state, mockTemplatingDataResponses.allVariableTypes); + state.variables = storeVariables; const variablesArray = getters.getCustomVariablesParams(state); expect(variablesArray).toEqual({ - 'variables[advCustomNormal]': 'value2', - 'variables[advText]': 'default', - 'variables[simpleCustom]': 'value1', - 'variables[simpleText]': 'Simple text', + 'variables[textSimple]': 'My default value', + 'variables[textAdvanced]': 'A default value', + 'variables[customSimple]': 'value1', + 'variables[customAdvanced]': 'value2', + 'variables[customAdvancedWithoutLabel]': 'value2', + 'variables[customAdvancedWithoutOptText]': 'value2', }); }); it('transforms the variables object to an empty array when no keys are present', () => { - mutations[types.SET_VARIABLES](state, {}); + state.variables = []; const variablesArray = getters.getCustomVariablesParams(state); expect(variablesArray).toEqual({}); @@ -361,45 +367,53 @@ describe('Monitoring store Getters', () => { describe('selectedDashboard', () => { const { selectedDashboard } = getters; + const localGetters = state => ({ + fullDashboardPath: getters.fullDashboardPath(state), + }); it('returns a dashboard', () => { const state = { allDashboards: dashboardGitResponse, currentDashboard: dashboardGitResponse[0].path, + customDashboardBasePath, }; - expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]); + expect(selectedDashboard(state, localGetters(state))).toEqual(dashboardGitResponse[0]); }); it('returns a non-default dashboard', () => { const state = { allDashboards: dashboardGitResponse, currentDashboard: dashboardGitResponse[1].path, + customDashboardBasePath, }; - expect(selectedDashboard(state)).toEqual(dashboardGitResponse[1]); + expect(selectedDashboard(state, localGetters(state))).toEqual(dashboardGitResponse[1]); }); it('returns a default dashboard when no dashboard is selected', () => { const state = { allDashboards: dashboardGitResponse, currentDashboard: null, + customDashboardBasePath, }; - expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]); + expect(selectedDashboard(state, localGetters(state))).toEqual(dashboardGitResponse[0]); }); it('returns a default dashboard when dashboard cannot be found', () => { const state = { allDashboards: dashboardGitResponse, currentDashboard: 'wrong_path', + customDashboardBasePath, }; - expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]); + expect(selectedDashboard(state, localGetters(state))).toEqual(dashboardGitResponse[0]); }); it('returns null when no dashboards are present', () => { const state = { allDashboards: [], currentDashboard: dashboardGitResponse[0].path, + customDashboardBasePath, }; - expect(selectedDashboard(state)).toEqual(null); + expect(selectedDashboard(state, localGetters(state))).toEqual(null); }); }); diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js index 0283f1a86a4..14b38d79aa2 100644 --- a/spec/frontend/monitoring/store/mutations_spec.js +++ b/spec/frontend/monitoring/store/mutations_spec.js @@ -3,9 +3,9 @@ import httpStatusCodes from '~/lib/utils/http_status'; import mutations from '~/monitoring/stores/mutations'; import * as types from '~/monitoring/stores/mutation_types'; import state from '~/monitoring/stores/state'; -import { metricStates } from '~/monitoring/constants'; +import { dashboardEmptyStates, metricStates } from '~/monitoring/constants'; -import { deploymentData, dashboardGitResponse } from '../mock_data'; +import { deploymentData, dashboardGitResponse, storeTextVariables } from '../mock_data'; import { metricsDashboardPayload } from '../fixture_data'; describe('Monitoring mutations', () => { @@ -15,6 +15,14 @@ describe('Monitoring mutations', () => { stateCopy = state(); }); + describe('REQUEST_METRICS_DASHBOARD', () => { + it('sets an empty loading state', () => { + mutations[types.REQUEST_METRICS_DASHBOARD](stateCopy); + + expect(stateCopy.emptyState).toBe(dashboardEmptyStates.LOADING); + }); + }); + describe('RECEIVE_METRICS_DASHBOARD_SUCCESS', () => { let payload; const getGroups = () => stateCopy.dashboard.panelGroups; @@ -23,6 +31,18 @@ describe('Monitoring mutations', () => { stateCopy.dashboard.panelGroups = []; payload = metricsDashboardPayload; }); + it('sets an empty noData state when the dashboard is empty', () => { + const emptyDashboardPayload = { + ...payload, + panel_groups: [], + }; + + mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](stateCopy, emptyDashboardPayload); + const groups = getGroups(); + + expect(groups).toEqual([]); + expect(stateCopy.emptyState).toBe(dashboardEmptyStates.NO_DATA); + }); it('adds a key to the group', () => { mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](stateCopy, payload); const groups = getGroups(); @@ -72,6 +92,20 @@ describe('Monitoring mutations', () => { }); }); + describe('RECEIVE_METRICS_DASHBOARD_FAILURE', () => { + it('sets an empty noData state when an empty error occurs', () => { + mutations[types.RECEIVE_METRICS_DASHBOARD_FAILURE](stateCopy); + + expect(stateCopy.emptyState).toBe(dashboardEmptyStates.NO_DATA); + }); + + it('sets an empty unableToConnect state when an error occurs', () => { + mutations[types.RECEIVE_METRICS_DASHBOARD_FAILURE](stateCopy, 'myerror'); + + expect(stateCopy.emptyState).toBe(dashboardEmptyStates.UNABLE_TO_CONNECT); + }); + }); + describe('Dashboard starring mutations', () => { it('REQUEST_DASHBOARD_STARRING', () => { stateCopy = { isUpdatingStarredValue: false }; @@ -225,11 +259,28 @@ describe('Monitoring mutations', () => { describe('Individual panel/metric results', () => { const metricId = 'NO_DB_response_metrics_nginx_ingress_throughput_status_code'; - const result = [ - { - values: [[0, 1], [1, 1], [1, 3]], - }, - ]; + const data = { + resultType: 'matrix', + result: [ + { + metric: { + __name__: 'up', + job: 'prometheus', + instance: 'localhost:9090', + }, + values: [[1435781430.781, '1'], [1435781445.781, '1'], [1435781460.781, '1']], + }, + { + metric: { + __name__: 'up', + job: 'node', + instance: 'localhost:9091', + }, + values: [[1435781430.781, '0'], [1435781445.781, '0'], [1435781460.781, '1']], + }, + ], + }; + const dashboard = metricsDashboardPayload; const getMetric = () => stateCopy.dashboard.panelGroups[1].panels[0].metrics[0]; @@ -238,13 +289,10 @@ describe('Monitoring mutations', () => { mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](stateCopy, dashboard); }); it('stores a loading state on a metric', () => { - expect(stateCopy.showEmptyState).toBe(true); - mutations[types.REQUEST_METRIC_RESULT](stateCopy, { metricId, }); - expect(stateCopy.showEmptyState).toBe(true); expect(getMetric()).toEqual( expect.objectContaining({ loading: true, @@ -257,26 +305,16 @@ describe('Monitoring mutations', () => { beforeEach(() => { mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](stateCopy, dashboard); }); - it('clears empty state', () => { - expect(stateCopy.showEmptyState).toBe(true); - - mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](stateCopy, { - metricId, - result, - }); - - expect(stateCopy.showEmptyState).toBe(false); - }); it('adds results to the store', () => { expect(getMetric().result).toBe(null); mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](stateCopy, { metricId, - result, + data, }); - expect(getMetric().result).toHaveLength(result.length); + expect(getMetric().result).toHaveLength(data.result.length); expect(getMetric()).toEqual( expect.objectContaining({ loading: false, @@ -290,16 +328,6 @@ describe('Monitoring mutations', () => { beforeEach(() => { mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](stateCopy, dashboard); }); - it('maintains the loading state when a metric fails', () => { - expect(stateCopy.showEmptyState).toBe(true); - - mutations[types.RECEIVE_METRIC_RESULT_FAILURE](stateCopy, { - metricId, - error: 'an error', - }); - - expect(stateCopy.showEmptyState).toBe(true); - }); it('stores a timeout error in a metric', () => { mutations[types.RECEIVE_METRIC_RESULT_FAILURE](stateCopy, { @@ -369,6 +397,7 @@ describe('Monitoring mutations', () => { }); }); }); + describe('SET_ALL_DASHBOARDS', () => { it('stores `undefined` dashboards as an empty array', () => { mutations[types.SET_ALL_DASHBOARDS](stateCopy, undefined); @@ -410,30 +439,53 @@ describe('Monitoring mutations', () => { }); }); - describe('SET_VARIABLES', () => { - it('stores an empty variables array when no custom variables are given', () => { - mutations[types.SET_VARIABLES](stateCopy, {}); - - expect(stateCopy.variables).toEqual({}); - }); - - it('stores variables in the key key_value format in the array', () => { - mutations[types.SET_VARIABLES](stateCopy, { pod: 'POD', stage: 'main ops' }); + describe('UPDATE_VARIABLE_VALUE', () => { + it('updates only the value of the variable in variables', () => { + stateCopy.variables = storeTextVariables; + mutations[types.UPDATE_VARIABLE_VALUE](stateCopy, { name: 'textSimple', value: 'New Value' }); - expect(stateCopy.variables).toEqual({ pod: 'POD', stage: 'main ops' }); + expect(stateCopy.variables[0].value).toEqual('New Value'); }); }); - describe('UPDATE_VARIABLES', () => { - afterEach(() => { - mutations[types.SET_VARIABLES](stateCopy, {}); - }); - - it('updates only the value of the variable in variables', () => { - mutations[types.SET_VARIABLES](stateCopy, { environment: { value: 'prod', type: 'text' } }); - mutations[types.UPDATE_VARIABLES](stateCopy, { key: 'environment', value: 'new prod' }); + describe('UPDATE_VARIABLE_METRIC_LABEL_VALUES', () => { + it('updates options in a variable', () => { + const data = [ + { + __name__: 'up', + job: 'prometheus', + env: 'prd', + }, + { + __name__: 'up', + job: 'prometheus', + env: 'stg', + }, + { + __name__: 'up', + job: 'node', + env: 'prod', + }, + { + __name__: 'up', + job: 'node', + env: 'stg', + }, + ]; + + const variable = { + options: {}, + }; + + mutations[types.UPDATE_VARIABLE_METRIC_LABEL_VALUES](stateCopy, { + variable, + label: 'job', + data, + }); - expect(stateCopy.variables).toEqual({ environment: { value: 'new prod', type: 'text' } }); + expect(variable.options).toEqual({ + values: [{ text: 'prometheus', value: 'prometheus' }, { text: 'node', value: 'node' }], + }); }); }); }); diff --git a/spec/frontend/monitoring/store/utils_spec.js b/spec/frontend/monitoring/store/utils_spec.js index 2dea40585f1..b97948fa1bf 100644 --- a/spec/frontend/monitoring/store/utils_spec.js +++ b/spec/frontend/monitoring/store/utils_spec.js @@ -5,9 +5,10 @@ import { parseAnnotationsResponse, removeLeadingSlash, mapToDashboardViewModel, - normalizeQueryResult, + normalizeQueryResponseData, convertToGrafanaTimeRange, addDashboardMetaDataToLink, + normalizeCustomDashboardPath, } from '~/monitoring/stores/utils'; import * as urlUtils from '~/lib/utils/url_utility'; import { annotationsData } from '../mock_data'; @@ -21,7 +22,7 @@ describe('mapToDashboardViewModel', () => { dashboard: '', panelGroups: [], links: [], - variables: {}, + variables: [], }); }); @@ -51,7 +52,7 @@ describe('mapToDashboardViewModel', () => { expect(mapToDashboardViewModel(response)).toEqual({ dashboard: 'Dashboard Name', links: [], - variables: {}, + variables: [], panelGroups: [ { group: 'Group 1', @@ -423,22 +424,20 @@ describe('mapToDashboardViewModel', () => { urlUtils.queryToObject.mockReturnValueOnce(); - expect(mapToDashboardViewModel(response)).toMatchObject({ - dashboard: 'Dashboard Name', - links: [], - variables: { - pod: { - label: 'pod', - type: 'text', - value: 'kubernetes', - }, - pod_2: { - label: 'pod_2', - type: 'text', - value: 'kubernetes-2', - }, + expect(mapToDashboardViewModel(response).variables).toEqual([ + { + name: 'pod', + label: 'pod', + type: 'text', + value: 'kubernetes', }, - }); + { + name: 'pod_2', + label: 'pod_2', + type: 'text', + value: 'kubernetes-2', + }, + ]); }); it('sets variables as-is from yml file if URL has no matching variables', () => { @@ -457,22 +456,20 @@ describe('mapToDashboardViewModel', () => { 'var-environment': 'POD', }); - expect(mapToDashboardViewModel(response)).toMatchObject({ - dashboard: 'Dashboard Name', - links: [], - variables: { - pod: { - label: 'pod', - type: 'text', - value: 'kubernetes', - }, - pod_2: { - label: 'pod_2', - type: 'text', - value: 'kubernetes-2', - }, + expect(mapToDashboardViewModel(response).variables).toEqual([ + { + label: 'pod', + name: 'pod', + type: 'text', + value: 'kubernetes', }, - }); + { + label: 'pod_2', + name: 'pod_2', + type: 'text', + value: 'kubernetes-2', + }, + ]); }); it('merges variables from URL with the ones from yml file', () => { @@ -493,44 +490,20 @@ describe('mapToDashboardViewModel', () => { 'var-pod_2': 'POD2', }); - expect(mapToDashboardViewModel(response)).toMatchObject({ - dashboard: 'Dashboard Name', - links: [], - variables: { - pod: { - label: 'pod', - type: 'text', - value: 'POD1', - }, - pod_2: { - label: 'pod_2', - type: 'text', - value: 'POD2', - }, + expect(mapToDashboardViewModel(response).variables).toEqual([ + { + label: 'pod', + name: 'pod', + type: 'text', + value: 'POD1', }, - }); - }); - }); -}); - -describe('normalizeQueryResult', () => { - const testData = { - metric: { - __name__: 'up', - job: 'prometheus', - instance: 'localhost:9090', - }, - values: [[1435781430.781, '1'], [1435781445.781, '1'], [1435781460.781, '1']], - }; - - it('processes a simple matrix result', () => { - expect(normalizeQueryResult(testData)).toEqual({ - metric: { __name__: 'up', job: 'prometheus', instance: 'localhost:9090' }, - values: [ - ['2015-07-01T20:10:30.781Z', 1], - ['2015-07-01T20:10:45.781Z', 1], - ['2015-07-01T20:11:00.781Z', 1], - ], + { + label: 'pod_2', + name: 'pod_2', + type: 'text', + value: 'POD2', + }, + ]); }); }); }); @@ -720,3 +693,187 @@ describe('user-defined links utils', () => { }); }); }); + +describe('normalizeQueryResponseData', () => { + // Data examples from + // https://prometheus.io/docs/prometheus/latest/querying/api/#expression-queries + + it('processes a string result', () => { + const mockScalar = { + resultType: 'string', + result: [1435781451.781, '1'], + }; + + expect(normalizeQueryResponseData(mockScalar)).toEqual([ + { + metric: {}, + value: ['2015-07-01T20:10:51.781Z', '1'], + values: [['2015-07-01T20:10:51.781Z', '1']], + }, + ]); + }); + + it('processes a scalar result', () => { + const mockScalar = { + resultType: 'scalar', + result: [1435781451.781, '1'], + }; + + expect(normalizeQueryResponseData(mockScalar)).toEqual([ + { + metric: {}, + value: ['2015-07-01T20:10:51.781Z', 1], + values: [['2015-07-01T20:10:51.781Z', 1]], + }, + ]); + }); + + it('processes a vector result', () => { + const mockVector = { + resultType: 'vector', + result: [ + { + metric: { + __name__: 'up', + job: 'prometheus', + instance: 'localhost:9090', + }, + value: [1435781451.781, '1'], + }, + { + metric: { + __name__: 'up', + job: 'node', + instance: 'localhost:9100', + }, + value: [1435781451.781, '0'], + }, + ], + }; + + expect(normalizeQueryResponseData(mockVector)).toEqual([ + { + metric: { __name__: 'up', job: 'prometheus', instance: 'localhost:9090' }, + value: ['2015-07-01T20:10:51.781Z', 1], + values: [['2015-07-01T20:10:51.781Z', 1]], + }, + { + metric: { __name__: 'up', job: 'node', instance: 'localhost:9100' }, + value: ['2015-07-01T20:10:51.781Z', 0], + values: [['2015-07-01T20:10:51.781Z', 0]], + }, + ]); + }); + + it('processes a matrix result', () => { + const mockMatrix = { + resultType: 'matrix', + result: [ + { + metric: { + __name__: 'up', + job: 'prometheus', + instance: 'localhost:9090', + }, + values: [[1435781430.781, '1'], [1435781445.781, '2'], [1435781460.781, '3']], + }, + { + metric: { + __name__: 'up', + job: 'node', + instance: 'localhost:9091', + }, + values: [[1435781430.781, '4'], [1435781445.781, '5'], [1435781460.781, '6']], + }, + ], + }; + + expect(normalizeQueryResponseData(mockMatrix)).toEqual([ + { + metric: { __name__: 'up', instance: 'localhost:9090', job: 'prometheus' }, + value: ['2015-07-01T20:11:00.781Z', 3], + values: [ + ['2015-07-01T20:10:30.781Z', 1], + ['2015-07-01T20:10:45.781Z', 2], + ['2015-07-01T20:11:00.781Z', 3], + ], + }, + { + metric: { __name__: 'up', instance: 'localhost:9091', job: 'node' }, + value: ['2015-07-01T20:11:00.781Z', 6], + values: [ + ['2015-07-01T20:10:30.781Z', 4], + ['2015-07-01T20:10:45.781Z', 5], + ['2015-07-01T20:11:00.781Z', 6], + ], + }, + ]); + }); + + it('processes a scalar result with a NaN result', () => { + // Queries may return "NaN" string values. + // e.g. when Prometheus cannot find a metric the query + // `scalar(does_not_exist)` will return a "NaN" value. + + const mockScalar = { + resultType: 'scalar', + result: [1435781451.781, 'NaN'], + }; + + expect(normalizeQueryResponseData(mockScalar)).toEqual([ + { + metric: {}, + value: ['2015-07-01T20:10:51.781Z', NaN], + values: [['2015-07-01T20:10:51.781Z', NaN]], + }, + ]); + }); + + it('processes a matrix result with a "NaN" value', () => { + // Queries may return "NaN" string values. + const mockMatrix = { + resultType: 'matrix', + result: [ + { + metric: { + __name__: 'up', + job: 'prometheus', + instance: 'localhost:9090', + }, + values: [[1435781430.781, '1'], [1435781460.781, 'NaN']], + }, + ], + }; + + expect(normalizeQueryResponseData(mockMatrix)).toEqual([ + { + metric: { __name__: 'up', instance: 'localhost:9090', job: 'prometheus' }, + value: ['2015-07-01T20:11:00.781Z', NaN], + values: [['2015-07-01T20:10:30.781Z', 1], ['2015-07-01T20:11:00.781Z', NaN]], + }, + ]); + }); +}); + +describe('normalizeCustomDashboardPath', () => { + it.each` + input | expected + ${[undefined]} | ${''} + ${[null]} | ${''} + ${[]} | ${''} + ${['links.yml']} | ${'links.yml'} + ${['links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/links.yml'} + ${['config/prometheus/common_metrics.yml']} | ${'config/prometheus/common_metrics.yml'} + ${['config/prometheus/common_metrics.yml', '.gitlab/dashboards']} | ${'config/prometheus/common_metrics.yml'} + ${['dir1/links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/dir1/links.yml'} + ${['dir1/dir2/links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/dir1/dir2/links.yml'} + ${['.gitlab/dashboards/links.yml']} | ${'.gitlab/dashboards/links.yml'} + ${['.gitlab/dashboards/links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/links.yml'} + ${['.gitlab/dashboards/dir1/links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/dir1/links.yml'} + ${['.gitlab/dashboards/dir1/dir2/links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/dir1/dir2/links.yml'} + ${['config/prometheus/pod_metrics.yml', '.gitlab/dashboards']} | ${'config/prometheus/pod_metrics.yml'} + ${['config/prometheus/pod_metrics.yml']} | ${'config/prometheus/pod_metrics.yml'} + `(`normalizeCustomDashboardPath returns $expected for $input`, ({ input, expected }) => { + expect(normalizeCustomDashboardPath(...input)).toEqual(expected); + }); +}); diff --git a/spec/frontend/monitoring/store/variable_mapping_spec.js b/spec/frontend/monitoring/store/variable_mapping_spec.js index 5164ed1b54b..de124b0313c 100644 --- a/spec/frontend/monitoring/store/variable_mapping_spec.js +++ b/spec/frontend/monitoring/store/variable_mapping_spec.js @@ -1,94 +1,209 @@ -import { parseTemplatingVariables, mergeURLVariables } from '~/monitoring/stores/variable_mapping'; +import { + parseTemplatingVariables, + mergeURLVariables, + optionsFromSeriesData, +} from '~/monitoring/stores/variable_mapping'; +import { + templatingVariablesExamples, + storeTextVariables, + storeCustomVariables, + storeMetricLabelValuesVariables, +} from '../mock_data'; import * as urlUtils from '~/lib/utils/url_utility'; -import { mockTemplatingData, mockTemplatingDataResponses } from '../mock_data'; - -describe('parseTemplatingVariables', () => { - it.each` - case | input | expected - ${'Returns empty object for no dashboard input'} | ${{}} | ${{}} - ${'Returns empty object for empty dashboard input'} | ${{ dashboard: {} }} | ${{}} - ${'Returns empty object for empty templating prop'} | ${mockTemplatingData.emptyTemplatingProp} | ${{}} - ${'Returns empty object for empty variables prop'} | ${mockTemplatingData.emptyVariablesProp} | ${{}} - ${'Returns parsed object for simple text variable'} | ${mockTemplatingData.simpleText} | ${mockTemplatingDataResponses.simpleText} - ${'Returns parsed object for advanced text variable'} | ${mockTemplatingData.advText} | ${mockTemplatingDataResponses.advText} - ${'Returns parsed object for simple custom variable'} | ${mockTemplatingData.simpleCustom} | ${mockTemplatingDataResponses.simpleCustom} - ${'Returns parsed object for advanced custom variable without options'} | ${mockTemplatingData.advCustomWithoutOpts} | ${mockTemplatingDataResponses.advCustomWithoutOpts} - ${'Returns parsed object for advanced custom variable for option without text'} | ${mockTemplatingData.advCustomWithoutOptText} | ${mockTemplatingDataResponses.advCustomWithoutOptText} - ${'Returns parsed object for advanced custom variable without type'} | ${mockTemplatingData.advCustomWithoutType} | ${{}} - ${'Returns parsed object for advanced custom variable without label'} | ${mockTemplatingData.advCustomWithoutLabel} | ${mockTemplatingDataResponses.advCustomWithoutLabel} - ${'Returns parsed object for simple and advanced custom variables'} | ${mockTemplatingData.simpleAndAdv} | ${mockTemplatingDataResponses.simpleAndAdv} - ${'Returns parsed object for all variable types'} | ${mockTemplatingData.allVariableTypes} | ${mockTemplatingDataResponses.allVariableTypes} - `('$case', ({ input, expected }) => { - expect(parseTemplatingVariables(input?.dashboard?.templating)).toEqual(expected); - }); -}); -describe('mergeURLVariables', () => { - beforeEach(() => { - jest.spyOn(urlUtils, 'queryToObject'); - }); +describe('Monitoring variable mapping', () => { + describe('parseTemplatingVariables', () => { + it.each` + case | input + ${'For undefined templating object'} | ${undefined} + ${'For empty templating object'} | ${{}} + `('$case, returns an empty array', ({ input }) => { + expect(parseTemplatingVariables(input)).toEqual([]); + }); - afterEach(() => { - urlUtils.queryToObject.mockRestore(); + it.each` + case | input | output + ${'Returns parsed object for text variables'} | ${templatingVariablesExamples.text} | ${storeTextVariables} + ${'Returns parsed object for custom variables'} | ${templatingVariablesExamples.custom} | ${storeCustomVariables} + ${'Returns parsed object for metric label value variables'} | ${templatingVariablesExamples.metricLabelValues} | ${storeMetricLabelValuesVariables} + `('$case, returns an empty array', ({ input, output }) => { + expect(parseTemplatingVariables(input)).toEqual(output); + }); }); - it('returns empty object if variables are not defined in yml or URL', () => { - urlUtils.queryToObject.mockReturnValueOnce({}); + describe('mergeURLVariables', () => { + beforeEach(() => { + jest.spyOn(urlUtils, 'queryToObject'); + }); - expect(mergeURLVariables({})).toEqual({}); - }); + afterEach(() => { + urlUtils.queryToObject.mockRestore(); + }); - it('returns empty object if variables are defined in URL but not in yml', () => { - urlUtils.queryToObject.mockReturnValueOnce({ - 'var-env': 'one', - 'var-instance': 'localhost', + it('returns empty object if variables are not defined in yml or URL', () => { + urlUtils.queryToObject.mockReturnValueOnce({}); + + expect(mergeURLVariables([])).toEqual([]); }); - expect(mergeURLVariables({})).toEqual({}); - }); + it('returns empty object if variables are defined in URL but not in yml', () => { + urlUtils.queryToObject.mockReturnValueOnce({ + 'var-env': 'one', + 'var-instance': 'localhost', + }); - it('returns yml variables if variables defined in yml but not in the URL', () => { - urlUtils.queryToObject.mockReturnValueOnce({}); + expect(mergeURLVariables([])).toEqual([]); + }); - const params = { - env: 'one', - instance: 'localhost', - }; + it('returns yml variables if variables defined in yml but not in the URL', () => { + urlUtils.queryToObject.mockReturnValueOnce({}); + + const variables = [ + { + name: 'env', + value: 'one', + }, + { + name: 'instance', + value: 'localhost', + }, + ]; + + expect(mergeURLVariables(variables)).toEqual(variables); + }); - expect(mergeURLVariables(params)).toEqual(params); - }); + it('returns yml variables if variables defined in URL do not match with yml variables', () => { + const urlParams = { + 'var-env': 'one', + 'var-instance': 'localhost', + }; + const variables = [ + { + name: 'env', + value: 'one', + }, + { + name: 'service', + value: 'database', + }, + ]; + urlUtils.queryToObject.mockReturnValueOnce(urlParams); + + expect(mergeURLVariables(variables)).toEqual(variables); + }); - it('returns yml variables if variables defined in URL do not match with yml variables', () => { - const urlParams = { - 'var-env': 'one', - 'var-instance': 'localhost', - }; - const ymlParams = { - pod: { value: 'one' }, - service: { value: 'database' }, - }; - urlUtils.queryToObject.mockReturnValueOnce(urlParams); - - expect(mergeURLVariables(ymlParams)).toEqual(ymlParams); + it('returns merged yml and URL variables if there is some match', () => { + const urlParams = { + 'var-env': 'one', + 'var-instance': 'localhost:8080', + }; + const variables = [ + { + name: 'instance', + value: 'localhost', + }, + { + name: 'service', + value: 'database', + }, + ]; + + urlUtils.queryToObject.mockReturnValueOnce(urlParams); + + expect(mergeURLVariables(variables)).toEqual([ + { + name: 'instance', + value: 'localhost:8080', + }, + { + name: 'service', + value: 'database', + }, + ]); + }); }); - it('returns merged yml and URL variables if there is some match', () => { - const urlParams = { - 'var-env': 'one', - 'var-instance': 'localhost:8080', - }; - const ymlParams = { - instance: { value: 'localhost' }, - service: { value: 'database' }, - }; + describe('optionsFromSeriesData', () => { + it('fetches the label values from missing data', () => { + expect(optionsFromSeriesData({ label: 'job' })).toEqual([]); + }); - const merged = { - instance: { value: 'localhost:8080' }, - service: { value: 'database' }, - }; + it('fetches the label values from a simple series', () => { + const data = [ + { + __name__: 'up', + job: 'job1', + }, + { + __name__: 'up', + job: 'job2', + }, + ]; + + expect(optionsFromSeriesData({ label: 'job', data })).toEqual([ + { text: 'job1', value: 'job1' }, + { text: 'job2', value: 'job2' }, + ]); + }); - urlUtils.queryToObject.mockReturnValueOnce(urlParams); + it('fetches the label values from multiple series', () => { + const data = [ + { + __name__: 'up', + job: 'job1', + instance: 'host1', + }, + { + __name__: 'up', + job: 'job2', + instance: 'host1', + }, + { + __name__: 'up', + job: 'job1', + instance: 'host2', + }, + { + __name__: 'up', + job: 'job2', + instance: 'host2', + }, + ]; + + expect(optionsFromSeriesData({ label: '__name__', data })).toEqual([ + { text: 'up', value: 'up' }, + ]); + + expect(optionsFromSeriesData({ label: 'job', data })).toEqual([ + { text: 'job1', value: 'job1' }, + { text: 'job2', value: 'job2' }, + ]); + + expect(optionsFromSeriesData({ label: 'instance', data })).toEqual([ + { text: 'host1', value: 'host1' }, + { text: 'host2', value: 'host2' }, + ]); + }); - expect(mergeURLVariables(ymlParams)).toEqual(merged); + it('fetches the label values from a series with missing values', () => { + const data = [ + { + __name__: 'up', + job: 'job1', + }, + { + __name__: 'up', + job: 'job2', + }, + { + __name__: 'up', + }, + ]; + + expect(optionsFromSeriesData({ label: 'job', data })).toEqual([ + { text: 'job1', value: 'job1' }, + { text: 'job2', value: 'job2' }, + ]); + }); }); }); diff --git a/spec/frontend/monitoring/store_utils.js b/spec/frontend/monitoring/store_utils.js index eb2578aa9db..6c8267e6a3c 100644 --- a/spec/frontend/monitoring/store_utils.js +++ b/spec/frontend/monitoring/store_utils.js @@ -8,7 +8,10 @@ export const setMetricResult = ({ store, result, group = 0, panel = 0, metric = store.commit(`monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, { metricId, - result, + data: { + resultType: 'matrix', + result, + }, }); }; @@ -32,12 +35,6 @@ export const setupStoreWithDashboard = store => { ); }; -export const setupStoreWithVariable = store => { - store.commit(`monitoringDashboard/${types.SET_VARIABLES}`, { - label1: 'pod', - }); -}; - export const setupStoreWithLinks = store => { store.commit(`monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`, { ...metricsDashboardPayload, @@ -60,3 +57,24 @@ export const setupStoreWithData = store => { setEnvironmentData(store); }; + +export const setupStoreWithDataForPanelCount = (store, panelCount) => { + const payloadPanelGroup = metricsDashboardPayload.panel_groups[0]; + + const panelGroupCustom = { + ...payloadPanelGroup, + panels: payloadPanelGroup.panels.slice(0, panelCount), + }; + + const metricsDashboardPayloadCustom = { + ...metricsDashboardPayload, + panel_groups: [panelGroupCustom], + }; + + store.commit( + `monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`, + metricsDashboardPayloadCustom, + ); + + setMetricResult({ store, result: metricsResult, panel: 0 }); +}; diff --git a/spec/frontend/monitoring/utils_spec.js b/spec/frontend/monitoring/utils_spec.js index 039cf275eea..35ca6ba9b52 100644 --- a/spec/frontend/monitoring/utils_spec.js +++ b/spec/frontend/monitoring/utils_spec.js @@ -1,12 +1,8 @@ import * as monitoringUtils from '~/monitoring/utils'; import * as urlUtils from '~/lib/utils/url_utility'; import { TEST_HOST } from 'jest/helpers/test_constants'; -import { - mockProjectDir, - singleStatMetricsResult, - anomalyMockGraphData, - barMockData, -} from './mock_data'; +import { mockProjectDir, barMockData } from './mock_data'; +import { singleStatGraphData, anomalyGraphData } from './graph_data'; import { metricsDashboardViewModel, graphData } from './fixture_data'; const mockPath = `${TEST_HOST}${mockProjectDir}/-/environments/29/metrics`; @@ -82,7 +78,7 @@ describe('monitoring/utils', () => { it('validates data with the query format', () => { const validGraphData = monitoringUtils.graphDataValidatorForValues( true, - singleStatMetricsResult, + singleStatGraphData(), ); expect(validGraphData).toBe(true); @@ -105,13 +101,13 @@ describe('monitoring/utils', () => { let threeMetrics; let fourMetrics; beforeEach(() => { - oneMetric = singleStatMetricsResult; - threeMetrics = anomalyMockGraphData; + oneMetric = singleStatGraphData(); + threeMetrics = anomalyGraphData(); const metrics = [...threeMetrics.metrics]; metrics.push(threeMetrics.metrics[0]); fourMetrics = { - ...anomalyMockGraphData, + ...anomalyGraphData(), metrics, }; }); @@ -429,14 +425,41 @@ describe('monitoring/utils', () => { describe('convertVariablesForURL', () => { it.each` - input | expected - ${undefined} | ${{}} - ${null} | ${{}} - ${{}} | ${{}} - ${{ env: { value: 'prod' } }} | ${{ 'var-env': 'prod' }} - ${{ 'var-env': { value: 'prod' } }} | ${{ 'var-var-env': 'prod' }} + input | expected + ${[]} | ${{}} + ${[{ name: 'env', value: 'prod' }]} | ${{ 'var-env': 'prod' }} + ${[{ name: 'env1', value: 'prod' }, { name: 'env2', value: null }]} | ${{ 'var-env1': 'prod' }} + ${[{ name: 'var-env', value: 'prod' }]} | ${{ 'var-var-env': 'prod' }} `('convertVariablesForURL returns $expected with input $input', ({ input, expected }) => { expect(monitoringUtils.convertVariablesForURL(input)).toEqual(expected); }); }); + + describe('setCustomVariablesFromUrl', () => { + beforeEach(() => { + jest.spyOn(urlUtils, 'updateHistory'); + }); + + afterEach(() => { + urlUtils.updateHistory.mockRestore(); + }); + + it.each` + input | urlParams + ${[]} | ${''} + ${[{ name: 'env', value: 'prod' }]} | ${'?var-env=prod'} + ${[{ name: 'env1', value: 'prod' }, { name: 'env2', value: null }]} | ${'?var-env=prod&var-env1=prod'} + `( + 'setCustomVariablesFromUrl updates history with query "$urlParams" with input $input', + ({ input, urlParams }) => { + monitoringUtils.setCustomVariablesFromUrl(input); + + expect(urlUtils.updateHistory).toHaveBeenCalledTimes(1); + expect(urlUtils.updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/${urlParams}`, + title: '', + }); + }, + ); + }); }); diff --git a/spec/frontend/namespace_storage_limit_alert_spec.js b/spec/frontend/namespace_storage_limit_alert_spec.js deleted file mode 100644 index ef398b12e1f..00000000000 --- a/spec/frontend/namespace_storage_limit_alert_spec.js +++ /dev/null @@ -1,36 +0,0 @@ -import Cookies from 'js-cookie'; -import initNamespaceStorageLimitAlert from '~/namespace_storage_limit_alert'; - -describe('broadcast message on dismiss', () => { - const dismiss = () => { - const button = document.querySelector('.js-namespace-storage-alert-dismiss'); - button.click(); - }; - - beforeEach(() => { - setFixtures(` - <div class="js-namespace-storage-alert"> - <button class="js-namespace-storage-alert-dismiss" data-id="1" data-level="info"></button> - </div> - `); - - initNamespaceStorageLimitAlert(); - }); - - it('removes alert', () => { - expect(document.querySelector('.js-namespace-storage-alert')).toBeTruthy(); - - dismiss(); - - expect(document.querySelector('.js-namespace-storage-alert')).toBeNull(); - }); - - it('calls Cookies.set', () => { - jest.spyOn(Cookies, 'set'); - dismiss(); - - expect(Cookies.set).toHaveBeenCalledWith('hide_storage_limit_alert_1_info', true, { - expires: 365, - }); - }); -}); diff --git a/spec/frontend/notes/components/multiline_comment_utils_spec.js b/spec/frontend/notes/components/multiline_comment_utils_spec.js index 261bfb106e7..af4394cc648 100644 --- a/spec/frontend/notes/components/multiline_comment_utils_spec.js +++ b/spec/frontend/notes/components/multiline_comment_utils_spec.js @@ -2,35 +2,23 @@ import { getSymbol, getStartLineNumber, getEndLineNumber, + getCommentedLines, } from '~/notes/components/multiline_comment_utils'; describe('Multiline comment utilities', () => { - describe('getStartLineNumber', () => { + describe('get start & end line numbers', () => { + const lineRanges = ['old', 'new', null].map(type => ({ + start: { new_line: 1, old_line: 1, type }, + end: { new_line: 2, old_line: 2, type }, + })); it.each` - lineCode | type | result - ${'abcdef_1_1'} | ${'old'} | ${'-1'} - ${'abcdef_1_1'} | ${'new'} | ${'+1'} - ${'abcdef_1_1'} | ${null} | ${'1'} - ${'abcdef'} | ${'new'} | ${''} - ${'abcdef'} | ${'old'} | ${''} - ${'abcdef'} | ${null} | ${''} - `('returns line number', ({ lineCode, type, result }) => { - expect(getStartLineNumber({ start_line_code: lineCode, start_line_type: type })).toEqual( - result, - ); - }); - }); - describe('getEndLineNumber', () => { - it.each` - lineCode | type | result - ${'abcdef_1_1'} | ${'old'} | ${'-1'} - ${'abcdef_1_1'} | ${'new'} | ${'+1'} - ${'abcdef_1_1'} | ${null} | ${'1'} - ${'abcdef'} | ${'new'} | ${''} - ${'abcdef'} | ${'old'} | ${''} - ${'abcdef'} | ${null} | ${''} - `('returns line number', ({ lineCode, type, result }) => { - expect(getEndLineNumber({ end_line_code: lineCode, end_line_type: type })).toEqual(result); + lineRange | start | end + ${lineRanges[0]} | ${'-1'} | ${'-2'} + ${lineRanges[1]} | ${'+1'} | ${'+2'} + ${lineRanges[2]} | ${'1'} | ${'2'} + `('returns line numbers `$start` & `$end`', ({ lineRange, start, end }) => { + expect(getStartLineNumber(lineRange)).toEqual(start); + expect(getEndLineNumber(lineRange)).toEqual(end); }); }); describe('getSymbol', () => { @@ -46,4 +34,30 @@ describe('Multiline comment utilities', () => { expect(getSymbol(type)).toEqual(result); }); }); + describe('getCommentedLines', () => { + const diffLines = [{ line_code: '1' }, { line_code: '2' }, { line_code: '3' }]; + it('returns a default object when `selectedCommentPosition` is not provided', () => { + expect(getCommentedLines(undefined, diffLines)).toEqual({ startLine: 4, endLine: 4 }); + }); + it('returns an object with startLine and endLine equal to 0', () => { + const selectedCommentPosition = { + start: { line_code: '1' }, + end: { line_code: '1' }, + }; + expect(getCommentedLines(selectedCommentPosition, diffLines)).toEqual({ + startLine: 0, + endLine: 0, + }); + }); + it('returns an object with startLine and endLine equal to 0 and 1', () => { + const selectedCommentPosition = { + start: { line_code: '1' }, + end: { line_code: '2' }, + }; + expect(getCommentedLines(selectedCommentPosition, diffLines)).toEqual({ + startLine: 0, + endLine: 1, + }); + }); + }); }); diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js index 220ac22d8eb..5cc56cdefae 100644 --- a/spec/frontend/notes/components/note_actions_spec.js +++ b/spec/frontend/notes/components/note_actions_spec.js @@ -127,25 +127,63 @@ describe('noteActions', () => { .catch(done.fail); }); - it('should be possible to assign or unassign the comment author', () => { - wrapper = shallowMountNoteActions(props, { - targetType: () => 'issue', - }); - + it('should not be possible to assign or unassign the comment author in a merge request', () => { const assignUserButton = wrapper.find('[data-testid="assign-user"]'); - expect(assignUserButton.exists()).toBe(true); + expect(assignUserButton.exists()).toBe(false); + }); + }); + }); - assignUserButton.trigger('click'); - axiosMock.onPut(`${TEST_HOST}/api/v4/projects/group/project/issues/1`).reply(() => { - expect(actions.updateAssignees).toHaveBeenCalled(); - }); + describe('when a user has access to edit an issue', () => { + const testButtonClickTriggersAction = () => { + axiosMock.onPut(`${TEST_HOST}/api/v4/projects/group/project/issues/1`).reply(() => { + expect(actions.updateAssignees).toHaveBeenCalled(); }); - it('should not be possible to assign or unassign the comment author in a merge request', () => { - const assignUserButton = wrapper.find('[data-testid="assign-user"]'); - expect(assignUserButton.exists()).toBe(false); + const assignUserButton = wrapper.find('[data-testid="assign-user"]'); + expect(assignUserButton.exists()).toBe(true); + assignUserButton.trigger('click'); + }; + + beforeEach(() => { + wrapper = shallowMountNoteActions(props, { + targetType: () => 'issue', }); + store.state.noteableData = { + current_user: { + can_update: true, + }, + }; + store.state.userData = userDataMock; }); + + afterEach(() => { + wrapper.destroy(); + axiosMock.restore(); + }); + + it('should be possible to assign the comment author', testButtonClickTriggersAction); + it('should be possible to unassign the comment author', testButtonClickTriggersAction); + }); + + describe('when a user does not have access to edit an issue', () => { + const testButtonDoesNotRender = () => { + const assignUserButton = wrapper.find('[data-testid="assign-user"]'); + expect(assignUserButton.exists()).toBe(false); + }; + + beforeEach(() => { + wrapper = shallowMountNoteActions(props, { + targetType: () => 'issue', + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should not be possible to assign the comment author', testButtonDoesNotRender); + it('should not be possible to unassign the comment author', testButtonDoesNotRender); }); describe('user is not logged in', () => { diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index 15802841c57..a5b5204509e 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -245,6 +245,24 @@ describe('issue_note_form component', () => { expect(updateDraft).toHaveBeenCalledWith(dummyAutosaveKey, dummyContent); }); + + it('does not save draft when ctrl+enter is pressed', () => { + const options = { + noteBody: '', + autosaveKey: dummyAutosaveKey, + }; + + props = { ...props, ...options }; + wrapper = createComponentWrapper(); + + wrapper.setData({ isSubmittingWithKeydown: true }); + + const textarea = wrapper.find('textarea'); + textarea.setValue('some content'); + textarea.trigger('keydown.enter', { metaKey: true }); + + expect(updateDraft).not.toHaveBeenCalled(); + }); }); describe('with batch comments', () => { diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js index aa3eaa97e20..fc238feb974 100644 --- a/spec/frontend/notes/components/noteable_note_spec.js +++ b/spec/frontend/notes/components/noteable_note_spec.js @@ -34,7 +34,13 @@ describe('issue_note', () => { note, }, localVue, - stubs: ['note-header', 'user-avatar-link', 'note-actions', 'note-body'], + stubs: [ + 'note-header', + 'user-avatar-link', + 'note-actions', + 'note-body', + 'multiline-comment-form', + ], }); }); @@ -46,12 +52,30 @@ describe('issue_note', () => { it('should render if has multiline comment', () => { const position = { line_range: { - start_line_code: 'abc_1_1', - end_line_code: 'abc_2_2', + start: { + line_code: 'abc_1_1', + type: null, + old_line: '1', + new_line: '1', + }, + end: { + line_code: 'abc_2_2', + type: null, + old_line: '2', + new_line: '2', + }, }, }; + const line = { + line_code: 'abc_1_1', + type: null, + old_line: '1', + new_line: '1', + }; wrapper.setProps({ note: { ...note, position }, + discussionRoot: true, + line, }); return wrapper.vm.$nextTick().then(() => { @@ -59,15 +83,51 @@ describe('issue_note', () => { }); }); + it('should render multiline comment if editing discussion root', () => { + wrapper.setProps({ discussionRoot: true }); + wrapper.vm.isEditing = true; + + return wrapper.vm.$nextTick().then(() => { + expect(findMultilineComment().exists()).toBe(true); + }); + }); + + it('should not render multiline comment form unless it is the discussion root', () => { + wrapper.setProps({ discussionRoot: false }); + wrapper.vm.isEditing = true; + + return wrapper.vm.$nextTick().then(() => { + expect(findMultilineComment().exists()).toBe(false); + }); + }); + it('should not render if has single line comment', () => { const position = { line_range: { - start_line_code: 'abc_1_1', - end_line_code: 'abc_1_1', + start: { + line_code: 'abc_1_1', + type: null, + old_line: '1', + new_line: '1', + }, + end: { + line_code: 'abc_1_1', + type: null, + old_line: '1', + new_line: '1', + }, }, }; + const line = { + line_code: 'abc_1_1', + type: null, + old_line: '1', + new_line: '1', + }; wrapper.setProps({ note: { ...note, position }, + discussionRoot: true, + line, }); return wrapper.vm.$nextTick().then(() => { @@ -139,6 +199,7 @@ describe('issue_note', () => { store.hotUpdate({ actions: { updateNote() {}, + setSelectedCommentPositionHover() {}, }, }); const noteBodyComponent = wrapper.find(NoteBody); diff --git a/spec/frontend/notes/mixins/discussion_navigation_spec.js b/spec/frontend/notes/mixins/discussion_navigation_spec.js index ae30a36fc81..ecff95b6fe0 100644 --- a/spec/frontend/notes/mixins/discussion_navigation_spec.js +++ b/spec/frontend/notes/mixins/discussion_navigation_spec.js @@ -91,6 +91,8 @@ describe('Discussion navigation mixin', () => { beforeEach(() => { window.mrTabs.currentAction = 'show'; wrapper.vm[fn](...args); + + return wrapper.vm.$nextTick(); }); it('sets current discussion', () => { @@ -112,6 +114,8 @@ describe('Discussion navigation mixin', () => { beforeEach(() => { window.mrTabs.currentAction = 'diffs'; wrapper.vm[fn](...args); + + return wrapper.vm.$nextTick(); }); it('sets current discussion', () => { @@ -137,6 +141,8 @@ describe('Discussion navigation mixin', () => { beforeEach(() => { window.mrTabs.currentAction = 'other'; wrapper.vm[fn](...args); + + return wrapper.vm.$nextTick(); }); it('sets current discussion', () => { diff --git a/spec/frontend/notes/old_notes_spec.js b/spec/frontend/notes/old_notes_spec.js index cb1d563ece7..dee4f93f0ce 100644 --- a/spec/frontend/notes/old_notes_spec.js +++ b/spec/frontend/notes/old_notes_spec.js @@ -624,7 +624,7 @@ describe.skip('Old Notes (~/notes.js)', () => { }); }); - describe('postComment with Slash commands', () => { + describe('postComment with quick actions', () => { const sampleComment = '/assign @root\n/award :100:'; const note = { commands_changes: { @@ -640,6 +640,7 @@ describe.skip('Old Notes (~/notes.js)', () => { let $notesContainer; beforeEach(() => { + loadFixtures('commit/show.html'); mockAxios.onPost(NOTES_POST_PATH).reply(200, note); new Notes('', []); @@ -659,14 +660,49 @@ describe.skip('Old Notes (~/notes.js)', () => { $form.find('textarea.js-note-text').val(sampleComment); }); - it('should remove slash command placeholder when comment with slash commands is done posting', done => { + it('should remove quick action placeholder when comment with quick actions is done posting', done => { jest.spyOn(gl.awardsHandler, 'addAwardToEmojiBar'); $('.js-comment-button').click(); - expect($notesContainer.find('.system-note.being-posted').length).toEqual(1); // Placeholder shown + expect($notesContainer.find('.note.being-posted').length).toEqual(1); // Placeholder shown setImmediate(() => { - expect($notesContainer.find('.system-note.being-posted').length).toEqual(0); // Placeholder removed + expect($notesContainer.find('.note.being-posted').length).toEqual(0); // Placeholder removed + done(); + }); + }); + }); + + describe('postComment with slash when quick actions are not supported', () => { + const sampleComment = '/assign @root'; + let $form; + let $notesContainer; + + beforeEach(() => { + const note = { + id: 1234, + html: `<li class="note note-row-1234 timeline-entry" id="note_1234"> + <div class="note-text">${sampleComment}</div> + </li>`, + note: sampleComment, + valid: true, + }; + mockAxios.onPost(NOTES_POST_PATH).reply(200, note); + + new Notes('', []); + $form = $('form.js-main-target-form'); + $notesContainer = $('ul.main-notes-list'); + $form.find('textarea.js-note-text').val(sampleComment); + }); + + it('should show message placeholder including lines starting with slash', done => { + $('.js-comment-button').click(); + + expect($notesContainer.find('.note.being-posted').length).toEqual(1); // Placeholder shown + expect($notesContainer.find('.note-body p').text()).toEqual(sampleComment); // No quick action processing + + setImmediate(() => { + expect($notesContainer.find('.note.being-posted').length).toEqual(0); // Placeholder removed done(); }); }); diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index ef87cb3bee7..909a4a797ae 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -18,6 +18,8 @@ import { batchSuggestionsInfoMock, } from '../mock_data'; import axios from '~/lib/utils/axios_utils'; +import * as utils from '~/notes/stores/utils'; +import updateIssueConfidentialMutation from '~/sidebar/components/confidential/queries/update_issue_confidential.mutation.graphql'; const TEST_ERROR_MESSAGE = 'Test error message'; jest.mock('~/flash'); @@ -272,9 +274,54 @@ describe('Actions Notes Store', () => { }); }); + describe('fetchData', () => { + describe('given there are no notes', () => { + const lastFetchedAt = '13579'; + + beforeEach(() => { + axiosMock + .onGet(notesDataMock.notesPath) + .replyOnce(200, { notes: [], last_fetched_at: lastFetchedAt }); + }); + + it('should commit SET_LAST_FETCHED_AT', () => + testAction( + actions.fetchData, + undefined, + { notesData: notesDataMock }, + [{ type: 'SET_LAST_FETCHED_AT', payload: lastFetchedAt }], + [], + )); + }); + + describe('given there are notes', () => { + const lastFetchedAt = '12358'; + + beforeEach(() => { + axiosMock + .onGet(notesDataMock.notesPath) + .replyOnce(200, { notes: discussionMock.notes, last_fetched_at: lastFetchedAt }); + }); + + it('should dispatch updateOrCreateNotes, startTaskList and commit SET_LAST_FETCHED_AT', () => + testAction( + actions.fetchData, + undefined, + { notesData: notesDataMock }, + [{ type: 'SET_LAST_FETCHED_AT', payload: lastFetchedAt }], + [ + { type: 'updateOrCreateNotes', payload: discussionMock.notes }, + { type: 'startTaskList' }, + ], + )); + }); + }); + describe('poll', () => { beforeEach(done => { - jest.spyOn(axios, 'get'); + axiosMock + .onGet(notesDataMock.notesPath) + .reply(200, { notes: [], last_fetched_at: '123456' }, { 'poll-interval': '1000' }); store .dispatch('setNotesData', notesDataMock) @@ -283,15 +330,10 @@ describe('Actions Notes Store', () => { }); it('calls service with last fetched state', done => { - axiosMock - .onAny() - .reply(200, { notes: [], last_fetched_at: '123456' }, { 'poll-interval': '1000' }); - store .dispatch('poll') .then(() => new Promise(resolve => requestAnimationFrame(resolve))) .then(() => { - expect(axios.get).toHaveBeenCalled(); expect(store.state.lastFetchedAt).toBe('123456'); jest.advanceTimersByTime(1500); @@ -303,8 +345,9 @@ describe('Actions Notes Store', () => { }), ) .then(() => { - expect(axios.get.mock.calls.length).toBe(2); - expect(axios.get.mock.calls[axios.get.mock.calls.length - 1][1].headers).toEqual({ + const expectedGetRequests = 2; + expect(axiosMock.history.get.length).toBe(expectedGetRequests); + expect(axiosMock.history.get[expectedGetRequests - 1].headers).toMatchObject({ 'X-Last-Fetched-At': '123456', }); }) @@ -449,7 +492,7 @@ describe('Actions Notes Store', () => { it('commits ADD_NEW_NOTE and dispatches updateMergeRequestWidget', done => { testAction( actions.createNewNote, - { endpoint: `${gl.TEST_HOST}`, data: {} }, + { endpoint: `${TEST_HOST}`, data: {} }, store.state, [ { @@ -485,7 +528,7 @@ describe('Actions Notes Store', () => { it('does not commit ADD_NEW_NOTE or dispatch updateMergeRequestWidget', done => { testAction( actions.createNewNote, - { endpoint: `${gl.TEST_HOST}`, data: {} }, + { endpoint: `${TEST_HOST}`, data: {} }, store.state, [], [], @@ -508,7 +551,7 @@ describe('Actions Notes Store', () => { it('commits UPDATE_NOTE and dispatches updateMergeRequestWidget', done => { testAction( actions.toggleResolveNote, - { endpoint: `${gl.TEST_HOST}`, isResolved: true, discussion: false }, + { endpoint: `${TEST_HOST}`, isResolved: true, discussion: false }, store.state, [ { @@ -533,7 +576,7 @@ describe('Actions Notes Store', () => { it('commits UPDATE_DISCUSSION and dispatches updateMergeRequestWidget', done => { testAction( actions.toggleResolveNote, - { endpoint: `${gl.TEST_HOST}`, isResolved: true, discussion: true }, + { endpoint: `${TEST_HOST}`, isResolved: true, discussion: true }, store.state, [ { @@ -1084,6 +1127,19 @@ describe('Actions Notes Store', () => { }); }); + describe('setSelectedCommentPosition', () => { + it('calls the correct mutation with the correct args', done => { + testAction( + actions.setSelectedCommentPosition, + {}, + {}, + [{ type: mutationTypes.SET_SELECTED_COMMENT_POSITION, payload: {} }], + [], + done, + ); + }); + }); + describe('softDeleteDescriptionVersion', () => { const endpoint = '/path/to/diff/1'; const payload = { @@ -1142,6 +1198,14 @@ describe('Actions Notes Store', () => { }); }); + describe('setConfidentiality', () => { + it('calls the correct mutation with the correct args', () => { + testAction(actions.setConfidentiality, true, { noteableData: { confidential: false } }, [ + { type: mutationTypes.SET_ISSUE_CONFIDENTIAL, payload: true }, + ]); + }); + }); + describe('updateAssignees', () => { it('update the assignees state', done => { testAction( @@ -1154,4 +1218,49 @@ describe('Actions Notes Store', () => { ); }); }); + + describe('updateConfidentialityOnIssue', () => { + state = { noteableData: { confidential: false } }; + const iid = '1'; + const projectPath = 'full/path'; + const getters = { getNoteableData: { iid } }; + const actionArgs = { fullPath: projectPath, confidential: true }; + const confidential = true; + + beforeEach(() => { + jest + .spyOn(utils.gqClient, 'mutate') + .mockResolvedValue({ data: { issueSetConfidential: { issue: { confidential } } } }); + }); + + it('calls gqClient mutation one time', () => { + actions.updateConfidentialityOnIssue({ commit: () => {}, state, getters }, actionArgs); + + expect(utils.gqClient.mutate).toHaveBeenCalledTimes(1); + }); + + it('calls gqClient mutation with the correct values', () => { + actions.updateConfidentialityOnIssue({ commit: () => {}, state, getters }, actionArgs); + + expect(utils.gqClient.mutate).toHaveBeenCalledWith({ + mutation: updateIssueConfidentialMutation, + variables: { input: { iid, projectPath, confidential } }, + }); + }); + + describe('on success of mutation', () => { + it('calls commit with the correct values', () => { + const commitSpy = jest.fn(); + + return actions + .updateConfidentialityOnIssue({ commit: commitSpy, state, getters }, actionArgs) + .then(() => { + expect(commitSpy).toHaveBeenCalledWith( + mutationTypes.SET_ISSUE_CONFIDENTIAL, + confidential, + ); + }); + }); + }); + }); }); diff --git a/spec/frontend/notes/stores/mutation_spec.js b/spec/frontend/notes/stores/mutation_spec.js index 75ef007b78d..0ad18ba9b6a 100644 --- a/spec/frontend/notes/stores/mutation_spec.js +++ b/spec/frontend/notes/stores/mutation_spec.js @@ -524,6 +524,26 @@ describe('Notes Store mutations', () => { }); }); + describe('SET_SELECTED_COMMENT_POSITION', () => { + it('should set comment position state', () => { + const state = {}; + + mutations.SET_SELECTED_COMMENT_POSITION(state, {}); + + expect(state.selectedCommentPosition).toEqual({}); + }); + }); + + describe('SET_SELECTED_COMMENT_POSITION_HOVER', () => { + it('should set comment hover position state', () => { + const state = {}; + + mutations.SET_SELECTED_COMMENT_POSITION_HOVER(state, {}); + + expect(state.selectedCommentPositionHover).toEqual({}); + }); + }); + describe('DISABLE_COMMENTS', () => { it('should set comments disabled state', () => { const state = {}; @@ -806,6 +826,20 @@ describe('Notes Store mutations', () => { }); }); + describe('SET_ISSUE_CONFIDENTIAL', () => { + let state; + + beforeEach(() => { + state = { noteableData: { confidential: false } }; + }); + + it('sets sort order', () => { + mutations.SET_ISSUE_CONFIDENTIAL(state, true); + + expect(state.noteableData.confidential).toBe(true); + }); + }); + describe('UPDATE_ASSIGNEES', () => { it('should update assignees', () => { const state = { diff --git a/spec/frontend/pager_spec.js b/spec/frontend/pager_spec.js index d7177a32cde..47056c2804c 100644 --- a/spec/frontend/pager_spec.js +++ b/spec/frontend/pager_spec.js @@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import Pager from '~/pager'; import { removeParams } from '~/lib/utils/url_utility'; +import { TEST_HOST } from 'jest/helpers/test_constants'; jest.mock('~/lib/utils/url_utility', () => ({ removeParams: jest.fn().mockName('removeParams'), @@ -32,7 +33,7 @@ describe('pager', () => { }); it('should use data-href attribute from list element', () => { - const href = `${gl.TEST_HOST}/some_list.json`; + const href = `${TEST_HOST}/some_list.json`; setFixtures(`<div class="content_list" data-href="${href}"></div>`); Pager.init(); @@ -40,7 +41,7 @@ describe('pager', () => { }); it('should use current url if data-href attribute not provided', () => { - const href = `${gl.TEST_HOST}/some_list`; + const href = `${TEST_HOST}/some_list`; removeParams.mockReturnValue(href); Pager.init(); @@ -56,7 +57,7 @@ describe('pager', () => { it('keeps extra query parameters from url', () => { window.history.replaceState({}, null, '?filter=test&offset=100'); - const href = `${gl.TEST_HOST}/some_list?filter=test`; + const href = `${TEST_HOST}/some_list?filter=test`; removeParams.mockReturnValue(href); Pager.init(); diff --git a/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js b/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js index fe17c03389c..fb7a07b7bc7 100644 --- a/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js +++ b/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js @@ -3,6 +3,7 @@ import { redirectTo } from '~/lib/utils/url_utility'; import mountComponent from 'helpers/vue_mount_component_helper'; import axios from '~/lib/utils/axios_utils'; import stopJobsModal from '~/pages/admin/jobs/index/components/stop_jobs_modal.vue'; +import { TEST_HOST } from 'jest/helpers/test_constants'; jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), @@ -11,7 +12,7 @@ jest.mock('~/lib/utils/url_utility', () => ({ describe('stop_jobs_modal.vue', () => { const props = { - url: `${gl.TEST_HOST}/stop_jobs_modal.vue/stopAll`, + url: `${TEST_HOST}/stop_jobs_modal.vue/stopAll`, }; let vm; @@ -26,7 +27,7 @@ describe('stop_jobs_modal.vue', () => { describe('onSubmit', () => { it('stops jobs and redirects to overview page', done => { - const responseURL = `${gl.TEST_HOST}/stop_jobs_modal.vue/jobs`; + const responseURL = `${TEST_HOST}/stop_jobs_modal.vue/jobs`; jest.spyOn(axios, 'post').mockImplementation(url => { expect(url).toBe(props.url); return Promise.resolve({ diff --git a/spec/frontend/pages/labels/components/promote_label_modal_spec.js b/spec/frontend/pages/labels/components/promote_label_modal_spec.js index 9d5beca70b5..d4aabcc02f4 100644 --- a/spec/frontend/pages/labels/components/promote_label_modal_spec.js +++ b/spec/frontend/pages/labels/components/promote_label_modal_spec.js @@ -3,6 +3,7 @@ import mountComponent from 'helpers/vue_mount_component_helper'; import promoteLabelModal from '~/pages/projects/labels/components/promote_label_modal.vue'; import eventHub from '~/pages/projects/labels/event_hub'; import axios from '~/lib/utils/axios_utils'; +import { TEST_HOST } from 'jest/helpers/test_constants'; describe('Promote label modal', () => { let vm; @@ -11,7 +12,7 @@ describe('Promote label modal', () => { labelTitle: 'Documentation', labelColor: '#5cb85c', labelTextColor: '#ffffff', - url: `${gl.TEST_HOST}/dummy/promote/labels`, + url: `${TEST_HOST}/dummy/promote/labels`, groupName: 'group', }; @@ -51,7 +52,7 @@ describe('Promote label modal', () => { }); it('redirects when a label is promoted', done => { - const responseURL = `${gl.TEST_HOST}/dummy/endpoint`; + const responseURL = `${TEST_HOST}/dummy/endpoint`; jest.spyOn(axios, 'post').mockImplementation(url => { expect(url).toBe(labelMockData.url); expect(eventHub.$emit).toHaveBeenCalledWith( diff --git a/spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js b/spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js index ff5dc6d8988..c376cf02594 100644 --- a/spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js +++ b/spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js @@ -4,6 +4,7 @@ import mountComponent from 'helpers/vue_mount_component_helper'; import axios from '~/lib/utils/axios_utils'; import deleteMilestoneModal from '~/pages/milestones/shared/components/delete_milestone_modal.vue'; import eventHub from '~/pages/milestones/shared/event_hub'; +import { TEST_HOST } from 'jest/helpers/test_constants'; jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), @@ -17,7 +18,7 @@ describe('delete_milestone_modal.vue', () => { mergeRequestCount: 2, milestoneId: 3, milestoneTitle: 'my milestone title', - milestoneUrl: `${gl.TEST_HOST}/delete_milestone_modal.vue/milestone`, + milestoneUrl: `${TEST_HOST}/delete_milestone_modal.vue/milestone`, }; let vm; @@ -32,7 +33,7 @@ describe('delete_milestone_modal.vue', () => { }); it('deletes milestone and redirects to overview page', done => { - const responseURL = `${gl.TEST_HOST}/delete_milestone_modal.vue/milestoneOverview`; + const responseURL = `${TEST_HOST}/delete_milestone_modal.vue/milestoneOverview`; jest.spyOn(axios, 'delete').mockImplementation(url => { expect(url).toBe(props.milestoneUrl); expect(eventHub.$emit).toHaveBeenCalledWith( diff --git a/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js b/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js index ff896354d96..87d32a67d47 100644 --- a/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js +++ b/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js @@ -3,13 +3,14 @@ import mountComponent from 'helpers/vue_mount_component_helper'; import promoteMilestoneModal from '~/pages/milestones/shared/components/promote_milestone_modal.vue'; import eventHub from '~/pages/milestones/shared/event_hub'; import axios from '~/lib/utils/axios_utils'; +import { TEST_HOST } from 'jest/helpers/test_constants'; describe('Promote milestone modal', () => { let vm; const Component = Vue.extend(promoteMilestoneModal); const milestoneMockData = { milestoneTitle: 'v1.0', - url: `${gl.TEST_HOST}/dummy/promote/milestones`, + url: `${TEST_HOST}/dummy/promote/milestones`, groupName: 'group', }; @@ -46,7 +47,7 @@ describe('Promote milestone modal', () => { }); it('redirects when a milestone is promoted', done => { - const responseURL = `${gl.TEST_HOST}/dummy/endpoint`; + const responseURL = `${TEST_HOST}/dummy/endpoint`; jest.spyOn(axios, 'post').mockImplementation(url => { expect(url).toBe(milestoneMockData.url); expect(eventHub.$emit).toHaveBeenCalledWith( diff --git a/spec/frontend/pages/projects/forks/new/components/fork_groups_list_item_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_groups_list_item_spec.js new file mode 100644 index 00000000000..73e3c385d33 --- /dev/null +++ b/spec/frontend/pages/projects/forks/new/components/fork_groups_list_item_spec.js @@ -0,0 +1,78 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlBadge, GlButton, GlLink } from '@gitlab/ui'; +import ForkGroupsListItem from '~/pages/projects/forks/new/components/fork_groups_list_item.vue'; + +describe('Fork groups list item component', () => { + let wrapper; + + const DEFAULT_PROPS = { + hasReachedProjectLimit: false, + }; + + const DEFAULT_GROUP_DATA = { + id: 22, + name: 'Gitlab Org', + description: 'Ad et ipsam earum id aut nobis.', + visibility: 'public', + full_name: 'Gitlab Org', + created_at: '2020-06-22T03:32:05.664Z', + updated_at: '2020-06-22T03:32:05.664Z', + avatar_url: null, + fork_path: '/twitter/typeahead-js/-/forks?namespace_key=22', + forked_project_path: null, + permission: 'Owner', + relative_path: '/gitlab-org', + markdown_description: + '<p data-sourcepos="1:1-1:31" dir="auto">Ad et ipsam earum id aut nobis.</p>', + can_create_project: true, + marked_for_deletion: false, + }; + + const DUMMY_PATH = '/dummy/path'; + + const createWrapper = propsData => { + wrapper = shallowMount(ForkGroupsListItem, { + propsData: { + ...DEFAULT_PROPS, + ...propsData, + }, + }); + }; + + it('renders pending removal badge if applicable', () => { + createWrapper({ group: { ...DEFAULT_GROUP_DATA, marked_for_deletion: true } }); + + expect(wrapper.find(GlBadge).text()).toBe('pending removal'); + }); + + it('renders go to fork button if has forked project', () => { + createWrapper({ group: { ...DEFAULT_GROUP_DATA, forked_project_path: DUMMY_PATH } }); + + expect(wrapper.find(GlButton).text()).toBe('Go to fork'); + expect(wrapper.find(GlButton).attributes().href).toBe(DUMMY_PATH); + }); + + it('renders select button if has no forked project', () => { + createWrapper({ + group: { ...DEFAULT_GROUP_DATA, forked_project_path: null, fork_path: DUMMY_PATH }, + }); + + expect(wrapper.find(GlButton).text()).toBe('Select'); + expect(wrapper.find('form').attributes().action).toBe(DUMMY_PATH); + }); + + it('renders link to current group', () => { + const DUMMY_FULL_NAME = 'dummy'; + createWrapper({ + group: { ...DEFAULT_GROUP_DATA, relative_path: DUMMY_PATH, full_name: DUMMY_FULL_NAME }, + }); + + expect( + wrapper + .findAll(GlLink) + .filter(w => w.text() === DUMMY_FULL_NAME) + .at(0) + .attributes().href, + ).toBe(DUMMY_PATH); + }); +}); diff --git a/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js new file mode 100644 index 00000000000..979dff78eba --- /dev/null +++ b/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js @@ -0,0 +1,133 @@ +import AxiosMockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import createFlash from '~/flash'; +import ForkGroupsList from '~/pages/projects/forks/new/components/fork_groups_list.vue'; +import ForkGroupsListItem from '~/pages/projects/forks/new/components/fork_groups_list_item.vue'; +import waitForPromises from 'helpers/wait_for_promises'; + +jest.mock('~/flash', () => jest.fn()); + +describe('Fork groups list component', () => { + let wrapper; + let axiosMock; + + const DEFAULT_PROPS = { + endpoint: '/dummy', + hasReachedProjectLimit: false, + }; + + const replyWith = (...args) => axiosMock.onGet(DEFAULT_PROPS.endpoint).reply(...args); + + const createWrapper = propsData => { + wrapper = shallowMount(ForkGroupsList, { + propsData: { + ...DEFAULT_PROPS, + ...propsData, + }, + stubs: { + GlTabs: { + template: '<div><slot></slot><slot name="tabs-end"></slot></div>', + }, + }, + }); + }; + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + axiosMock.reset(); + + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + it('fires load groups request on mount', async () => { + replyWith(200, { namespaces: [] }); + createWrapper(); + + await waitForPromises(); + + expect(axiosMock.history.get[0].url).toBe(DEFAULT_PROPS.endpoint); + }); + + it('displays flash if loading groups fails', async () => { + replyWith(500); + createWrapper(); + + await waitForPromises(); + + expect(createFlash).toHaveBeenCalled(); + }); + + it('displays loading indicator while loading groups', () => { + replyWith(() => new Promise(() => {})); + createWrapper(); + + expect(wrapper.contains(GlLoadingIcon)).toBe(true); + }); + + it('displays empty text if no groups are available', async () => { + const EMPTY_TEXT = 'No available groups to fork the project.'; + replyWith(200, { namespaces: [] }); + createWrapper(); + + await waitForPromises(); + + expect(wrapper.text()).toContain(EMPTY_TEXT); + }); + + it('displays filter field when groups are available', async () => { + replyWith(200, { namespaces: [{ name: 'dummy1' }, { name: 'dummy2' }] }); + createWrapper(); + + await waitForPromises(); + + expect(wrapper.contains(GlSearchBoxByType)).toBe(true); + }); + + it('renders list items for each available group', async () => { + const namespaces = [{ name: 'dummy1' }, { name: 'dummy2' }, { name: 'otherdummy' }]; + const hasReachedProjectLimit = true; + + replyWith(200, { namespaces }); + createWrapper({ hasReachedProjectLimit }); + + await waitForPromises(); + + expect(wrapper.findAll(ForkGroupsListItem)).toHaveLength(namespaces.length); + + namespaces.forEach((namespace, idx) => { + expect( + wrapper + .findAll(ForkGroupsListItem) + .at(idx) + .props(), + ).toStrictEqual({ group: namespace, hasReachedProjectLimit }); + }); + }); + + it('filters repositories on the fly', async () => { + replyWith(200, { + namespaces: [{ name: 'dummy1' }, { name: 'dummy2' }, { name: 'otherdummy' }], + }); + createWrapper(); + await waitForPromises(); + wrapper.find(GlSearchBoxByType).vm.$emit('input', 'other'); + await nextTick(); + + expect(wrapper.findAll(ForkGroupsListItem)).toHaveLength(1); + expect( + wrapper + .findAll(ForkGroupsListItem) + .at(0) + .props().group.name, + ).toBe('otherdummy'); + }); +}); diff --git a/spec/frontend/pages/projects/graphs/code_coverage_spec.js b/spec/frontend/pages/projects/graphs/code_coverage_spec.js index 4990985b076..30c7ff78c6e 100644 --- a/spec/frontend/pages/projects/graphs/code_coverage_spec.js +++ b/spec/frontend/pages/projects/graphs/code_coverage_spec.js @@ -5,7 +5,7 @@ import { GlAreaChart } from '@gitlab/ui/dist/charts'; import axios from '~/lib/utils/axios_utils'; import CodeCoverage from '~/pages/projects/graphs/components/code_coverage.vue'; -import codeCoverageMockData from './mock_data'; +import { codeCoverageMockData, sortedDataByDates } from './mock_data'; import waitForPromises from 'helpers/wait_for_promises'; import httpStatusCodes from '~/lib/utils/http_status'; @@ -52,6 +52,10 @@ describe('Code Coverage', () => { expect(findAreaChart().exists()).toBe(true); }); + it('sorts the dates in ascending order', () => { + expect(wrapper.vm.sortedData).toEqual(sortedDataByDates); + }); + it('matches the snapshot', () => { expect(wrapper.element).toMatchSnapshot(); }); diff --git a/spec/frontend/pages/projects/graphs/mock_data.js b/spec/frontend/pages/projects/graphs/mock_data.js index a15f861ee7a..28d97b9d3f0 100644 --- a/spec/frontend/pages/projects/graphs/mock_data.js +++ b/spec/frontend/pages/projects/graphs/mock_data.js @@ -1,60 +1,69 @@ -export default [ +export const codeCoverageMockData = [ { group_name: 'rspec', data: [ - { date: '2020-04-30', coverage: 40.0 }, - { date: '2020-05-01', coverage: 80.0 }, - { date: '2020-05-02', coverage: 99.0 }, - { date: '2020-05-10', coverage: 80.0 }, - { date: '2020-05-15', coverage: 70.0 }, { date: '2020-05-20', coverage: 69.0 }, + { date: '2020-05-15', coverage: 70.0 }, + { date: '2020-05-10', coverage: 80.0 }, + { date: '2020-05-02', coverage: 99.0 }, + { date: '2020-05-01', coverage: 80.0 }, + { date: '2020-04-30', coverage: 40.0 }, ], }, { group_name: 'cypress', data: [ - { date: '2022-07-30', coverage: 1.0 }, - { date: '2022-08-01', coverage: 2.4 }, - { date: '2022-08-02', coverage: 5.0 }, - { date: '2022-08-10', coverage: 15.0 }, - { date: '2022-08-15', coverage: 30.0 }, { date: '2022-08-20', coverage: 40.0 }, + { date: '2022-08-15', coverage: 30.0 }, + { date: '2022-08-10', coverage: 15.0 }, + { date: '2022-08-02', coverage: 5.0 }, + { date: '2022-08-01', coverage: 2.4 }, + { date: '2022-07-30', coverage: 1.0 }, ], }, { group_name: 'karma', data: [ - { date: '2020-05-01', coverage: 94.0 }, - { date: '2020-05-02', coverage: 94.0 }, - { date: '2020-05-03', coverage: 94.0 }, - { date: '2020-05-04', coverage: 94.0 }, - { date: '2020-05-05', coverage: 92.0 }, - { date: '2020-05-06', coverage: 91.0 }, - { date: '2020-05-07', coverage: 78.0 }, - { date: '2020-05-08', coverage: 94.0 }, - { date: '2020-05-09', coverage: 94.0 }, - { date: '2020-05-10', coverage: 94.0 }, - { date: '2020-05-11', coverage: 94.0 }, - { date: '2020-05-12', coverage: 94.0 }, - { date: '2020-05-13', coverage: 92.0 }, - { date: '2020-05-14', coverage: 91.0 }, - { date: '2020-05-15', coverage: 78.0 }, - { date: '2020-05-16', coverage: 94.0 }, - { date: '2020-05-17', coverage: 94.0 }, - { date: '2020-05-18', coverage: 93.0 }, - { date: '2020-05-19', coverage: 92.0 }, - { date: '2020-05-20', coverage: 91.0 }, - { date: '2020-05-21', coverage: 90.0 }, - { date: '2020-05-22', coverage: 91.0 }, - { date: '2020-05-23', coverage: 92.0 }, - { date: '2020-05-24', coverage: 75.0 }, - { date: '2020-05-25', coverage: 74.0 }, - { date: '2020-05-26', coverage: 74.0 }, - { date: '2020-05-27', coverage: 74.0 }, - { date: '2020-05-28', coverage: 80.0 }, - { date: '2020-05-29', coverage: 85.0 }, - { date: '2020-05-30', coverage: 92.0 }, { date: '2020-05-31', coverage: 91.0 }, + { date: '2020-05-30', coverage: 94.0 }, + { date: '2020-05-29', coverage: 94.0 }, + { date: '2020-05-28', coverage: 92.0 }, + { date: '2020-05-27', coverage: 91.0 }, + { date: '2020-05-26', coverage: 78.0 }, + { date: '2020-05-25', coverage: 94.0 }, + { date: '2020-05-24', coverage: 94.0 }, + { date: '2020-05-23', coverage: 94.0 }, + { date: '2020-05-22', coverage: 94.0 }, + { date: '2020-05-21', coverage: 94.0 }, + { date: '2020-05-20', coverage: 92.0 }, + { date: '2020-05-19', coverage: 91.0 }, + { date: '2020-05-18', coverage: 78.0 }, + { date: '2020-05-17', coverage: 94.0 }, + { date: '2020-05-16', coverage: 94.0 }, + { date: '2020-05-15', coverage: 93.0 }, + { date: '2020-05-14', coverage: 92.0 }, + { date: '2020-05-13', coverage: 91.0 }, + { date: '2020-05-12', coverage: 90.0 }, + { date: '2020-05-11', coverage: 91.0 }, + { date: '2020-05-10', coverage: 92.0 }, + { date: '2020-05-09', coverage: 75.0 }, + { date: '2020-05-08', coverage: 74.0 }, + { date: '2020-05-07', coverage: 74.0 }, + { date: '2020-05-06', coverage: 74.0 }, + { date: '2020-05-05', coverage: 80.0 }, + { date: '2020-05-04', coverage: 85.0 }, + { date: '2020-05-03', coverage: 92.0 }, + { date: '2020-05-02', coverage: 94.0 }, + { date: '2020-05-01', coverage: 94.0 }, ], }, ]; + +export const sortedDataByDates = [ + { date: '2020-04-30', coverage: 40.0 }, + { date: '2020-05-01', coverage: 80.0 }, + { date: '2020-05-02', coverage: 99.0 }, + { date: '2020-05-10', coverage: 80.0 }, + { date: '2020-05-15', coverage: 70.0 }, + { date: '2020-05-20', coverage: 69.0 }, +]; diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js index 9cc1d6eeb5a..9a119377542 100644 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js @@ -1,4 +1,5 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; +import { trimText } from 'helpers/text_helper'; import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue'; describe('Interval Pattern Input Component', () => { @@ -14,15 +15,22 @@ describe('Interval Pattern Input Component', () => { everyWeek: `0 ${mockHour} * * ${mockWeekDayIndex}`, everyMonth: `0 ${mockHour} ${mockDay} * *`, }; - - const findEveryDayRadio = () => wrapper.find('#every-day'); - const findEveryWeekRadio = () => wrapper.find('#every-week'); - const findEveryMonthRadio = () => wrapper.find('#every-month'); - const findCustomRadio = () => wrapper.find('#custom'); + const customKey = 'custom'; + const everyDayKey = 'everyDay'; + const cronIntervalNotInPreset = `0 12 * * *`; + + const findEveryDayRadio = () => wrapper.find(`[data-testid=${everyDayKey}]`); + const findEveryWeekRadio = () => wrapper.find('[data-testid="everyWeek"]'); + const findEveryMonthRadio = () => wrapper.find('[data-testid="everyMonth"]'); + const findCustomRadio = () => wrapper.find(`[data-testid="${customKey}"]`); const findCustomInput = () => wrapper.find('#schedule_cron'); - const selectEveryDayRadio = () => findEveryDayRadio().setChecked(); - const selectEveryWeekRadio = () => findEveryWeekRadio().setChecked(); - const selectEveryMonthRadio = () => findEveryMonthRadio().setChecked(); + const findAllLabels = () => wrapper.findAll('label'); + const findSelectedRadio = () => + wrapper.findAll('input[type="radio"]').wrappers.find(x => x.element.checked); + const findSelectedRadioKey = () => findSelectedRadio()?.attributes('data-testid'); + const selectEveryDayRadio = () => findEveryDayRadio().trigger('click'); + const selectEveryWeekRadio = () => findEveryWeekRadio().trigger('click'); + const selectEveryMonthRadio = () => findEveryMonthRadio().trigger('click'); const selectCustomRadio = () => findCustomRadio().trigger('click'); const createWrapper = (props = {}, data = {}) => { @@ -30,7 +38,7 @@ describe('Interval Pattern Input Component', () => { throw new Error('A wrapper already exists'); } - wrapper = shallowMount(IntervalPatternInput, { + wrapper = mount(IntervalPatternInput, { propsData: { ...props }, data() { return { @@ -63,8 +71,8 @@ describe('Interval Pattern Input Component', () => { createWrapper(); }); - it('to a non empty string when no initial value is not passed', () => { - expect(findCustomInput()).not.toBe(''); + it('defaults to every day value when no `initialCronInterval` is passed', () => { + expect(findCustomInput().element.value).toBe(cronIntervalPresets.everyDay); }); }); @@ -85,20 +93,20 @@ describe('Interval Pattern Input Component', () => { createWrapper(); }); - it('when a default option is selected', () => { + it('when a default option is selected', async () => { selectEveryDayRadio(); - return wrapper.vm.$nextTick().then(() => { - expect(findCustomInput().attributes('disabled')).toBeUndefined(); - }); + await wrapper.vm.$nextTick(); + + expect(findCustomInput().attributes('disabled')).toBeUndefined(); }); - it('when the custom option is selected', () => { + it('when the custom option is selected', async () => { selectCustomRadio(); - return wrapper.vm.$nextTick().then(() => { - expect(findCustomInput().attributes('disabled')).toBeUndefined(); - }); + await wrapper.vm.$nextTick(); + + expect(findCustomInput().attributes('disabled')).toBeUndefined(); }); }); @@ -115,40 +123,83 @@ describe('Interval Pattern Input Component', () => { }); }); + describe('Time strings', () => { + beforeEach(() => { + createWrapper(); + }); + + it('renders each label for radio options properly', () => { + const labels = findAllLabels().wrappers.map(el => trimText(el.text())); + + expect(labels).toEqual([ + 'Every day (at 4:00am)', + 'Every week (Monday at 4:00am)', + 'Every month (Day 1 at 4:00am)', + 'Custom ( Cron syntax )', + ]); + }); + }); + describe('User Actions with radio buttons', () => { - it.each` - desc | initialCronInterval | act | expectedValue - ${'when everyday is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryDayRadio} | ${cronIntervalPresets.everyDay} - ${'when everyweek is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryWeekRadio} | ${cronIntervalPresets.everyWeek} - ${'when everymonth is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryMonthRadio} | ${cronIntervalPresets.everyMonth} - ${'when custom is selected, add space to value'} | ${cronIntervalPresets.everyMonth} | ${selectCustomRadio} | ${`${cronIntervalPresets.everyMonth} `} - `('$desc', ({ initialCronInterval, act, expectedValue }) => { - createWrapper({ initialCronInterval }); + describe('Default option', () => { + beforeEach(() => { + createWrapper(); + }); + + it('when everyday is selected, update value', async () => { + selectEveryWeekRadio(); + await wrapper.vm.$nextTick(); + expect(findCustomInput().element.value).toBe(cronIntervalPresets.everyWeek); + + selectEveryDayRadio(); + await wrapper.vm.$nextTick(); + expect(findCustomInput().element.value).toBe(cronIntervalPresets.everyDay); + }); + }); + + describe('Other options', () => { + it.each` + desc | initialCronInterval | act | expectedValue + ${'when everyweek is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryWeekRadio} | ${cronIntervalPresets.everyWeek} + ${'when everymonth is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryMonthRadio} | ${cronIntervalPresets.everyMonth} + ${'when custom is selected, value remains the same'} | ${cronIntervalPresets.everyMonth} | ${selectCustomRadio} | ${cronIntervalPresets.everyMonth} + `('$desc', async ({ initialCronInterval, act, expectedValue }) => { + createWrapper({ initialCronInterval }); + + act(); - act(); + await wrapper.vm.$nextTick(); - return wrapper.vm.$nextTick().then(() => { expect(findCustomInput().element.value).toBe(expectedValue); }); }); }); + describe('User actions with input field for Cron syntax', () => { beforeEach(() => { createWrapper(); }); - it('when editing the cron input it selects the custom radio button', () => { + it('when editing the cron input it selects the custom radio button', async () => { const newValue = '0 * * * *'; + expect(findSelectedRadioKey()).toBe(everyDayKey); + findCustomInput().setValue(newValue); - expect(wrapper.vm.cronInterval).toBe(newValue); + await wrapper.vm.$nextTick; + + expect(findSelectedRadioKey()).toBe(customKey); }); + }); - it('when value of input is one of the defaults, it selects the corresponding radio button', () => { - findCustomInput().setValue(cronIntervalPresets.everyWeek); + describe('Edit form field', () => { + beforeEach(() => { + createWrapper({ initialCronInterval: cronIntervalNotInPreset }); + }); - expect(wrapper.vm.cronInterval).toBe(cronIntervalPresets.everyWeek); + it('loads with the custom option being selected', () => { + expect(findSelectedRadioKey()).toBe(customKey); }); }); }); diff --git a/spec/frontend/persistent_user_callout_spec.js b/spec/frontend/persistent_user_callout_spec.js index db324990e71..97985ba3a07 100644 --- a/spec/frontend/persistent_user_callout_spec.js +++ b/spec/frontend/persistent_user_callout_spec.js @@ -43,6 +43,23 @@ describe('PersistentUserCallout', () => { return fixture; } + function createFollowLinkFixture() { + const fixture = document.createElement('div'); + fixture.innerHTML = ` + <ul> + <li + class="container" + data-dismiss-endpoint="${dismissEndpoint}" + data-feature-id="${featureName}" + > + <a class="js-follow-link" href="/somewhere-pleasant">A Link</a> + </li> + </ul> + `; + + return fixture; + } + describe('dismiss', () => { let button; let mockAxios; @@ -144,6 +161,55 @@ describe('PersistentUserCallout', () => { }); }); + describe('follow links', () => { + let link; + let mockAxios; + let persistentUserCallout; + + beforeEach(() => { + const fixture = createFollowLinkFixture(); + const container = fixture.querySelector('.container'); + link = fixture.querySelector('.js-follow-link'); + mockAxios = new MockAdapter(axios); + + persistentUserCallout = new PersistentUserCallout(container); + jest.spyOn(persistentUserCallout.container, 'remove').mockImplementation(() => {}); + + delete window.location; + window.location = { assign: jest.fn() }; + }); + + afterEach(() => { + mockAxios.restore(); + }); + + it('uses a link to trigger callout and defers following until callout is finished', () => { + const { href } = link; + mockAxios.onPost(dismissEndpoint).replyOnce(200); + + link.click(); + + return waitForPromises().then(() => { + expect(window.location.assign).toBeCalledWith(href); + expect(mockAxios.history.post[0].data).toBe(JSON.stringify({ feature_name: featureName })); + expect(persistentUserCallout.container.remove).not.toHaveBeenCalled(); + }); + }); + + it('invokes Flash when the dismiss request fails', () => { + mockAxios.onPost(dismissEndpoint).replyOnce(500); + + link.click(); + + return waitForPromises().then(() => { + expect(window.location.assign).not.toHaveBeenCalled(); + expect(Flash).toHaveBeenCalledWith( + 'An error occurred while acknowledging the notification. Refresh the page and try again.', + ); + }); + }); + }); + describe('factory', () => { it('returns an instance of PersistentUserCallout with the provided container property', () => { const fixture = createFixture(); diff --git a/spec/frontend/pipelines/blank_state_spec.js b/spec/frontend/pipelines/blank_state_spec.js index 033bd5ccb73..bb069fdc2c8 100644 --- a/spec/frontend/pipelines/blank_state_spec.js +++ b/spec/frontend/pipelines/blank_state_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import component from '~/pipelines/components/blank_state.vue'; +import component from '~/pipelines/components/pipelines_list/blank_state.vue'; import mountComponent from '../helpers/vue_mount_component_helper'; describe('Pipelines Blank State', () => { diff --git a/spec/frontend/pipelines/components/dag/__snapshots__/dag_graph_spec.js.snap b/spec/frontend/pipelines/components/dag/__snapshots__/dag_graph_spec.js.snap index 629efc6d3fa..cb5f6ff5307 100644 --- a/spec/frontend/pipelines/components/dag/__snapshots__/dag_graph_spec.js.snap +++ b/spec/frontend/pipelines/components/dag/__snapshots__/dag_graph_spec.js.snap @@ -3,7 +3,7 @@ exports[`The DAG graph in the basic case renders the graph svg 1`] = ` "<svg viewBox=\\"0,0,1000,540\\" width=\\"1000\\" height=\\"540\\"> <g fill=\\"none\\" stroke-opacity=\\"0.8\\"> - <g id=\\"dag-link43\\" class=\\"dag-link gl-cursor-pointer\\"> + <g id=\\"dag-link43\\" class=\\"dag-link gl-transition-property-stroke-opacity gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\"> <linearGradient id=\\"dag-grad53\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"116\\" x2=\\"361.3333333333333\\"> <stop offset=\\"0%\\" stop-color=\\"#e17223\\"></stop> <stop offset=\\"100%\\" stop-color=\\"#83ab4a\\"></stop> @@ -20,7 +20,7 @@ exports[`The DAG graph in the basic case renders the graph svg 1`] = ` </clipPath> <path d=\\"M108,129L190,129L190,129L369.3333333333333,129\\" stroke=\\"url(#dag-grad53)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip63)\\"></path> </g> - <g id=\\"dag-link44\\" class=\\"dag-link gl-cursor-pointer\\"> + <g id=\\"dag-link44\\" class=\\"dag-link gl-transition-property-stroke-opacity gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\"> <linearGradient id=\\"dag-grad54\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"377.3333333333333\\" x2=\\"622.6666666666666\\"> <stop offset=\\"0%\\" stop-color=\\"#83ab4a\\"></stop> <stop offset=\\"100%\\" stop-color=\\"#6f3500\\"></stop> @@ -37,7 +37,7 @@ exports[`The DAG graph in the basic case renders the graph svg 1`] = ` </clipPath> <path d=\\"M369.3333333333333,129L509.3333333333333,129L509.3333333333333,129.0000000000002L630.6666666666666,129.0000000000002\\" stroke=\\"url(#dag-grad54)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip64)\\"></path> </g> - <g id=\\"dag-link45\\" class=\\"dag-link gl-cursor-pointer\\"> + <g id=\\"dag-link45\\" class=\\"dag-link gl-transition-property-stroke-opacity gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\"> <linearGradient id=\\"dag-grad55\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"116\\" x2=\\"622.6666666666666\\"> <stop offset=\\"0%\\" stop-color=\\"#5772ff\\"></stop> <stop offset=\\"100%\\" stop-color=\\"#6f3500\\"></stop> @@ -54,7 +54,7 @@ exports[`The DAG graph in the basic case renders the graph svg 1`] = ` </clipPath> <path d=\\"M108,212.00000000000003L306,212.00000000000003L306,187.0000000000002L630.6666666666666,187.0000000000002\\" stroke=\\"url(#dag-grad55)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip65)\\"></path> </g> - <g id=\\"dag-link46\\" class=\\"dag-link gl-cursor-pointer\\"> + <g id=\\"dag-link46\\" class=\\"dag-link gl-transition-property-stroke-opacity gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\"> <linearGradient id=\\"dag-grad56\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"116\\" x2=\\"361.3333333333333\\"> <stop offset=\\"0%\\" stop-color=\\"#b24800\\"></stop> <stop offset=\\"100%\\" stop-color=\\"#006887\\"></stop> @@ -71,7 +71,7 @@ exports[`The DAG graph in the basic case renders the graph svg 1`] = ` </clipPath> <path d=\\"M108,295L338.93333333333334,295L338.93333333333334,269.9999999999998L369.3333333333333,269.9999999999998\\" stroke=\\"url(#dag-grad56)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip66)\\"></path> </g> - <g id=\\"dag-link47\\" class=\\"dag-link gl-cursor-pointer\\"> + <g id=\\"dag-link47\\" class=\\"dag-link gl-transition-property-stroke-opacity gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\"> <linearGradient id=\\"dag-grad57\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"116\\" x2=\\"361.3333333333333\\"> <stop offset=\\"0%\\" stop-color=\\"#25d2d2\\"></stop> <stop offset=\\"100%\\" stop-color=\\"#487900\\"></stop> @@ -88,7 +88,7 @@ exports[`The DAG graph in the basic case renders the graph svg 1`] = ` </clipPath> <path d=\\"M108,378.00000000000006L144.66666666666669,378.00000000000006L144.66666666666669,352.99999999999994L369.3333333333333,352.99999999999994\\" stroke=\\"url(#dag-grad57)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip67)\\"></path> </g> - <g id=\\"dag-link48\\" class=\\"dag-link gl-cursor-pointer\\"> + <g id=\\"dag-link48\\" class=\\"dag-link gl-transition-property-stroke-opacity gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\"> <linearGradient id=\\"dag-grad58\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"377.3333333333333\\" x2=\\"622.6666666666666\\"> <stop offset=\\"0%\\" stop-color=\\"#006887\\"></stop> <stop offset=\\"100%\\" stop-color=\\"#d84280\\"></stop> @@ -105,7 +105,7 @@ exports[`The DAG graph in the basic case renders the graph svg 1`] = ` </clipPath> <path d=\\"M369.3333333333333,269.9999999999998L464,269.9999999999998L464,270.0000000000001L630.6666666666666,270.0000000000001\\" stroke=\\"url(#dag-grad58)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip68)\\"></path> </g> - <g id=\\"dag-link49\\" class=\\"dag-link gl-cursor-pointer\\"> + <g id=\\"dag-link49\\" class=\\"dag-link gl-transition-property-stroke-opacity gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\"> <linearGradient id=\\"dag-grad59\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"377.3333333333333\\" x2=\\"622.6666666666666\\"> <stop offset=\\"0%\\" stop-color=\\"#487900\\"></stop> <stop offset=\\"100%\\" stop-color=\\"#d84280\\"></stop> @@ -122,7 +122,7 @@ exports[`The DAG graph in the basic case renders the graph svg 1`] = ` </clipPath> <path d=\\"M369.3333333333333,352.99999999999994L522,352.99999999999994L522,328.0000000000001L630.6666666666666,328.0000000000001\\" stroke=\\"url(#dag-grad59)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip69)\\"></path> </g> - <g id=\\"dag-link50\\" class=\\"dag-link gl-cursor-pointer\\"> + <g id=\\"dag-link50\\" class=\\"dag-link gl-transition-property-stroke-opacity gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\"> <linearGradient id=\\"dag-grad60\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"377.3333333333333\\" x2=\\"622.6666666666666\\"> <stop offset=\\"0%\\" stop-color=\\"#487900\\"></stop> <stop offset=\\"100%\\" stop-color=\\"#3547de\\"></stop> @@ -139,7 +139,7 @@ exports[`The DAG graph in the basic case renders the graph svg 1`] = ` </clipPath> <path d=\\"M369.3333333333333,410.99999999999994L580,410.99999999999994L580,411L630.6666666666666,411\\" stroke=\\"url(#dag-grad60)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip70)\\"></path> </g> - <g id=\\"dag-link51\\" class=\\"dag-link gl-cursor-pointer\\"> + <g id=\\"dag-link51\\" class=\\"dag-link gl-transition-property-stroke-opacity gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\"> <linearGradient id=\\"dag-grad61\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"638.6666666666666\\" x2=\\"884\\"> <stop offset=\\"0%\\" stop-color=\\"#d84280\\"></stop> <stop offset=\\"100%\\" stop-color=\\"#006887\\"></stop> @@ -156,7 +156,7 @@ exports[`The DAG graph in the basic case renders the graph svg 1`] = ` </clipPath> <path d=\\"M630.6666666666666,270.0000000000001L861.6,270.0000000000001L861.6,270.1890725105691L892,270.1890725105691\\" stroke=\\"url(#dag-grad61)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip71)\\"></path> </g> - <g id=\\"dag-link52\\" class=\\"dag-link gl-cursor-pointer\\"> + <g id=\\"dag-link52\\" class=\\"dag-link gl-transition-property-stroke-opacity gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\"> <linearGradient id=\\"dag-grad62\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"638.6666666666666\\" x2=\\"884\\"> <stop offset=\\"0%\\" stop-color=\\"#3547de\\"></stop> <stop offset=\\"100%\\" stop-color=\\"#275600\\"></stop> @@ -175,18 +175,18 @@ exports[`The DAG graph in the basic case renders the graph svg 1`] = ` </g> </g> <g> - <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node73\\" stroke=\\"#e17223\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"108\\" x2=\\"108\\" y1=\\"104\\" y2=\\"154.00000000000003\\"></line> - <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node74\\" stroke=\\"#83ab4a\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"369\\" x2=\\"369\\" y1=\\"104\\" y2=\\"154\\"></line> - <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node75\\" stroke=\\"#5772ff\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"108\\" x2=\\"108\\" y1=\\"187.00000000000003\\" y2=\\"237.00000000000003\\"></line> - <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node76\\" stroke=\\"#b24800\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"108\\" x2=\\"108\\" y1=\\"270\\" y2=\\"320.00000000000006\\"></line> - <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node77\\" stroke=\\"#25d2d2\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"108\\" x2=\\"108\\" y1=\\"353.00000000000006\\" y2=\\"403.0000000000001\\"></line> - <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node78\\" stroke=\\"#6f3500\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"630\\" x2=\\"630\\" y1=\\"104.0000000000002\\" y2=\\"212.00000000000009\\"></line> - <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node79\\" stroke=\\"#006887\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"369\\" x2=\\"369\\" y1=\\"244.99999999999977\\" y2=\\"294.99999999999994\\"></line> - <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node80\\" stroke=\\"#487900\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"369\\" x2=\\"369\\" y1=\\"327.99999999999994\\" y2=\\"436\\"></line> - <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node81\\" stroke=\\"#d84280\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"630\\" x2=\\"630\\" y1=\\"245.00000000000009\\" y2=\\"353\\"></line> - <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node82\\" stroke=\\"#3547de\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"630\\" x2=\\"630\\" y1=\\"386\\" y2=\\"436\\"></line> - <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node83\\" stroke=\\"#006887\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"892\\" x2=\\"892\\" y1=\\"245.18907251056908\\" y2=\\"295.1890725105691\\"></line> - <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node84\\" stroke=\\"#275600\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"892\\" x2=\\"892\\" y1=\\"386\\" y2=\\"436\\"></line> + <line class=\\"dag-node gl-transition-property-stroke gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\" id=\\"dag-node73\\" stroke=\\"#e17223\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"108\\" x2=\\"108\\" y1=\\"104\\" y2=\\"154.00000000000003\\"></line> + <line class=\\"dag-node gl-transition-property-stroke gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\" id=\\"dag-node74\\" stroke=\\"#83ab4a\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"369\\" x2=\\"369\\" y1=\\"104\\" y2=\\"154\\"></line> + <line class=\\"dag-node gl-transition-property-stroke gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\" id=\\"dag-node75\\" stroke=\\"#5772ff\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"108\\" x2=\\"108\\" y1=\\"187.00000000000003\\" y2=\\"237.00000000000003\\"></line> + <line class=\\"dag-node gl-transition-property-stroke gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\" id=\\"dag-node76\\" stroke=\\"#b24800\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"108\\" x2=\\"108\\" y1=\\"270\\" y2=\\"320.00000000000006\\"></line> + <line class=\\"dag-node gl-transition-property-stroke gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\" id=\\"dag-node77\\" stroke=\\"#25d2d2\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"108\\" x2=\\"108\\" y1=\\"353.00000000000006\\" y2=\\"403.0000000000001\\"></line> + <line class=\\"dag-node gl-transition-property-stroke gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\" id=\\"dag-node78\\" stroke=\\"#6f3500\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"630\\" x2=\\"630\\" y1=\\"104.0000000000002\\" y2=\\"212.00000000000009\\"></line> + <line class=\\"dag-node gl-transition-property-stroke gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\" id=\\"dag-node79\\" stroke=\\"#006887\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"369\\" x2=\\"369\\" y1=\\"244.99999999999977\\" y2=\\"294.99999999999994\\"></line> + <line class=\\"dag-node gl-transition-property-stroke gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\" id=\\"dag-node80\\" stroke=\\"#487900\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"369\\" x2=\\"369\\" y1=\\"327.99999999999994\\" y2=\\"436\\"></line> + <line class=\\"dag-node gl-transition-property-stroke gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\" id=\\"dag-node81\\" stroke=\\"#d84280\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"630\\" x2=\\"630\\" y1=\\"245.00000000000009\\" y2=\\"353\\"></line> + <line class=\\"dag-node gl-transition-property-stroke gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\" id=\\"dag-node82\\" stroke=\\"#3547de\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"630\\" x2=\\"630\\" y1=\\"386\\" y2=\\"436\\"></line> + <line class=\\"dag-node gl-transition-property-stroke gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\" id=\\"dag-node83\\" stroke=\\"#006887\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"892\\" x2=\\"892\\" y1=\\"245.18907251056908\\" y2=\\"295.1890725105691\\"></line> + <line class=\\"dag-node gl-transition-property-stroke gl-cursor-pointer gl-transition-duration-slow gl-transition-timing-function-ease\\" id=\\"dag-node84\\" stroke=\\"#275600\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"892\\" x2=\\"892\\" y1=\\"386\\" y2=\\"436\\"></line> </g> <g class=\\"gl-font-sm\\"> <foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"58.00000000000003px\\" width=\\"84\\" x=\\"8\\" y=\\"100\\" class=\\"gl-overflow-visible\\"> diff --git a/spec/frontend/pipelines/components/dag/dag_annotations_spec.js b/spec/frontend/pipelines/components/dag/dag_annotations_spec.js new file mode 100644 index 00000000000..5747c91bee8 --- /dev/null +++ b/spec/frontend/pipelines/components/dag/dag_annotations_spec.js @@ -0,0 +1,112 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import DagAnnotations from '~/pipelines/components/dag/dag_annotations.vue'; +import { singleNote, multiNote } from './mock_data'; + +describe('The DAG annotations', () => { + let wrapper; + + const getColorBlock = () => wrapper.find('[data-testid="dag-color-block"]'); + const getAllColorBlocks = () => wrapper.findAll('[data-testid="dag-color-block"]'); + const getTextBlock = () => wrapper.find('[data-testid="dag-note-text"]'); + const getAllTextBlocks = () => wrapper.findAll('[data-testid="dag-note-text"]'); + const getToggleButton = () => wrapper.find(GlButton); + + const createComponent = (propsData = {}, method = shallowMount) => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = method(DagAnnotations, { + propsData, + data() { + return { + showList: true, + }; + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when there is one annotation', () => { + const currentNote = singleNote['dag-link103']; + + beforeEach(() => { + createComponent({ annotations: singleNote }); + }); + + it('displays the color block', () => { + expect(getColorBlock().exists()).toBe(true); + }); + + it('displays the text block', () => { + expect(getTextBlock().exists()).toBe(true); + expect(getTextBlock().text()).toBe(`${currentNote.source.name} → ${currentNote.target.name}`); + }); + + it('does not display the list toggle link', () => { + expect(getToggleButton().exists()).toBe(false); + }); + }); + + describe('when there are multiple annoataions', () => { + beforeEach(() => { + createComponent({ annotations: multiNote }); + }); + + it('displays a color block for each link', () => { + expect(getAllColorBlocks().length).toBe(Object.keys(multiNote).length); + }); + + it('displays a text block for each link', () => { + expect(getAllTextBlocks().length).toBe(Object.keys(multiNote).length); + + Object.values(multiNote).forEach((item, idx) => { + expect( + getAllTextBlocks() + .at(idx) + .text(), + ).toBe(`${item.source.name} → ${item.target.name}`); + }); + }); + + it('displays the list toggle link', () => { + expect(getToggleButton().exists()).toBe(true); + expect(getToggleButton().text()).toBe('Hide list'); + }); + }); + + describe('the list toggle', () => { + beforeEach(() => { + createComponent({ annotations: multiNote }, mount); + }); + + describe('clicking hide', () => { + it('hides listed items and changes text to show', () => { + expect(getAllTextBlocks().length).toBe(Object.keys(multiNote).length); + expect(getToggleButton().text()).toBe('Hide list'); + getToggleButton().trigger('click'); + return wrapper.vm.$nextTick().then(() => { + expect(getAllTextBlocks().length).toBe(0); + expect(getToggleButton().text()).toBe('Show list'); + }); + }); + }); + + describe('clicking show', () => { + it('shows listed items and changes text to hide', () => { + getToggleButton().trigger('click'); + getToggleButton().trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(getAllTextBlocks().length).toBe(Object.keys(multiNote).length); + expect(getToggleButton().text()).toBe('Hide list'); + }); + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/components/dag/dag_graph_spec.js b/spec/frontend/pipelines/components/dag/dag_graph_spec.js index 017461dfb84..e312791b01f 100644 --- a/spec/frontend/pipelines/components/dag/dag_graph_spec.js +++ b/spec/frontend/pipelines/components/dag/dag_graph_spec.js @@ -1,4 +1,4 @@ -import { mount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import DagGraph from '~/pipelines/components/dag/dag_graph.vue'; import { IS_HIGHLIGHTED, LINK_SELECTOR, NODE_SELECTOR } from '~/pipelines/components/dag/constants'; import { highlightIn, highlightOut } from '~/pipelines/components/dag/interactions'; @@ -19,7 +19,7 @@ describe('The DAG graph', () => { wrapper.destroy(); } - wrapper = mount(DagGraph, { + wrapper = shallowMount(DagGraph, { attachToDocument: true, propsData, data() { diff --git a/spec/frontend/pipelines/components/dag/dag_spec.js b/spec/frontend/pipelines/components/dag/dag_spec.js index 666b4cfaa2f..7dea6d819b9 100644 --- a/spec/frontend/pipelines/components/dag/dag_spec.js +++ b/spec/frontend/pipelines/components/dag/dag_spec.js @@ -2,17 +2,28 @@ import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import waitForPromises from 'helpers/wait_for_promises'; -import { GlAlert } from '@gitlab/ui'; +import { GlAlert, GlEmptyState } from '@gitlab/ui'; import Dag from '~/pipelines/components/dag/dag.vue'; import DagGraph from '~/pipelines/components/dag/dag_graph.vue'; +import DagAnnotations from '~/pipelines/components/dag/dag_annotations.vue'; import { + ADD_NOTE, + REMOVE_NOTE, + REPLACE_NOTES, DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA, } from '~/pipelines/components/dag//constants'; -import { mockBaseData, tooSmallGraph, unparseableGraph } from './mock_data'; +import { + mockBaseData, + tooSmallGraph, + unparseableGraph, + graphWithoutDependencies, + singleNote, + multiNote, +} from './mock_data'; describe('Pipeline DAG graph wrapper', () => { let wrapper; @@ -20,7 +31,9 @@ describe('Pipeline DAG graph wrapper', () => { const getAlert = () => wrapper.find(GlAlert); const getAllAlerts = () => wrapper.findAll(GlAlert); const getGraph = () => wrapper.find(DagGraph); + const getNotes = () => wrapper.find(DagAnnotations); const getErrorText = type => wrapper.vm.$options.errorTexts[type]; + const getEmptyState = () => wrapper.find(GlEmptyState); const dataPath = '/root/test/pipelines/90/dag.json'; @@ -30,7 +43,11 @@ describe('Pipeline DAG graph wrapper', () => { } wrapper = method(Dag, { - propsData, + propsData: { + emptySvgPath: '/my-svg', + dagDocPath: '/my-doc', + ...propsData, + }, data() { return { showFailureAlert: false, @@ -59,79 +76,153 @@ describe('Pipeline DAG graph wrapper', () => { expect(getAlert().text()).toBe(getErrorText(DEFAULT)); expect(getGraph().exists()).toBe(false); }); + + it('does not render the empty state', () => { + expect(getEmptyState().exists()).toBe(false); + }); }); describe('when there is a dataUrl', () => { describe('but the data fetch fails', () => { - beforeEach(() => { + beforeEach(async () => { mock.onGet(dataPath).replyOnce(500); createComponent({ graphUrl: dataPath }); + + await wrapper.vm.$nextTick(); + + return waitForPromises(); }); it('shows the LOAD_FAILURE alert and not the graph', () => { - return wrapper.vm - .$nextTick() - .then(waitForPromises) - .then(() => { - expect(getAlert().exists()).toBe(true); - expect(getAlert().text()).toBe(getErrorText(LOAD_FAILURE)); - expect(getGraph().exists()).toBe(false); - }); + expect(getAlert().exists()).toBe(true); + expect(getAlert().text()).toBe(getErrorText(LOAD_FAILURE)); + expect(getGraph().exists()).toBe(false); + }); + + it('does not render the empty state', () => { + expect(getEmptyState().exists()).toBe(false); }); }); describe('the data fetch succeeds but the parse fails', () => { - beforeEach(() => { + beforeEach(async () => { mock.onGet(dataPath).replyOnce(200, unparseableGraph); createComponent({ graphUrl: dataPath }); + + await wrapper.vm.$nextTick(); + + return waitForPromises(); }); it('shows the PARSE_FAILURE alert and not the graph', () => { - return wrapper.vm - .$nextTick() - .then(waitForPromises) - .then(() => { - expect(getAlert().exists()).toBe(true); - expect(getAlert().text()).toBe(getErrorText(PARSE_FAILURE)); - expect(getGraph().exists()).toBe(false); - }); + expect(getAlert().exists()).toBe(true); + expect(getAlert().text()).toBe(getErrorText(PARSE_FAILURE)); + expect(getGraph().exists()).toBe(false); + }); + + it('does not render the empty state', () => { + expect(getEmptyState().exists()).toBe(false); }); }); describe('and the data fetch and parse succeeds', () => { - beforeEach(() => { + beforeEach(async () => { mock.onGet(dataPath).replyOnce(200, mockBaseData); createComponent({ graphUrl: dataPath }, mount); + + await wrapper.vm.$nextTick(); + + return waitForPromises(); }); - it('shows the graph and not the beta alert', () => { - return wrapper.vm - .$nextTick() - .then(waitForPromises) - .then(() => { - expect(getAllAlerts().length).toBe(1); - expect(getAlert().text()).toContain('This feature is currently in beta.'); - expect(getGraph().exists()).toBe(true); - }); + it('shows the graph and the beta alert', () => { + expect(getAllAlerts().length).toBe(1); + expect(getAlert().text()).toContain('This feature is currently in beta.'); + expect(getGraph().exists()).toBe(true); + }); + + it('does not render the empty state', () => { + expect(getEmptyState().exists()).toBe(false); }); }); describe('the data fetch and parse succeeds, but the resulting graph is too small', () => { - beforeEach(() => { + beforeEach(async () => { mock.onGet(dataPath).replyOnce(200, tooSmallGraph); createComponent({ graphUrl: dataPath }); + + await wrapper.vm.$nextTick(); + + return waitForPromises(); }); it('shows the UNSUPPORTED_DATA alert and not the graph', () => { - return wrapper.vm - .$nextTick() - .then(waitForPromises) - .then(() => { - expect(getAlert().exists()).toBe(true); - expect(getAlert().text()).toBe(getErrorText(UNSUPPORTED_DATA)); - expect(getGraph().exists()).toBe(false); - }); + expect(getAlert().exists()).toBe(true); + expect(getAlert().text()).toBe(getErrorText(UNSUPPORTED_DATA)); + expect(getGraph().exists()).toBe(false); + }); + + it('does not show the empty dag graph state', () => { + expect(getEmptyState().exists()).toBe(false); + }); + }); + + describe('the data fetch succeeds but the returned data is empty', () => { + beforeEach(async () => { + mock.onGet(dataPath).replyOnce(200, graphWithoutDependencies); + createComponent({ graphUrl: dataPath }, mount); + + await wrapper.vm.$nextTick(); + + return waitForPromises(); + }); + + it('does not render an error alert or the graph', () => { + expect(getAllAlerts().length).toBe(1); + expect(getAlert().text()).toContain('This feature is currently in beta.'); + expect(getGraph().exists()).toBe(false); }); + + it('shows the empty dag graph state', () => { + expect(getEmptyState().exists()).toBe(true); + }); + }); + }); + + describe('annotations', () => { + beforeEach(async () => { + mock.onGet(dataPath).replyOnce(200, mockBaseData); + createComponent({ graphUrl: dataPath }, mount); + + await wrapper.vm.$nextTick(); + + return waitForPromises(); + }); + + it('toggles on link mouseover and mouseout', async () => { + const currentNote = singleNote['dag-link103']; + + expect(getNotes().exists()).toBe(false); + + getGraph().vm.$emit('update-annotation', { type: ADD_NOTE, data: currentNote }); + await wrapper.vm.$nextTick(); + expect(getNotes().exists()).toBe(true); + + getGraph().vm.$emit('update-annotation', { type: REMOVE_NOTE, data: currentNote }); + await wrapper.vm.$nextTick(); + expect(getNotes().exists()).toBe(false); + }); + + it('toggles on node and link click', async () => { + expect(getNotes().exists()).toBe(false); + + getGraph().vm.$emit('update-annotation', { type: REPLACE_NOTES, data: multiNote }); + await wrapper.vm.$nextTick(); + expect(getNotes().exists()).toBe(true); + + getGraph().vm.$emit('update-annotation', { type: REPLACE_NOTES, data: {} }); + await wrapper.vm.$nextTick(); + expect(getNotes().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/pipelines/components/dag/mock_data.js b/spec/frontend/pipelines/components/dag/mock_data.js index 5de8697170a..3b39b9cd21c 100644 --- a/spec/frontend/pipelines/components/dag/mock_data.js +++ b/spec/frontend/pipelines/components/dag/mock_data.js @@ -83,6 +83,46 @@ export const tooSmallGraph = { ], }; +export const graphWithoutDependencies = { + stages: [ + { + name: 'test', + groups: [ + { + name: 'jest', + size: 2, + jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }], + }, + { + name: 'rspec', + size: 1, + jobs: [{ name: 'rspec' }], + }, + ], + }, + { + name: 'fixtures', + groups: [ + { + name: 'frontend fixtures', + size: 1, + jobs: [{ name: 'frontend fixtures' }], + }, + ], + }, + { + name: 'un-needed', + groups: [ + { + name: 'un-needed', + size: 1, + jobs: [{ name: 'un-needed' }], + }, + ], + }, + ], +}; + export const unparseableGraph = [ { name: 'test', @@ -388,3 +428,43 @@ export const parsedData = { }, ], }; + +export const singleNote = { + 'dag-link103': { + uid: 'dag-link103', + source: { + name: 'canary_a', + color: '#b31756', + }, + target: { + name: 'production_a', + color: '#b24800', + }, + }, +}; + +export const multiNote = { + ...singleNote, + 'dag-link104': { + uid: 'dag-link104', + source: { + name: 'build_a', + color: '#e17223', + }, + target: { + name: 'test_c', + color: '#006887', + }, + }, + 'dag-link105': { + uid: 'dag-link105', + source: { + name: 'test_c', + color: '#006887', + }, + target: { + name: 'post_test_c', + color: '#3547de', + }, + }, +}; diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js index bdc807fcbfe..add7b56845e 100644 --- a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js +++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js @@ -2,7 +2,7 @@ import Api from '~/api'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; -import PipelinesFilteredSearch from '~/pipelines/components/pipelines_filtered_search.vue'; +import PipelinesFilteredSearch from '~/pipelines/components/pipelines_list/pipelines_filtered_search.vue'; import { users, mockSearch, branches, tags } from '../mock_data'; import { GlFilteredSearch } from '@gitlab/ui'; diff --git a/spec/frontend/pipelines/empty_state_spec.js b/spec/frontend/pipelines/empty_state_spec.js index f12950b8fce..79356664834 100644 --- a/spec/frontend/pipelines/empty_state_spec.js +++ b/spec/frontend/pipelines/empty_state_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import emptyStateComp from '~/pipelines/components/empty_state.vue'; +import emptyStateComp from '~/pipelines/components/pipelines_list/empty_state.vue'; import mountComponent from '../helpers/vue_mount_component_helper'; describe('Pipelines Empty State', () => { diff --git a/spec/frontend/pipelines/graph/job_item_spec.js b/spec/frontend/pipelines/graph/job_item_spec.js index da777466e3e..2c5e7a1f6e9 100644 --- a/spec/frontend/pipelines/graph/job_item_spec.js +++ b/spec/frontend/pipelines/graph/job_item_spec.js @@ -5,6 +5,8 @@ import JobItem from '~/pipelines/components/graph/job_item.vue'; describe('pipeline graph job item', () => { let wrapper; + const findJobWithoutLink = () => wrapper.find('[data-testid="job-without-link"]'); + const createWrapper = propsData => { wrapper = mount(JobItem, { propsData, @@ -57,7 +59,7 @@ describe('pipeline graph job item', () => { }); describe('name without link', () => { - it('it should render status and name', () => { + beforeEach(() => { createWrapper({ job: { id: 4257, @@ -71,13 +73,22 @@ describe('pipeline graph job item', () => { has_details: false, }, }, + cssClassJobName: 'css-class-job-name', + jobHovered: 'test', }); + }); + it('it should render status and name', () => { expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true); expect(wrapper.find('a').exists()).toBe(false); expect(trimText(wrapper.find('.ci-status-text').text())).toEqual(mockJob.name); }); + + it('should apply hover class and provided class name', () => { + expect(findJobWithoutLink().classes()).toContain('gl-inset-border-1-blue-500'); + expect(findJobWithoutLink().classes()).toContain('css-class-job-name'); + }); }); describe('action icon', () => { diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js index cf78aa3ef71..133d5695afb 100644 --- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js @@ -11,7 +11,10 @@ const invalidTriggeredPipelineId = mockPipeline.project.id + 5; describe('Linked pipeline', () => { let wrapper; + const findButton = () => wrapper.find('button'); + const findPipelineLabel = () => wrapper.find('[data-testid="downstream-pipeline-label"]'); + const findLinkedPipeline = () => wrapper.find({ ref: 'linkedPipeline' }); const createWrapper = propsData => { wrapper = mount(LinkedPipelineComponent, { @@ -69,6 +72,8 @@ describe('Linked pipeline', () => { it('should correctly compute the tooltip text', () => { expect(wrapper.vm.tooltipText).toContain(mockPipeline.project.name); expect(wrapper.vm.tooltipText).toContain(mockPipeline.details.status.label); + expect(wrapper.vm.tooltipText).toContain(mockPipeline.source_job.name); + expect(wrapper.vm.tooltipText).toContain(mockPipeline.id); }); it('should render the tooltip text as the title attribute', () => { @@ -83,9 +88,8 @@ describe('Linked pipeline', () => { expect(wrapper.find('.js-linked-pipeline-loading').exists()).toBe(false); }); - it('should not display child label when pipeline project id is not the same as triggered pipeline project id', () => { - const labelContainer = wrapper.find('.parent-child-label-container'); - expect(labelContainer.exists()).toBe(false); + it('should display multi-project label when pipeline project id is not the same as triggered pipeline project id', () => { + expect(findPipelineLabel().text()).toBe('Multi-project'); }); }); @@ -103,17 +107,17 @@ describe('Linked pipeline', () => { it('parent/child label container should exist', () => { createWrapper(downstreamProps); - expect(wrapper.find('.parent-child-label-container').exists()).toBe(true); + expect(findPipelineLabel().exists()).toBe(true); }); it('should display child label when pipeline project id is the same as triggered pipeline project id', () => { createWrapper(downstreamProps); - expect(wrapper.find('.parent-child-label-container').text()).toContain('Child'); + expect(findPipelineLabel().exists()).toBe(true); }); it('should display parent label when pipeline project id is the same as triggered_by pipeline project id', () => { createWrapper(upstreamProps); - expect(wrapper.find('.parent-child-label-container').text()).toContain('Parent'); + expect(findPipelineLabel().exists()).toBe(true); }); }); @@ -133,7 +137,7 @@ describe('Linked pipeline', () => { }); }); - describe('on click', () => { + describe('on click/hover', () => { const props = { pipeline: mockPipeline, projectId: validTriggeredPipelineId, @@ -160,5 +164,15 @@ describe('Linked pipeline', () => { 'js-linked-pipeline-34993051', ]); }); + + it('should emit downstreamHovered with job name on mouseover', () => { + findLinkedPipeline().trigger('mouseover'); + expect(wrapper.emitted().downstreamHovered).toStrictEqual([['trigger_job']]); + }); + + it('should emit downstreamHovered with empty string on mouseleave', () => { + findLinkedPipeline().trigger('mouseleave'); + expect(wrapper.emitted().downstreamHovered).toStrictEqual([['']]); + }); }); }); diff --git a/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js b/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js index 3e9c0814403..5756a666ff3 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js @@ -14,6 +14,9 @@ export default { active: false, coverage: null, source: 'push', + source_job: { + name: 'trigger_job', + }, created_at: '2018-06-05T11:31:30.452Z', updated_at: '2018-10-31T16:35:31.305Z', path: '/gitlab-org/gitlab-runner/pipelines/23211253', @@ -381,6 +384,9 @@ export default { active: false, coverage: null, source: 'pipeline', + source_job: { + name: 'trigger_job', + }, path: '/gitlab-com/gitlab-docs/pipelines/34993051', details: { status: { @@ -889,6 +895,9 @@ export default { active: false, coverage: null, source: 'pipeline', + source_job: { + name: 'trigger_job', + }, path: '/gitlab-com/gitlab-docs/pipelines/34993051', details: { status: { @@ -1402,6 +1411,9 @@ export default { active: false, coverage: null, source: 'pipeline', + source_job: { + name: 'trigger_job', + }, path: '/gitlab-com/gitlab-docs/pipelines/34993051', details: { status: { @@ -1912,6 +1924,9 @@ export default { active: false, coverage: null, source: 'pipeline', + source_job: { + name: 'trigger_job', + }, path: '/gitlab-com/gitlab-docs/pipelines/34993051', details: { status: { @@ -2412,6 +2427,9 @@ export default { active: false, coverage: null, source: 'push', + source_job: { + name: 'trigger_job', + }, created_at: '2019-01-06T17:48:37.599Z', updated_at: '2019-01-06T17:48:38.371Z', path: '/h5bp/html5-boilerplate/pipelines/26', @@ -3743,6 +3761,9 @@ export default { active: false, coverage: null, source: 'push', + source_job: { + name: 'trigger_job', + }, path: '/gitlab-org/gitlab-test/pipelines/4', details: { status: { diff --git a/spec/frontend/pipelines/nav_controls_spec.js b/spec/frontend/pipelines/nav_controls_spec.js index 6d28da0ea2a..139d53881c8 100644 --- a/spec/frontend/pipelines/nav_controls_spec.js +++ b/spec/frontend/pipelines/nav_controls_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import navControlsComp from '~/pipelines/components/nav_controls.vue'; +import navControlsComp from '~/pipelines/components/pipelines_list/nav_controls.vue'; import mountComponent from '../helpers/vue_mount_component_helper'; describe('Pipelines Nav Controls', () => { diff --git a/spec/frontend/pipelines/pipeline_triggerer_spec.js b/spec/frontend/pipelines/pipeline_triggerer_spec.js index a8eec274487..6fd9a143d82 100644 --- a/spec/frontend/pipelines/pipeline_triggerer_spec.js +++ b/spec/frontend/pipelines/pipeline_triggerer_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import pipelineTriggerer from '~/pipelines/components/pipeline_triggerer.vue'; +import pipelineTriggerer from '~/pipelines/components/pipelines_list/pipeline_triggerer.vue'; describe('Pipelines Triggerer', () => { let wrapper; diff --git a/spec/frontend/pipelines/pipeline_url_spec.js b/spec/frontend/pipelines/pipeline_url_spec.js index 70b94f2c8e1..0bcc3f96f7c 100644 --- a/spec/frontend/pipelines/pipeline_url_spec.js +++ b/spec/frontend/pipelines/pipeline_url_spec.js @@ -1,108 +1,140 @@ import $ from 'jquery'; import { trimText } from 'helpers/text_helper'; import { shallowMount } from '@vue/test-utils'; -import PipelineUrlComponent from '~/pipelines/components/pipeline_url.vue'; +import PipelineUrlComponent from '~/pipelines/components/pipelines_list/pipeline_url.vue'; $.fn.popover = () => {}; describe('Pipeline Url Component', () => { let wrapper; + const findPipelineUrlLink = () => wrapper.find('[data-testid="pipeline-url-link"]'); + const findScheduledTag = () => wrapper.find('[data-testid="pipeline-url-scheduled"]'); + const findLatestTag = () => wrapper.find('[data-testid="pipeline-url-latest"]'); + const findYamlTag = () => wrapper.find('[data-testid="pipeline-url-yaml"]'); + const findFailureTag = () => wrapper.find('[data-testid="pipeline-url-failure"]'); + const findAutoDevopsTag = () => wrapper.find('[data-testid="pipeline-url-autodevops"]'); + const findStuckTag = () => wrapper.find('[data-testid="pipeline-url-stuck"]'); + const findDetachedTag = () => wrapper.find('[data-testid="pipeline-url-detached"]'); + + const defaultProps = { + pipeline: { + id: 1, + path: 'foo', + flags: {}, + }, + autoDevopsHelpPath: 'foo', + pipelineScheduleUrl: 'foo', + }; + const createComponent = props => { wrapper = shallowMount(PipelineUrlComponent, { - propsData: props, + propsData: { ...defaultProps, ...props }, }); }; afterEach(() => { wrapper.destroy(); + wrapper = null; }); it('should render a table cell', () => { + createComponent(); + + expect(wrapper.attributes('class')).toContain('table-section'); + }); + + it('should render a link the provided path and id', () => { + createComponent(); + + expect(findPipelineUrlLink().attributes('href')).toBe('foo'); + + expect(findPipelineUrlLink().text()).toBe('#1'); + }); + + it('should render the stuck tag when flag is provided', () => { createComponent({ pipeline: { - id: 1, - path: 'foo', - flags: {}, + flags: { + stuck: true, + }, }, - autoDevopsHelpPath: 'foo', }); - expect(wrapper.attributes('class')).toContain('table-section'); + expect(findStuckTag().text()).toContain('stuck'); }); - it('should render a link the provided path and id', () => { + it('should render latest tag when flag is provided', () => { createComponent({ pipeline: { - id: 1, - path: 'foo', - flags: {}, + flags: { + latest: true, + }, }, - autoDevopsHelpPath: 'foo', }); - expect(wrapper.find('.js-pipeline-url-link').attributes('href')).toBe('foo'); - - expect(wrapper.find('.js-pipeline-url-link span').text()).toBe('#1'); + expect(findLatestTag().text()).toContain('latest'); }); - it('should render latest, yaml invalid, merge request, and stuck flags when provided', () => { + it('should render a yaml badge when it is invalid', () => { createComponent({ pipeline: { - id: 1, - path: 'foo', flags: { - latest: true, yaml_errors: true, - stuck: true, - merge_request_pipeline: true, - detached_merge_request_pipeline: true, }, }, - autoDevopsHelpPath: 'foo', }); - expect(wrapper.find('.js-pipeline-url-latest').text()).toContain('latest'); - - expect(wrapper.find('.js-pipeline-url-yaml').text()).toContain('yaml invalid'); + expect(findYamlTag().text()).toContain('yaml invalid'); + }); - expect(wrapper.find('.js-pipeline-url-stuck').text()).toContain('stuck'); + it('should render an autodevops badge when flag is provided', () => { + createComponent({ + pipeline: { + flags: { + auto_devops: true, + }, + }, + }); - expect(wrapper.find('.js-pipeline-url-detached').text()).toContain('detached'); + expect(trimText(findAutoDevopsTag().text())).toBe('Auto DevOps'); }); - it('should render a badge for autodevops', () => { + it('should render a detached badge when flag is provided', () => { createComponent({ pipeline: { - id: 1, - path: 'foo', flags: { - latest: true, - yaml_errors: true, - stuck: true, - auto_devops: true, + detached_merge_request_pipeline: true, }, }, - autoDevopsHelpPath: 'foo', }); - expect(trimText(wrapper.find('.js-pipeline-url-autodevops').text())).toEqual('Auto DevOps'); + expect(findDetachedTag().text()).toContain('detached'); }); it('should render error badge when pipeline has a failure reason set', () => { createComponent({ pipeline: { - id: 1, - path: 'foo', flags: { failure_reason: true, }, failure_reason: 'some reason', }, - autoDevopsHelpPath: 'foo', }); - expect(wrapper.find('.js-pipeline-url-failure').text()).toContain('error'); - expect(wrapper.find('.js-pipeline-url-failure').attributes('title')).toContain('some reason'); + expect(findFailureTag().text()).toContain('error'); + expect(findFailureTag().attributes('title')).toContain('some reason'); + }); + + it('should render scheduled badge when pipeline was triggered by a schedule', () => { + createComponent({ + pipeline: { + flags: {}, + source: 'schedule', + }, + }); + + expect(findScheduledTag().exists()).toBe(true); + expect(findScheduledTag().text()).toContain('Scheduled'); }); }); diff --git a/spec/frontend/pipelines/pipelines_actions_spec.js b/spec/frontend/pipelines/pipelines_actions_spec.js index 5e8d21660de..aef54d94974 100644 --- a/spec/frontend/pipelines/pipelines_actions_spec.js +++ b/spec/frontend/pipelines/pipelines_actions_spec.js @@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'spec/test_constants'; import axios from '~/lib/utils/axios_utils'; -import PipelinesActions from '~/pipelines/components/pipelines_actions.vue'; +import PipelinesActions from '~/pipelines/components/pipelines_list/pipelines_actions.vue'; import { GlDeprecatedButton } from '@gitlab/ui'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; import waitForPromises from 'helpers/wait_for_promises'; diff --git a/spec/frontend/pipelines/pipelines_artifacts_spec.js b/spec/frontend/pipelines/pipelines_artifacts_spec.js index a93cc8a62ab..512205c3fc3 100644 --- a/spec/frontend/pipelines/pipelines_artifacts_spec.js +++ b/spec/frontend/pipelines/pipelines_artifacts_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import PipelineArtifacts from '~/pipelines/components/pipelines_artifacts.vue'; +import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; import { GlLink } from '@gitlab/ui'; describe('Pipelines Artifacts dropdown', () => { diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index 0eeaef01a2d..66446b9aa1d 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import waitForPromises from 'helpers/wait_for_promises'; -import PipelinesComponent from '~/pipelines/components/pipelines.vue'; +import PipelinesComponent from '~/pipelines/components/pipelines_list/pipelines.vue'; import Store from '~/pipelines/stores/pipelines_store'; import { pipelineWithStages, stageReply, users, mockSearch, branches } from './mock_data'; import { RAW_TEXT_WARNING } from '~/pipelines/constants'; @@ -343,12 +343,8 @@ describe('Pipelines', () => { }); it('should render navigation tabs', () => { - expect(wrapper.find('.js-pipelines-tab-pending').text()).toContain('Pending'); - expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All'); - expect(wrapper.find('.js-pipelines-tab-running').text()).toContain('Running'); - expect(wrapper.find('.js-pipelines-tab-finished').text()).toContain('Finished'); expect(wrapper.find('.js-pipelines-tab-branches').text()).toContain('Branches'); @@ -452,8 +448,6 @@ describe('Pipelines', () => { it('returns default tabs', () => { expect(wrapper.vm.tabs).toEqual([ { name: 'All', scope: 'all', count: undefined, isActive: true }, - { name: 'Pending', scope: 'pending', count: undefined, isActive: false }, - { name: 'Running', scope: 'running', count: undefined, isActive: false }, { name: 'Finished', scope: 'finished', count: undefined, isActive: false }, { name: 'Branches', scope: 'branches', isActive: false }, { name: 'Tags', scope: 'tags', isActive: false }, @@ -462,11 +456,11 @@ describe('Pipelines', () => { }); describe('emptyTabMessage', () => { - it('returns message with scope', () => { - wrapper.vm.scope = 'pending'; + it('returns message with finished scope', () => { + wrapper.vm.scope = 'finished'; return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.emptyTabMessage).toEqual('There are currently no pending pipelines.'); + expect(wrapper.vm.emptyTabMessage).toEqual('There are currently no finished pipelines.'); }); }); diff --git a/spec/frontend/pipelines/pipelines_table_row_spec.js b/spec/frontend/pipelines/pipelines_table_row_spec.js index 3d564c8758c..9901f476f1b 100644 --- a/spec/frontend/pipelines/pipelines_table_row_spec.js +++ b/spec/frontend/pipelines/pipelines_table_row_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import PipelinesTableRowComponent from '~/pipelines/components/pipelines_table_row.vue'; +import PipelinesTableRowComponent from '~/pipelines/components/pipelines_list/pipelines_table_row.vue'; import eventHub from '~/pipelines/event_hub'; describe('Pipelines Table Row', () => { diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js index b0ab250dd16..c7d104bbde8 100644 --- a/spec/frontend/pipelines/pipelines_table_spec.js +++ b/spec/frontend/pipelines/pipelines_table_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import PipelinesTable from '~/pipelines/components/pipelines_table.vue'; +import PipelinesTable from '~/pipelines/components/pipelines_list/pipelines_table.vue'; describe('Pipelines Table', () => { let pipeline; diff --git a/spec/frontend/pipelines/stage_spec.js b/spec/frontend/pipelines/stage_spec.js index 6aa041bcb7f..547f8994ca5 100644 --- a/spec/frontend/pipelines/stage_spec.js +++ b/spec/frontend/pipelines/stage_spec.js @@ -1,7 +1,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; -import StageComponent from '~/pipelines/components/stage.vue'; +import StageComponent from '~/pipelines/components/pipelines_list/stage.vue'; import eventHub from '~/pipelines/event_hub'; import { stageReply } from './mock_data'; import waitForPromises from 'helpers/wait_for_promises'; diff --git a/spec/frontend/pipelines/test_reports/stores/actions_spec.js b/spec/frontend/pipelines/test_reports/stores/actions_spec.js index 56148361e0a..d4647c55a53 100644 --- a/spec/frontend/pipelines/test_reports/stores/actions_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/actions_spec.js @@ -14,31 +14,100 @@ describe('Actions TestReports Store', () => { let state; const testReports = getJSONFixture('pipelines/test_report.json'); + const summary = { total_count: 1 }; - const endpoint = `${TEST_HOST}/test_reports.json`; + const fullReportEndpoint = `${TEST_HOST}/test_reports.json`; + const summaryEndpoint = `${TEST_HOST}/test_reports/summary.json`; const defaultState = { - endpoint, + fullReportEndpoint, + summaryEndpoint, testReports: {}, - selectedSuite: {}, + selectedSuite: null, + useBuildSummaryReport: false, }; beforeEach(() => { mock = new MockAdapter(axios); - state = defaultState; + state = { ...defaultState }; }); afterEach(() => { mock.restore(); }); - describe('fetch reports', () => { + describe('fetch report summary', () => { beforeEach(() => { - mock.onGet(`${TEST_HOST}/test_reports.json`).replyOnce(200, testReports, {}); + mock.onGet(summaryEndpoint).replyOnce(200, summary, {}); + }); + + describe('when useBuildSummaryReport in state is true', () => { + it('sets testReports and shows tests', done => { + testAction( + actions.fetchSummary, + null, + { ...state, useBuildSummaryReport: true }, + [{ type: types.SET_SUMMARY, payload: summary }], + [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], + done, + ); + }); + + it('should create flash on API error', done => { + testAction( + actions.fetchSummary, + null, + { + summaryEndpoint: null, + useBuildSummaryReport: true, + }, + [], + [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], + () => { + expect(createFlash).toHaveBeenCalled(); + done(); + }, + ); + }); + }); + + describe('when useBuildSummaryReport in state is false', () => { + it('sets testReports and shows tests', done => { + testAction( + actions.fetchSummary, + null, + state, + [{ type: types.SET_SUMMARY, payload: summary }], + [], + done, + ); + }); + + it('should create flash on API error', done => { + testAction( + actions.fetchSummary, + null, + { + summaryEndpoint: null, + }, + [], + [], + () => { + expect(createFlash).toHaveBeenCalled(); + done(); + }, + ); + }); + }); + }); + + describe('fetch full report', () => { + beforeEach(() => { + mock.onGet(fullReportEndpoint).replyOnce(200, testReports, {}); }); it('sets testReports and shows tests', done => { testAction( - actions.fetchReports, + actions.fetchFullReport, null, state, [{ type: types.SET_REPORTS, payload: testReports }], @@ -49,10 +118,10 @@ describe('Actions TestReports Store', () => { it('should create flash on API error', done => { testAction( - actions.fetchReports, + actions.fetchFullReport, null, { - endpoint: null, + fullReportEndpoint: null, }, [], [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], @@ -64,28 +133,28 @@ describe('Actions TestReports Store', () => { }); }); - describe('set selected suite', () => { - const selectedSuite = testReports.test_suites[0]; + describe('set selected suite index', () => { + it('sets selectedSuiteIndex', done => { + const selectedSuiteIndex = 0; - it('sets selectedSuite', done => { testAction( - actions.setSelectedSuite, - selectedSuite, - state, - [{ type: types.SET_SELECTED_SUITE, payload: selectedSuite }], + actions.setSelectedSuiteIndex, + selectedSuiteIndex, + { ...state, hasFullReport: true }, + [{ type: types.SET_SELECTED_SUITE_INDEX, payload: selectedSuiteIndex }], [], done, ); }); }); - describe('remove selected suite', () => { - it('sets selectedSuite to {}', done => { + describe('remove selected suite index', () => { + it('sets selectedSuiteIndex to null', done => { testAction( - actions.removeSelectedSuite, + actions.removeSelectedSuiteIndex, {}, state, - [{ type: types.SET_SELECTED_SUITE, payload: {} }], + [{ type: types.SET_SELECTED_SUITE_INDEX, payload: null }], [], done, ); diff --git a/spec/frontend/pipelines/test_reports/stores/getters_spec.js b/spec/frontend/pipelines/test_reports/stores/getters_spec.js index 011a7e68908..ca9ebb54138 100644 --- a/spec/frontend/pipelines/test_reports/stores/getters_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/getters_spec.js @@ -9,12 +9,12 @@ describe('Getters TestReports Store', () => { const defaultState = { testReports, - selectedSuite: testReports.test_suites[0], + selectedSuiteIndex: 0, }; const emptyState = { testReports: {}, - selectedSuite: {}, + selectedSuite: null, }; beforeEach(() => { @@ -47,6 +47,17 @@ describe('Getters TestReports Store', () => { }); }); + describe('getSelectedSuite', () => { + it('should return the selected suite', () => { + setupState(); + + const selectedSuite = getters.getSelectedSuite(state); + const expected = testReports.test_suites[state.selectedSuiteIndex]; + + expect(selectedSuite).toEqual(expected); + }); + }); + describe('getSuiteTests', () => { it('should return the test cases inside the suite', () => { setupState(); diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js index a0eb93c4e6b..f4cc5c4bc5d 100644 --- a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js @@ -10,21 +10,13 @@ describe('Mutations TestReports Store', () => { const defaultState = { endpoint: '', testReports: {}, - selectedSuite: {}, + selectedSuite: null, isLoading: false, + hasFullReport: false, }; beforeEach(() => { - mockState = defaultState; - }); - - describe('set endpoint', () => { - it('should set endpoint', () => { - const expectedState = { ...mockState, endpoint: 'foo' }; - mutations[types.SET_ENDPOINT](mockState, 'foo'); - - expect(mockState.endpoint).toEqual(expectedState.endpoint); - }); + mockState = { ...defaultState }; }); describe('set reports', () => { @@ -33,15 +25,25 @@ describe('Mutations TestReports Store', () => { mutations[types.SET_REPORTS](mockState, testReports); expect(mockState.testReports).toEqual(expectedState.testReports); + expect(mockState.hasFullReport).toBe(true); + }); + }); + + describe('set selected suite index', () => { + it('should set selectedSuiteIndex', () => { + const selectedSuiteIndex = 0; + mutations[types.SET_SELECTED_SUITE_INDEX](mockState, selectedSuiteIndex); + + expect(mockState.selectedSuiteIndex).toEqual(selectedSuiteIndex); }); }); - describe('set selected suite', () => { - it('should set selectedSuite', () => { - const selectedSuite = testReports.test_suites[0]; - mutations[types.SET_SELECTED_SUITE](mockState, selectedSuite); + describe('set summary', () => { + it('should set summary', () => { + const summary = { total_count: 1 }; + mutations[types.SET_SUMMARY](mockState, summary); - expect(mockState.selectedSuite).toEqual(selectedSuite); + expect(mockState.testReports).toEqual(summary); }); }); diff --git a/spec/frontend/pipelines/test_reports/test_reports_spec.js b/spec/frontend/pipelines/test_reports/test_reports_spec.js index cc86ba6d46d..ef0bcffabe3 100644 --- a/spec/frontend/pipelines/test_reports/test_reports_spec.js +++ b/spec/frontend/pipelines/test_reports/test_reports_spec.js @@ -1,8 +1,13 @@ import Vuex from 'vuex'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; import { getJSONFixture } from 'helpers/fixtures'; import TestReports from '~/pipelines/components/test_reports/test_reports.vue'; -import * as actions from '~/pipelines/stores/test_reports/actions'; +import TestSummary from '~/pipelines/components/test_reports/test_summary.vue'; +import TestSummaryTable from '~/pipelines/components/test_reports/test_summary_table.vue'; +import * as getters from '~/pipelines/stores/test_reports/getters'; + +const localVue = createLocalVue(); +localVue.use(Vuex); describe('Test reports app', () => { let wrapper; @@ -13,20 +18,31 @@ describe('Test reports app', () => { const loadingSpinner = () => wrapper.find('.js-loading-spinner'); const testsDetail = () => wrapper.find('.js-tests-detail'); const noTestsToShow = () => wrapper.find('.js-no-tests-to-show'); + const testSummary = () => wrapper.find(TestSummary); + const testSummaryTable = () => wrapper.find(TestSummaryTable); + + const actionSpies = { + fetchFullReport: jest.fn(), + fetchSummary: jest.fn(), + setSelectedSuiteIndex: jest.fn(), + removeSelectedSuiteIndex: jest.fn(), + }; const createComponent = (state = {}) => { store = new Vuex.Store({ state: { isLoading: false, - selectedSuite: {}, + selectedSuiteIndex: null, testReports, ...state, }, - actions, + actions: actionSpies, + getters, }); wrapper = shallowMount(TestReports, { store, + localVue, }); }; @@ -34,6 +50,16 @@ describe('Test reports app', () => { wrapper.destroy(); }); + describe('when component is created', () => { + beforeEach(() => { + createComponent(); + }); + + it('should call fetchSummary', () => { + expect(actionSpies.fetchSummary).toHaveBeenCalled(); + }); + }); + describe('when loading', () => { beforeEach(() => createComponent({ isLoading: true })); @@ -63,4 +89,41 @@ describe('Test reports app', () => { expect(wrapper.vm.showTests).toBeTruthy(); }); }); + + describe('when a suite is clicked', () => { + describe('when the full test report has already been received', () => { + beforeEach(() => { + createComponent({ hasFullReport: true }); + testSummaryTable().vm.$emit('row-click', 0); + }); + + it('should only call setSelectedSuiteIndex', () => { + expect(actionSpies.setSelectedSuiteIndex).toHaveBeenCalled(); + expect(actionSpies.fetchFullReport).not.toHaveBeenCalled(); + }); + }); + + describe('when the full test report has not been received', () => { + beforeEach(() => { + createComponent({ hasFullReport: false }); + testSummaryTable().vm.$emit('row-click', 0); + }); + + it('should call setSelectedSuiteIndex and fetchFullReport', () => { + expect(actionSpies.setSelectedSuiteIndex).toHaveBeenCalled(); + expect(actionSpies.fetchFullReport).toHaveBeenCalled(); + }); + }); + }); + + describe('when clicking back to summary', () => { + beforeEach(() => { + createComponent({ selectedSuiteIndex: 0 }); + testSummary().vm.$emit('on-back-click'); + }); + + it('should call removeSelectedSuiteIndex', () => { + expect(actionSpies.removeSelectedSuiteIndex).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js index a5b093cf769..65bffe7039a 100644 --- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js +++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js @@ -1,11 +1,14 @@ import Vuex from 'vuex'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; import { getJSONFixture } from 'helpers/fixtures'; import SuiteTable from '~/pipelines/components/test_reports/test_suite_table.vue'; import * as getters from '~/pipelines/stores/test_reports/getters'; import { TestStatus } from '~/pipelines/constants'; import skippedTestCases from './mock_data'; +const localVue = createLocalVue(); +localVue.use(Vuex); + describe('Test reports suite table', () => { let wrapper; let store; @@ -25,13 +28,17 @@ describe('Test reports suite table', () => { const createComponent = (suite = testSuite) => { store = new Vuex.Store({ state: { - selectedSuite: suite, + testReports: { + test_suites: [suite], + }, + selectedSuiteIndex: 0, }, getters, }); wrapper = shallowMount(SuiteTable, { store, + localVue, }); }; diff --git a/spec/frontend/pipelines/test_reports/test_summary_spec.js b/spec/frontend/pipelines/test_reports/test_summary_spec.js index 8f041e46472..79be6c168cf 100644 --- a/spec/frontend/pipelines/test_reports/test_summary_spec.js +++ b/spec/frontend/pipelines/test_reports/test_summary_spec.js @@ -60,7 +60,7 @@ describe('Test reports summary', () => { }); it('displays the correct total', () => { - expect(totalTests().text()).toBe('4 jobs'); + expect(totalTests().text()).toBe('4 tests'); }); it('displays the correct failure count', () => { diff --git a/spec/frontend/pipelines/time_ago_spec.js b/spec/frontend/pipelines/time_ago_spec.js index 1bd16182d47..04934fb93b0 100644 --- a/spec/frontend/pipelines/time_ago_spec.js +++ b/spec/frontend/pipelines/time_ago_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import TimeAgo from '~/pipelines/components/time_ago.vue'; +import TimeAgo from '~/pipelines/components/pipelines_list/time_ago.vue'; describe('Timeago component', () => { let wrapper; diff --git a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js index 1a85221581e..650dd8a1def 100644 --- a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js @@ -1,7 +1,7 @@ import Api from '~/api'; import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import PipelineBranchNameToken from '~/pipelines/components/tokens/pipeline_branch_name_token.vue'; +import PipelineBranchNameToken from '~/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue'; import { branches, mockBranchesAfterMap } from '../mock_data'; describe('Pipeline Branch Name Token', () => { diff --git a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js index ee3694868a5..096e4cd97f6 100644 --- a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js @@ -1,6 +1,6 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import PipelineStatusToken from '~/pipelines/components/tokens/pipeline_status_token.vue'; +import PipelineStatusToken from '~/pipelines/components/pipelines_list/tokens/pipeline_status_token.vue'; describe('Pipeline Status Token', () => { let wrapper; diff --git a/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js index 9fecc9412b7..15b283dc2ff 100644 --- a/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js @@ -1,7 +1,7 @@ import Api from '~/api'; import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import PipelineTagNameToken from '~/pipelines/components/tokens/pipeline_tag_name_token.vue'; +import PipelineTagNameToken from '~/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue'; import { tags, mockTagsAfterMap } from '../mock_data'; describe('Pipeline Branch Name Token', () => { diff --git a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js index 98de4f40c51..0b5cf2e202b 100644 --- a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js @@ -1,7 +1,7 @@ import Api from '~/api'; import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import PipelineTriggerAuthorToken from '~/pipelines/components/tokens/pipeline_trigger_author_token.vue'; +import PipelineTriggerAuthorToken from '~/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue'; import { users } from '../mock_data'; describe('Pipeline Trigger Author Token', () => { diff --git a/spec/frontend/polyfills/element_spec.js b/spec/frontend/polyfills/element_spec.js deleted file mode 100644 index 64ce248ca44..00000000000 --- a/spec/frontend/polyfills/element_spec.js +++ /dev/null @@ -1,46 +0,0 @@ -import '~/commons/polyfills/element'; - -describe('Element polyfills', () => { - let testContext; - - beforeEach(() => { - testContext = {}; - }); - - beforeEach(() => { - testContext.element = document.createElement('ul'); - }); - - describe('matches', () => { - it('returns true if element matches the selector', () => { - expect(testContext.element.matches('ul')).toBeTruthy(); - }); - - it("returns false if element doesn't match the selector", () => { - expect(testContext.element.matches('.not-an-element')).toBeFalsy(); - }); - }); - - describe('closest', () => { - beforeEach(() => { - testContext.childElement = document.createElement('li'); - testContext.element.appendChild(testContext.childElement); - }); - - it('returns the closest parent that matches the selector', () => { - expect(testContext.childElement.closest('ul').toString()).toBe( - testContext.element.toString(), - ); - }); - - it('returns itself if it matches the selector', () => { - expect(testContext.childElement.closest('li').toString()).toBe( - testContext.childElement.toString(), - ); - }); - - it('returns undefined if nothing matches the selector', () => { - expect(testContext.childElement.closest('.no-an-element')).toBeFalsy(); - }); - }); -}); diff --git a/spec/frontend/projects/commits/store/actions_spec.js b/spec/frontend/projects/commits/store/actions_spec.js index c9945e1cc27..886224252ad 100644 --- a/spec/frontend/projects/commits/store/actions_spec.js +++ b/spec/frontend/projects/commits/store/actions_spec.js @@ -45,7 +45,7 @@ describe('Project commits actions', () => { describe('fetchAuthors', () => { it('dispatches request/receive', () => { - const path = '/autocomplete/users.json'; + const path = '/-/autocomplete/users.json'; state.projectId = '8'; const data = [{ id: 1 }]; @@ -60,7 +60,7 @@ describe('Project commits actions', () => { }); it('dispatches request/receive on error', () => { - const path = '/autocomplete/users.json'; + const path = '/-/autocomplete/users.json'; mock.onGet(path).replyOnce(500); testAction(actions.fetchAuthors, null, state, [], [{ type: 'receiveAuthorsError' }]); diff --git a/spec/frontend/projects/components/__snapshots__/remove_modal_spec.js.snap b/spec/frontend/projects/components/__snapshots__/remove_modal_spec.js.snap new file mode 100644 index 00000000000..4d5b6c56a34 --- /dev/null +++ b/spec/frontend/projects/components/__snapshots__/remove_modal_spec.js.snap @@ -0,0 +1,126 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Project remove modal initialized matches the snapshot 1`] = ` +<form + action="some/path" + method="post" +> + <input + name="_method" + type="hidden" + value="delete" + /> + + <input + name="authenticity_token" + type="hidden" + /> + + <b-button-stub + class="[object Object]" + event="click" + role="button" + routertag="a" + size="md" + tabindex="0" + tag="button" + type="button" + variant="danger" + > + <!----> + + <!----> + + <span + class="gl-button-text" + > + Remove project + </span> + </b-button-stub> + + <b-modal-stub + canceltitle="Cancel" + cancelvariant="secondary" + footerclass="bg-gray-light gl-p-5" + headerclosecontent="×" + headercloselabel="Close" + id="remove-project-modal" + ignoreenforcefocusselector="" + lazy="true" + modalclass="gl-modal," + oktitle="OK" + okvariant="danger" + size="sm" + title="" + titletag="h4" + > + + <div> + <p + class="gl-text-red-500 gl-font-weight-bold" + > + This can lead to data loss. + </p> + + <p + class="gl-mb-0" + > + This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention. + </p> + + <p> + <gl-sprintf-stub + message="Please type %{phrase_code} to proceed or close this modal to cancel." + /> + </p> + + <gl-form-input-stub + id="confirm_name_input" + name="confirm_name_input" + type="text" + /> + </div> + + <template /> + + <template> + Confirmation required + </template> + + <template /> + + <template /> + + <template /> + + <template> + <div + class="gl-w-full gl-display-flex gl-just-content-start gl-m-0" + > + <b-button-stub + class="[object Object]" + disabled="true" + event="click" + routertag="a" + size="md" + tag="button" + type="button" + variant="danger" + > + <!----> + + <!----> + + <span + class="gl-button-text" + > + + Confirm + + </span> + </b-button-stub> + </div> + </template> + </b-modal-stub> +</form> +`; diff --git a/spec/frontend/projects/components/remove_modal_spec.js b/spec/frontend/projects/components/remove_modal_spec.js new file mode 100644 index 00000000000..339aee65b99 --- /dev/null +++ b/spec/frontend/projects/components/remove_modal_spec.js @@ -0,0 +1,62 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton, GlModal } from '@gitlab/ui'; +import ProjectRemoveModal from '~/projects/components/remove_modal.vue'; + +describe('Project remove modal', () => { + let wrapper; + + const findFormElement = () => wrapper.find('form').element; + const findConfirmButton = () => wrapper.find(GlModal).find(GlButton); + + const defaultProps = { + formPath: 'some/path', + confirmPhrase: 'foo', + warningMessage: 'This can lead to data loss.', + }; + + const createComponent = (data = {}) => { + wrapper = shallowMount(ProjectRemoveModal, { + propsData: defaultProps, + data: () => data, + stubs: { + GlButton, + GlModal, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('initialized', () => { + beforeEach(() => { + createComponent(); + }); + + it('matches the snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('user input matches the confirmPhrase', () => { + beforeEach(() => { + createComponent({ userInput: defaultProps.confirmPhrase }); + }); + + it('the confirm button is not dislabled', () => { + expect(findConfirmButton().attributes('disabled')).toBe(undefined); + }); + + describe('and when the confirmation button is clicked', () => { + beforeEach(() => { + findConfirmButton().vm.$emit('click'); + }); + + it('submits the form element', () => { + expect(findFormElement().submit).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/projects/pipelines/charts/components/__snapshots__/pipelines_area_chart_spec.js.snap b/spec/frontend/projects/pipelines/charts/components/__snapshots__/pipelines_area_chart_spec.js.snap index f280ecaa0bc..d68e009f46e 100644 --- a/spec/frontend/projects/pipelines/charts/components/__snapshots__/pipelines_area_chart_spec.js.snap +++ b/spec/frontend/projects/pipelines/charts/components/__snapshots__/pipelines_area_chart_spec.js.snap @@ -2,7 +2,7 @@ exports[`PipelinesAreaChart matches the snapshot 1`] = ` <div - class="prepend-top-default" + class="gl-mt-3" > <p> Some title diff --git a/spec/frontend/projects/project_new_spec.js b/spec/frontend/projects/project_new_spec.js index 7c6ff90aff6..7aafbd33fc8 100644 --- a/spec/frontend/projects/project_new_spec.js +++ b/spec/frontend/projects/project_new_spec.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import projectNew from '~/projects/project_new'; +import { TEST_HOST } from 'jest/helpers/test_constants'; describe('New Project', () => { let $projectImportUrl; @@ -33,7 +34,7 @@ describe('New Project', () => { }); describe('deriveProjectPathFromUrl', () => { - const dummyImportUrl = `${gl.TEST_HOST}/dummy/import/url.git`; + const dummyImportUrl = `${TEST_HOST}/dummy/import/url.git`; beforeEach(() => { projectNew.bindEvents(); diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js new file mode 100644 index 00000000000..4c873bdfd60 --- /dev/null +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js @@ -0,0 +1,226 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import ServiceDeskRoot from '~/projects/settings_service_desk/components/service_desk_root.vue'; +import axios from '~/lib/utils/axios_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; + +describe('ServiceDeskRoot', () => { + const endpoint = '/gitlab-org/gitlab-test/service_desk'; + const initialIncomingEmail = 'servicedeskaddress@example.com'; + let axiosMock; + let wrapper; + let spy; + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + axiosMock.restore(); + wrapper.destroy(); + if (spy) { + spy.mockRestore(); + } + }); + + it('fetches incoming email when there is no incoming email provided', () => { + axiosMock.onGet(endpoint).replyOnce(httpStatusCodes.OK); + + wrapper = shallowMount(ServiceDeskRoot, { + propsData: { + initialIsEnabled: true, + initialIncomingEmail: '', + endpoint, + }, + }); + + return wrapper.vm + .$nextTick() + .then(waitForPromises) + .then(() => { + expect(axiosMock.history.get).toHaveLength(1); + }); + }); + + it('does not fetch incoming email when there is an incoming email provided', () => { + axiosMock.onGet(endpoint).replyOnce(httpStatusCodes.OK); + + wrapper = shallowMount(ServiceDeskRoot, { + propsData: { + initialIsEnabled: true, + initialIncomingEmail, + endpoint, + }, + }); + + return wrapper.vm + .$nextTick() + .then(waitForPromises) + .then(() => { + expect(axiosMock.history.get).toHaveLength(0); + }); + }); + + it('shows an error message when incoming email is not fetched correctly', () => { + axiosMock.onGet(endpoint).networkError(); + + wrapper = shallowMount(ServiceDeskRoot, { + propsData: { + initialIsEnabled: true, + initialIncomingEmail: '', + endpoint, + }, + }); + + return wrapper.vm + .$nextTick() + .then(waitForPromises) + .then(() => { + expect(wrapper.html()).toContain( + 'An error occurred while fetching the Service Desk address.', + ); + }); + }); + + it('sends a request to toggle service desk off when the toggle is clicked from the on state', () => { + axiosMock.onPut(endpoint).replyOnce(httpStatusCodes.OK); + + spy = jest.spyOn(axios, 'put'); + + wrapper = mount(ServiceDeskRoot, { + propsData: { + initialIsEnabled: true, + initialIncomingEmail, + endpoint, + }, + }); + + wrapper.find('button.gl-toggle').trigger('click'); + + return wrapper.vm + .$nextTick() + .then(waitForPromises) + .then(() => { + expect(spy).toHaveBeenCalledWith(endpoint, { service_desk_enabled: false }); + }); + }); + + it('sends a request to toggle service desk on when the toggle is clicked from the off state', () => { + axiosMock.onPut(endpoint).replyOnce(httpStatusCodes.OK); + + spy = jest.spyOn(axios, 'put'); + + wrapper = mount(ServiceDeskRoot, { + propsData: { + initialIsEnabled: false, + initialIncomingEmail: '', + endpoint, + }, + }); + + wrapper.find('button.gl-toggle').trigger('click'); + + return wrapper.vm.$nextTick(() => { + expect(spy).toHaveBeenCalledWith(endpoint, { service_desk_enabled: true }); + }); + }); + + it('shows an error message when there is an issue toggling service desk on', () => { + axiosMock.onPut(endpoint).networkError(); + + wrapper = mount(ServiceDeskRoot, { + propsData: { + initialIsEnabled: false, + initialIncomingEmail: '', + endpoint, + }, + }); + + wrapper.find('button.gl-toggle').trigger('click'); + + return wrapper.vm + .$nextTick() + .then(waitForPromises) + .then(() => { + expect(wrapper.html()).toContain('An error occurred while enabling Service Desk.'); + }); + }); + + it('sends a request to update template when the "Save template" button is clicked', () => { + axiosMock.onPut(endpoint).replyOnce(httpStatusCodes.OK); + + spy = jest.spyOn(axios, 'put'); + + wrapper = mount(ServiceDeskRoot, { + propsData: { + initialIsEnabled: true, + endpoint, + initialIncomingEmail, + selectedTemplate: 'Bug', + outgoingName: 'GitLab Support Bot', + templates: ['Bug', 'Documentation'], + projectKey: 'key', + }, + }); + + wrapper.find('button.btn-success').trigger('click'); + + return wrapper.vm.$nextTick(() => { + expect(spy).toHaveBeenCalledWith(endpoint, { + issue_template_key: 'Bug', + outgoing_name: 'GitLab Support Bot', + project_key: 'key', + service_desk_enabled: true, + }); + }); + }); + + it('saves the template when the "Save template" button is clicked', () => { + axiosMock.onPut(endpoint).replyOnce(httpStatusCodes.OK); + + wrapper = mount(ServiceDeskRoot, { + propsData: { + initialIsEnabled: true, + endpoint, + initialIncomingEmail, + selectedTemplate: 'Bug', + templates: ['Bug', 'Documentation'], + }, + }); + + wrapper.find('button.btn-success').trigger('click'); + + return wrapper.vm + .$nextTick() + .then(waitForPromises) + .then(() => { + expect(wrapper.html()).toContain('Template was successfully saved.'); + }); + }); + + it('shows an error message when there is an issue saving the template', () => { + axiosMock.onPut(endpoint).networkError(); + + wrapper = mount(ServiceDeskRoot, { + propsData: { + initialIsEnabled: true, + endpoint, + initialIncomingEmail, + selectedTemplate: 'Bug', + templates: ['Bug', 'Documentation'], + }, + }); + + wrapper.find('button.btn-success').trigger('click'); + + return wrapper.vm + .$nextTick() + .then(waitForPromises) + .then(() => { + expect(wrapper.html()).toContain( + 'An error occurred while saving the template. Please check if the template exists.', + ); + }); + }); +}); diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js new file mode 100644 index 00000000000..7fe310aa400 --- /dev/null +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js @@ -0,0 +1,234 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import eventHub from '~/projects/settings_service_desk/event_hub'; +import ServiceDeskSetting from '~/projects/settings_service_desk/components/service_desk_setting.vue'; + +describe('ServiceDeskSetting', () => { + let wrapper; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + const findTemplateDropdown = () => wrapper.find('#service-desk-template-select'); + + describe('when isEnabled=true', () => { + describe('only isEnabled', () => { + describe('as project admin', () => { + beforeEach(() => { + wrapper = shallowMount(ServiceDeskSetting, { + propsData: { + isEnabled: true, + }, + }); + }); + + it('should see activation checkbox', () => { + expect(wrapper.contains('#service-desk-checkbox')).toBe(true); + }); + + it('should see main panel with the email info', () => { + expect(wrapper.contains('#incoming-email-describer')).toBe(true); + }); + + it('should see loading spinner and not the incoming email', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.contains('.incoming-email')).toBe(false); + }); + }); + }); + + describe('service desk toggle', () => { + it('emits an event to turn on Service Desk when clicked', () => { + const eventSpy = jest.fn(); + eventHub.$on('serviceDeskEnabledCheckboxToggled', eventSpy); + + wrapper = mount(ServiceDeskSetting, { + propsData: { + isEnabled: false, + }, + }); + + wrapper.find('#service-desk-checkbox').trigger('click'); + + expect(eventSpy).toHaveBeenCalledWith(true); + + eventHub.$off('serviceDeskEnabledCheckboxToggled', eventSpy); + eventSpy.mockRestore(); + }); + }); + + describe('with incomingEmail', () => { + const incomingEmail = 'foo@bar.com'; + + beforeEach(() => { + wrapper = mount(ServiceDeskSetting, { + propsData: { + isEnabled: true, + incomingEmail, + }, + }); + }); + + it('should see email and not the loading spinner', () => { + expect(wrapper.find('.incoming-email').element.value).toEqual(incomingEmail); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + }); + + it('renders a copy to clipboard button', () => { + expect(wrapper.contains('.qa-clipboard-button')).toBe(true); + expect(wrapper.find('.qa-clipboard-button').element.dataset.clipboardText).toBe( + incomingEmail, + ); + }); + }); + + describe('templates dropdown', () => { + it('renders a dropdown to choose a template', () => { + wrapper = shallowMount(ServiceDeskSetting, { + propsData: { + isEnabled: true, + }, + }); + + expect(wrapper.contains('#service-desk-template-select')).toBe(true); + }); + + it('renders a dropdown with a default value of ""', () => { + wrapper = mount(ServiceDeskSetting, { + propsData: { + isEnabled: true, + }, + }); + + expect(findTemplateDropdown().element.value).toEqual(''); + }); + + it('renders a dropdown with a value of "Bug" when it is the initial value', () => { + const templates = ['Bug', 'Documentation', 'Security release']; + + wrapper = mount(ServiceDeskSetting, { + propsData: { + isEnabled: true, + initialSelectedTemplate: 'Bug', + templates, + }, + }); + + expect(findTemplateDropdown().element.value).toEqual('Bug'); + }); + + it('renders a dropdown with no options when the project has no templates', () => { + wrapper = mount(ServiceDeskSetting, { + propsData: { + isEnabled: true, + templates: [], + }, + }); + + // The dropdown by default has one empty option + expect(findTemplateDropdown().element.children).toHaveLength(1); + }); + + it('renders a dropdown with options when the project has templates', () => { + const templates = ['Bug', 'Documentation', 'Security release']; + wrapper = mount(ServiceDeskSetting, { + propsData: { + isEnabled: true, + templates, + }, + }); + + // An empty-named template is prepended so the user can select no template + const expectedTemplates = [''].concat(templates); + + const dropdown = findTemplateDropdown(); + const dropdownList = Array.from(dropdown.element.children).map(option => option.innerText); + + expect(dropdown.element.children).toHaveLength(expectedTemplates.length); + expect(dropdownList.includes('Bug')).toEqual(true); + expect(dropdownList.includes('Documentation')).toEqual(true); + expect(dropdownList.includes('Security release')).toEqual(true); + }); + }); + }); + + describe('save button', () => { + it('renders a save button to save a template', () => { + wrapper = mount(ServiceDeskSetting, { + propsData: { + isEnabled: true, + }, + }); + + expect(wrapper.find('button.btn-success').text()).toContain('Save template'); + }); + + it('emits a save event with the chosen template when the save button is clicked', () => { + const eventSpy = jest.fn(); + eventHub.$on('serviceDeskTemplateSave', eventSpy); + + wrapper = mount(ServiceDeskSetting, { + propsData: { + isEnabled: true, + initialSelectedTemplate: 'Bug', + initialOutgoingName: 'GitLab Support Bot', + initialProjectKey: 'key', + }, + }); + + wrapper.find('button.btn-success').trigger('click'); + + expect(eventSpy).toHaveBeenCalledWith({ + selectedTemplate: 'Bug', + outgoingName: 'GitLab Support Bot', + projectKey: 'key', + }); + + eventHub.$off('serviceDeskTemplateSave', eventSpy); + eventSpy.mockRestore(); + }); + }); + + describe('when isEnabled=false', () => { + beforeEach(() => { + wrapper = shallowMount(ServiceDeskSetting, { + propsData: { + isEnabled: false, + }, + }); + }); + + it('does not render email panel', () => { + expect(wrapper.contains('#incoming-email-describer')).toBe(false); + }); + + it('does not render template dropdown', () => { + expect(wrapper.contains('#service-desk-template-select')).toBe(false); + }); + + it('does not render template save button', () => { + expect(wrapper.contains('button.btn-success')).toBe(false); + }); + + it('emits an event to turn on Service Desk when the toggle is clicked', () => { + const eventSpy = jest.fn(); + eventHub.$on('serviceDeskEnabledCheckboxToggled', eventSpy); + + wrapper = mount(ServiceDeskSetting, { + propsData: { + isEnabled: true, + }, + }); + + wrapper.find('#service-desk-checkbox').trigger('click'); + + expect(eventSpy).toHaveBeenCalledWith(false); + + eventHub.$off('serviceDeskEnabledCheckboxToggled', eventSpy); + eventSpy.mockRestore(); + }); + }); +}); diff --git a/spec/frontend/projects/settings_service_desk/services/service_desk_service_spec.js b/spec/frontend/projects/settings_service_desk/services/service_desk_service_spec.js new file mode 100644 index 00000000000..f9e4d55245a --- /dev/null +++ b/spec/frontend/projects/settings_service_desk/services/service_desk_service_spec.js @@ -0,0 +1,129 @@ +import AxiosMockAdapter from 'axios-mock-adapter'; +import ServiceDeskService from '~/projects/settings_service_desk/services/service_desk_service'; +import axios from '~/lib/utils/axios_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; + +describe('ServiceDeskService', () => { + const endpoint = `/gitlab-org/gitlab-test/service_desk`; + const dummyResponse = { message: 'Dummy response' }; + const errorMessage = 'Network Error'; + let axiosMock; + let service; + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + service = new ServiceDeskService(endpoint); + }); + + afterEach(() => { + axiosMock.restore(); + }); + + describe('fetchIncomingEmail', () => { + it('makes a request to fetch incoming email', () => { + axiosMock.onGet(endpoint).replyOnce(httpStatusCodes.OK, dummyResponse); + + return service.fetchIncomingEmail().then(response => { + expect(response.data).toEqual(dummyResponse); + }); + }); + + it('fails on error response', () => { + axiosMock.onGet(endpoint).networkError(); + + return service.fetchIncomingEmail().catch(error => { + expect(error.message).toBe(errorMessage); + }); + }); + }); + + describe('toggleServiceDesk', () => { + it('makes a request to set service desk', () => { + axiosMock.onPut(endpoint).replyOnce(httpStatusCodes.OK, dummyResponse); + + return service.toggleServiceDesk(true).then(response => { + expect(response.data).toEqual(dummyResponse); + }); + }); + + it('fails on error response', () => { + axiosMock.onPut(endpoint).networkError(); + + return service.toggleServiceDesk(true).catch(error => { + expect(error.message).toBe(errorMessage); + }); + }); + + it('makes a request with the expected body', () => { + axiosMock.onPut(endpoint).replyOnce(httpStatusCodes.OK, dummyResponse); + + const spy = jest.spyOn(axios, 'put'); + + service.toggleServiceDesk(true); + + expect(spy).toHaveBeenCalledWith(endpoint, { + service_desk_enabled: true, + }); + + spy.mockRestore(); + }); + }); + + describe('updateTemplate', () => { + it('makes a request to update template', () => { + axiosMock.onPut(endpoint).replyOnce(httpStatusCodes.OK, dummyResponse); + + return service + .updateTemplate( + { + selectedTemplate: 'Bug', + outgoingName: 'GitLab Support Bot', + }, + true, + ) + .then(response => { + expect(response.data).toEqual(dummyResponse); + }); + }); + + it('fails on error response', () => { + axiosMock.onPut(endpoint).networkError(); + + return service + .updateTemplate( + { + selectedTemplate: 'Bug', + outgoingName: 'GitLab Support Bot', + }, + true, + ) + .catch(error => { + expect(error.message).toBe(errorMessage); + }); + }); + + it('makes a request with the expected body', () => { + axiosMock.onPut(endpoint).replyOnce(httpStatusCodes.OK, dummyResponse); + + const spy = jest.spyOn(axios, 'put'); + + service.updateTemplate( + { + selectedTemplate: 'Bug', + outgoingName: 'GitLab Support Bot', + projectKey: 'key', + }, + true, + ); + + expect(spy).toHaveBeenCalledWith(endpoint, { + issue_template_key: 'Bug', + outgoing_name: 'GitLab Support Bot', + project_key: 'key', + service_desk_enabled: true, + }); + + spy.mockRestore(); + }); + }); +}); diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js new file mode 100644 index 00000000000..2688e4b3428 --- /dev/null +++ b/spec/frontend/ref/components/ref_selector_spec.js @@ -0,0 +1,532 @@ +import Vuex from 'vuex'; +import { mount, createLocalVue } from '@vue/test-utils'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { GlLoadingIcon, GlSearchBoxByType, GlNewDropdownItem, GlIcon } from '@gitlab/ui'; +import { trimText } from 'helpers/text_helper'; +import { sprintf } from '~/locale'; +import RefSelector from '~/ref/components/ref_selector.vue'; +import { X_TOTAL_HEADER, DEFAULT_I18N } from '~/ref/constants'; +import createStore from '~/ref/stores/'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Ref selector component', () => { + const fixtures = { + branches: getJSONFixture('api/branches/branches.json'), + tags: getJSONFixture('api/tags/tags.json'), + commit: getJSONFixture('api/commits/commit.json'), + }; + + const projectId = '8'; + + let wrapper; + let branchesApiCallSpy; + let tagsApiCallSpy; + let commitApiCallSpy; + + const createComponent = () => { + wrapper = mount(RefSelector, { + propsData: { + projectId, + value: '', + }, + listeners: { + // simulate a parent component v-model binding + input: selectedRef => { + wrapper.setProps({ value: selectedRef }); + }, + }, + stubs: { + GlSearchBoxByType: true, + }, + localVue, + store: createStore(), + }); + }; + + beforeEach(() => { + const mock = new MockAdapter(axios); + gon.api_version = 'v4'; + + branchesApiCallSpy = jest + .fn() + .mockReturnValue([200, fixtures.branches, { [X_TOTAL_HEADER]: '123' }]); + tagsApiCallSpy = jest.fn().mockReturnValue([200, fixtures.tags, { [X_TOTAL_HEADER]: '456' }]); + commitApiCallSpy = jest.fn().mockReturnValue([200, fixtures.commit]); + + mock + .onGet(`/api/v4/projects/${projectId}/repository/branches`) + .reply(config => branchesApiCallSpy(config)); + mock + .onGet(`/api/v4/projects/${projectId}/repository/tags`) + .reply(config => tagsApiCallSpy(config)); + mock + .onGet(new RegExp(`/api/v4/projects/${projectId}/repository/commits/.*`)) + .reply(config => commitApiCallSpy(config)); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + // + // Finders + // + const findButtonContent = () => wrapper.find('[data-testid="button-content"]'); + + const findNoResults = () => wrapper.find('[data-testid="no-results"]'); + + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + + const findBranchesSection = () => wrapper.find('[data-testid="branches-section"]'); + const findBranchDropdownItems = () => findBranchesSection().findAll(GlNewDropdownItem); + const findFirstBranchDropdownItem = () => findBranchDropdownItems().at(0); + + const findTagsSection = () => wrapper.find('[data-testid="tags-section"]'); + const findTagDropdownItems = () => findTagsSection().findAll(GlNewDropdownItem); + const findFirstTagDropdownItem = () => findTagDropdownItems().at(0); + + const findCommitsSection = () => wrapper.find('[data-testid="commits-section"]'); + const findCommitDropdownItems = () => findCommitsSection().findAll(GlNewDropdownItem); + const findFirstCommitDropdownItem = () => findCommitDropdownItems().at(0); + + // + // Expecters + // + const branchesSectionContainsErrorMessage = () => { + const branchesSection = findBranchesSection(); + + return branchesSection.text().includes(DEFAULT_I18N.branchesErrorMessage); + }; + + const tagsSectionContainsErrorMessage = () => { + const tagsSection = findTagsSection(); + + return tagsSection.text().includes(DEFAULT_I18N.tagsErrorMessage); + }; + + const commitsSectionContainsErrorMessage = () => { + const commitsSection = findCommitsSection(); + + return commitsSection.text().includes(DEFAULT_I18N.commitsErrorMessage); + }; + + // + // Convenience methods + // + const updateQuery = newQuery => { + wrapper.find(GlSearchBoxByType).vm.$emit('input', newQuery); + }; + + const selectFirstBranch = () => { + findFirstBranchDropdownItem().vm.$emit('click'); + }; + + const selectFirstTag = () => { + findFirstTagDropdownItem().vm.$emit('click'); + }; + + const selectFirstCommit = () => { + findFirstCommitDropdownItem().vm.$emit('click'); + }; + + const waitForRequests = ({ andClearMocks } = { andClearMocks: false }) => + axios.waitForAll().then(() => { + if (andClearMocks) { + branchesApiCallSpy.mockClear(); + tagsApiCallSpy.mockClear(); + commitApiCallSpy.mockClear(); + } + }); + + describe('initialization behavior', () => { + beforeEach(createComponent); + + it('initializes the dropdown with branches and tags when mounted', () => { + return waitForRequests().then(() => { + expect(branchesApiCallSpy).toHaveBeenCalledTimes(1); + expect(tagsApiCallSpy).toHaveBeenCalledTimes(1); + expect(commitApiCallSpy).not.toHaveBeenCalled(); + }); + }); + + it('shows a spinner while network requests are in progress', () => { + expect(findLoadingIcon().exists()).toBe(true); + + return waitForRequests().then(() => { + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + }); + + describe('post-initialization behavior', () => { + describe('when the search query is updated', () => { + beforeEach(() => { + createComponent(); + + return waitForRequests({ andClearMocks: true }); + }); + + it('requeries the endpoints when the search query is updated', () => { + updateQuery('v1.2.3'); + + return waitForRequests().then(() => { + expect(branchesApiCallSpy).toHaveBeenCalledTimes(1); + expect(tagsApiCallSpy).toHaveBeenCalledTimes(1); + }); + }); + + it("does not make a call to the commit endpoint if the query doesn't look like a SHA", () => { + updateQuery('not a sha'); + + return waitForRequests().then(() => { + expect(commitApiCallSpy).not.toHaveBeenCalled(); + }); + }); + + it('searches for a commit if the query could potentially be a SHA', () => { + updateQuery('abcdef'); + + return waitForRequests().then(() => { + expect(commitApiCallSpy).toHaveBeenCalled(); + }); + }); + }); + + describe('when no results are found', () => { + beforeEach(() => { + branchesApiCallSpy = jest.fn().mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]); + tagsApiCallSpy = jest.fn().mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]); + commitApiCallSpy = jest.fn().mockReturnValue([404]); + + createComponent(); + + return waitForRequests(); + }); + + describe('when the search query is empty', () => { + it('renders a "no results" message', () => { + expect(findNoResults().text()).toBe(DEFAULT_I18N.noResults); + }); + }); + + describe('when the search query is not empty', () => { + const query = 'hello'; + + beforeEach(() => { + updateQuery(query); + + return waitForRequests(); + }); + + it('renders a "no results" message that includes the search query', () => { + expect(findNoResults().text()).toBe(sprintf(DEFAULT_I18N.noResultsWithQuery, { query })); + }); + }); + }); + + describe('branches', () => { + describe('when the branches search returns results', () => { + beforeEach(() => { + createComponent(); + + return waitForRequests(); + }); + + it('renders the branches section in the dropdown', () => { + expect(findBranchesSection().exists()).toBe(true); + }); + + it('renders the "Branches" heading with a total number indicator', () => { + expect( + findBranchesSection() + .find('[data-testid="section-header"]') + .text(), + ).toBe('Branches 123'); + }); + + it("does not render an error message in the branches section's body", () => { + expect(branchesSectionContainsErrorMessage()).toBe(false); + }); + + it('renders each non-default branch as a selectable item', () => { + const dropdownItems = findBranchDropdownItems(); + + fixtures.branches.forEach((b, i) => { + if (!b.default) { + expect(dropdownItems.at(i).text()).toBe(b.name); + } + }); + }); + + it('renders the default branch as a selectable item with a "default" badge', () => { + const dropdownItems = findBranchDropdownItems(); + + const defaultBranch = fixtures.branches.find(b => b.default); + const defaultBranchIndex = fixtures.branches.indexOf(defaultBranch); + + expect(trimText(dropdownItems.at(defaultBranchIndex).text())).toBe( + `${defaultBranch.name} default`, + ); + }); + }); + + describe('when the branches search returns no results', () => { + beforeEach(() => { + branchesApiCallSpy = jest.fn().mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]); + + createComponent(); + + return waitForRequests(); + }); + + it('does not render the branches section in the dropdown', () => { + expect(findBranchesSection().exists()).toBe(false); + }); + }); + + describe('when the branches search returns an error', () => { + beforeEach(() => { + branchesApiCallSpy = jest.fn().mockReturnValue([500]); + + createComponent(); + + return waitForRequests(); + }); + + it('renders the branches section in the dropdown', () => { + expect(findBranchesSection().exists()).toBe(true); + }); + + it("renders an error message in the branches section's body", () => { + expect(branchesSectionContainsErrorMessage()).toBe(true); + }); + }); + }); + + describe('tags', () => { + describe('when the tags search returns results', () => { + beforeEach(() => { + createComponent(); + + return waitForRequests(); + }); + + it('renders the tags section in the dropdown', () => { + expect(findTagsSection().exists()).toBe(true); + }); + + it('renders the "Tags" heading with a total number indicator', () => { + expect( + findTagsSection() + .find('[data-testid="section-header"]') + .text(), + ).toBe('Tags 456'); + }); + + it("does not render an error message in the tags section's body", () => { + expect(tagsSectionContainsErrorMessage()).toBe(false); + }); + + it('renders each tag as a selectable item', () => { + const dropdownItems = findTagDropdownItems(); + + fixtures.tags.forEach((t, i) => { + expect(dropdownItems.at(i).text()).toBe(t.name); + }); + }); + }); + + describe('when the tags search returns no results', () => { + beforeEach(() => { + tagsApiCallSpy = jest.fn().mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]); + + createComponent(); + + return waitForRequests(); + }); + + it('does not render the tags section in the dropdown', () => { + expect(findTagsSection().exists()).toBe(false); + }); + }); + + describe('when the tags search returns an error', () => { + beforeEach(() => { + tagsApiCallSpy = jest.fn().mockReturnValue([500]); + + createComponent(); + + return waitForRequests(); + }); + + it('renders the tags section in the dropdown', () => { + expect(findTagsSection().exists()).toBe(true); + }); + + it("renders an error message in the tags section's body", () => { + expect(tagsSectionContainsErrorMessage()).toBe(true); + }); + }); + }); + + describe('commits', () => { + describe('when the commit search returns results', () => { + beforeEach(() => { + createComponent(); + + updateQuery('abcd1234'); + + return waitForRequests(); + }); + + it('renders the commit section in the dropdown', () => { + expect(findCommitsSection().exists()).toBe(true); + }); + + it('renders the "Commits" heading with a total number indicator', () => { + expect( + findCommitsSection() + .find('[data-testid="section-header"]') + .text(), + ).toBe('Commits 1'); + }); + + it("does not render an error message in the comits section's body", () => { + expect(commitsSectionContainsErrorMessage()).toBe(false); + }); + + it('renders each commit as a selectable item with the short SHA and commit title', () => { + const dropdownItems = findCommitDropdownItems(); + + const { commit } = fixtures; + + expect(dropdownItems.at(0).text()).toBe(`${commit.short_id} ${commit.title}`); + }); + }); + + describe('when the commit search returns no results (i.e. a 404)', () => { + beforeEach(() => { + commitApiCallSpy = jest.fn().mockReturnValue([404]); + + createComponent(); + + updateQuery('abcd1234'); + + return waitForRequests(); + }); + + it('does not render the commits section in the dropdown', () => { + expect(findCommitsSection().exists()).toBe(false); + }); + }); + + describe('when the commit search returns an error (other than a 404)', () => { + beforeEach(() => { + commitApiCallSpy = jest.fn().mockReturnValue([500]); + + createComponent(); + + updateQuery('abcd1234'); + + return waitForRequests(); + }); + + it('renders the commits section in the dropdown', () => { + expect(findCommitsSection().exists()).toBe(true); + }); + + it("renders an error message in the commits section's body", () => { + expect(commitsSectionContainsErrorMessage()).toBe(true); + }); + }); + }); + + describe('selection', () => { + beforeEach(() => { + createComponent(); + + updateQuery(fixtures.commit.short_id); + + return waitForRequests(); + }); + + it('renders a checkmark by the selected item', () => { + expect(findFirstBranchDropdownItem().find(GlIcon).element).toHaveClass( + 'gl-visibility-hidden', + ); + + selectFirstBranch(); + + return localVue.nextTick().then(() => { + expect(findFirstBranchDropdownItem().find(GlIcon).element).not.toHaveClass( + 'gl-visibility-hidden', + ); + }); + }); + + describe('when a branch is seleceted', () => { + it("displays the branch name in the dropdown's button", () => { + expect(findButtonContent().text()).toBe(DEFAULT_I18N.noRefSelected); + + selectFirstBranch(); + + return localVue.nextTick().then(() => { + expect(findButtonContent().text()).toBe(fixtures.branches[0].name); + }); + }); + + it("updates the v-model binding with the branch's name", () => { + expect(wrapper.vm.value).toEqual(''); + + selectFirstBranch(); + + expect(wrapper.vm.value).toEqual(fixtures.branches[0].name); + }); + }); + + describe('when a tag is seleceted', () => { + it("displays the tag name in the dropdown's button", () => { + expect(findButtonContent().text()).toBe(DEFAULT_I18N.noRefSelected); + + selectFirstTag(); + + return localVue.nextTick().then(() => { + expect(findButtonContent().text()).toBe(fixtures.tags[0].name); + }); + }); + + it("updates the v-model binding with the tag's name", () => { + expect(wrapper.vm.value).toEqual(''); + + selectFirstTag(); + + expect(wrapper.vm.value).toEqual(fixtures.tags[0].name); + }); + }); + + describe('when a commit is selected', () => { + it("displays the full SHA in the dropdown's button", () => { + expect(findButtonContent().text()).toBe(DEFAULT_I18N.noRefSelected); + + selectFirstCommit(); + + return localVue.nextTick().then(() => { + expect(findButtonContent().text()).toBe(fixtures.commit.id); + }); + }); + + it("updates the v-model binding with the commit's full SHA", () => { + expect(wrapper.vm.value).toEqual(''); + + selectFirstCommit(); + + expect(wrapper.vm.value).toEqual(fixtures.commit.id); + }); + }); + }); + }); +}); diff --git a/spec/frontend/ref/stores/actions_spec.js b/spec/frontend/ref/stores/actions_spec.js new file mode 100644 index 00000000000..32966354c95 --- /dev/null +++ b/spec/frontend/ref/stores/actions_spec.js @@ -0,0 +1,180 @@ +import testAction from 'helpers/vuex_action_helper'; +import createState from '~/ref/stores/state'; +import * as actions from '~/ref/stores/actions'; +import * as types from '~/ref/stores/mutation_types'; + +let mockBranchesReturnValue; +let mockTagsReturnValue; +let mockCommitReturnValue; + +jest.mock('~/api', () => ({ + // `__esModule: true` is required when mocking modules with default exports: + // https://jestjs.io/docs/en/jest-object#jestmockmodulename-factory-options + __esModule: true, + default: { + branches: () => mockBranchesReturnValue, + tags: () => mockTagsReturnValue, + commit: () => mockCommitReturnValue, + }, +})); + +describe('Ref selector Vuex store actions', () => { + let state; + + beforeEach(() => { + state = createState(); + }); + + describe('setProjectId', () => { + it(`commits ${types.SET_PROJECT_ID} with the new project ID`, () => { + const projectId = '4'; + testAction(actions.setProjectId, projectId, state, [ + { type: types.SET_PROJECT_ID, payload: projectId }, + ]); + }); + }); + + describe('setSelectedRef', () => { + it(`commits ${types.SET_SELECTED_REF} with the new selected ref name`, () => { + const selectedRef = 'v1.2.3'; + testAction(actions.setSelectedRef, selectedRef, state, [ + { type: types.SET_SELECTED_REF, payload: selectedRef }, + ]); + }); + }); + + describe('search', () => { + it(`commits ${types.SET_QUERY} with the new search query`, () => { + const query = 'hello'; + testAction( + actions.search, + query, + state, + [{ type: types.SET_QUERY, payload: query }], + [{ type: 'searchBranches' }, { type: 'searchTags' }, { type: 'searchCommits' }], + ); + }); + }); + + describe('searchBranches', () => { + describe('when the search is successful', () => { + const branchesApiResponse = { data: [{ name: 'my-feature-branch' }] }; + + beforeEach(() => { + mockBranchesReturnValue = Promise.resolve(branchesApiResponse); + }); + + it(`commits ${types.REQUEST_START}, ${types.RECEIVE_BRANCHES_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => { + return testAction(actions.searchBranches, undefined, state, [ + { type: types.REQUEST_START }, + { type: types.RECEIVE_BRANCHES_SUCCESS, payload: branchesApiResponse }, + { type: types.REQUEST_FINISH }, + ]); + }); + }); + + describe('when the search fails', () => { + const error = new Error('Something went wrong!'); + + beforeEach(() => { + mockBranchesReturnValue = Promise.reject(error); + }); + + it(`commits ${types.REQUEST_START}, ${types.RECEIVE_BRANCHES_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => { + return testAction(actions.searchBranches, undefined, state, [ + { type: types.REQUEST_START }, + { type: types.RECEIVE_BRANCHES_ERROR, payload: error }, + { type: types.REQUEST_FINISH }, + ]); + }); + }); + }); + + describe('searchTags', () => { + describe('when the search is successful', () => { + const tagsApiResponse = { data: [{ name: 'v1.2.3' }] }; + + beforeEach(() => { + mockTagsReturnValue = Promise.resolve(tagsApiResponse); + }); + + it(`commits ${types.REQUEST_START}, ${types.RECEIVE_TAGS_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => { + return testAction(actions.searchTags, undefined, state, [ + { type: types.REQUEST_START }, + { type: types.RECEIVE_TAGS_SUCCESS, payload: tagsApiResponse }, + { type: types.REQUEST_FINISH }, + ]); + }); + }); + + describe('when the search fails', () => { + const error = new Error('Something went wrong!'); + + beforeEach(() => { + mockTagsReturnValue = Promise.reject(error); + }); + + it(`commits ${types.REQUEST_START}, ${types.RECEIVE_TAGS_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => { + return testAction(actions.searchTags, undefined, state, [ + { type: types.REQUEST_START }, + { type: types.RECEIVE_TAGS_ERROR, payload: error }, + { type: types.REQUEST_FINISH }, + ]); + }); + }); + }); + + describe('searchCommits', () => { + describe('when the search query potentially matches a commit SHA', () => { + beforeEach(() => { + state.isQueryPossiblyASha = true; + }); + + describe('when the search is successful', () => { + const commitApiResponse = { data: [{ id: 'abcd1234' }] }; + + beforeEach(() => { + mockCommitReturnValue = Promise.resolve(commitApiResponse); + }); + + it(`commits ${types.REQUEST_START}, ${types.RECEIVE_COMMITS_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => { + return testAction(actions.searchCommits, undefined, state, [ + { type: types.REQUEST_START }, + { type: types.RECEIVE_COMMITS_SUCCESS, payload: commitApiResponse }, + { type: types.REQUEST_FINISH }, + ]); + }); + }); + + describe('when the search fails', () => { + const error = new Error('Something went wrong!'); + + beforeEach(() => { + mockCommitReturnValue = Promise.reject(error); + }); + + describe('when the search query might match a commit SHA', () => { + it(`commits ${types.REQUEST_START}, ${types.RECEIVE_COMMITS_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => { + return testAction(actions.searchCommits, undefined, state, [ + { type: types.REQUEST_START }, + { type: types.RECEIVE_COMMITS_ERROR, payload: error }, + { type: types.REQUEST_FINISH }, + ]); + }); + }); + }); + }); + + describe('when the search query will not match a commit SHA', () => { + beforeEach(() => { + state.isQueryPossiblyASha = false; + }); + + it(`commits ${types.RESET_COMMIT_MATCHES}`, () => { + return testAction(actions.searchCommits, undefined, state, [ + { type: types.RESET_COMMIT_MATCHES }, + ]); + }); + }); + }); +}); diff --git a/spec/frontend/ref/stores/getters_spec.js b/spec/frontend/ref/stores/getters_spec.js new file mode 100644 index 00000000000..49d74e5b9e4 --- /dev/null +++ b/spec/frontend/ref/stores/getters_spec.js @@ -0,0 +1,36 @@ +import * as getters from '~/ref/stores/getters'; + +describe('Ref selector Vuex store getters', () => { + describe('isQueryPossiblyASha', () => { + it.each` + query | isPossiblyASha + ${'abcd'} | ${true} + ${'ABCD'} | ${true} + ${'0123456789abcdef0123456789abcdef01234567'} | ${true} + ${'0123456789abcdef0123456789abcdef012345678'} | ${false} + ${'abc'} | ${false} + ${'ghij'} | ${false} + ${' abcd'} | ${false} + ${''} | ${false} + ${null} | ${false} + ${undefined} | ${false} + `( + 'returns true when the query potentially refers to a commit SHA', + ({ query, isPossiblyASha }) => { + expect(getters.isQueryPossiblyASha({ query })).toBe(isPossiblyASha); + }, + ); + }); + + describe('isLoading', () => { + it.each` + requestCount | isLoading + ${2} | ${true} + ${1} | ${true} + ${0} | ${false} + ${-1} | ${false} + `('returns true when at least one request is in progress', ({ requestCount, isLoading }) => { + expect(getters.isLoading({ requestCount })).toBe(isLoading); + }); + }); +}); diff --git a/spec/frontend/ref/stores/mutations_spec.js b/spec/frontend/ref/stores/mutations_spec.js new file mode 100644 index 00000000000..78117436c33 --- /dev/null +++ b/spec/frontend/ref/stores/mutations_spec.js @@ -0,0 +1,274 @@ +import createState from '~/ref/stores/state'; +import mutations from '~/ref/stores/mutations'; +import * as types from '~/ref/stores/mutation_types'; +import { X_TOTAL_HEADER } from '~/ref/constants'; + +describe('Ref selector Vuex store mutations', () => { + let state; + + beforeEach(() => { + state = createState(); + }); + + describe('initial state', () => { + it('is created with the correct structure and initial values', () => { + expect(state).toEqual({ + projectId: null, + + query: '', + matches: { + branches: { + list: [], + totalCount: 0, + error: null, + }, + tags: { + list: [], + totalCount: 0, + error: null, + }, + commits: { + list: [], + totalCount: 0, + error: null, + }, + }, + selectedRef: null, + requestCount: 0, + }); + }); + }); + + describe(`${types.SET_PROJECT_ID}`, () => { + it('updates the project ID', () => { + const newProjectId = '4'; + mutations[types.SET_PROJECT_ID](state, newProjectId); + + expect(state.projectId).toBe(newProjectId); + }); + }); + + describe(`${types.SET_SELECTED_REF}`, () => { + it('updates the selected ref', () => { + const newSelectedRef = 'my-feature-branch'; + mutations[types.SET_SELECTED_REF](state, newSelectedRef); + + expect(state.selectedRef).toBe(newSelectedRef); + }); + }); + + describe(`${types.SET_QUERY}`, () => { + it('updates the search query', () => { + const newQuery = 'hello'; + mutations[types.SET_QUERY](state, newQuery); + + expect(state.query).toBe(newQuery); + }); + }); + + describe(`${types.REQUEST_START}`, () => { + it('increments requestCount by 1', () => { + mutations[types.REQUEST_START](state); + expect(state.requestCount).toBe(1); + + mutations[types.REQUEST_START](state); + expect(state.requestCount).toBe(2); + + mutations[types.REQUEST_START](state); + expect(state.requestCount).toBe(3); + }); + }); + + describe(`${types.REQUEST_FINISH}`, () => { + it('decrements requestCount by 1', () => { + state.requestCount = 3; + + mutations[types.REQUEST_FINISH](state); + expect(state.requestCount).toBe(2); + + mutations[types.REQUEST_FINISH](state); + expect(state.requestCount).toBe(1); + + mutations[types.REQUEST_FINISH](state); + expect(state.requestCount).toBe(0); + }); + }); + + describe(`${types.RECEIVE_BRANCHES_SUCCESS}`, () => { + it('updates state.matches.branches based on the provided API response', () => { + const response = { + data: [ + { + name: 'master', + default: true, + + // everything except "name" and "default" should be stripped + merged: false, + protected: true, + }, + { + name: 'my-feature-branch', + default: false, + }, + ], + headers: { + [X_TOTAL_HEADER]: 37, + }, + }; + + mutations[types.RECEIVE_BRANCHES_SUCCESS](state, response); + + expect(state.matches.branches).toEqual({ + list: [ + { + name: 'master', + default: true, + }, + { + name: 'my-feature-branch', + default: false, + }, + ], + totalCount: 37, + error: null, + }); + }); + }); + + describe(`${types.RECEIVE_BRANCHES_ERROR}`, () => { + it('updates state.matches.branches to an empty state with the error object', () => { + const error = new Error('Something went wrong!'); + + state.matches.branches = { + list: [{ name: 'my-feature-branch' }], + totalCount: 1, + error: null, + }; + + mutations[types.RECEIVE_BRANCHES_ERROR](state, error); + + expect(state.matches.branches).toEqual({ + list: [], + totalCount: 0, + error, + }); + }); + }); + + describe(`${types.RECEIVE_REQUEST_TAGS_SUCCESS}`, () => { + it('updates state.matches.tags based on the provided API response', () => { + const response = { + data: [ + { + name: 'v1.2', + + // everything except "name" should be stripped + target: '2695effb5807a22ff3d138d593fd856244e155e7', + }, + ], + headers: { + [X_TOTAL_HEADER]: 23, + }, + }; + + mutations[types.RECEIVE_TAGS_SUCCESS](state, response); + + expect(state.matches.tags).toEqual({ + list: [ + { + name: 'v1.2', + }, + ], + totalCount: 23, + error: null, + }); + }); + }); + + describe(`${types.RECEIVE_TAGS_ERROR}`, () => { + it('updates state.matches.tags to an empty state with the error object', () => { + const error = new Error('Something went wrong!'); + + state.matches.tags = { + list: [{ name: 'v1.2' }], + totalCount: 1, + error: null, + }; + + mutations[types.RECEIVE_TAGS_ERROR](state, error); + + expect(state.matches.tags).toEqual({ + list: [], + totalCount: 0, + error, + }); + }); + }); + + describe(`${types.RECEIVE_COMMITS_SUCCESS}`, () => { + it('updates state.matches.commits based on the provided API response', () => { + const response = { + data: { + id: '2695effb5807a22ff3d138d593fd856244e155e7', + short_id: '2695effb580', + title: 'Initial commit', + + // everything except "id", "short_id", and "title" should be stripped + author_name: 'Example User', + }, + }; + + mutations[types.RECEIVE_COMMITS_SUCCESS](state, response); + + expect(state.matches.commits).toEqual({ + list: [ + { + name: '2695effb580', + value: '2695effb5807a22ff3d138d593fd856244e155e7', + subtitle: 'Initial commit', + }, + ], + totalCount: 1, + error: null, + }); + }); + }); + + describe(`${types.RECEIVE_COMMITS_ERROR}`, () => { + it('updates state.matches.commits to an empty state with the error object', () => { + const error = new Error('Something went wrong!'); + + state.matches.commits = { + list: [{ name: 'abcd0123' }], + totalCount: 1, + error: null, + }; + + mutations[types.RECEIVE_COMMITS_ERROR](state, error); + + expect(state.matches.commits).toEqual({ + list: [], + totalCount: 0, + error, + }); + }); + }); + + describe(`${types.RESET_COMMIT_MATCHES}`, () => { + it('resets the commit results back to their original (empty) state', () => { + state.matches.commits = { + list: [{ name: 'abcd0123' }], + totalCount: 1, + error: null, + }; + + mutations[types.RESET_COMMIT_MATCHES](state); + + expect(state.matches.commits).toEqual({ + list: [], + totalCount: 0, + error: null, + }); + }); + }); +}); diff --git a/spec/frontend/registry/explorer/components/delete_button_spec.js b/spec/frontend/registry/explorer/components/delete_button_spec.js new file mode 100644 index 00000000000..bb0fe81117a --- /dev/null +++ b/spec/frontend/registry/explorer/components/delete_button_spec.js @@ -0,0 +1,73 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import component from '~/registry/explorer/components/delete_button.vue'; + +describe('delete_button', () => { + let wrapper; + + const defaultProps = { + title: 'Foo title', + tooltipTitle: 'Bar tooltipTitle', + }; + + const findButton = () => wrapper.find(GlButton); + + const mountComponent = props => { + wrapper = shallowMount(component, { + propsData: { + ...defaultProps, + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('tooltip', () => { + it('the title is controlled by tooltipTitle prop', () => { + mountComponent(); + const tooltip = getBinding(wrapper.element, 'gl-tooltip'); + expect(tooltip).toBeDefined(); + expect(tooltip.value.title).toBe(defaultProps.tooltipTitle); + }); + + it('is disabled when tooltipTitle is disabled', () => { + mountComponent({ tooltipDisabled: true }); + const tooltip = getBinding(wrapper.element, 'gl-tooltip'); + expect(tooltip.value.disabled).toBe(true); + }); + + describe('button', () => { + it('exists', () => { + mountComponent(); + expect(findButton().exists()).toBe(true); + }); + + it('has the correct props/attributes bound', () => { + mountComponent({ disabled: true }); + expect(findButton().attributes()).toMatchObject({ + 'aria-label': 'Foo title', + category: 'secondary', + icon: 'remove', + title: 'Foo title', + variant: 'danger', + disabled: 'true', + }); + }); + + it('emits a delete event', () => { + mountComponent(); + expect(wrapper.emitted('delete')).toEqual(undefined); + findButton().vm.$emit('click'); + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + }); + }); +}); diff --git a/spec/frontend/registry/explorer/components/details_page/details_row_spec.js b/spec/frontend/registry/explorer/components/details_page/details_row_spec.js new file mode 100644 index 00000000000..95b8e18d677 --- /dev/null +++ b/spec/frontend/registry/explorer/components/details_page/details_row_spec.js @@ -0,0 +1,43 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import component from '~/registry/explorer/components/details_page/details_row.vue'; + +describe('DetailsRow', () => { + let wrapper; + + const findIcon = () => wrapper.find(GlIcon); + const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]'); + + const mountComponent = () => { + wrapper = shallowMount(component, { + propsData: { + icon: 'clock', + }, + slots: { + default: '<div data-testid="default-slot"></div>', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('contains an icon', () => { + mountComponent(); + expect(findIcon().exists()).toBe(true); + }); + + it('icon has the correct props', () => { + mountComponent(); + expect(findIcon().props()).toMatchObject({ + name: 'clock', + }); + }); + + it('has a default slot', () => { + mountComponent(); + expect(findDefaultSlot().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/registry/explorer/components/details_page/empty_tags_state.js b/spec/frontend/registry/explorer/components/details_page/empty_tags_state_spec.js index da80c75a26a..09afd9d2d84 100644 --- a/spec/frontend/registry/explorer/components/details_page/empty_tags_state.js +++ b/spec/frontend/registry/explorer/components/details_page/empty_tags_state_spec.js @@ -29,7 +29,7 @@ describe('EmptyTagsState component', () => { it('contains gl-empty-state', () => { mountComponent(); - expect(findEmptyState().exist()).toBe(true); + expect(findEmptyState().exists()).toBe(true); }); it('has the correct props', () => { diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js new file mode 100644 index 00000000000..9e876d6d8a3 --- /dev/null +++ b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js @@ -0,0 +1,330 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlFormCheckbox, GlSprintf, GlIcon } from '@gitlab/ui'; + +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import component from '~/registry/explorer/components/details_page/tags_list_row.vue'; +import DeleteButton from '~/registry/explorer/components/delete_button.vue'; +import DetailsRow from '~/registry/explorer/components/details_page/details_row.vue'; +import { + REMOVE_TAG_BUTTON_TITLE, + REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, + MISSING_MANIFEST_WARNING_TOOLTIP, + NOT_AVAILABLE_TEXT, + NOT_AVAILABLE_SIZE, +} from '~/registry/explorer/constants/index'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +import { tagsListResponse } from '../../mock_data'; +import { ListItem } from '../../stubs'; + +describe('tags list row', () => { + let wrapper; + const [tag] = [...tagsListResponse.data]; + + const defaultProps = { tag, isDesktop: true, index: 0 }; + + const findCheckbox = () => wrapper.find(GlFormCheckbox); + const findName = () => wrapper.find('[data-testid="name"]'); + const findSize = () => wrapper.find('[data-testid="size"]'); + const findTime = () => wrapper.find('[data-testid="time"]'); + const findShortRevision = () => wrapper.find('[data-testid="digest"]'); + const findClipboardButton = () => wrapper.find(ClipboardButton); + const findDeleteButton = () => wrapper.find(DeleteButton); + const findTimeAgoTooltip = () => wrapper.find(TimeAgoTooltip); + const findDetailsRows = () => wrapper.findAll(DetailsRow); + const findPublishedDateDetail = () => wrapper.find('[data-testid="published-date-detail"]'); + const findManifestDetail = () => wrapper.find('[data-testid="manifest-detail"]'); + const findConfigurationDetail = () => wrapper.find('[data-testid="configuration-detail"]'); + const findWarningIcon = () => wrapper.find(GlIcon); + + const mountComponent = (propsData = defaultProps) => { + wrapper = shallowMount(component, { + stubs: { + GlSprintf, + ListItem, + DetailsRow, + }, + propsData, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('checkbox', () => { + it('exists', () => { + mountComponent(); + + expect(findCheckbox().exists()).toBe(true); + }); + + it("does not exist when the row can't be deleted", () => { + const customTag = { ...tag, destroy_path: '' }; + + mountComponent({ ...defaultProps, tag: customTag }); + + expect(findCheckbox().exists()).toBe(false); + }); + + it('is disabled when the digest is missing', () => { + mountComponent({ tag: { ...tag, digest: null } }); + expect(findCheckbox().attributes('disabled')).toBe('true'); + }); + + it('is wired to the selected prop', () => { + mountComponent({ ...defaultProps, selected: true }); + + expect(findCheckbox().attributes('checked')).toBe('true'); + }); + + it('when changed emit a select event', () => { + mountComponent(); + + findCheckbox().vm.$emit('change'); + + expect(wrapper.emitted('select')).toEqual([[]]); + }); + }); + + describe('tag name', () => { + it('exists', () => { + mountComponent(); + + expect(findName().exists()).toBe(true); + }); + + it('has the correct text', () => { + mountComponent(); + + expect(findName().text()).toBe(tag.name); + }); + + it('has a tooltip', () => { + mountComponent(); + + const tooltip = getBinding(findName().element, 'gl-tooltip'); + + expect(tooltip.value.title).toBe(tag.name); + }); + + it('on mobile has mw-s class', () => { + mountComponent({ ...defaultProps, isDesktop: false }); + + expect(findName().classes('mw-s')).toBe(true); + }); + }); + + describe('clipboard button', () => { + it('exist if tag.location exist', () => { + mountComponent(); + + expect(findClipboardButton().exists()).toBe(true); + }); + + it('is hidden if tag does not have a location', () => { + mountComponent({ ...defaultProps, tag: { ...tag, location: null } }); + + expect(findClipboardButton().exists()).toBe(false); + }); + + it('has the correct props/attributes', () => { + mountComponent(); + + expect(findClipboardButton().attributes()).toMatchObject({ + text: 'location', + title: 'location', + }); + }); + }); + + describe('warning icon', () => { + it('is normally hidden', () => { + mountComponent(); + + expect(findWarningIcon().exists()).toBe(false); + }); + + it('is shown when the tag is broken', () => { + mountComponent({ tag: { ...tag, digest: null } }); + + expect(findWarningIcon().exists()).toBe(true); + }); + + it('has an appropriate tooltip', () => { + mountComponent({ tag: { ...tag, digest: null } }); + + const tooltip = getBinding(findWarningIcon().element, 'gl-tooltip'); + expect(tooltip.value.title).toBe(MISSING_MANIFEST_WARNING_TOOLTIP); + }); + }); + + describe('size', () => { + it('exists', () => { + mountComponent(); + + expect(findSize().exists()).toBe(true); + }); + + it('contains the total_size and layers', () => { + mountComponent({ ...defaultProps, tag: { ...tag, total_size: 1024 } }); + + expect(findSize().text()).toMatchInterpolatedText('1.00 KiB · 10 layers'); + }); + + it('when total_size is missing', () => { + mountComponent(); + + expect(findSize().text()).toMatchInterpolatedText(`${NOT_AVAILABLE_SIZE} · 10 layers`); + }); + + it('when layers are missing', () => { + mountComponent({ ...defaultProps, tag: { ...tag, total_size: 1024, layers: null } }); + + expect(findSize().text()).toMatchInterpolatedText('1.00 KiB'); + }); + + it('when there is 1 layer', () => { + mountComponent({ ...defaultProps, tag: { ...tag, layers: 1 } }); + + expect(findSize().text()).toMatchInterpolatedText(`${NOT_AVAILABLE_SIZE} · 1 layer`); + }); + }); + + describe('time', () => { + it('exists', () => { + mountComponent(); + + expect(findTime().exists()).toBe(true); + }); + + it('has the correct text', () => { + mountComponent(); + + expect(findTime().text()).toBe('Published'); + }); + + it('contains time_ago_tooltip component', () => { + mountComponent(); + + expect(findTimeAgoTooltip().exists()).toBe(true); + }); + + it('pass the correct props to time ago tooltip', () => { + mountComponent(); + + expect(findTimeAgoTooltip().attributes()).toMatchObject({ time: tag.created_at }); + }); + }); + + describe('digest', () => { + it('exists', () => { + mountComponent(); + + expect(findShortRevision().exists()).toBe(true); + }); + + it('has the correct text', () => { + mountComponent(); + + expect(findShortRevision().text()).toMatchInterpolatedText('Digest: 1ab51d5'); + }); + + it(`displays ${NOT_AVAILABLE_TEXT} when digest is missing`, () => { + mountComponent({ tag: { ...tag, digest: null } }); + + expect(findShortRevision().text()).toMatchInterpolatedText(`Digest: ${NOT_AVAILABLE_TEXT}`); + }); + }); + + describe('delete button', () => { + it('exists', () => { + mountComponent(); + + expect(findDeleteButton().exists()).toBe(true); + }); + + it('has the correct props/attributes', () => { + mountComponent(); + + expect(findDeleteButton().attributes()).toMatchObject({ + title: REMOVE_TAG_BUTTON_TITLE, + tooltiptitle: REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, + tooltipdisabled: 'true', + }); + }); + + it.each` + destroy_path | digest + ${'foo'} | ${null} + ${null} | ${'foo'} + ${null} | ${null} + `( + 'is disabled when destroy_path is $destroy_path and digest is $digest', + ({ destroy_path, digest }) => { + mountComponent({ ...defaultProps, tag: { ...tag, destroy_path, digest } }); + + expect(findDeleteButton().attributes('disabled')).toBe('true'); + }, + ); + + it('delete event emits delete', () => { + mountComponent(); + + findDeleteButton().vm.$emit('delete'); + + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + }); + + describe('details rows', () => { + describe('when the tag has a digest', () => { + beforeEach(() => { + mountComponent(); + + return wrapper.vm.$nextTick(); + }); + + it('has 3 details rows', () => { + expect(findDetailsRows().length).toBe(3); + }); + + describe.each` + name | finderFunction | text | icon | clipboard + ${'published date detail'} | ${findPublishedDateDetail} | ${'Published to the bar image repository at 10:23 GMT+0000 on 2020-06-29'} | ${'clock'} | ${false} + ${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:1ab51d519f574b636ae7788051c60239334ae8622a9fd82a0cf7bae7786dfd5c'} | ${'log'} | ${true} + ${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43'} | ${'cloud-gear'} | ${true} + `('$name details row', ({ finderFunction, text, icon, clipboard }) => { + it(`has ${text} as text`, () => { + expect(finderFunction().text()).toMatchInterpolatedText(text); + }); + + it(`has the ${icon} icon`, () => { + expect(finderFunction().props('icon')).toBe(icon); + }); + + it(`is ${clipboard} that clipboard button exist`, () => { + expect( + finderFunction() + .find(ClipboardButton) + .exists(), + ).toBe(clipboard); + }); + }); + }); + + describe('when the tag does not have a digest', () => { + it('hides the details rows', async () => { + mountComponent({ tag: { ...tag, digest: null } }); + + await wrapper.vm.$nextTick(); + expect(findDetailsRows().length).toBe(0); + }); + }); + }); +}); diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js new file mode 100644 index 00000000000..1f560753476 --- /dev/null +++ b/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js @@ -0,0 +1,146 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import component from '~/registry/explorer/components/details_page/tags_list.vue'; +import TagsListRow from '~/registry/explorer/components/details_page/tags_list_row.vue'; +import { TAGS_LIST_TITLE, REMOVE_TAGS_BUTTON_TITLE } from '~/registry/explorer/constants/index'; +import { tagsListResponse } from '../../mock_data'; + +describe('Tags List', () => { + let wrapper; + const tags = [...tagsListResponse.data]; + const readOnlyTags = tags.map(t => ({ ...t, destroy_path: undefined })); + + const findTagsListRow = () => wrapper.findAll(TagsListRow); + const findDeleteButton = () => wrapper.find(GlButton); + const findListTitle = () => wrapper.find('[data-testid="list-title"]'); + + const mountComponent = (propsData = { tags, isDesktop: true }) => { + wrapper = shallowMount(component, { + propsData, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('List title', () => { + it('exists', () => { + mountComponent(); + + expect(findListTitle().exists()).toBe(true); + }); + + it('has the correct text', () => { + mountComponent(); + + expect(findListTitle().text()).toBe(TAGS_LIST_TITLE); + }); + }); + + describe('delete button', () => { + it.each` + inputTags | isDesktop | isVisible + ${tags} | ${true} | ${true} + ${tags} | ${false} | ${false} + ${readOnlyTags} | ${true} | ${false} + ${readOnlyTags} | ${false} | ${false} + `( + 'is $isVisible that delete button exists when tags is $inputTags and isDesktop is $isDesktop', + ({ inputTags, isDesktop, isVisible }) => { + mountComponent({ tags: inputTags, isDesktop }); + + expect(findDeleteButton().exists()).toBe(isVisible); + }, + ); + + it('has the correct text', () => { + mountComponent(); + + expect(findDeleteButton().text()).toBe(REMOVE_TAGS_BUTTON_TITLE); + }); + + it('has the correct props', () => { + mountComponent(); + + expect(findDeleteButton().attributes()).toMatchObject({ + category: 'secondary', + variant: 'danger', + }); + }); + + it('is disabled when no item is selected', () => { + mountComponent(); + + expect(findDeleteButton().attributes('disabled')).toBe('true'); + }); + + it('is enabled when at least one item is selected', async () => { + mountComponent(); + findTagsListRow() + .at(0) + .vm.$emit('select'); + await wrapper.vm.$nextTick(); + expect(findDeleteButton().attributes('disabled')).toBe(undefined); + }); + + it('click event emits a deleted event with selected items', () => { + mountComponent(); + findTagsListRow() + .at(0) + .vm.$emit('select'); + + findDeleteButton().vm.$emit('click'); + expect(wrapper.emitted('delete')).toEqual([[{ centos6: true }]]); + }); + }); + + describe('list rows', () => { + it('one row exist for each tag', () => { + mountComponent(); + + expect(findTagsListRow()).toHaveLength(tags.length); + }); + + it('the correct props are bound to it', () => { + mountComponent(); + + const rows = findTagsListRow(); + + expect(rows.at(0).attributes()).toMatchObject({ + first: 'true', + isdesktop: 'true', + }); + + // The list has only two tags and for some reasons .at(-1) does not work + expect(rows.at(1).attributes()).toMatchObject({ + last: 'true', + isdesktop: 'true', + }); + }); + + describe('events', () => { + it('select event update the selected items', async () => { + mountComponent(); + findTagsListRow() + .at(0) + .vm.$emit('select'); + await wrapper.vm.$nextTick(); + expect( + findTagsListRow() + .at(0) + .attributes('selected'), + ).toBe('true'); + }); + + it('delete event emit a delete event', () => { + mountComponent(); + findTagsListRow() + .at(0) + .vm.$emit('delete'); + expect(wrapper.emitted('delete')).toEqual([[{ centos6: true }]]); + }); + }); + }); +}); diff --git a/spec/frontend/registry/explorer/components/details_page/tags_table_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_table_spec.js deleted file mode 100644 index a60a362dcfe..00000000000 --- a/spec/frontend/registry/explorer/components/details_page/tags_table_spec.js +++ /dev/null @@ -1,286 +0,0 @@ -import { mount } from '@vue/test-utils'; -import stubChildren from 'helpers/stub_children'; -import component from '~/registry/explorer/components/details_page/tags_table.vue'; -import { tagsListResponse } from '../../mock_data'; - -describe('tags_table', () => { - let wrapper; - const tags = [...tagsListResponse.data]; - - const findMainCheckbox = () => wrapper.find('[data-testid="mainCheckbox"]'); - const findFirstRowItem = testid => wrapper.find(`[data-testid="${testid}"]`); - const findBulkDeleteButton = () => wrapper.find('[data-testid="bulkDeleteButton"]'); - const findAllDeleteButtons = () => wrapper.findAll('[data-testid="singleDeleteButton"]'); - const findAllCheckboxes = () => wrapper.findAll('[data-testid="rowCheckbox"]'); - const findCheckedCheckboxes = () => findAllCheckboxes().filter(c => c.attributes('checked')); - const findFirsTagColumn = () => wrapper.find('.js-tag-column'); - const findFirstTagNameText = () => wrapper.find('[data-testid="rowNameText"]'); - - const findLoaderSlot = () => wrapper.find('[data-testid="loaderSlot"]'); - const findEmptySlot = () => wrapper.find('[data-testid="emptySlot"]'); - - const mountComponent = (propsData = { tags, isDesktop: true }) => { - wrapper = mount(component, { - stubs: { - ...stubChildren(component), - GlTable: false, - }, - propsData, - slots: { - loader: '<div data-testid="loaderSlot"></div>', - empty: '<div data-testid="emptySlot"></div>', - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it.each([ - 'rowCheckbox', - 'rowName', - 'rowShortRevision', - 'rowSize', - 'rowTime', - 'singleDeleteButton', - ])('%s exist in the table', element => { - mountComponent(); - - expect(findFirstRowItem(element).exists()).toBe(true); - }); - - describe('header checkbox', () => { - it('exists', () => { - mountComponent(); - expect(findMainCheckbox().exists()).toBe(true); - }); - - it('if selected selects all the rows', () => { - mountComponent(); - findMainCheckbox().vm.$emit('change'); - return wrapper.vm.$nextTick().then(() => { - expect(findMainCheckbox().attributes('checked')).toBeTruthy(); - expect(findCheckedCheckboxes()).toHaveLength(tags.length); - }); - }); - - it('if deselect deselects all the row', () => { - mountComponent(); - findMainCheckbox().vm.$emit('change'); - return wrapper.vm - .$nextTick() - .then(() => { - expect(findMainCheckbox().attributes('checked')).toBeTruthy(); - findMainCheckbox().vm.$emit('change'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(findMainCheckbox().attributes('checked')).toBe(undefined); - expect(findCheckedCheckboxes()).toHaveLength(0); - }); - }); - }); - - describe('row checkbox', () => { - beforeEach(() => { - mountComponent(); - }); - - it('selecting and deselecting the checkbox works as intended', () => { - findFirstRowItem('rowCheckbox').vm.$emit('change'); - return wrapper.vm - .$nextTick() - .then(() => { - expect(wrapper.vm.selectedItems).toEqual([tags[0].name]); - expect(findFirstRowItem('rowCheckbox').attributes('checked')).toBeTruthy(); - findFirstRowItem('rowCheckbox').vm.$emit('change'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(wrapper.vm.selectedItems.length).toBe(0); - expect(findFirstRowItem('rowCheckbox').attributes('checked')).toBe(undefined); - }); - }); - }); - - describe('header delete button', () => { - beforeEach(() => { - mountComponent(); - }); - - it('exists', () => { - expect(findBulkDeleteButton().exists()).toBe(true); - }); - - it('is disabled if no item is selected', () => { - expect(findBulkDeleteButton().attributes('disabled')).toBe('true'); - }); - - it('is enabled if at least one item is selected', () => { - expect(findBulkDeleteButton().attributes('disabled')).toBe('true'); - findFirstRowItem('rowCheckbox').vm.$emit('change'); - return wrapper.vm.$nextTick().then(() => { - expect(findBulkDeleteButton().attributes('disabled')).toBeFalsy(); - }); - }); - - describe('on click', () => { - it('when one item is selected', () => { - findFirstRowItem('rowCheckbox').vm.$emit('change'); - findBulkDeleteButton().vm.$emit('click'); - expect(wrapper.emitted('delete')).toEqual([[['centos6']]]); - }); - - it('when multiple items are selected', () => { - findMainCheckbox().vm.$emit('change'); - findBulkDeleteButton().vm.$emit('click'); - - expect(wrapper.emitted('delete')).toEqual([[tags.map(t => t.name)]]); - }); - }); - }); - - describe('row delete button', () => { - beforeEach(() => { - mountComponent(); - }); - - it('exists', () => { - expect( - findAllDeleteButtons() - .at(0) - .exists(), - ).toBe(true); - }); - - it('is disabled if the item has no destroy_path', () => { - expect( - findAllDeleteButtons() - .at(1) - .attributes('disabled'), - ).toBe('true'); - }); - - it('on click', () => { - findAllDeleteButtons() - .at(0) - .vm.$emit('click'); - - expect(wrapper.emitted('delete')).toEqual([[['centos6']]]); - }); - }); - - describe('name cell', () => { - it('tag column has a tooltip with the tag name', () => { - mountComponent(); - expect(findFirstTagNameText().attributes('title')).toBe(tagsListResponse.data[0].name); - }); - - describe('on desktop viewport', () => { - beforeEach(() => { - mountComponent(); - }); - - it('table header has class w-25', () => { - expect(findFirsTagColumn().classes()).toContain('w-25'); - }); - - it('tag column has the mw-m class', () => { - expect(findFirstRowItem('rowName').classes()).toContain('mw-m'); - }); - }); - - describe('on mobile viewport', () => { - beforeEach(() => { - mountComponent({ tags, isDesktop: false }); - }); - - it('table header does not have class w-25', () => { - expect(findFirsTagColumn().classes()).not.toContain('w-25'); - }); - - it('tag column has the gl-justify-content-end class', () => { - expect(findFirstRowItem('rowName').classes()).toContain('gl-justify-content-end'); - }); - }); - }); - - describe('last updated cell', () => { - let timeCell; - - beforeEach(() => { - mountComponent(); - timeCell = findFirstRowItem('rowTime'); - }); - - it('displays the time in string format', () => { - expect(timeCell.text()).toBe('2 years ago'); - }); - - it('has a tooltip timestamp', () => { - expect(timeCell.attributes('title')).toBe('Sep 19, 2017 1:45pm GMT+0000'); - }); - }); - - describe('empty state slot', () => { - describe('when the table is empty', () => { - beforeEach(() => { - mountComponent({ tags: [], isDesktop: true }); - }); - - it('does not show table rows', () => { - expect(findFirstTagNameText().exists()).toBe(false); - }); - - it('has the empty state slot', () => { - expect(findEmptySlot().exists()).toBe(true); - }); - }); - - describe('when the table is not empty', () => { - beforeEach(() => { - mountComponent({ tags, isDesktop: true }); - }); - - it('does show table rows', () => { - expect(findFirstTagNameText().exists()).toBe(true); - }); - - it('does not show the empty state', () => { - expect(findEmptySlot().exists()).toBe(false); - }); - }); - }); - - describe('loader slot', () => { - describe('when the data is loading', () => { - beforeEach(() => { - mountComponent({ isLoading: true, tags }); - }); - - it('show the loader', () => { - expect(findLoaderSlot().exists()).toBe(true); - }); - - it('does not show the table rows', () => { - expect(findFirstTagNameText().exists()).toBe(false); - }); - }); - - describe('when the data is not loading', () => { - beforeEach(() => { - mountComponent({ isLoading: false, tags }); - }); - - it('does not show the loader', () => { - expect(findLoaderSlot().exists()).toBe(false); - }); - - it('shows the table rows', () => { - expect(findFirstTagNameText().exists()).toBe(true); - }); - }); - }); -}); diff --git a/spec/frontend/registry/explorer/components/list_item_spec.js b/spec/frontend/registry/explorer/components/list_item_spec.js new file mode 100644 index 00000000000..f244627a8c3 --- /dev/null +++ b/spec/frontend/registry/explorer/components/list_item_spec.js @@ -0,0 +1,156 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import component from '~/registry/explorer/components/list_item.vue'; + +describe('list item', () => { + let wrapper; + + const findLeftActionSlot = () => wrapper.find('[data-testid="left-action"]'); + const findLeftPrimarySlot = () => wrapper.find('[data-testid="left-primary"]'); + const findLeftSecondarySlot = () => wrapper.find('[data-testid="left-secondary"]'); + const findRightPrimarySlot = () => wrapper.find('[data-testid="right-primary"]'); + const findRightSecondarySlot = () => wrapper.find('[data-testid="right-secondary"]'); + const findRightActionSlot = () => wrapper.find('[data-testid="right-action"]'); + const findDetailsSlot = name => wrapper.find(`[data-testid="${name}"]`); + const findToggleDetailsButton = () => wrapper.find(GlButton); + + const mountComponent = (propsData, slots) => { + wrapper = shallowMount(component, { + propsData, + slots: { + 'left-action': '<div data-testid="left-action" />', + 'left-primary': '<div data-testid="left-primary" />', + 'left-secondary': '<div data-testid="left-secondary" />', + 'right-primary': '<div data-testid="right-primary" />', + 'right-secondary': '<div data-testid="right-secondary" />', + 'right-action': '<div data-testid="right-action" />', + ...slots, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it.each` + slotName | finderFunction + ${'left-primary'} | ${findLeftPrimarySlot} + ${'left-secondary'} | ${findLeftSecondarySlot} + ${'right-primary'} | ${findRightPrimarySlot} + ${'right-secondary'} | ${findRightSecondarySlot} + ${'left-action'} | ${findLeftActionSlot} + ${'right-action'} | ${findRightActionSlot} + `('has a $slotName slot', ({ finderFunction }) => { + mountComponent(); + + expect(finderFunction().exists()).toBe(true); + }); + + describe.each` + slotNames + ${['details_foo']} + ${['details_foo', 'details_bar']} + ${['details_foo', 'details_bar', 'details_baz']} + `('$slotNames details slots', ({ slotNames }) => { + const slotMocks = slotNames.reduce((acc, current) => { + acc[current] = `<div data-testid="${current}" />`; + return acc; + }, {}); + + it('are visible when details is shown', async () => { + mountComponent({}, slotMocks); + + await wrapper.vm.$nextTick(); + findToggleDetailsButton().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + slotNames.forEach(name => { + expect(findDetailsSlot(name).exists()).toBe(true); + }); + }); + it('are not visible when details are not shown', () => { + mountComponent({}, slotMocks); + + slotNames.forEach(name => { + expect(findDetailsSlot(name).exists()).toBe(false); + }); + }); + }); + + describe('details toggle button', () => { + it('is visible when at least one details slot exists', async () => { + mountComponent({}, { details_foo: '<span></span>' }); + await wrapper.vm.$nextTick(); + expect(findToggleDetailsButton().exists()).toBe(true); + }); + + it('is hidden without details slot', () => { + mountComponent(); + expect(findToggleDetailsButton().exists()).toBe(false); + }); + }); + + describe('disabled prop', () => { + it('when true applies disabled-content class', () => { + mountComponent({ disabled: true }); + + expect(wrapper.classes('disabled-content')).toBe(true); + }); + + it('when false does not apply disabled-content class', () => { + mountComponent({ disabled: false }); + + expect(wrapper.classes('disabled-content')).toBe(false); + }); + }); + + describe('first prop', () => { + it('when is true displays a double top border', () => { + mountComponent({ first: true }); + + expect(wrapper.classes('gl-border-t-2')).toBe(true); + }); + + it('when is false display a single top border', () => { + mountComponent({ first: false }); + + expect(wrapper.classes('gl-border-t-1')).toBe(true); + }); + }); + + describe('last prop', () => { + it('when is true displays a double bottom border', () => { + mountComponent({ last: true }); + + expect(wrapper.classes('gl-border-b-2')).toBe(true); + }); + + it('when is false display a single bottom border', () => { + mountComponent({ last: false }); + + expect(wrapper.classes('gl-border-b-1')).toBe(true); + }); + }); + + describe('selected prop', () => { + it('when true applies the selected border and background', () => { + mountComponent({ selected: true }); + + expect(wrapper.classes()).toEqual( + expect.arrayContaining(['gl-bg-blue-50', 'gl-border-blue-200']), + ); + expect(wrapper.classes()).toEqual(expect.not.arrayContaining(['gl-border-gray-100'])); + }); + + it('when false applies the default border', () => { + mountComponent({ selected: false }); + + expect(wrapper.classes()).toEqual( + expect.not.arrayContaining(['gl-bg-blue-50', 'gl-border-blue-200']), + ); + expect(wrapper.classes()).toEqual(expect.arrayContaining(['gl-border-gray-100'])); + }); + }); +}); diff --git a/spec/frontend/registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap b/spec/frontend/registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap index 3761369c944..a8412e2bde9 100644 --- a/spec/frontend/registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap +++ b/spec/frontend/registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap @@ -2,13 +2,10 @@ exports[`Registry Group Empty state to match the default snapshot 1`] = ` <div - class="container-message" svg-path="foo" title="There are no container images available in this group" > - <p - class="js-no-container-images-text" - > + <p> With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. <gl-link-stub href="baz" diff --git a/spec/frontend/registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap b/spec/frontend/registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap index d8ec9c3ca4d..8413e17c7b2 100644 --- a/spec/frontend/registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap +++ b/spec/frontend/registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap @@ -2,13 +2,10 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` <div - class="container-message" svg-path="bazFoo" title="There are no container images stored for this project" > - <p - class="js-no-container-images-text" - > + <p> With the Container Registry, every project can have its own space to store its Docker images. <gl-link-stub href="baz" @@ -22,9 +19,7 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` CLI Commands </h5> - <p - class="js-not-logged-in-to-registry-text" - > + <p> If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have <gl-link-stub href="barBaz" @@ -42,78 +37,50 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` instead of a password. </p> - <div - class="input-group append-bottom-10" + <gl-form-input-group-stub + class="gl-mb-4" + predefinedoptions="[object Object]" + value="" > - <input - class="form-control monospace" - readonly="readonly" + <gl-form-input-stub + class="gl-font-monospace!" + readonly="" type="text" + value="docker login bar" /> - - <span - class="input-group-append" - > - <clipboard-button-stub - class="input-group-text" - cssclass="btn-default" - text="docker login bar" - title="Copy login command" - tooltipplacement="top" - /> - </span> - </div> + </gl-form-input-group-stub> - <p /> - - <p> + <p + class="gl-mb-4" + > You can add an image to this registry with the following commands: </p> - <div - class="input-group append-bottom-10" + <gl-form-input-group-stub + class="gl-mb-4 " + predefinedoptions="[object Object]" + value="" > - <input - class="form-control monospace" - readonly="readonly" + <gl-form-input-stub + class="gl-font-monospace!" + readonly="" type="text" + value="docker build -t foo ." /> - - <span - class="input-group-append" - > - <clipboard-button-stub - class="input-group-text" - cssclass="btn-default" - text="docker build -t foo ." - title="Copy build command" - tooltipplacement="top" - /> - </span> - </div> + </gl-form-input-group-stub> - <div - class="input-group" + <gl-form-input-group-stub + predefinedoptions="[object Object]" + value="" > - <input - class="form-control monospace" - readonly="readonly" + <gl-form-input-stub + class="gl-font-monospace!" + readonly="" type="text" + value="docker push foo" /> - - <span - class="input-group-append" - > - <clipboard-button-stub - class="input-group-text" - cssclass="btn-default" - text="docker push foo" - title="Copy push command" - tooltipplacement="top" - /> - </span> - </div> + </gl-form-input-group-stub> </div> `; diff --git a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js index 78de35ae1dc..aaeaaf00748 100644 --- a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js @@ -1,11 +1,14 @@ import { shallowMount } from '@vue/test-utils'; import { GlIcon, GlSprintf } from '@gitlab/ui'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import Component from '~/registry/explorer/components/list_page/image_list_row.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import Component from '~/registry/explorer/components/list_page/image_list_row.vue'; +import ListItem from '~/registry/explorer/components/list_item.vue'; +import DeleteButton from '~/registry/explorer/components/delete_button.vue'; import { ROW_SCHEDULED_FOR_DELETION, LIST_DELETE_BUTTON_DISABLED, + REMOVE_REPOSITORY_LABEL, } from '~/registry/explorer/constants'; import { RouterLink } from '../../stubs'; import { imagesListResponse } from '../../mock_data'; @@ -13,10 +16,10 @@ import { imagesListResponse } from '../../mock_data'; describe('Image List Row', () => { let wrapper; const item = imagesListResponse.data[0]; - const findDeleteBtn = () => wrapper.find('[data-testid="deleteImageButton"]'); + const findDetailsLink = () => wrapper.find('[data-testid="detailsLink"]'); const findTagsCount = () => wrapper.find('[data-testid="tagsCount"]'); - const findDeleteButtonWrapper = () => wrapper.find('[data-testid="deleteButtonWrapper"]'); + const findDeleteBtn = () => wrapper.find(DeleteButton); const findClipboardButton = () => wrapper.find(ClipboardButton); const mountComponent = props => { @@ -24,6 +27,7 @@ describe('Image List Row', () => { stubs: { RouterLink, GlSprintf, + ListItem, }, propsData: { item, @@ -72,29 +76,24 @@ describe('Image List Row', () => { }); }); - describe('delete button wrapper', () => { - it('has a tooltip', () => { - mountComponent(); - const tooltip = getBinding(findDeleteButtonWrapper().element, 'gl-tooltip'); - expect(tooltip).toBeDefined(); - expect(tooltip.value.title).toBe(LIST_DELETE_BUTTON_DISABLED); - }); - it('tooltip is enabled when destroy_path is falsy', () => { - mountComponent({ item: { ...item, destroy_path: null } }); - const tooltip = getBinding(findDeleteButtonWrapper().element, 'gl-tooltip'); - expect(tooltip.value.disabled).toBeFalsy(); - }); - }); - describe('delete button', () => { it('exists', () => { mountComponent(); expect(findDeleteBtn().exists()).toBe(true); }); + it('has the correct props', () => { + mountComponent(); + expect(findDeleteBtn().attributes()).toMatchObject({ + title: REMOVE_REPOSITORY_LABEL, + tooltipdisabled: `${Boolean(item.destroy_path)}`, + tooltiptitle: LIST_DELETE_BUTTON_DISABLED, + }); + }); + it('emits a delete event', () => { mountComponent(); - findDeleteBtn().vm.$emit('click'); + findDeleteBtn().vm.$emit('delete'); expect(wrapper.emitted('delete')).toEqual([[item]]); }); diff --git a/spec/frontend/registry/explorer/mock_data.js b/spec/frontend/registry/explorer/mock_data.js index e2b33826503..a7ffed4c9fd 100644 --- a/spec/frontend/registry/explorer/mock_data.js +++ b/spec/frontend/registry/explorer/mock_data.js @@ -70,9 +70,10 @@ export const tagsListResponse = { size: 19, layers: 10, location: 'location', - path: 'bar', - created_at: 1505828744434, + path: 'bar:centos6', + created_at: '2020-06-29T10:23:51.766+00:00', destroy_path: 'path', + digest: 'sha256:1ab51d519f574b636ae7788051c60239334ae8622a9fd82a0cf7bae7786dfd5c', }, { name: 'test-tag', @@ -80,9 +81,10 @@ export const tagsListResponse = { short_revision: 'b969de599', size: 19, layers: 10, - path: 'foo', + path: 'foo:test-tag', location: 'location-2', - created_at: 1505828744434, + created_at: '2020-06-29T10:23:51.766+00:00', + digest: 'sha256:1ab51d519f574b636ae7788051c60239334ae8622a9fd82a0cf7bae7736dfd5c', }, ], headers, diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js index b7e01cad9bc..9bc0bae5c23 100644 --- a/spec/frontend/registry/explorer/pages/details_spec.js +++ b/spec/frontend/registry/explorer/pages/details_spec.js @@ -5,6 +5,7 @@ import component from '~/registry/explorer/pages/details.vue'; import DeleteAlert from '~/registry/explorer/components/details_page/delete_alert.vue'; import DetailsHeader from '~/registry/explorer/components/details_page/details_header.vue'; import TagsLoader from '~/registry/explorer/components/details_page/tags_loader.vue'; +import TagsList from '~/registry/explorer/components/details_page/tags_list.vue'; import EmptyTagsState from '~/registry/explorer/components/details_page/empty_tags_state.vue'; import { createStore } from '~/registry/explorer/stores/'; import { @@ -15,7 +16,7 @@ import { } from '~/registry/explorer/stores/mutation_types/'; import { tagsListResponse } from '../mock_data'; -import { TagsTable, DeleteModal } from '../stubs'; +import { DeleteModal } from '../stubs'; describe('Details Page', () => { let wrapper; @@ -25,18 +26,23 @@ describe('Details Page', () => { const findDeleteModal = () => wrapper.find(DeleteModal); const findPagination = () => wrapper.find(GlPagination); const findTagsLoader = () => wrapper.find(TagsLoader); - const findTagsTable = () => wrapper.find(TagsTable); + const findTagsList = () => wrapper.find(TagsList); const findDeleteAlert = () => wrapper.find(DeleteAlert); const findDetailsHeader = () => wrapper.find(DetailsHeader); const findEmptyTagsState = () => wrapper.find(EmptyTagsState); const routeId = window.btoa(JSON.stringify({ name: 'foo', tags_path: 'bar' })); + const tagsArrayToSelectedTags = tags => + tags.reduce((acc, c) => { + acc[c.name] = true; + return acc; + }, {}); + const mountComponent = options => { wrapper = shallowMount(component, { store, stubs: { - TagsTable, DeleteModal, }, mocks: { @@ -66,15 +72,18 @@ describe('Details Page', () => { describe('when isLoading is true', () => { beforeEach(() => { - mountComponent(); store.commit(SET_MAIN_LOADING, true); - return wrapper.vm.$nextTick(); + mountComponent(); }); afterEach(() => store.commit(SET_MAIN_LOADING, false)); - it('binds isLoading to tags-table', () => { - expect(findTagsTable().props('isLoading')).toBe(true); + it('shows the loader', () => { + expect(findTagsLoader().exists()).toBe(true); + }); + + it('does not show the list', () => { + expect(findTagsList().exists()).toBe(false); }); it('does not show pagination', () => { @@ -82,8 +91,9 @@ describe('Details Page', () => { }); }); - describe('table slots', () => { + describe('when the list of tags is empty', () => { beforeEach(() => { + store.commit(SET_TAGS_LIST_SUCCESS, []); mountComponent(); }); @@ -91,32 +101,37 @@ describe('Details Page', () => { expect(findEmptyTagsState().exists()).toBe(true); }); - it('has a skeleton loader', () => { - expect(findTagsLoader().exists()).toBe(true); + it('does not show the loader', () => { + expect(findTagsLoader().exists()).toBe(false); + }); + + it('does not show the list', () => { + expect(findTagsList().exists()).toBe(false); }); }); - describe('table', () => { + describe('list', () => { beforeEach(() => { mountComponent(); }); it('exists', () => { - expect(findTagsTable().exists()).toBe(true); + expect(findTagsList().exists()).toBe(true); }); it('has the correct props bound', () => { - expect(findTagsTable().props()).toMatchObject({ + expect(findTagsList().props()).toMatchObject({ isDesktop: true, - isLoading: false, tags: store.state.tags, }); }); describe('deleteEvent', () => { describe('single item', () => { + let tagToBeDeleted; beforeEach(() => { - findTagsTable().vm.$emit('delete', [store.state.tags[0].name]); + [tagToBeDeleted] = store.state.tags; + findTagsList().vm.$emit('delete', { [tagToBeDeleted.name]: true }); }); it('open the modal', () => { @@ -124,7 +139,7 @@ describe('Details Page', () => { }); it('maps the selection to itemToBeDeleted', () => { - expect(wrapper.vm.itemsToBeDeleted).toEqual([store.state.tags[0]]); + expect(wrapper.vm.itemsToBeDeleted).toEqual([tagToBeDeleted]); }); it('tracks a single delete event', () => { @@ -136,7 +151,7 @@ describe('Details Page', () => { describe('multiple items', () => { beforeEach(() => { - findTagsTable().vm.$emit('delete', store.state.tags.map(t => t.name)); + findTagsList().vm.$emit('delete', tagsArrayToSelectedTags(store.state.tags)); }); it('open the modal', () => { @@ -202,7 +217,7 @@ describe('Details Page', () => { describe('when one item is selected to be deleted', () => { beforeEach(() => { mountComponent(); - findTagsTable().vm.$emit('delete', [store.state.tags[0].name]); + findTagsList().vm.$emit('delete', { [store.state.tags[0].name]: true }); }); it('dispatch requestDeleteTag with the right parameters', () => { @@ -217,7 +232,7 @@ describe('Details Page', () => { describe('when more than one item is selected to be deleted', () => { beforeEach(() => { mountComponent(); - findTagsTable().vm.$emit('delete', store.state.tags.map(t => t.name)); + findTagsList().vm.$emit('delete', tagsArrayToSelectedTags(store.state.tags)); }); it('dispatch requestDeleteTags with the right parameters', () => { diff --git a/spec/frontend/registry/explorer/stubs.js b/spec/frontend/registry/explorer/stubs.js index d3518c36c82..8f95fce2867 100644 --- a/spec/frontend/registry/explorer/stubs.js +++ b/spec/frontend/registry/explorer/stubs.js @@ -1,5 +1,5 @@ -import RealTagsTable from '~/registry/explorer/components/details_page/tags_table.vue'; import RealDeleteModal from '~/registry/explorer/components/details_page/delete_modal.vue'; +import RealListItem from '~/registry/explorer/components/list_item.vue'; export const GlModal = { template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>', @@ -18,11 +18,6 @@ export const RouterLink = { props: ['to'], }; -export const TagsTable = { - props: RealTagsTable.props, - template: `<div><slot name="empty"></slot><slot name="loader"></slot></div>`, -}; - export const DeleteModal = { template: '<div></div>', methods: { @@ -35,3 +30,13 @@ export const GlSkeletonLoader = { template: `<div><slot></slot></div>`, props: ['width', 'height'], }; + +export const ListItem = { + ...RealListItem, + data() { + return { + detailsSlots: [], + isDetailsShown: true, + }; + }, +}; diff --git a/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap b/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap index 966acdf52be..11393c89d06 100644 --- a/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap +++ b/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap @@ -2,24 +2,6 @@ exports[`Registry Settings App renders 1`] = ` <div> - <p> - - Tag expiration policy is designed to: - - </p> - - <ul> - <li> - Keep and protect the images that matter most. - </li> - - <li> - - Automatically remove extra images that aren't designed to be kept. - - </li> - </ul> - <settings-form-stub /> </div> `; diff --git a/spec/frontend/registry/settings/components/registry_settings_app_spec.js b/spec/frontend/registry/settings/components/registry_settings_app_spec.js index 95f784c9727..9551ee72e51 100644 --- a/spec/frontend/registry/settings/components/registry_settings_app_spec.js +++ b/spec/frontend/registry/settings/components/registry_settings_app_spec.js @@ -5,6 +5,11 @@ import SettingsForm from '~/registry/settings/components/settings_form.vue'; import { createStore } from '~/registry/settings/store/'; import { SET_SETTINGS, SET_INITIAL_STATE } from '~/registry/settings/store/mutation_types'; import { FETCH_SETTINGS_ERROR_MESSAGE } from '~/registry/shared/constants'; +import { + UNAVAILABLE_FEATURE_INTRO_TEXT, + UNAVAILABLE_USER_FEATURE_TEXT, +} from '~/registry/settings/constants'; + import { stringifiedFormOptions } from '../../shared/mock_data'; describe('Registry Settings App', () => { @@ -68,10 +73,8 @@ describe('Registry Settings App', () => { it('shows an alert', () => { const text = findAlert().text(); - expect(text).toContain( - 'The Container Registry tag expiration and retention policies for this project have not been enabled.', - ); - expect(text).toContain('Please contact your administrator.'); + expect(text).toContain(UNAVAILABLE_FEATURE_INTRO_TEXT); + expect(text).toContain(UNAVAILABLE_USER_FEATURE_TEXT); }); describe('an admin is visiting the page', () => { diff --git a/spec/frontend/registry/settings/components/settings_form_spec.js b/spec/frontend/registry/settings/components/settings_form_spec.js index 2b3e529b283..9b9ca92270c 100644 --- a/spec/frontend/registry/settings/components/settings_form_spec.js +++ b/spec/frontend/registry/settings/components/settings_form_spec.js @@ -7,6 +7,7 @@ import { UPDATE_SETTINGS_ERROR_MESSAGE, UPDATE_SETTINGS_SUCCESS_MESSAGE, } from '~/registry/shared/constants'; +import waitForPromises from 'helpers/wait_for_promises'; import { stringifiedFormOptions } from '../../shared/mock_data'; describe('Settings Form', () => { @@ -36,12 +37,17 @@ describe('Settings Form', () => { const findSaveButton = () => wrapper.find({ ref: 'save-button' }); const findLoadingIcon = (parent = wrapper) => parent.find(GlLoadingIcon); - const mountComponent = () => { + const mountComponent = (data = {}) => { wrapper = shallowMount(component, { stubs: { GlCard, GlLoadingIcon, }, + data() { + return { + ...data, + }; + }, mocks: { $toast: { show: jest.fn(), @@ -55,7 +61,6 @@ describe('Settings Form', () => { store = createStore(); store.dispatch('setInitialState', stringifiedFormOptions); dispatchSpy = jest.spyOn(store, 'dispatch'); - mountComponent(); jest.spyOn(Tracking, 'event'); }); @@ -63,20 +68,30 @@ describe('Settings Form', () => { wrapper.destroy(); }); + describe('data binding', () => { + it('v-model change update the settings property', () => { + mountComponent(); + findFields().vm.$emit('input', { newValue: 'foo' }); + expect(dispatchSpy).toHaveBeenCalledWith('updateSettings', { settings: 'foo' }); + }); + + it('v-model change update the api error property', () => { + const apiErrors = { baz: 'bar' }; + mountComponent({ apiErrors }); + expect(findFields().props('apiErrors')).toEqual(apiErrors); + findFields().vm.$emit('input', { newValue: 'foo', modified: 'baz' }); + expect(findFields().props('apiErrors')).toEqual({}); + }); + }); + describe('form', () => { let form; beforeEach(() => { + mountComponent(); form = findForm(); dispatchSpy.mockReturnValue(); }); - describe('data binding', () => { - it('v-model change update the settings property', () => { - findFields().vm.$emit('input', 'foo'); - expect(dispatchSpy).toHaveBeenCalledWith('updateSettings', { settings: 'foo' }); - }); - }); - describe('form reset event', () => { beforeEach(() => { form.trigger('reset'); @@ -108,24 +123,40 @@ describe('Settings Form', () => { expect(Tracking.event).toHaveBeenCalledWith(undefined, 'submit_form', trackingPayload); }); - it('show a success toast when submit succeed', () => { + it('show a success toast when submit succeed', async () => { dispatchSpy.mockResolvedValue(); form.trigger('submit'); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE, { - type: 'success', - }); + await waitForPromises(); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE, { + type: 'success', }); }); - it('show an error toast when submit fails', () => { - dispatchSpy.mockRejectedValue(); - form.trigger('submit'); - return wrapper.vm.$nextTick().then(() => { + describe('when submit fails', () => { + it('shows an error', async () => { + dispatchSpy.mockRejectedValue({ response: {} }); + form.trigger('submit'); + await waitForPromises(); expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error', }); }); + + it('parses the error messages', async () => { + dispatchSpy.mockRejectedValue({ + response: { + data: { + message: { + foo: 'bar', + 'container_expiration_policy.name': ['baz'], + }, + }, + }, + }); + form.trigger('submit'); + await waitForPromises(); + expect(findFields().props('apiErrors')).toEqual({ name: 'baz' }); + }); }); }); }); @@ -134,6 +165,7 @@ describe('Settings Form', () => { describe('cancel button', () => { beforeEach(() => { store.commit('SET_SETTINGS', { foo: 'bar' }); + mountComponent(); }); it('has type reset', () => { @@ -165,6 +197,7 @@ describe('Settings Form', () => { describe('when isLoading is true', () => { beforeEach(() => { store.commit('TOGGLE_LOADING'); + mountComponent(); }); afterEach(() => { store.commit('TOGGLE_LOADING'); diff --git a/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap b/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap index a9034b81d2f..69953fb5e03 100644 --- a/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap +++ b/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap @@ -2,32 +2,30 @@ exports[`Expiration Policy Form renders 1`] = ` <div - class="lh-2" + class="gl-line-height-20" > <gl-form-group-stub id="expiration-policy-toggle-group" - label="Expiration policy:" + label="Cleanup policy:" label-align="right" label-cols="3" label-for="expiration-policy-toggle" > <div - class="d-flex align-items-start" + class="gl-display-flex" > <gl-toggle-stub id="expiration-policy-toggle" - labeloff="Toggle Status: OFF" - labelon="Toggle Status: ON" - labelposition="hidden" + labelposition="top" /> <span - class="mb-2 ml-1 lh-2" + class="gl-mb-3 gl-ml-3 gl-line-height-20" > - Docker tag expiration policy is <strong> - disabled + Disabled </strong> + - Tags matching the patterns defined below will be scheduled for deletion </span> </div> </gl-form-group-stub> @@ -116,7 +114,6 @@ exports[`Expiration Policy Form renders 1`] = ` <gl-form-group-stub id="expiration-policy-name-matching-group" - invalid-feedback="The value of this input should be less than 255 characters" label-align="right" label-cols="3" label-for="expiration-policy-name-matching" @@ -125,6 +122,7 @@ exports[`Expiration Policy Form renders 1`] = ` <gl-form-textarea-stub disabled="true" id="expiration-policy-name-matching" + noresize="true" placeholder=".*" trim="" value="" @@ -132,7 +130,6 @@ exports[`Expiration Policy Form renders 1`] = ` </gl-form-group-stub> <gl-form-group-stub id="expiration-policy-keep-name-group" - invalid-feedback="The value of this input should be less than 255 characters" label-align="right" label-cols="3" label-for="expiration-policy-keep-name" @@ -141,6 +138,7 @@ exports[`Expiration Policy Form renders 1`] = ` <gl-form-textarea-stub disabled="true" id="expiration-policy-keep-name" + noresize="true" placeholder="" trim="" value="" diff --git a/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js b/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js index 4825351a6d3..ee765ffd1c0 100644 --- a/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js +++ b/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js @@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import { GlSprintf } from '@gitlab/ui'; import component from '~/registry/shared/components/expiration_policy_fields.vue'; -import { NAME_REGEX_LENGTH } from '~/registry/shared/constants'; +import { NAME_REGEX_LENGTH, ENABLED_TEXT, DISABLED_TEXT } from '~/registry/shared/constants'; import { formOptions } from '../mock_data'; describe('Expiration Policy Form', () => { @@ -94,7 +94,9 @@ describe('Expiration Policy Form', () => { : 'input'; element.vm.$emit(modelUpdateEvent, value); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('input')).toEqual([[{ [modelName]: value }]]); + expect(wrapper.emitted('input')).toEqual([ + [{ newValue: { [modelName]: value }, modified: modelName }], + ]); }); }); @@ -126,42 +128,61 @@ describe('Expiration Policy Form', () => { }); describe.each` - modelName | elementName | stateVariable - ${'name_regex'} | ${'name-matching'} | ${'nameRegexState'} - ${'name_regex_keep'} | ${'keep-name'} | ${'nameKeepRegexState'} - `('regex textarea validation', ({ modelName, elementName, stateVariable }) => { - describe(`when name regex is longer than ${NAME_REGEX_LENGTH}`, () => { - const invalidString = new Array(NAME_REGEX_LENGTH + 2).join(','); - - beforeEach(() => { - mountComponent({ value: { [modelName]: invalidString } }); + modelName | elementName + ${'name_regex'} | ${'name-matching'} + ${'name_regex_keep'} | ${'keep-name'} + `('regex textarea validation', ({ modelName, elementName }) => { + const invalidString = new Array(NAME_REGEX_LENGTH + 2).join(','); + + describe('when apiError contains an error message', () => { + const errorMessage = 'something went wrong'; + + it('shows the error message on the relevant field', () => { + mountComponent({ apiErrors: { [modelName]: errorMessage } }); + expect(findFormGroup(elementName).attributes('invalid-feedback')).toBe(errorMessage); }); - it(`${stateVariable} is false`, () => { - expect(wrapper.vm.textAreaState[stateVariable]).toBe(false); - }); - - it('emit the @invalidated event', () => { - expect(wrapper.emitted('invalidated')).toBeTruthy(); + it('gives precedence to API errors compared to local ones', () => { + mountComponent({ + apiErrors: { [modelName]: errorMessage }, + value: { [modelName]: invalidString }, + }); + expect(findFormGroup(elementName).attributes('invalid-feedback')).toBe(errorMessage); }); }); - it('if the user did not type validation is null', () => { - mountComponent({ value: { [modelName]: '' } }); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.textAreaState[stateVariable]).toBe(null); + describe('when apiErrors is empty', () => { + it('if the user did not type validation is null', async () => { + mountComponent({ value: { [modelName]: '' } }); + expect(findFormGroup(elementName).attributes('state')).toBeUndefined(); expect(wrapper.emitted('validated')).toBeTruthy(); }); - }); - it(`if the user typed and is less than ${NAME_REGEX_LENGTH} state is true`, () => { - mountComponent({ value: { [modelName]: 'foo' } }); - return wrapper.vm.$nextTick().then(() => { + it(`if the user typed and is less than ${NAME_REGEX_LENGTH} state is true`, () => { + mountComponent({ value: { [modelName]: 'foo' } }); + const formGroup = findFormGroup(elementName); const formElement = findFormElements(elementName, formGroup); expect(formGroup.attributes('state')).toBeTruthy(); expect(formElement.attributes('state')).toBeTruthy(); }); + + describe(`when name regex is longer than ${NAME_REGEX_LENGTH}`, () => { + beforeEach(() => { + mountComponent({ value: { [modelName]: invalidString } }); + }); + + it('textAreaValidation state is false', () => { + expect(findFormGroup(elementName).attributes('state')).toBeUndefined(); + // we are forced to check the model attribute because falsy attrs are all casted to undefined in attrs + // while in this case false shows an error and null instead shows nothing. + expect(wrapper.vm.textAreaValidation[modelName].state).toBe(false); + }); + + it('emit the @invalidated event', () => { + expect(wrapper.emitted('invalidated')).toBeTruthy(); + }); + }); }); }); @@ -169,13 +190,13 @@ describe('Expiration Policy Form', () => { it('toggleDescriptionText show disabled when settings.enabled is false', () => { mountComponent(); const toggleHelpText = findFormGroup('toggle').find('span'); - expect(toggleHelpText.html()).toContain('disabled'); + expect(toggleHelpText.html()).toContain(DISABLED_TEXT); }); it('toggleDescriptionText show enabled when settings.enabled is true', () => { mountComponent({ value: { enabled: true } }); const toggleHelpText = findFormGroup('toggle').find('span'); - expect(toggleHelpText.html()).toContain('enabled'); + expect(toggleHelpText.html()).toContain(ENABLED_TEXT); }); }); }); diff --git a/spec/frontend/releases/components/app_new_spec.js b/spec/frontend/releases/components/app_new_spec.js new file mode 100644 index 00000000000..0d5664766e5 --- /dev/null +++ b/spec/frontend/releases/components/app_new_spec.js @@ -0,0 +1,26 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import { mount } from '@vue/test-utils'; +import ReleaseNewApp from '~/releases/components/app_new.vue'; + +Vue.use(Vuex); + +describe('Release new component', () => { + let wrapper; + + const factory = () => { + const store = new Vuex.Store(); + wrapper = mount(ReleaseNewApp, { store }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders the app', () => { + factory(); + + expect(wrapper.exists()).toBe(true); + }); +}); diff --git a/spec/frontend/releases/components/release_block_assets_spec.js b/spec/frontend/releases/components/release_block_assets_spec.js index 44b190b0d19..a85532a8118 100644 --- a/spec/frontend/releases/components/release_block_assets_spec.js +++ b/spec/frontend/releases/components/release_block_assets_spec.js @@ -4,6 +4,7 @@ import ReleaseBlockAssets from '~/releases/components/release_block_assets.vue'; import { ASSET_LINK_TYPE } from '~/releases/constants'; import { trimText } from 'helpers/text_helper'; import { assets } from '../mock_data'; +import { cloneDeep } from 'lodash'; describe('Release block assets', () => { let wrapper; @@ -30,7 +31,7 @@ describe('Release block assets', () => { wrapper.findAll('h5').filter(h5 => h5.text() === sections[type]); beforeEach(() => { - defaultProps = { assets }; + defaultProps = { assets: cloneDeep(assets) }; }); describe('with default props', () => { @@ -96,6 +97,35 @@ describe('Release block assets', () => { }); }); + describe('sources', () => { + const testSources = ({ shouldSourcesBeRendered }) => { + assets.sources.forEach(s => { + expect(wrapper.find(`a[href="${s.url}"]`).exists()).toBe(shouldSourcesBeRendered); + }); + }; + + describe('when the release has sources', () => { + beforeEach(() => { + createComponent(defaultProps); + }); + + it('renders sources', () => { + testSources({ shouldSourcesBeRendered: true }); + }); + }); + + describe('when the release does not have sources', () => { + beforeEach(() => { + delete defaultProps.assets.sources; + createComponent(defaultProps); + }); + + it('does not render any sources', () => { + testSources({ shouldSourcesBeRendered: false }); + }); + }); + }); + describe('external vs internal links', () => { const containsExternalSourceIndicator = () => wrapper.contains('[data-testid="external-link-indicator"]'); diff --git a/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js b/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js new file mode 100644 index 00000000000..3e11af9c9df --- /dev/null +++ b/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js @@ -0,0 +1,62 @@ +import { shallowMount } from '@vue/test-utils'; +import component from '~/reports/codequality_report/components/codequality_issue_body.vue'; +import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/reports/constants'; + +describe('code quality issue body issue body', () => { + let wrapper; + + const codequalityIssue = { + name: + 'rubygem-rest-client: session fixation vulnerability via Set-Cookie headers in 30x redirection responses', + path: 'Gemfile.lock', + severity: 'normal', + type: 'Issue', + urlPath: '/Gemfile.lock#L22', + }; + + const mountWithStatus = initialStatus => { + wrapper = shallowMount(component, { + propsData: { + issue: codequalityIssue, + status: initialStatus, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('with success', () => { + it('renders fixed label', () => { + mountWithStatus(STATUS_SUCCESS); + + expect(wrapper.text()).toContain('Fixed'); + }); + }); + + describe('without success', () => { + it('renders fixed label', () => { + mountWithStatus(STATUS_FAILED); + + expect(wrapper.text()).not.toContain('Fixed'); + }); + }); + + describe('name', () => { + it('renders name', () => { + mountWithStatus(STATUS_NEUTRAL); + + expect(wrapper.text()).toContain(codequalityIssue.name); + }); + }); + + describe('path', () => { + it('renders the report-link path using the correct code quality issue', () => { + mountWithStatus(STATUS_NEUTRAL); + + expect(wrapper.find('report-link-stub').props('issue')).toBe(codequalityIssue); + }); + }); +}); diff --git a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js new file mode 100644 index 00000000000..1905ca0d5e1 --- /dev/null +++ b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js @@ -0,0 +1,146 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import GroupedCodequalityReportsApp from '~/reports/codequality_report/grouped_codequality_reports_app.vue'; +import CodequalityIssueBody from '~/reports/codequality_report/components/codequality_issue_body.vue'; +import store from '~/reports/codequality_report/store'; +import { mockParsedHeadIssues, mockParsedBaseIssues } from './mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Grouped code quality reports app', () => { + const Component = localVue.extend(GroupedCodequalityReportsApp); + let wrapper; + let mockStore; + + const mountComponent = (props = {}) => { + wrapper = mount(Component, { + store: mockStore, + localVue, + propsData: { + basePath: 'base.json', + headPath: 'head.json', + baseBlobPath: 'base/blob/path/', + headBlobPath: 'head/blob/path/', + codequalityHelpPath: 'codequality_help.html', + ...props, + }, + methods: { + fetchReports: () => {}, + }, + }); + }; + + const findWidget = () => wrapper.find('.js-codequality-widget'); + const findIssueBody = () => wrapper.find(CodequalityIssueBody); + + beforeEach(() => { + mockStore = store(); + mountComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when it is loading reports', () => { + beforeEach(() => { + mockStore.state.isLoading = true; + }); + + it('should render loading text', () => { + expect(findWidget().text()).toEqual('Loading codeclimate report'); + }); + }); + + describe('when base and head reports are loaded and compared', () => { + describe('with no issues', () => { + beforeEach(() => { + mockStore.state.newIssues = []; + mockStore.state.resolvedIssues = []; + }); + + it('renders no changes text', () => { + expect(findWidget().text()).toEqual('No changes to code quality'); + }); + }); + + describe('with issues', () => { + describe('with new issues', () => { + beforeEach(() => { + mockStore.state.newIssues = [mockParsedHeadIssues[0]]; + mockStore.state.resolvedIssues = []; + }); + + it('renders summary text', () => { + expect(findWidget().text()).toContain('Code quality degraded on 1 point'); + }); + + it('renders custom codequality issue body', () => { + expect(findIssueBody().props('issue')).toEqual(mockParsedHeadIssues[0]); + }); + }); + + describe('with resolved issues', () => { + beforeEach(() => { + mockStore.state.newIssues = []; + mockStore.state.resolvedIssues = [mockParsedBaseIssues[0]]; + }); + + it('renders summary text', () => { + expect(findWidget().text()).toContain('Code quality improved on 1 point'); + }); + + it('renders custom codequality issue body', () => { + expect(findIssueBody().props('issue')).toEqual(mockParsedBaseIssues[0]); + }); + }); + + describe('with new and resolved issues', () => { + beforeEach(() => { + mockStore.state.newIssues = [mockParsedHeadIssues[0]]; + mockStore.state.resolvedIssues = [mockParsedBaseIssues[0]]; + }); + + it('renders summary text', () => { + expect(findWidget().text()).toContain( + 'Code quality improved on 1 point and degraded on 1 point', + ); + }); + + it('renders custom codequality issue body', () => { + expect(findIssueBody().props('issue')).toEqual(mockParsedHeadIssues[0]); + }); + }); + }); + }); + + describe('when there is a head report but no base report', () => { + beforeEach(() => { + mockStore.state.basePath = null; + mockStore.state.hasError = true; + }); + + it('renders error text', () => { + expect(findWidget().text()).toEqual('Failed to load codeclimate report'); + }); + + it('renders a help icon with more information', () => { + expect(findWidget().html()).toContain('ic-question'); + }); + }); + + describe('on error', () => { + beforeEach(() => { + mockStore.state.hasError = true; + }); + + it('renders error text', () => { + expect(findWidget().text()).toContain('Failed to load codeclimate report'); + }); + + it('does not render a help icon', () => { + expect(findWidget().html()).not.toContain('ic-question'); + }); + }); +}); diff --git a/spec/frontend/reports/codequality_report/mock_data.js b/spec/frontend/reports/codequality_report/mock_data.js new file mode 100644 index 00000000000..9bd61527d3f --- /dev/null +++ b/spec/frontend/reports/codequality_report/mock_data.js @@ -0,0 +1,90 @@ +export const headIssues = [ + { + check_name: 'Rubocop/Lint/UselessAssignment', + description: 'Insecure Dependency', + location: { + path: 'lib/six.rb', + lines: { + begin: 6, + end: 7, + }, + }, + fingerprint: 'e879dd9bbc0953cad5037cde7ff0f627', + }, + { + categories: ['Security'], + check_name: 'Insecure Dependency', + description: 'Insecure Dependency', + location: { + path: 'Gemfile.lock', + lines: { + begin: 22, + end: 22, + }, + }, + fingerprint: 'ca2e59451e98ae60ba2f54e3857c50e5', + }, +]; + +export const mockParsedHeadIssues = [ + { + ...headIssues[1], + name: 'Insecure Dependency', + path: 'lib/six.rb', + urlPath: 'headPath/lib/six.rb#L6', + line: 6, + }, +]; + +export const baseIssues = [ + { + categories: ['Security'], + check_name: 'Insecure Dependency', + description: 'Insecure Dependency', + location: { + path: 'Gemfile.lock', + lines: { + begin: 22, + end: 22, + }, + }, + fingerprint: 'ca2e59451e98ae60ba2f54e3857c50e5', + }, + { + categories: ['Security'], + check_name: 'Insecure Dependency', + description: 'Insecure Dependency', + location: { + path: 'Gemfile.lock', + lines: { + begin: 21, + end: 21, + }, + }, + fingerprint: 'ca2354534dee94ae60ba2f54e3857c50e5', + }, +]; + +export const mockParsedBaseIssues = [ + { + ...baseIssues[1], + name: 'Insecure Dependency', + path: 'Gemfile.lock', + line: 21, + urlPath: 'basePath/Gemfile.lock#L21', + }, +]; + +export const issueDiff = [ + { + categories: ['Security'], + check_name: 'Insecure Dependency', + description: 'Insecure Dependency', + fingerprint: 'ca2e59451e98ae60ba2f54e3857c50e5', + line: 6, + location: { lines: { begin: 22, end: 22 }, path: 'Gemfile.lock' }, + name: 'Insecure Dependency', + path: 'lib/six.rb', + urlPath: 'headPath/lib/six.rb#L6', + }, +]; diff --git a/spec/frontend/reports/codequality_report/store/actions_spec.js b/spec/frontend/reports/codequality_report/store/actions_spec.js new file mode 100644 index 00000000000..6c30fdb7871 --- /dev/null +++ b/spec/frontend/reports/codequality_report/store/actions_spec.js @@ -0,0 +1,151 @@ +import axios from '~/lib/utils/axios_utils'; +import MockAdapter from 'axios-mock-adapter'; +import * as actions from '~/reports/codequality_report/store/actions'; +import * as types from '~/reports/codequality_report/store/mutation_types'; +import createStore from '~/reports/codequality_report/store'; +import { TEST_HOST } from 'spec/test_constants'; +import testAction from 'helpers/vuex_action_helper'; +import { headIssues, baseIssues, mockParsedHeadIssues, mockParsedBaseIssues } from '../mock_data'; + +// mock codequality comparison worker +jest.mock('~/reports/codequality_report/workers/codequality_comparison_worker', () => + jest.fn().mockImplementation(() => { + return { + addEventListener: (eventName, callback) => { + callback({ + data: { + newIssues: [mockParsedHeadIssues[0]], + resolvedIssues: [mockParsedBaseIssues[0]], + }, + }); + }, + }; + }), +); + +describe('Codequality Reports actions', () => { + let localState; + let localStore; + + beforeEach(() => { + localStore = createStore(); + localState = localStore.state; + }); + + describe('setPaths', () => { + it('should commit SET_PATHS mutation', done => { + const paths = { + basePath: 'basePath', + headPath: 'headPath', + baseBlobPath: 'baseBlobPath', + headBlobPath: 'headBlobPath', + helpPath: 'codequalityHelpPath', + }; + + testAction( + actions.setPaths, + paths, + localState, + [{ type: types.SET_PATHS, payload: paths }], + [], + done, + ); + }); + }); + + describe('fetchReports', () => { + let mock; + + beforeEach(() => { + localState.headPath = `${TEST_HOST}/head.json`; + localState.basePath = `${TEST_HOST}/base.json`; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('on success', () => { + it('commits REQUEST_REPORTS and dispatches receiveReportsSuccess', done => { + mock.onGet(`${TEST_HOST}/head.json`).reply(200, headIssues); + mock.onGet(`${TEST_HOST}/base.json`).reply(200, baseIssues); + + testAction( + actions.fetchReports, + null, + localState, + [{ type: types.REQUEST_REPORTS }], + [ + { + payload: { + newIssues: [mockParsedHeadIssues[0]], + resolvedIssues: [mockParsedBaseIssues[0]], + }, + type: 'receiveReportsSuccess', + }, + ], + done, + ); + }); + }); + + describe('on error', () => { + it('commits REQUEST_REPORTS and dispatches receiveReportsError', done => { + mock.onGet(`${TEST_HOST}/head.json`).reply(500); + + testAction( + actions.fetchReports, + null, + localState, + [{ type: types.REQUEST_REPORTS }], + [{ type: 'receiveReportsError' }], + done, + ); + }); + }); + + describe('with no base path', () => { + it('commits REQUEST_REPORTS and dispatches receiveReportsError', done => { + localState.basePath = null; + + testAction( + actions.fetchReports, + null, + localState, + [{ type: types.REQUEST_REPORTS }], + [{ type: 'receiveReportsError' }], + done, + ); + }); + }); + }); + + describe('receiveReportsSuccess', () => { + it('commits RECEIVE_REPORTS_SUCCESS', done => { + const data = { issues: [] }; + + testAction( + actions.receiveReportsSuccess, + data, + localState, + [{ type: types.RECEIVE_REPORTS_SUCCESS, payload: data }], + [], + done, + ); + }); + }); + + describe('receiveReportsError', () => { + it('commits RECEIVE_REPORTS_ERROR', done => { + testAction( + actions.receiveReportsError, + null, + localState, + [{ type: types.RECEIVE_REPORTS_ERROR }], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/reports/codequality_report/store/getters_spec.js b/spec/frontend/reports/codequality_report/store/getters_spec.js new file mode 100644 index 00000000000..a641e2fe74f --- /dev/null +++ b/spec/frontend/reports/codequality_report/store/getters_spec.js @@ -0,0 +1,95 @@ +import * as getters from '~/reports/codequality_report/store/getters'; +import createStore from '~/reports/codequality_report/store'; +import { LOADING, ERROR, SUCCESS } from '~/reports/constants'; + +describe('Codequality reports store getters', () => { + let localState; + let localStore; + + beforeEach(() => { + localStore = createStore(); + localState = localStore.state; + }); + + describe('hasCodequalityIssues', () => { + describe('when there are issues', () => { + it('returns true', () => { + localState.newIssues = [{ reason: 'repetitive code' }]; + localState.resolvedIssues = []; + + expect(getters.hasCodequalityIssues(localState)).toEqual(true); + + localState.newIssues = []; + localState.resolvedIssues = [{ reason: 'repetitive code' }]; + + expect(getters.hasCodequalityIssues(localState)).toEqual(true); + }); + }); + + describe('when there are no issues', () => { + it('returns false when there are no issues', () => { + expect(getters.hasCodequalityIssues(localState)).toEqual(false); + }); + }); + }); + + describe('codequalityStatus', () => { + describe('when loading', () => { + it('returns loading status', () => { + localState.isLoading = true; + + expect(getters.codequalityStatus(localState)).toEqual(LOADING); + }); + }); + + describe('on error', () => { + it('returns error status', () => { + localState.hasError = true; + + expect(getters.codequalityStatus(localState)).toEqual(ERROR); + }); + }); + + describe('when successfully loaded', () => { + it('returns error status', () => { + expect(getters.codequalityStatus(localState)).toEqual(SUCCESS); + }); + }); + }); + + describe('codequalityText', () => { + it.each` + resolvedIssues | newIssues | expectedText + ${0} | ${0} | ${'No changes to code quality'} + ${0} | ${1} | ${'Code quality degraded on 1 point'} + ${2} | ${0} | ${'Code quality improved on 2 points'} + ${1} | ${2} | ${'Code quality improved on 1 point and degraded on 2 points'} + `( + 'returns a summary containing $resolvedIssues resolved issues and $newIssues new issues', + ({ newIssues, resolvedIssues, expectedText }) => { + localState.newIssues = new Array(newIssues).fill({ reason: 'Repetitive code' }); + localState.resolvedIssues = new Array(resolvedIssues).fill({ reason: 'Repetitive code' }); + + expect(getters.codequalityText(localState)).toEqual(expectedText); + }, + ); + }); + + describe('codequalityPopover', () => { + describe('when head report is available but base report is not', () => { + it('returns a popover with a documentation link', () => { + localState.headPath = 'head.json'; + localState.basePath = undefined; + localState.helpPath = 'codequality_help.html'; + + expect(getters.codequalityPopover(localState).title).toEqual( + 'Base pipeline codequality artifact not found', + ); + expect(getters.codequalityPopover(localState).content).toContain( + 'Learn more about codequality reports', + 'href="codequality_help.html"', + ); + }); + }); + }); +}); diff --git a/spec/frontend/reports/codequality_report/store/mutations_spec.js b/spec/frontend/reports/codequality_report/store/mutations_spec.js new file mode 100644 index 00000000000..658abf3088c --- /dev/null +++ b/spec/frontend/reports/codequality_report/store/mutations_spec.js @@ -0,0 +1,80 @@ +import mutations from '~/reports/codequality_report/store/mutations'; +import createStore from '~/reports/codequality_report/store'; + +describe('Codequality Reports mutations', () => { + let localState; + let localStore; + + beforeEach(() => { + localStore = createStore(); + localState = localStore.state; + }); + + describe('SET_PATHS', () => { + it('sets paths to given values', () => { + const basePath = 'base.json'; + const headPath = 'head.json'; + const baseBlobPath = 'base/blob/path/'; + const headBlobPath = 'head/blob/path/'; + const helpPath = 'help.html'; + + mutations.SET_PATHS(localState, { + basePath, + headPath, + baseBlobPath, + headBlobPath, + helpPath, + }); + + expect(localState.basePath).toEqual(basePath); + expect(localState.headPath).toEqual(headPath); + expect(localState.baseBlobPath).toEqual(baseBlobPath); + expect(localState.headBlobPath).toEqual(headBlobPath); + expect(localState.helpPath).toEqual(helpPath); + }); + }); + + describe('REQUEST_REPORTS', () => { + it('sets isLoading to true', () => { + mutations.REQUEST_REPORTS(localState); + + expect(localState.isLoading).toEqual(true); + }); + }); + + describe('RECEIVE_REPORTS_SUCCESS', () => { + it('sets isLoading to false', () => { + mutations.RECEIVE_REPORTS_SUCCESS(localState, {}); + + expect(localState.isLoading).toEqual(false); + }); + + it('sets hasError to false', () => { + mutations.RECEIVE_REPORTS_SUCCESS(localState, {}); + + expect(localState.hasError).toEqual(false); + }); + + it('sets newIssues and resolvedIssues from response data', () => { + const data = { newIssues: [{ id: 1 }], resolvedIssues: [{ id: 2 }] }; + mutations.RECEIVE_REPORTS_SUCCESS(localState, data); + + expect(localState.newIssues).toEqual(data.newIssues); + expect(localState.resolvedIssues).toEqual(data.resolvedIssues); + }); + }); + + describe('RECEIVE_REPORTS_ERROR', () => { + it('sets isLoading to false', () => { + mutations.RECEIVE_REPORTS_ERROR(localState); + + expect(localState.isLoading).toEqual(false); + }); + + it('sets hasError to true', () => { + mutations.RECEIVE_REPORTS_ERROR(localState); + + expect(localState.hasError).toEqual(true); + }); + }); +}); diff --git a/spec/frontend/reports/codequality_report/store/utils/codequality_comparison_spec.js b/spec/frontend/reports/codequality_report/store/utils/codequality_comparison_spec.js new file mode 100644 index 00000000000..5dd69d3c4d4 --- /dev/null +++ b/spec/frontend/reports/codequality_report/store/utils/codequality_comparison_spec.js @@ -0,0 +1,139 @@ +import { + parseCodeclimateMetrics, + doCodeClimateComparison, +} from '~/reports/codequality_report/store/utils/codequality_comparison'; +import { baseIssues, mockParsedHeadIssues, mockParsedBaseIssues } from '../../mock_data'; + +jest.mock('~/reports/codequality_report/workers/codequality_comparison_worker', () => { + let mockPostMessageCallback; + return jest.fn().mockImplementation(() => { + return { + addEventListener: (_, callback) => { + mockPostMessageCallback = callback; + }, + postMessage: data => { + if (!data.headIssues) return mockPostMessageCallback({ data: {} }); + if (!data.baseIssues) throw new Error(); + const key = 'fingerprint'; + return mockPostMessageCallback({ + data: { + newIssues: data.headIssues.filter( + item => !data.baseIssues.find(el => el[key] === item[key]), + ), + resolvedIssues: data.baseIssues.filter( + item => !data.headIssues.find(el => el[key] === item[key]), + ), + }, + }); + }, + }; + }); +}); + +describe('Codequality report store utils', () => { + let result; + + describe('parseCodeclimateMetrics', () => { + it('should parse the received issues', () => { + [result] = parseCodeclimateMetrics(baseIssues, 'path'); + + expect(result.name).toEqual(baseIssues[0].check_name); + expect(result.path).toEqual(baseIssues[0].location.path); + expect(result.line).toEqual(baseIssues[0].location.lines.begin); + }); + + describe('when an issue has no location or path', () => { + const issue = { description: 'Insecure Dependency' }; + + beforeEach(() => { + [result] = parseCodeclimateMetrics([issue], 'path'); + }); + + it('is parsed', () => { + expect(result.name).toEqual(issue.description); + }); + }); + + describe('when an issue has a path but no line', () => { + const issue = { description: 'Insecure Dependency', location: { path: 'Gemfile.lock' } }; + + beforeEach(() => { + [result] = parseCodeclimateMetrics([issue], 'path'); + }); + + it('is parsed', () => { + expect(result.name).toEqual(issue.description); + expect(result.path).toEqual(issue.location.path); + expect(result.urlPath).toEqual(`path/${issue.location.path}`); + }); + }); + + describe('when an issue has a line nested in positions', () => { + const issue = { + description: 'Insecure Dependency', + location: { + path: 'Gemfile.lock', + positions: { begin: { line: 84 } }, + }, + }; + + beforeEach(() => { + [result] = parseCodeclimateMetrics([issue], 'path'); + }); + + it('is parsed', () => { + expect(result.name).toEqual(issue.description); + expect(result.path).toEqual(issue.location.path); + expect(result.urlPath).toEqual( + `path/${issue.location.path}#L${issue.location.positions.begin.line}`, + ); + }); + }); + + describe('with an empty issue array', () => { + beforeEach(() => { + result = parseCodeclimateMetrics([], 'path'); + }); + + it('returns an empty array', () => { + expect(result).toEqual([]); + }); + }); + }); + + describe('doCodeClimateComparison', () => { + describe('when the comparison worker finds changed issues', () => { + beforeEach(async () => { + result = await doCodeClimateComparison(mockParsedHeadIssues, mockParsedBaseIssues); + }); + + it('returns the new and resolved issues', () => { + expect(result.resolvedIssues[0]).toEqual(mockParsedBaseIssues[0]); + expect(result.newIssues[0]).toEqual(mockParsedHeadIssues[0]); + }); + }); + + describe('when the comparison worker finds no changed issues', () => { + beforeEach(async () => { + result = await doCodeClimateComparison([], []); + }); + + it('returns the empty issue arrays', () => { + expect(result.newIssues).toEqual([]); + expect(result.resolvedIssues).toEqual([]); + }); + }); + + describe('when the comparison worker is given malformed data', () => { + it('rejects the promise', () => { + return expect(doCodeClimateComparison(null)).rejects.toEqual({}); + }); + }); + + describe('when the comparison worker encounters an error', () => { + it('rejects the promise and throws an error', () => { + return expect(doCodeClimateComparison([], null)).rejects.toThrow(); + }); + }); + }); +}); diff --git a/spec/frontend/reports/components/grouped_test_reports_app_spec.js b/spec/frontend/reports/components/grouped_test_reports_app_spec.js index 6a402277f52..017e0335569 100644 --- a/spec/frontend/reports/components/grouped_test_reports_app_spec.js +++ b/spec/frontend/reports/components/grouped_test_reports_app_spec.js @@ -15,20 +15,29 @@ localVue.use(Vuex); describe('Grouped test reports app', () => { const endpoint = 'endpoint.json'; + const pipelinePath = '/path/to/pipeline'; const Component = localVue.extend(GroupedTestReportsApp); let wrapper; let mockStore; - const mountComponent = () => { + const mountComponent = ({ + glFeatures = { junitPipelineView: false }, + props = { pipelinePath }, + } = {}) => { wrapper = mount(Component, { store: mockStore, localVue, propsData: { endpoint, + pipelinePath, + ...props, }, methods: { fetchReports: () => {}, }, + provide: { + glFeatures, + }, }); }; @@ -39,6 +48,7 @@ describe('Grouped test reports app', () => { }; const findHeader = () => wrapper.find('[data-testid="report-section-code-text"]'); + const findFullTestReportLink = () => wrapper.find('[data-testid="group-test-reports-full-link"]'); const findSummaryDescription = () => wrapper.find('[data-testid="test-summary-row-description"]'); const findIssueDescription = () => wrapper.find('[data-testid="test-issue-body-description"]'); const findAllIssueDescriptions = () => @@ -67,6 +77,39 @@ describe('Grouped test reports app', () => { }); }); + describe('`View full report` button', () => { + it('should not render the full test report link', () => { + expect(findFullTestReportLink().exists()).toBe(false); + }); + + describe('With junitPipelineView feature flag enabled', () => { + beforeEach(() => { + mountComponent({ glFeatures: { junitPipelineView: true } }); + }); + + it('should render the full test report link', () => { + const fullTestReportLink = findFullTestReportLink(); + + expect(fullTestReportLink.exists()).toBe(true); + expect(pipelinePath).not.toBe(''); + expect(fullTestReportLink.attributes('href')).toBe(`${pipelinePath}/test_report`); + }); + }); + + describe('Without a pipelinePath', () => { + beforeEach(() => { + mountComponent({ + glFeatures: { junitPipelineView: true }, + props: { pipelinePath: '' }, + }); + }); + + it('should not render the full test report link', () => { + expect(findFullTestReportLink().exists()).toBe(false); + }); + }); + }); + describe('with new failed result', () => { beforeEach(() => { setReports(newFailedTestReports); diff --git a/spec/frontend/reports/components/report_section_spec.js b/spec/frontend/reports/components/report_section_spec.js index eaeb074acaf..a620b5d9afc 100644 --- a/spec/frontend/reports/components/report_section_spec.js +++ b/spec/frontend/reports/components/report_section_spec.js @@ -1,9 +1,11 @@ import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; import mountComponent, { mountComponentWithSlots } from 'helpers/vue_mount_component_helper'; import reportSection from '~/reports/components/report_section.vue'; describe('Report section', () => { let vm; + let wrapper; const ReportSection = Vue.extend(reportSection); const resolvedIssues = [ @@ -16,22 +18,41 @@ describe('Report section', () => { }, ]; + const defaultProps = { + component: '', + status: 'SUCCESS', + loadingText: 'Loading codeclimate report', + errorText: 'foo', + successText: 'Code quality improved on 1 point and degraded on 1 point', + resolvedIssues, + hasIssues: false, + alwaysOpen: false, + }; + + const createComponent = props => { + wrapper = shallowMount(reportSection, { + propsData: { + ...defaultProps, + ...props, + }, + }); + return wrapper; + }; + afterEach(() => { - vm.$destroy(); + if (vm) { + vm.$destroy(); + vm = null; + } + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } }); describe('computed', () => { beforeEach(() => { - vm = mountComponent(ReportSection, { - component: '', - status: 'SUCCESS', - loadingText: 'Loading codeclimate report', - errorText: 'foo', - successText: 'Code quality improved on 1 point and degraded on 1 point', - resolvedIssues, - hasIssues: false, - alwaysOpen: false, - }); + vm = mountComponent(ReportSection, defaultProps); }); describe('isCollapsible', () => { @@ -105,12 +126,7 @@ describe('Report section', () => { describe('with success status', () => { beforeEach(() => { vm = mountComponent(ReportSection, { - component: '', - status: 'SUCCESS', - loadingText: 'Loading codeclimate report', - errorText: 'foo', - successText: 'Code quality improved on 1 point and degraded on 1 point', - resolvedIssues, + ...defaultProps, hasIssues: true, }); }); @@ -160,6 +176,50 @@ describe('Report section', () => { }); }); + describe('snowplow events', () => { + it('does emit an event on issue toggle if the shouldEmitToggleEvent prop does exist', done => { + createComponent({ hasIssues: true, shouldEmitToggleEvent: true }); + + expect(wrapper.emitted().toggleEvent).toBeUndefined(); + + wrapper.vm.$el.querySelector('button').click(); + return wrapper.vm + .$nextTick() + .then(() => { + expect(wrapper.emitted().toggleEvent).toHaveLength(1); + }) + .then(done) + .catch(done.fail); + }); + + it('does not emit an event on issue toggle if the shouldEmitToggleEvent prop does not exist', done => { + createComponent({ hasIssues: true }); + + expect(wrapper.emitted().toggleEvent).toBeUndefined(); + + wrapper.vm.$el.querySelector('button').click(); + return wrapper.vm + .$nextTick() + .then(() => { + expect(wrapper.emitted().toggleEvent).toBeUndefined(); + }) + .then(done) + .catch(done.fail); + }); + + it('does not emit an event if always-open is set to true', done => { + createComponent({ alwaysOpen: true, hasIssues: true, shouldEmitToggleEvent: true }); + + wrapper.vm + .$nextTick() + .then(() => { + expect(wrapper.emitted().toggleEvent).toBeUndefined(); + }) + .then(done) + .catch(done.fail); + }); + }); + describe('with failed request', () => { it('should render error indicator', () => { vm = mountComponent(ReportSection, { @@ -199,7 +259,7 @@ describe('Report section', () => { }); describe('Success and Error slots', () => { - const createComponent = status => { + const createComponentWithSlots = status => { vm = mountComponentWithSlots(ReportSection, { props: { status, @@ -214,7 +274,7 @@ describe('Report section', () => { }; it('only renders success slot when status is "SUCCESS"', () => { - createComponent('SUCCESS'); + createComponentWithSlots('SUCCESS'); expect(vm.$el.textContent.trim()).toContain('This is a success'); expect(vm.$el.textContent.trim()).not.toContain('This is an error'); @@ -222,7 +282,7 @@ describe('Report section', () => { }); it('only renders error slot when status is "ERROR"', () => { - createComponent('ERROR'); + createComponentWithSlots('ERROR'); expect(vm.$el.textContent.trim()).toContain('This is an error'); expect(vm.$el.textContent.trim()).not.toContain('This is a success'); @@ -230,7 +290,7 @@ describe('Report section', () => { }); it('only renders loading slot when status is "LOADING"', () => { - createComponent('LOADING'); + createComponentWithSlots('LOADING'); expect(vm.$el.textContent.trim()).toContain('This is loading'); expect(vm.$el.textContent.trim()).not.toContain('This is an error'); diff --git a/spec/frontend/reports/components/summary_row_spec.js b/spec/frontend/reports/components/summary_row_spec.js index cb0cc025e80..85c68ed069b 100644 --- a/spec/frontend/reports/components/summary_row_spec.js +++ b/spec/frontend/reports/components/summary_row_spec.js @@ -1,10 +1,8 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import component from '~/reports/components/summary_row.vue'; +import { mount } from '@vue/test-utils'; +import SummaryRow from '~/reports/components/summary_row.vue'; describe('Summary row', () => { - const Component = Vue.extend(component); - let vm; + let wrapper; const props = { summary: 'SAST detected 1 new vulnerability and 1 fixed vulnerability', @@ -15,23 +13,42 @@ describe('Summary row', () => { statusIcon: 'warning', }; - beforeEach(() => { - vm = mountComponent(Component, props); - }); + const createComponent = ({ propsData = {}, slots = {} } = {}) => { + wrapper = mount(SummaryRow, { + propsData: { + ...props, + ...propsData, + }, + slots, + }); + }; + + const findSummary = () => wrapper.find('.report-block-list-issue-description-text'); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + wrapper = null; }); it('renders provided summary', () => { - expect( - vm.$el.querySelector('.report-block-list-issue-description-text').textContent.trim(), - ).toEqual(props.summary); + createComponent(); + expect(findSummary().text()).toEqual(props.summary); }); it('renders provided icon', () => { - expect(vm.$el.querySelector('.report-block-list-icon span').classList).toContain( + createComponent(); + expect(wrapper.find('.report-block-list-icon span').classes()).toContain( 'js-ci-status-icon-warning', ); }); + + describe('summary slot', () => { + it('replaces the summary prop', () => { + const summarySlotContent = 'Summary slot content'; + createComponent({ slots: { summary: summarySlotContent } }); + + expect(wrapper.text()).not.toContain(props.summary); + expect(findSummary().text()).toEqual(summarySlotContent); + }); + }); }); diff --git a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap index 97597ed8063..ac60fc4917d 100644 --- a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap +++ b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap @@ -1,5 +1,58 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Repository table row component renders a symlink table row 1`] = ` +<tr + class="tree-item" +> + <td + class="tree-item-file-name cursor-default position-relative" + > + <a + class="tree-item-link str-truncated" + data-qa-selector="file_name_link" + href="https://test.com" + > + <file-icon-stub + class="mr-1 position-relative text-secondary" + cssclasses="position-relative file-icon" + filemode="120000" + filename="test" + size="16" + /> + <span + class="position-relative" + > + test + </span> + </a> + + <!----> + + <!----> + + <!----> + </td> + + <td + class="d-none d-sm-table-cell tree-commit cursor-default" + > + <gl-skeleton-loading-stub + class="h-auto" + lines="1" + /> + </td> + + <td + class="tree-time-ago text-right cursor-default" + > + <gl-skeleton-loading-stub + class="ml-auto h-auto w-50" + lines="1" + /> + </td> +</tr> +`; + exports[`Repository table row component renders table row 1`] = ` <tr class="tree-item" @@ -15,6 +68,7 @@ exports[`Repository table row component renders table row 1`] = ` <file-icon-stub class="mr-1 position-relative text-secondary" cssclasses="position-relative file-icon" + filemode="" filename="test" size="16" /> @@ -67,6 +121,7 @@ exports[`Repository table row component renders table row for path with special <file-icon-stub class="mr-1 position-relative text-secondary" cssclasses="position-relative file-icon" + filemode="" filename="test" size="16" /> diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js index 9db90839b29..ed50f292b8c 100644 --- a/spec/frontend/repository/components/table/index_spec.js +++ b/spec/frontend/repository/components/table/index_spec.js @@ -23,6 +23,15 @@ const MOCK_BLOBS = [ type: 'blob', webUrl: 'http://test.com', }, + { + id: '125abc', + sha: '125abc', + flatPath: 'blob3', + name: 'blob3.md', + type: 'blob', + webUrl: 'http://test.com', + mode: '120000', + }, ]; function factory({ path, isLoading = false, entries = {} }) { @@ -74,7 +83,9 @@ describe('Repository table component', () => { }, }); - expect(vm.find(TableRow).exists()).toBe(true); - expect(vm.findAll(TableRow).length).toBe(2); + const rows = vm.findAll(TableRow); + + expect(rows.length).toEqual(3); + expect(rows.at(2).attributes().mode).toEqual('120000'); }); }); diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js index 800a7e586a8..767b117c798 100644 --- a/spec/frontend/repository/components/table/row_spec.js +++ b/spec/frontend/repository/components/table/row_spec.js @@ -2,6 +2,7 @@ import { shallowMount, RouterLinkStub } from '@vue/test-utils'; import { GlBadge, GlLink, GlIcon } from '@gitlab/ui'; import TableRow from '~/repository/components/table/row.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; +import { FILE_SYMLINK_MODE } from '~/vue_shared/constants'; let vm; let $router; @@ -48,6 +49,21 @@ describe('Repository table row component', () => { }); }); + it('renders a symlink table row', () => { + factory({ + id: '1', + sha: '123', + path: 'test', + type: 'blob', + currentPath: '/', + mode: FILE_SYMLINK_MODE, + }); + + return vm.vm.$nextTick().then(() => { + expect(vm.element).toMatchSnapshot(); + }); + }); + it('renders table row for path with special character', () => { factory({ id: '1', diff --git a/spec/frontend/repository/components/web_ide_link_spec.js b/spec/frontend/repository/components/web_ide_link_spec.js new file mode 100644 index 00000000000..59e1a4fd719 --- /dev/null +++ b/spec/frontend/repository/components/web_ide_link_spec.js @@ -0,0 +1,51 @@ +import WebIdeLink from '~/repository/components/web_ide_link.vue'; +import { mount } from '@vue/test-utils'; + +describe('Web IDE link component', () => { + let wrapper; + + function createComponent(props) { + wrapper = mount(WebIdeLink, { + propsData: { ...props }, + mocks: { + $route: { + params: {}, + }, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders link to the Web IDE for a project if only projectPath is given', () => { + createComponent({ projectPath: 'gitlab-org/gitlab', refSha: 'master' }); + + expect(wrapper.attributes('href')).toBe('/-/ide/project/gitlab-org/gitlab/edit/master/-/'); + expect(wrapper.text()).toBe('Web IDE'); + }); + + it('renders link to the Web IDE for a project even if both projectPath and forkPath are given', () => { + createComponent({ + projectPath: 'gitlab-org/gitlab', + refSha: 'master', + forkPath: 'my-namespace/gitlab', + }); + + expect(wrapper.attributes('href')).toBe('/-/ide/project/gitlab-org/gitlab/edit/master/-/'); + expect(wrapper.text()).toBe('Web IDE'); + }); + + it('renders link to the forked project if it exists and cannot write to the repo', () => { + createComponent({ + projectPath: 'gitlab-org/gitlab', + refSha: 'master', + forkPath: 'my-namespace/gitlab', + canPushCode: false, + }); + + expect(wrapper.attributes('href')).toBe('/-/ide/project/my-namespace/gitlab/edit/master/-/'); + expect(wrapper.text()).toBe('Edit fork in Web IDE'); + }); +}); diff --git a/spec/frontend/repository/utils/dom_spec.js b/spec/frontend/repository/utils/dom_spec.js index 0b61161c9d0..e8b0565868e 100644 --- a/spec/frontend/repository/utils/dom_spec.js +++ b/spec/frontend/repository/utils/dom_spec.js @@ -1,5 +1,6 @@ import { setHTMLFixture } from '../../helpers/fixtures'; import { updateElementsVisibility, updateFormAction } from '~/repository/utils/dom'; +import { TEST_HOST } from 'helpers/test_constants'; describe('updateElementsVisibility', () => { it('adds hidden class', () => { @@ -31,7 +32,7 @@ describe('updateFormAction', () => { updateFormAction('.js-test', '/gitlab/create', path); expect(document.querySelector('.js-test').action).toBe( - `http://localhost/gitlab/create/${path.replace(/^\//, '')}`, + `${TEST_HOST}/gitlab/create/${path.replace(/^\//, '')}`, ); }); }); diff --git a/spec/frontend/global_search_input_spec.js b/spec/frontend/search_autocomplete_spec.js index 8c00ea5f193..05b36474548 100644 --- a/spec/frontend/global_search_input_spec.js +++ b/spec/frontend/search_autocomplete_spec.js @@ -2,30 +2,24 @@ import $ from 'jquery'; import '~/gl_dropdown'; -import initGlobalSearchInput from '~/global_search_input'; +import initSearchAutocomplete from '~/search_autocomplete'; import '~/lib/utils/common_utils'; +import axios from '~/lib/utils/axios_utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; -describe('Global search input dropdown', () => { +describe('Search autocomplete dropdown', () => { let widget = null; const userName = 'root'; - const userId = 1; - const dashboardIssuesPath = '/dashboard/issues'; - const dashboardMRsPath = '/dashboard/merge_requests'; - const projectIssuesPath = '/gitlab-org/gitlab-foss/issues'; - const projectMRsPath = '/gitlab-org/gitlab-foss/-/merge_requests'; - const groupIssuesPath = '/groups/gitlab-org/-/issues'; - const groupMRsPath = '/groups/gitlab-org/-/merge_requests'; - + const autocompletePath = '/search/autocomplete'; const projectName = 'GitLab Community Edition'; - const groupName = 'Gitlab Org'; const removeBodyAttributes = () => { @@ -112,15 +106,16 @@ describe('Global search input dropdown', () => { expect(list.find(mrsIHaveCreatedLink).text()).toBe("Merge requests I've created"); }; - preloadFixtures('static/global_search_input.html'); + preloadFixtures('static/search_autocomplete.html'); beforeEach(() => { - loadFixtures('static/global_search_input.html'); + loadFixtures('static/search_autocomplete.html'); window.gon = {}; window.gon.current_user_id = userId; window.gon.current_username = userName; + window.gl = window.gl || (window.gl = {}); - return (widget = initGlobalSearchInput()); + return (widget = initSearchAutocomplete({ autocompletePath })); }); afterEach(() => { @@ -183,31 +178,105 @@ describe('Global search input dropdown', () => { widget.wrap.trigger($.Event('keydown', { which: DOWN })); const enterKeyEvent = $.Event('keydown', { which: ENTER }); widget.searchInput.trigger(enterKeyEvent); + // This does not currently catch failing behavior. For security reasons, // browsers will not trigger default behavior (form submit, in this // example) on JavaScript-created keypresses. expect(submitSpy).not.toHaveBeenCalled(); }); - describe('disableDropdown', () => { + describe('show autocomplete results', () => { + beforeEach(() => { + widget.enableAutocomplete(); + + const axiosMock = new AxiosMockAdapter(axios); + const autocompleteUrl = new RegExp(autocompletePath); + + axiosMock.onGet(autocompleteUrl).reply(200, [ + { + category: 'Projects', + id: 1, + value: 'Gitlab Test', + label: 'Gitlab Org / Gitlab Test', + url: '/gitlab-org/gitlab-test', + avatar_url: '', + }, + { + category: 'Groups', + id: 1, + value: 'Gitlab Org', + label: 'Gitlab Org', + url: '/gitlab-org', + avatar_url: '', + }, + ]); + }); + + function triggerAutocomplete() { + return new Promise(resolve => { + const dropdown = widget.searchInput.data('glDropdown'); + const filterCallback = dropdown.filter.options.callback; + dropdown.filter.options.callback = jest.fn(data => { + filterCallback(data); + + resolve(); + }); + + widget.searchInput.val('Gitlab'); + widget.searchInput.triggerHandler('input'); + }); + } + + it('suggest Projects', done => { + // eslint-disable-next-line promise/catch-or-return + triggerAutocomplete().finally(() => { + const list = widget.wrap.find('.dropdown-menu').find('ul'); + const link = "a[href$='/gitlab-org/gitlab-test']"; + + expect(list.find(link).length).toBe(1); + + done(); + }); + + // Make sure jest properly acknowledge the `done` invocation + jest.runOnlyPendingTimers(); + }); + + it('suggest Groups', done => { + // eslint-disable-next-line promise/catch-or-return + triggerAutocomplete().finally(() => { + const list = widget.wrap.find('.dropdown-menu').find('ul'); + const link = "a[href$='/gitlab-org']"; + + expect(list.find(link).length).toBe(1); + + done(); + }); + + // Make sure jest properly acknowledge the `done` invocation + jest.runOnlyPendingTimers(); + }); + }); + + describe('disableAutocomplete', () => { beforeEach(() => { - widget.enableDropdown(); + widget.enableAutocomplete(); }); it('should close the Dropdown', () => { const toggleSpy = jest.spyOn(widget.dropdownToggle, 'dropdown'); widget.dropdown.addClass('show'); - widget.disableDropdown(); + widget.disableAutocomplete(); expect(toggleSpy).toHaveBeenCalledWith('toggle'); }); }); - describe('enableDropdown', () => { + describe('enableAutocomplete', () => { it('should open the Dropdown', () => { const toggleSpy = jest.spyOn(widget.dropdownToggle, 'dropdown'); - widget.enableDropdown(); + widget.enableAutocomplete(); expect(toggleSpy).toHaveBeenCalledWith('toggle'); }); diff --git a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap index f7a9827a9ad..f4ac2f57261 100644 --- a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap +++ b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap @@ -47,9 +47,7 @@ exports[`self monitor component When the self monitor project has not been creat label-for="self-monitor-toggle" > <gl-toggle-stub - labeloff="Toggle Status: OFF" - labelon="Toggle Status: ON" - labelposition="hidden" + labelposition="top" name="self-monitor-toggle" /> </gl-form-group-stub> diff --git a/spec/frontend/self_monitor/components/self_monitor_form_spec.js b/spec/frontend/self_monitor/components/self_monitor_form_spec.js index 0e6abba08f3..aa6f71b6412 100644 --- a/spec/frontend/self_monitor/components/self_monitor_form_spec.js +++ b/spec/frontend/self_monitor/components/self_monitor_form_spec.js @@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import { GlDeprecatedButton } from '@gitlab/ui'; import SelfMonitor from '~/self_monitor/components/self_monitor_form.vue'; import { createStore } from '~/self_monitor/store'; +import { TEST_HOST } from 'helpers/test_constants'; describe('self monitor component', () => { let wrapper; @@ -82,7 +83,7 @@ describe('self monitor component', () => { .find({ ref: 'selfMonitoringFormText' }) .find('a') .attributes('href'), - ).toEqual('http://localhost/instance-administrators-random/gitlab-self-monitoring'); + ).toEqual(`${TEST_HOST}/instance-administrators-random/gitlab-self-monitoring`); }); }); }); diff --git a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap b/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap index cf7832f3948..da571af3a0d 100644 --- a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap +++ b/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap @@ -3,6 +3,7 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and isEditable = false 1`] = ` <div class="block issuable-sidebar-item confidentiality" + iid="" > <div class="sidebar-collapsed-icon" @@ -35,6 +36,7 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i <div class="no-value sidebar-item-value" + data-testid="not-confidential" > <icon-stub aria-hidden="true" @@ -55,6 +57,7 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i exports[`Confidential Issue Sidebar Block renders for confidential = false and isEditable = true 1`] = ` <div class="block issuable-sidebar-item confidentiality" + iid="" > <div class="sidebar-collapsed-icon" @@ -95,6 +98,7 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i <div class="no-value sidebar-item-value" + data-testid="not-confidential" > <icon-stub aria-hidden="true" @@ -115,6 +119,7 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i exports[`Confidential Issue Sidebar Block renders for confidential = true and isEditable = false 1`] = ` <div class="block issuable-sidebar-item confidentiality" + iid="" > <div class="sidebar-collapsed-icon" @@ -167,6 +172,7 @@ exports[`Confidential Issue Sidebar Block renders for confidential = true and is exports[`Confidential Issue Sidebar Block renders for confidential = true and isEditable = true 1`] = ` <div class="block issuable-sidebar-item confidentiality" + iid="" > <div class="sidebar-collapsed-icon" diff --git a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js index acdfb5139bf..15493d3087f 100644 --- a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js +++ b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js @@ -1,16 +1,49 @@ import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; import EditFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue'; +import eventHub from '~/sidebar/event_hub'; +import createStore from '~/notes/stores'; +import waitForPromises from 'helpers/wait_for_promises'; +import flash from '~/flash'; + +jest.mock('~/sidebar/event_hub', () => ({ $emit: jest.fn() })); +jest.mock('~/flash'); describe('Edit Form Buttons', () => { let wrapper; + let store; const findConfidentialToggle = () => wrapper.find('[data-testid="confidential-toggle"]'); - const createComponent = props => { + const createComponent = ({ + props = {}, + data = {}, + confidentialApolloSidebar = false, + resolved = true, + }) => { + store = createStore(); + if (resolved) { + jest.spyOn(store, 'dispatch').mockResolvedValue(); + } else { + jest.spyOn(store, 'dispatch').mockRejectedValue(); + } + wrapper = shallowMount(EditFormButtons, { propsData: { - updateConfidentialAttribute: () => {}, + fullPath: '', ...props, }, + data() { + return { + isLoading: true, + ...data, + }; + }, + provide: { + glFeatures: { + confidentialApolloSidebar, + }, + }, + store, }); }; @@ -19,10 +52,32 @@ describe('Edit Form Buttons', () => { wrapper = null; }); + describe('when isLoading', () => { + beforeEach(() => { + createComponent({}); + + wrapper.vm.$store.state.noteableData.confidential = false; + }); + + it('renders "Applying" in the toggle button', () => { + expect(findConfidentialToggle().text()).toBe('Applying'); + }); + + it('disables the toggle button', () => { + expect(findConfidentialToggle().attributes('disabled')).toBe('disabled'); + }); + + it('finds the GlLoadingIcon', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + }); + describe('when not confidential', () => { - it('renders Turn On in the ', () => { + it('renders Turn On in the toggle button', () => { createComponent({ - isConfidential: false, + data: { + isLoading: false, + }, }); expect(findConfidentialToggle().text()).toBe('Turn On'); @@ -30,12 +85,75 @@ describe('Edit Form Buttons', () => { }); describe('when confidential', () => { - it('renders on or off text based on confidentiality', () => { + beforeEach(() => { createComponent({ - isConfidential: true, + data: { + isLoading: false, + }, }); + wrapper.vm.$store.state.noteableData.confidential = true; + }); + + it('renders on or off text based on confidentiality', () => { expect(findConfidentialToggle().text()).toBe('Turn Off'); }); + + describe('when clicking on the confidential toggle', () => { + it('emits updateConfidentialAttribute', () => { + findConfidentialToggle().trigger('click'); + + expect(eventHub.$emit).toHaveBeenCalledWith('updateConfidentialAttribute'); + }); + }); + }); + + describe('when confidentialApolloSidebar is turned on', () => { + const isConfidential = true; + + describe('when succeeds', () => { + beforeEach(() => { + createComponent({ data: { isLoading: false }, confidentialApolloSidebar: true }); + wrapper.vm.$store.state.noteableData.confidential = isConfidential; + findConfidentialToggle().trigger('click'); + }); + + it('dispatches the correct action', () => { + expect(store.dispatch).toHaveBeenCalledWith('updateConfidentialityOnIssue', { + confidential: !isConfidential, + fullPath: '', + }); + }); + + it('resets loading', () => { + return waitForPromises().then(() => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + }); + }); + + it('emits close form', () => { + return waitForPromises().then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('closeConfidentialityForm'); + }); + }); + }); + + describe('when fails', () => { + beforeEach(() => { + createComponent({ + data: { isLoading: false }, + confidentialApolloSidebar: true, + resolved: false, + }); + wrapper.vm.$store.state.noteableData.confidential = isConfidential; + findConfidentialToggle().trigger('click'); + }); + + it('calls flash with the correct message', () => { + expect(flash).toHaveBeenCalledWith( + 'Something went wrong trying to change the confidentiality of this issue', + ); + }); + }); }); }); diff --git a/spec/frontend/sidebar/confidential/edit_form_spec.js b/spec/frontend/sidebar/confidential/edit_form_spec.js index 137019a1e1b..a22bbe5ae0d 100644 --- a/spec/frontend/sidebar/confidential/edit_form_spec.js +++ b/spec/frontend/sidebar/confidential/edit_form_spec.js @@ -10,6 +10,8 @@ describe('Edit Form Dropdown', () => { wrapper = shallowMount(EditForm, { propsData: { ...props, + isLoading: false, + fullPath: '', }, }); }; diff --git a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js b/spec/frontend/sidebar/confidential_issue_sidebar_spec.js index fe7c3aadeeb..06cf1e6166c 100644 --- a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js +++ b/spec/frontend/sidebar/confidential_issue_sidebar_spec.js @@ -7,6 +7,7 @@ import createFlash from '~/flash'; import RecaptchaModal from '~/vue_shared/components/recaptcha_modal.vue'; import createStore from '~/notes/stores'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; +import eventHub from '~/sidebar/event_hub'; jest.mock('~/flash'); jest.mock('~/sidebar/services/sidebar_service'); @@ -15,6 +16,9 @@ describe('Confidential Issue Sidebar Block', () => { useMockLocationHelper(); let wrapper; + const mutate = jest + .fn() + .mockResolvedValue({ data: { issueSetConfidential: { issue: { confidential: true } } } }); const findRecaptchaModal = () => wrapper.find(RecaptchaModal); @@ -25,24 +29,32 @@ describe('Confidential Issue Sidebar Block', () => { wrapper.vm .$nextTick() .then(() => { - const editForm = wrapper.find(EditForm); - const { updateConfidentialAttribute } = editForm.props(); - updateConfidentialAttribute(); + eventHub.$emit('updateConfidentialAttribute'); }) // wait for reCAPTCHA modal to render .then(() => wrapper.vm.$nextTick()) ); }; - const createComponent = propsData => { + const createComponent = ({ propsData, data = {} }) => { const store = createStore(); const service = new SidebarService(); wrapper = shallowMount(ConfidentialIssueSidebar, { store, + data() { + return data; + }, propsData: { service, + iid: '', + fullPath: '', ...propsData, }, + mocks: { + $apollo: { + mutate, + }, + }, }); }; @@ -60,7 +72,9 @@ describe('Confidential Issue Sidebar Block', () => { 'renders for confidential = $confidential and isEditable = $isEditable', ({ confidential, isEditable }) => { createComponent({ - isEditable, + propsData: { + isEditable, + }, }); wrapper.vm.$store.state.noteableData.confidential = confidential; @@ -73,7 +87,9 @@ describe('Confidential Issue Sidebar Block', () => { describe('if editable', () => { beforeEach(() => { createComponent({ - isEditable: true, + propsData: { + isEditable: true, + }, }); wrapper.vm.$store.state.noteableData.confidential = true; }); diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index 83f46dd347f..d2265dfd506 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -1,9 +1,8 @@ import { shallowMount } from '@vue/test-utils'; -import axios from '~/lib/utils/axios_utils'; import Flash from '~/flash'; import { GlLoadingIcon } from '@gitlab/ui'; -import { joinPaths, redirectTo } from '~/lib/utils/url_utility'; +import { redirectTo } from '~/lib/utils/url_utility'; import SnippetEditApp from '~/snippets/components/edit.vue'; import SnippetDescriptionEdit from '~/snippets/components/snippet_description_edit.vue'; @@ -16,25 +15,17 @@ import { SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR } from '~/ import UpdateSnippetMutation from '~/snippets/mutations/updateSnippet.mutation.graphql'; import CreateSnippetMutation from '~/snippets/mutations/createSnippet.mutation.graphql'; -import AxiosMockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import { ApolloMutation } from 'vue-apollo'; jest.mock('~/lib/utils/url_utility', () => ({ - getBaseURL: jest.fn().mockReturnValue('foo/'), redirectTo: jest.fn().mockName('redirectTo'), - joinPaths: jest - .fn() - .mockName('joinPaths') - .mockReturnValue('contentApiURL'), })); jest.mock('~/flash'); let flashSpy; -const contentMock = 'Foo Bar'; -const rawPathMock = '/foo/bar'; const rawProjectPathMock = '/project/path'; const newlyEditedSnippetUrl = 'http://foo.bar'; const apiError = { message: 'Ufff' }; @@ -43,15 +34,27 @@ const mutationError = 'Bummer'; const attachedFilePath1 = 'foo/bar'; const attachedFilePath2 = 'alpha/beta'; +const actionWithContent = { + content: 'Foo Bar', +}; +const actionWithoutContent = { + content: '', +}; + const defaultProps = { snippetGid: 'gid://gitlab/PersonalSnippet/42', markdownPreviewPath: 'http://preview.foo.bar', markdownDocsPath: 'http://docs.foo.bar', }; +const defaultData = { + blobsActions: { + ...actionWithContent, + action: '', + }, +}; describe('Snippet Edit app', () => { let wrapper; - let axiosMock; const resolveMutate = jest.fn().mockResolvedValue({ data: { @@ -156,18 +159,21 @@ describe('Snippet Edit app', () => { }); it.each` - title | content | expectation - ${''} | ${''} | ${true} - ${'foo'} | ${''} | ${true} - ${''} | ${'foo'} | ${true} - ${'foo'} | ${'bar'} | ${false} + title | blobsActions | expectation + ${''} | ${{}} | ${true} + ${''} | ${{ actionWithContent }} | ${true} + ${''} | ${{ actionWithoutContent }} | ${true} + ${'foo'} | ${{}} | ${true} + ${'foo'} | ${{ actionWithoutContent }} | ${true} + ${'foo'} | ${{ actionWithoutContent, actionWithContent }} | ${true} + ${'foo'} | ${{ actionWithContent }} | ${false} `( - 'disables submit button unless both title and content are present', - ({ title, content, expectation }) => { + 'disables submit button unless both title and content for all blobs are present', + ({ title, blobsActions, expectation }) => { createComponent({ data: { snippet: { title }, - content, + blobsActions, }, }); const isBtnDisabled = Boolean(findSubmitButton().attributes('disabled')); @@ -192,83 +198,31 @@ describe('Snippet Edit app', () => { }); describe('functionality', () => { - describe('handling of the data from GraphQL response', () => { - const snippet = { - blob: { - rawPath: rawPathMock, - }, - }; - const getResSchema = newSnippet => { - return { - data: { - snippets: { - edges: newSnippet ? [] : [snippet], - }, - }, + describe('form submission handling', () => { + it('does not submit unchanged blobs', () => { + const foo = { + action: '', + }; + const bar = { + action: 'update', }; - }; - - const bootstrapForExistingSnippet = resp => { createComponent({ data: { - snippet, + blobsActions: { + foo, + bar, + }, }, }); - - if (resp === 500) { - axiosMock.onGet('contentApiURL').reply(500); - } else { - axiosMock.onGet('contentApiURL').reply(200, contentMock); - } - wrapper.vm.onSnippetFetch(getResSchema()); - }; - - const bootstrapForNewSnippet = () => { - createComponent(); - wrapper.vm.onSnippetFetch(getResSchema(true)); - }; - - beforeEach(() => { - axiosMock = new AxiosMockAdapter(axios); - }); - - afterEach(() => { - axiosMock.restore(); - }); - - it('fetches blob content with the additional query', () => { - bootstrapForExistingSnippet(); - - return waitForPromises().then(() => { - expect(joinPaths).toHaveBeenCalledWith('foo/', rawPathMock); - expect(wrapper.vm.newSnippet).toBe(false); - expect(wrapper.vm.content).toBe(contentMock); - }); - }); - - it('flashes the error message if fetching content fails', () => { - bootstrapForExistingSnippet(500); - - return waitForPromises().then(() => { - expect(flashSpy).toHaveBeenCalled(); - expect(wrapper.vm.content).toBe(''); - }); - }); - - it('does not fetch content for new snippet', () => { - bootstrapForNewSnippet(); + clickSubmitBtn(); return waitForPromises().then(() => { - // we keep using waitForPromises to make sure we do not run failed test - expect(wrapper.vm.newSnippet).toBe(true); - expect(wrapper.vm.content).toBe(''); - expect(joinPaths).not.toHaveBeenCalled(); - expect(wrapper.vm.snippet).toEqual(wrapper.vm.$options.newSnippetSchema); + expect(resolveMutate).toHaveBeenCalledWith( + expect.objectContaining({ variables: { input: { files: [bar] } } }), + ); }); }); - }); - describe('form submission handling', () => { it.each` newSnippet | projectPath | mutation | mutationName ${true} | ${rawProjectPathMock} | ${CreateSnippetMutation} | ${'CreateSnippetMutation with projectPath'} @@ -279,6 +233,7 @@ describe('Snippet Edit app', () => { createComponent({ data: { newSnippet, + ...defaultData, }, props: { ...defaultProps, @@ -419,5 +374,57 @@ describe('Snippet Edit app', () => { expect(resolveMutate).toHaveBeenCalledWith(updateMutationPayload()); }); }); + + describe('on before unload', () => { + let event; + let returnValueSetter; + + const bootstrap = data => { + createComponent({ + data, + }); + + event = new Event('beforeunload'); + returnValueSetter = jest.spyOn(event, 'returnValue', 'set'); + }; + + it('does not prevent page navigation if there are no blobs', () => { + bootstrap(); + window.dispatchEvent(event); + + expect(returnValueSetter).not.toHaveBeenCalled(); + }); + + it('does not prevent page navigation if there are no changes to the blobs content', () => { + bootstrap({ + blobsActions: { + foo: { + ...actionWithContent, + action: '', + }, + }, + }); + window.dispatchEvent(event); + + expect(returnValueSetter).not.toHaveBeenCalled(); + }); + + it('prevents page navigation if there are some changes in the snippet content', () => { + bootstrap({ + blobsActions: { + foo: { + ...actionWithContent, + action: 'update', + }, + }, + }); + + window.dispatchEvent(event); + + expect(returnValueSetter).toHaveBeenCalledWith( + 'Are you sure you want to lose unsaved changes?', + ); + }); + }); }); }); diff --git a/spec/frontend/snippets/components/show_spec.js b/spec/frontend/snippets/components/show_spec.js index 33608df8cf2..b5446e70028 100644 --- a/spec/frontend/snippets/components/show_spec.js +++ b/spec/frontend/snippets/components/show_spec.js @@ -1,10 +1,13 @@ import SnippetApp from '~/snippets/components/show.vue'; +import BlobEmbeddable from '~/blob/components/blob_embeddable.vue'; import SnippetHeader from '~/snippets/components/snippet_header.vue'; import SnippetTitle from '~/snippets/components/snippet_title.vue'; import SnippetBlob from '~/snippets/components/snippet_blob_view.vue'; import { GlLoadingIcon } from '@gitlab/ui'; +import { Blob, BinaryBlob } from 'jest/blob/components/mock_data'; import { shallowMount } from '@vue/test-utils'; +import { SNIPPET_VISIBILITY_PUBLIC } from '~/snippets/constants'; describe('Snippet view app', () => { let wrapper; @@ -12,7 +15,7 @@ describe('Snippet view app', () => { snippetGid: 'gid://gitlab/PersonalSnippet/42', }; - function createComponent({ props = defaultProps, loading = false } = {}) { + function createComponent({ props = defaultProps, data = {}, loading = false } = {}) { const $apollo = { queries: { snippet: { @@ -26,6 +29,9 @@ describe('Snippet view app', () => { propsData: { ...props, }, + data() { + return data; + }, }); } afterEach(() => { @@ -37,10 +43,33 @@ describe('Snippet view app', () => { expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); }); - it('renders all components after the query is finished', () => { + it('renders all simple components after the query is finished', () => { createComponent(); expect(wrapper.find(SnippetHeader).exists()).toBe(true); expect(wrapper.find(SnippetTitle).exists()).toBe(true); - expect(wrapper.find(SnippetBlob).exists()).toBe(true); + }); + + it('renders embeddable component if visibility allows', () => { + createComponent({ + data: { + snippet: { + visibilityLevel: SNIPPET_VISIBILITY_PUBLIC, + webUrl: 'http://foo.bar', + }, + }, + }); + expect(wrapper.contains(BlobEmbeddable)).toBe(true); + }); + + it('renders correct snippet-blob components', () => { + createComponent({ + data: { + blobs: [Blob, BinaryBlob], + }, + }); + const blobs = wrapper.findAll(SnippetBlob); + expect(blobs.length).toBe(2); + expect(blobs.at(0).props('blob')).toEqual(Blob); + expect(blobs.at(1).props('blob')).toEqual(BinaryBlob); }); }); diff --git a/spec/frontend/snippets/components/snippet_blob_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_edit_spec.js index 75688e61892..009074b4558 100644 --- a/spec/frontend/snippets/components/snippet_blob_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_edit_spec.js @@ -4,78 +4,161 @@ import BlobContentEdit from '~/blob/components/blob_edit_content.vue'; import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import { joinPaths } from '~/lib/utils/url_utility'; +import waitForPromises from 'helpers/wait_for_promises'; jest.mock('~/blob/utils', () => jest.fn()); +jest.mock('~/lib/utils/url_utility', () => ({ + getBaseURL: jest.fn().mockReturnValue('foo/'), + joinPaths: jest + .fn() + .mockName('joinPaths') + .mockReturnValue('contentApiURL'), +})); + +jest.mock('~/flash'); + +let flashSpy; + describe('Snippet Blob Edit component', () => { let wrapper; - const value = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; - const fileName = 'lorem.txt'; - const findHeader = () => wrapper.find(BlobHeaderEdit); - const findContent = () => wrapper.find(BlobContentEdit); + let axiosMock; + const contentMock = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; + const pathMock = 'lorem.txt'; + const rawPathMock = 'foo/bar'; + const blob = { + path: pathMock, + content: contentMock, + rawPath: rawPathMock, + }; + const findComponent = component => wrapper.find(component); - function createComponent(props = {}) { + function createComponent(props = {}, data = { isContentLoading: false }) { wrapper = shallowMount(SnippetBlobEdit, { propsData: { - value, - fileName, - isLoading: false, ...props, }, + data() { + return { + ...data, + }; + }, }); + flashSpy = jest.spyOn(wrapper.vm, 'flashAPIFailure'); } beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); createComponent(); }); afterEach(() => { + axiosMock.restore(); wrapper.destroy(); }); describe('rendering', () => { it('matches the snapshot', () => { + createComponent({ blob }); expect(wrapper.element).toMatchSnapshot(); }); it('renders required components', () => { - expect(findHeader().exists()).toBe(true); - expect(findContent().exists()).toBe(true); + expect(findComponent(BlobHeaderEdit).exists()).toBe(true); + expect(findComponent(BlobContentEdit).exists()).toBe(true); }); - it('renders loader if isLoading equals true', () => { - createComponent({ isLoading: true }); + it('renders loader if existing blob is supplied but no content is fetched yet', () => { + createComponent({ blob }, { isContentLoading: true }); expect(wrapper.contains(GlLoadingIcon)).toBe(true); - expect(findContent().exists()).toBe(false); + expect(findComponent(BlobContentEdit).exists()).toBe(false); + }); + + it('does not render loader if when blob is not supplied', () => { + createComponent(); + expect(wrapper.contains(GlLoadingIcon)).toBe(false); + expect(findComponent(BlobContentEdit).exists()).toBe(true); }); }); describe('functionality', () => { - it('does not fail without content', () => { + it('does not fail without blob', () => { const spy = jest.spyOn(global.console, 'error'); - createComponent({ value: undefined }); + createComponent({ blob: undefined }); expect(spy).not.toHaveBeenCalled(); - expect(findContent().exists()).toBe(true); + expect(findComponent(BlobContentEdit).exists()).toBe(true); }); - it('emits "name-change" event when the file name gets changed', () => { - expect(wrapper.emitted('name-change')).toBeUndefined(); - const newFilename = 'foo.bar'; - findHeader().vm.$emit('input', newFilename); + it.each` + emitter | prop + ${BlobHeaderEdit} | ${'filePath'} + ${BlobContentEdit} | ${'content'} + `('emits "blob-updated" event when the $prop gets changed', ({ emitter, prop }) => { + expect(wrapper.emitted('blob-updated')).toBeUndefined(); + const newValue = 'foo.bar'; + findComponent(emitter).vm.$emit('input', newValue); return nextTick().then(() => { - expect(wrapper.emitted('name-change')[0]).toEqual([newFilename]); + expect(wrapper.emitted('blob-updated')[0]).toEqual([ + expect.objectContaining({ + [prop]: newValue, + }), + ]); }); }); - it('emits "input" event when the file content gets changed', () => { - expect(wrapper.emitted('input')).toBeUndefined(); - const newValue = 'foo.bar'; - findContent().vm.$emit('input', newValue); + describe('fetching blob content', () => { + const bootstrapForExistingSnippet = resp => { + createComponent({ + blob: { + ...blob, + content: '', + }, + }); - return nextTick().then(() => { - expect(wrapper.emitted('input')[0]).toEqual([newValue]); + if (resp === 500) { + axiosMock.onGet('contentApiURL').reply(500); + } else { + axiosMock.onGet('contentApiURL').reply(200, contentMock); + } + }; + + const bootstrapForNewSnippet = () => { + createComponent(); + }; + + it('fetches blob content with the additional query', () => { + bootstrapForExistingSnippet(); + + return waitForPromises().then(() => { + expect(joinPaths).toHaveBeenCalledWith('foo/', rawPathMock); + expect(findComponent(BlobHeaderEdit).props('value')).toBe(pathMock); + expect(findComponent(BlobContentEdit).props('value')).toBe(contentMock); + }); + }); + + it('flashes the error message if fetching content fails', () => { + bootstrapForExistingSnippet(500); + + return waitForPromises().then(() => { + expect(flashSpy).toHaveBeenCalled(); + expect(findComponent(BlobContentEdit).props('value')).toBe(''); + }); + }); + + it('does not fetch content for new snippet', () => { + bootstrapForNewSnippet(); + + return waitForPromises().then(() => { + // we keep using waitForPromises to make sure we do not run failed test + expect(findComponent(BlobHeaderEdit).props('value')).toBe(''); + expect(findComponent(BlobContentEdit).props('value')).toBe(''); + expect(joinPaths).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/spec/frontend/snippets/components/snippet_blob_view_spec.js b/spec/frontend/snippets/components/snippet_blob_view_spec.js index e4d8ee9b7df..c8f1c8fc8a9 100644 --- a/spec/frontend/snippets/components/snippet_blob_view_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_view_spec.js @@ -23,13 +23,17 @@ describe('Blob Embeddable', () => { id: 'gid://foo.bar/snippet', webUrl: 'https://foo.bar', visibilityLevel: SNIPPET_VISIBILITY_PUBLIC, - blob: BlobMock, }; const dataMock = { activeViewerType: SimpleViewerMock.type, }; - function createComponent(props = {}, data = dataMock, contentLoading = false) { + function createComponent({ + snippetProps = {}, + data = dataMock, + blob = BlobMock, + contentLoading = false, + } = {}) { const $apollo = { queries: { blobContent: { @@ -44,8 +48,9 @@ describe('Blob Embeddable', () => { propsData: { snippet: { ...snippet, - ...props, + ...snippetProps, }, + blob, }, data() { return { @@ -63,7 +68,6 @@ describe('Blob Embeddable', () => { describe('rendering', () => { it('renders correct components', () => { createComponent(); - expect(wrapper.find(BlobEmbeddable).exists()).toBe(true); expect(wrapper.find(BlobHeader).exists()).toBe(true); expect(wrapper.find(BlobContent).exists()).toBe(true); }); @@ -72,19 +76,14 @@ describe('Blob Embeddable', () => { 'does not render blob-embeddable by default', visibilityLevel => { createComponent({ - visibilityLevel, + snippetProps: { + visibilityLevel, + }, }); expect(wrapper.find(BlobEmbeddable).exists()).toBe(false); }, ); - it('does render blob-embeddable for public snippet', () => { - createComponent({ - visibilityLevel: SNIPPET_VISIBILITY_PUBLIC, - }); - expect(wrapper.find(BlobEmbeddable).exists()).toBe(true); - }); - it('sets simple viewer correctly', () => { createComponent(); expect(wrapper.find(SimpleViewer).exists()).toBe(true); @@ -92,7 +91,9 @@ describe('Blob Embeddable', () => { it('sets rich viewer correctly', () => { const data = { ...dataMock, activeViewerType: RichViewerMock.type }; - createComponent({}, data); + createComponent({ + data, + }); expect(wrapper.find(RichViewer).exists()).toBe(true); }); @@ -137,7 +138,9 @@ describe('Blob Embeddable', () => { }); it('renders simple viewer by default if URL contains hash', () => { - createComponent({}, {}); + createComponent({ + data: {}, + }); expect(wrapper.vm.activeViewerType).toBe(SimpleViewerMock.type); expect(wrapper.find(SimpleViewer).exists()).toBe(true); @@ -183,12 +186,11 @@ describe('Blob Embeddable', () => { }); it(`sets '${SimpleViewerMock.type}' as active on ${BLOB_RENDER_EVENT_SHOW_SOURCE} event`, () => { - createComponent( - {}, - { + createComponent({ + data: { activeViewerType: RichViewerMock.type, }, - ); + }); findContentEl().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE); expect(wrapper.vm.activeViewerType).toEqual(SimpleViewerMock.type); diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js index 5230910b6f5..0825da92118 100644 --- a/spec/frontend/snippets/components/snippet_header_spec.js +++ b/spec/frontend/snippets/components/snippet_header_spec.js @@ -3,6 +3,7 @@ import DeleteSnippetMutation from '~/snippets/mutations/deleteSnippet.mutation.g import { ApolloMutation } from 'vue-apollo'; import { GlButton, GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { Blob, BinaryBlob } from 'jest/blob/components/mock_data'; describe('Snippet header component', () => { let wrapper; @@ -20,9 +21,7 @@ describe('Snippet header component', () => { author: { name: 'Thor Odinson', }, - blob: { - binary: false, - }, + blobs: [Blob], }; const mutationVariables = { mutation: DeleteSnippetMutation, @@ -49,7 +48,6 @@ describe('Snippet header component', () => { mutationRes = mutationTypes.RESOLVE, snippetProps = {}, } = {}) { - // const defaultProps = Object.assign({}, snippet, snippetProps); const defaultProps = Object.assign(snippet, snippetProps); if (permissions) { Object.assign(defaultProps.userPermissions, { @@ -131,15 +129,18 @@ describe('Snippet header component', () => { expect(wrapper.find(GlModal).exists()).toBe(true); }); - it('renders Edit button as disabled for binary snippets', () => { + it.each` + blobs | isDisabled | condition + ${[Blob]} | ${false} | ${'no binary'} + ${[Blob, BinaryBlob]} | ${true} | ${'several blobs. incl. a binary'} + ${[BinaryBlob]} | ${true} | ${'binary'} + `('renders Edit button when snippet contains $condition file', ({ blobs, isDisabled }) => { createComponent({ snippetProps: { - blob: { - binary: true, - }, + blobs, }, }); - expect(wrapper.find('[href*="edit"]').props('disabled')).toBe(true); + expect(wrapper.find('[href*="edit"]').props('disabled')).toBe(isDisabled); }); describe('Delete mutation', () => { diff --git a/spec/frontend/static_site_editor/components/edit_area_spec.js b/spec/frontend/static_site_editor/components/edit_area_spec.js index d7c798e6620..11c5abf1b08 100644 --- a/spec/frontend/static_site_editor/components/edit_area_spec.js +++ b/spec/frontend/static_site_editor/components/edit_area_spec.js @@ -1,6 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; +import { EDITOR_TYPES } from '~/vue_shared/components/rich_content_editor/constants'; import EditArea from '~/static_site_editor/components/edit_area.vue'; import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue'; @@ -51,7 +52,7 @@ describe('~/static_site_editor/components/edit_area.vue', () => { it('renders rich content editor', () => { expect(findRichContentEditor().exists()).toBe(true); - expect(findRichContentEditor().props('value')).toBe(body); + expect(findRichContentEditor().props('content')).toBe(body); }); it('renders publish toolbar', () => { @@ -75,6 +76,15 @@ describe('~/static_site_editor/components/edit_area.vue', () => { return wrapper.vm.$nextTick(); }); + it('updates parsedSource with new content', () => { + const newContent = 'New content'; + const spySyncParsedSource = jest.spyOn(wrapper.vm.parsedSource, 'sync'); + + findRichContentEditor().vm.$emit('input', newContent); + + expect(spySyncParsedSource).toHaveBeenCalledWith(newContent, true); + }); + it('sets publish toolbar as saveable', () => { expect(findPublishToolbar().props('saveable')).toBe(true); }); @@ -91,4 +101,33 @@ describe('~/static_site_editor/components/edit_area.vue', () => { }); }); }); + + describe('when the mode changes', () => { + const setInitialMode = mode => { + wrapper.setData({ editorMode: mode }); + }; + + afterEach(() => { + setInitialMode(EDITOR_TYPES.wysiwyg); + }); + + it.each` + initialMode | targetMode | resetValue + ${EDITOR_TYPES.wysiwyg} | ${EDITOR_TYPES.markdown} | ${content} + ${EDITOR_TYPES.markdown} | ${EDITOR_TYPES.wysiwyg} | ${body} + `( + 'sets editorMode from $initialMode to $targetMode', + ({ initialMode, targetMode, resetValue }) => { + setInitialMode(initialMode); + + const resetInitialValue = jest.fn(); + + findRichContentEditor().setMethods({ resetInitialValue }); + findRichContentEditor().vm.$emit('modeChange', targetMode); + + expect(resetInitialValue).toHaveBeenCalledWith(resetValue); + expect(wrapper.vm.editorMode).toBe(targetMode); + }, + ); + }); }); diff --git a/spec/frontend/static_site_editor/mock_data.js b/spec/frontend/static_site_editor/mock_data.js index 422048a5f69..96de9b73af0 100644 --- a/spec/frontend/static_site_editor/mock_data.js +++ b/spec/frontend/static_site_editor/mock_data.js @@ -10,6 +10,8 @@ export const sourceContentBody = `## On this page - TOC {:toc .hidden-md .hidden-lg} + +![image](path/to/image1.png) `; export const sourceContent = `${sourceContentHeader}${sourceContentSpacing}${sourceContentBody}`; export const sourceContentTitle = 'Handbook'; @@ -48,3 +50,8 @@ export const createMergeRequestResponse = { }; export const trackingCategory = 'projects:static_site_editor:show'; + +export const images = new Map([ + ['path/to/image1.png', 'image1-content'], + ['path/to/image2.png', 'image2-content'], +]); diff --git a/spec/frontend/static_site_editor/services/parse_source_file_spec.js b/spec/frontend/static_site_editor/services/parse_source_file_spec.js index fe99c4f5334..4588548e614 100644 --- a/spec/frontend/static_site_editor/services/parse_source_file_spec.js +++ b/spec/frontend/static_site_editor/services/parse_source_file_spec.js @@ -1,64 +1,58 @@ -import { - sourceContent as content, - sourceContentHeader as header, - sourceContentSpacing as spacing, - sourceContentBody as body, -} from '../mock_data'; +import { sourceContent as content, sourceContentBody as body } from '../mock_data'; import parseSourceFile from '~/static_site_editor/services/parse_source_file'; describe('parseSourceFile', () => { - const contentSimple = content; const contentComplex = [content, content, content].join(''); + const complexBody = [body, content, content].join(''); + const edit = 'and more'; + const newContent = `${content} ${edit}`; + const newContentComplex = `${contentComplex} ${edit}`; - describe('the editable shape and its expected values', () => { + describe('unmodified content', () => { it.each` - sourceContent | sourceHeader | sourceSpacing | sourceBody | desc - ${contentSimple} | ${header} | ${spacing} | ${body} | ${'extracts header'} - ${contentComplex} | ${header} | ${spacing} | ${[body, content, content].join('')} | ${'extracts body'} - `('$desc', ({ sourceContent, sourceHeader, sourceSpacing, sourceBody }) => { - const { editable } = parseSourceFile(sourceContent); - - expect(editable).toMatchObject({ - raw: sourceContent, - header: sourceHeader, - spacing: sourceSpacing, - body: sourceBody, - }); + parsedSource + ${parseSourceFile(content)} + ${parseSourceFile(contentComplex)} + `('returns false by default', ({ parsedSource }) => { + expect(parsedSource.isModified()).toBe(false); }); - it('returns the same front matter regardless of front matter duplication', () => { - const parsedSourceSimple = parseSourceFile(contentSimple); - const parsedSourceComplex = parseSourceFile(contentComplex); - - expect(parsedSourceSimple.editable.header).toBe(parsedSourceComplex.editable.header); - }); - }); - - describe('editable body to raw content default and changes', () => { it.each` - sourceContent | desc - ${contentSimple} | ${'returns false by default for both raw and body'} - ${contentComplex} | ${'returns false by default for both raw and body'} - `('$desc', ({ sourceContent }) => { - const parsedSource = parseSourceFile(sourceContent); + parsedSource | isBody | target + ${parseSourceFile(content)} | ${undefined} | ${content} + ${parseSourceFile(content)} | ${false} | ${content} + ${parseSourceFile(content)} | ${true} | ${body} + ${parseSourceFile(contentComplex)} | ${undefined} | ${contentComplex} + ${parseSourceFile(contentComplex)} | ${false} | ${contentComplex} + ${parseSourceFile(contentComplex)} | ${true} | ${complexBody} + `( + 'returns only the $target content when the `isBody` parameter argument is $isBody', + ({ parsedSource, isBody, target }) => { + expect(parsedSource.content(isBody)).toBe(target); + }, + ); + }); - expect(parsedSource.isModifiedRaw()).toBe(false); - expect(parsedSource.isModifiedBody()).toBe(false); - }); + describe('modified content', () => { + const newBody = `${body} ${edit}`; + const newComplexBody = `${complexBody} ${edit}`; it.each` - sourceContent | editableKey | syncKey | isModifiedKey | desc - ${contentSimple} | ${'body'} | ${'syncRaw'} | ${'isModifiedRaw'} | ${'returns true after modification and sync'} - ${contentSimple} | ${'raw'} | ${'syncBody'} | ${'isModifiedBody'} | ${'returns true after modification and sync'} - ${contentComplex} | ${'body'} | ${'syncRaw'} | ${'isModifiedRaw'} | ${'returns true after modification and sync'} - ${contentComplex} | ${'raw'} | ${'syncBody'} | ${'isModifiedBody'} | ${'returns true after modification and sync'} - `('$desc', ({ sourceContent, editableKey, syncKey, isModifiedKey }) => { - const parsedSource = parseSourceFile(sourceContent); - parsedSource.editable[editableKey] += 'Added content'; - parsedSource[syncKey](); - - expect(parsedSource[isModifiedKey]()).toBe(true); - }); + parsedSource | isModified | targetRaw | targetBody + ${parseSourceFile(content)} | ${false} | ${content} | ${body} + ${parseSourceFile(content)} | ${true} | ${newContent} | ${newBody} + ${parseSourceFile(contentComplex)} | ${false} | ${contentComplex} | ${complexBody} + ${parseSourceFile(contentComplex)} | ${true} | ${newContentComplex} | ${newComplexBody} + `( + 'returns $isModified after a $targetRaw sync', + ({ parsedSource, isModified, targetRaw, targetBody }) => { + parsedSource.sync(targetRaw); + + expect(parsedSource.isModified()).toBe(isModified); + expect(parsedSource.content()).toBe(targetRaw); + expect(parsedSource.content(true)).toBe(targetBody); + }, + ); }); }); diff --git a/spec/frontend/static_site_editor/services/submit_content_changes_spec.js b/spec/frontend/static_site_editor/services/submit_content_changes_spec.js index 3636de3fe70..a9169eb3e16 100644 --- a/spec/frontend/static_site_editor/services/submit_content_changes_spec.js +++ b/spec/frontend/static_site_editor/services/submit_content_changes_spec.js @@ -22,6 +22,7 @@ import { sourcePath, sourceContent as content, trackingCategory, + images, } from '../mock_data'; jest.mock('~/static_site_editor/services/generate_branch_name'); @@ -69,7 +70,7 @@ describe('submitContentChanges', () => { }); it('commits the content changes to the branch when creating branch succeeds', () => { - return submitContentChanges({ username, projectId, sourcePath, content }).then(() => { + return submitContentChanges({ username, projectId, sourcePath, content, images }).then(() => { expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, { branch, commit_message: mergeRequestTitle, @@ -79,6 +80,35 @@ describe('submitContentChanges', () => { file_path: sourcePath, content, }, + { + action: 'create', + content: 'image1-content', + encoding: 'base64', + file_path: 'path/to/image1.png', + }, + ], + }); + }); + }); + + it('does not commit an image if it has been removed from the content', () => { + const contentWithoutImages = '## Content without images'; + return submitContentChanges({ + username, + projectId, + sourcePath, + content: contentWithoutImages, + images, + }).then(() => { + expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, { + branch, + commit_message: mergeRequestTitle, + actions: [ + { + action: 'update', + file_path: sourcePath, + content: contentWithoutImages, + }, ], }); }); @@ -87,13 +117,13 @@ describe('submitContentChanges', () => { it('notifies error when content could not be committed', () => { Api.commitMultiple.mockRejectedValueOnce(); - return expect(submitContentChanges({ username, projectId })).rejects.toThrow( + return expect(submitContentChanges({ username, projectId, images })).rejects.toThrow( SUBMIT_CHANGES_COMMIT_ERROR, ); }); it('creates a merge request when commiting changes succeeds', () => { - return submitContentChanges({ username, projectId, sourcePath, content }).then(() => { + return submitContentChanges({ username, projectId, sourcePath, content, images }).then(() => { expect(Api.createProjectMergeRequest).toHaveBeenCalledWith( projectId, convertObjectPropsToSnakeCase({ @@ -108,7 +138,7 @@ describe('submitContentChanges', () => { it('notifies error when merge request could not be created', () => { Api.createProjectMergeRequest.mockRejectedValueOnce(); - return expect(submitContentChanges({ username, projectId })).rejects.toThrow( + return expect(submitContentChanges({ username, projectId, images })).rejects.toThrow( SUBMIT_CHANGES_MERGE_REQUEST_ERROR, ); }); @@ -117,9 +147,11 @@ describe('submitContentChanges', () => { let result; beforeEach(() => { - return submitContentChanges({ username, projectId, sourcePath, content }).then(_result => { - result = _result; - }); + return submitContentChanges({ username, projectId, sourcePath, content, images }).then( + _result => { + result = _result; + }, + ); }); it('returns the branch name', () => { @@ -147,7 +179,7 @@ describe('submitContentChanges', () => { describe('sends the correct tracking event', () => { beforeEach(() => { - return submitContentChanges({ username, projectId, sourcePath, content }); + return submitContentChanges({ username, projectId, sourcePath, content, images }); }); it('for committing changes', () => { diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js new file mode 100644 index 00000000000..e39f66d3f30 --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js @@ -0,0 +1,391 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import Approvals from '~/vue_merge_request_widget/components/approvals/approvals.vue'; +import ApprovalsSummary from '~/vue_merge_request_widget/components/approvals/approvals_summary.vue'; +import ApprovalsSummaryOptional from '~/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue'; +import createFlash from '~/flash'; +import { + FETCH_LOADING, + FETCH_ERROR, + APPROVE_ERROR, + UNAPPROVE_ERROR, +} from '~/vue_merge_request_widget/components/approvals/messages'; +import eventHub from '~/vue_merge_request_widget/event_hub'; + +jest.mock('~/flash'); + +const TEST_HELP_PATH = 'help/path'; +const testApprovedBy = () => [1, 7, 10].map(id => ({ id })); +const testApprovals = () => ({ + approved: false, + approved_by: testApprovedBy().map(user => ({ user })), + approval_rules_left: [], + approvals_left: 4, + suggested_approvers: [], + user_can_approve: true, + user_has_approved: true, + require_password_to_approve: false, +}); +const testApprovalRulesResponse = () => ({ rules: [{ id: 2 }] }); + +// For some reason, the `Promise.resolve()` needs to be deferred +// or the timing doesn't work. +const tick = () => Promise.resolve(); +const waitForTick = done => + tick() + .then(done) + .catch(done.fail); + +describe('MRWidget approvals', () => { + let wrapper; + let service; + let mr; + + const createComponent = (props = {}) => { + wrapper = shallowMount(Approvals, { + propsData: { + mr, + service, + ...props, + }, + }); + }; + + const findAction = () => wrapper.find(GlButton); + const findActionData = () => { + const action = findAction(); + + return !action.exists() + ? null + : { + variant: action.props('variant'), + category: action.props('category'), + text: action.text(), + }; + }; + const findSummary = () => wrapper.find(ApprovalsSummary); + const findOptionalSummary = () => wrapper.find(ApprovalsSummaryOptional); + + beforeEach(() => { + service = { + ...{ + fetchApprovals: jest.fn().mockReturnValue(Promise.resolve(testApprovals())), + fetchApprovalSettings: jest + .fn() + .mockReturnValue(Promise.resolve(testApprovalRulesResponse())), + approveMergeRequest: jest.fn().mockReturnValue(Promise.resolve(testApprovals())), + unapproveMergeRequest: jest.fn().mockReturnValue(Promise.resolve(testApprovals())), + approveMergeRequestWithAuth: jest.fn().mockReturnValue(Promise.resolve(testApprovals())), + }, + }; + mr = { + ...{ + setApprovals: jest.fn(), + setApprovalRules: jest.fn(), + }, + approvalsHelpPath: TEST_HELP_PATH, + approvals: testApprovals(), + approvalRules: [], + isOpen: true, + state: 'open', + }; + + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when created', () => { + beforeEach(() => { + createComponent(); + }); + + it('shows loading message', () => { + wrapper.setData({ fetchingApprovals: true }); + + return tick().then(() => { + expect(wrapper.text()).toContain(FETCH_LOADING); + }); + }); + + it('fetches approvals', () => { + expect(service.fetchApprovals).toHaveBeenCalled(); + }); + }); + + describe('when fetch approvals error', () => { + beforeEach(done => { + jest.spyOn(service, 'fetchApprovals').mockReturnValue(Promise.reject()); + createComponent(); + waitForTick(done); + }); + + it('still shows loading message', () => { + expect(wrapper.text()).toContain(FETCH_LOADING); + }); + + it('flashes error', () => { + expect(createFlash).toHaveBeenCalledWith(FETCH_ERROR); + }); + }); + + describe('action button', () => { + describe('when mr is closed', () => { + beforeEach(done => { + mr.isOpen = false; + mr.approvals.user_has_approved = false; + mr.approvals.user_can_approve = true; + + createComponent(); + waitForTick(done); + }); + + it('action is not rendered', () => { + expect(findActionData()).toBe(null); + }); + }); + + describe('when user cannot approve', () => { + beforeEach(done => { + mr.approvals.user_has_approved = false; + mr.approvals.user_can_approve = false; + + createComponent(); + waitForTick(done); + }); + + it('action is not rendered', () => { + expect(findActionData()).toBe(null); + }); + }); + + describe('when user can approve', () => { + beforeEach(() => { + mr.approvals.user_has_approved = false; + mr.approvals.user_can_approve = true; + }); + + describe('and MR is unapproved', () => { + beforeEach(done => { + createComponent(); + waitForTick(done); + }); + + it('approve action is rendered', () => { + expect(findActionData()).toEqual({ + variant: 'info', + text: 'Approve', + category: 'primary', + }); + }); + }); + + describe('and MR is approved', () => { + beforeEach(() => { + mr.approvals.approved = true; + }); + + describe('with no approvers', () => { + beforeEach(done => { + mr.approvals.approved_by = []; + createComponent(); + waitForTick(done); + }); + + it('approve action (with inverted style) is rendered', () => { + expect(findActionData()).toEqual({ + variant: 'info', + text: 'Approve', + category: 'secondary', + }); + }); + }); + + describe('with approvers', () => { + beforeEach(done => { + mr.approvals.approved_by = [{ user: { id: 7 } }]; + createComponent(); + waitForTick(done); + }); + + it('approve additionally action is rendered', () => { + expect(findActionData()).toEqual({ + variant: 'info', + text: 'Approve additionally', + category: 'secondary', + }); + }); + }); + }); + + describe('when approve action is clicked', () => { + beforeEach(done => { + createComponent(); + waitForTick(done); + }); + + it('shows loading icon', () => { + jest.spyOn(service, 'approveMergeRequest').mockReturnValue(new Promise(() => {})); + const action = findAction(); + + expect(action.props('loading')).toBe(false); + + action.vm.$emit('click'); + + return tick().then(() => { + expect(action.props('loading')).toBe(true); + }); + }); + + describe('and after loading', () => { + beforeEach(done => { + findAction().vm.$emit('click'); + waitForTick(done); + }); + + it('calls service approve', () => { + expect(service.approveMergeRequest).toHaveBeenCalled(); + }); + + it('emits to eventHub', () => { + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + }); + + it('calls store setApprovals', () => { + expect(mr.setApprovals).toHaveBeenCalledWith(testApprovals()); + }); + }); + + describe('and error', () => { + beforeEach(done => { + jest.spyOn(service, 'approveMergeRequest').mockReturnValue(Promise.reject()); + findAction().vm.$emit('click'); + waitForTick(done); + }); + + it('flashes error message', () => { + expect(createFlash).toHaveBeenCalledWith(APPROVE_ERROR); + }); + }); + }); + }); + + describe('when user has approved', () => { + beforeEach(done => { + mr.approvals.user_has_approved = true; + mr.approvals.user_can_approve = false; + + createComponent(); + waitForTick(done); + }); + + it('revoke action is rendered', () => { + expect(findActionData()).toEqual({ + variant: 'warning', + text: 'Revoke approval', + category: 'secondary', + }); + }); + + describe('when revoke action is clicked', () => { + describe('and successful', () => { + beforeEach(done => { + findAction().vm.$emit('click'); + waitForTick(done); + }); + + it('calls service unapprove', () => { + expect(service.unapproveMergeRequest).toHaveBeenCalled(); + }); + + it('emits to eventHub', () => { + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + }); + + it('calls store setApprovals', () => { + expect(mr.setApprovals).toHaveBeenCalledWith(testApprovals()); + }); + }); + + describe('and error', () => { + beforeEach(done => { + jest.spyOn(service, 'unapproveMergeRequest').mockReturnValue(Promise.reject()); + findAction().vm.$emit('click'); + waitForTick(done); + }); + + it('flashes error message', () => { + expect(createFlash).toHaveBeenCalledWith(UNAPPROVE_ERROR); + }); + }); + }); + }); + }); + + describe('approvals optional summary', () => { + describe('when no approvals required and no approvers', () => { + beforeEach(() => { + mr.approvals.approved_by = []; + mr.approvals.approvals_required = 0; + mr.approvals.user_has_approved = false; + }); + + describe('and can approve', () => { + beforeEach(done => { + mr.approvals.user_can_approve = true; + + createComponent(); + waitForTick(done); + }); + + it('is shown', () => { + expect(findSummary().exists()).toBe(false); + expect(findOptionalSummary().props()).toEqual({ + canApprove: true, + helpPath: TEST_HELP_PATH, + }); + }); + }); + + describe('and cannot approve', () => { + beforeEach(done => { + mr.approvals.user_can_approve = false; + + createComponent(); + waitForTick(done); + }); + + it('is shown', () => { + expect(findSummary().exists()).toBe(false); + expect(findOptionalSummary().props()).toEqual({ + canApprove: false, + helpPath: TEST_HELP_PATH, + }); + }); + }); + }); + }); + + describe('approvals summary', () => { + beforeEach(done => { + createComponent(); + waitForTick(done); + }); + + it('is rendered with props', () => { + const expected = testApprovals(); + const summary = findSummary(); + + expect(findOptionalSummary().exists()).toBe(false); + expect(summary.exists()).toBe(true); + expect(summary.props()).toMatchObject({ + approvalsLeft: expected.approvals_left, + rulesLeft: expected.approval_rules_left, + approvers: testApprovedBy(), + }); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_optional_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_optional_spec.js new file mode 100644 index 00000000000..77fad7f51ab --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_optional_spec.js @@ -0,0 +1,57 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink } from '@gitlab/ui'; +import { + OPTIONAL, + OPTIONAL_CAN_APPROVE, +} from '~/vue_merge_request_widget/components/approvals/messages'; +import ApprovalsSummaryOptional from '~/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue'; + +const TEST_HELP_PATH = 'help/path'; + +describe('MRWidget approvals summary optional', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(ApprovalsSummaryOptional, { + propsData: props, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findHelpLink = () => wrapper.find(GlLink); + + describe('when can approve', () => { + beforeEach(() => { + createComponent({ canApprove: true, helpPath: TEST_HELP_PATH }); + }); + + it('shows optional can approve message', () => { + expect(wrapper.text()).toEqual(OPTIONAL_CAN_APPROVE); + }); + + it('shows help link', () => { + const link = findHelpLink(); + + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe(TEST_HELP_PATH); + }); + }); + + describe('when cannot approve', () => { + beforeEach(() => { + createComponent({ canApprove: false, helpPath: TEST_HELP_PATH }); + }); + + it('shows optional message', () => { + expect(wrapper.text()).toEqual(OPTIONAL); + }); + + it('does not show help link', () => { + expect(findHelpLink().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js new file mode 100644 index 00000000000..822d075f28f --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js @@ -0,0 +1,93 @@ +import { shallowMount } from '@vue/test-utils'; +import { APPROVED_MESSAGE } from '~/vue_merge_request_widget/components/approvals/messages'; +import ApprovalsSummary from '~/vue_merge_request_widget/components/approvals/approvals_summary.vue'; +import { toNounSeriesText } from '~/lib/utils/grammar'; +import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; + +const testApprovers = () => Array.from({ length: 5 }, (_, i) => i).map(id => ({ id })); +const testRulesLeft = () => ['Lorem', 'Ipsum', 'dolar & sit']; +const TEST_APPROVALS_LEFT = 3; + +describe('MRWidget approvals summary', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(ApprovalsSummary, { + propsData: { + approved: false, + approvers: testApprovers(), + approvalsLeft: TEST_APPROVALS_LEFT, + rulesLeft: testRulesLeft(), + ...props, + }, + }); + }; + + const findAvatars = () => wrapper.find(UserAvatarList); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when approved', () => { + beforeEach(() => { + createComponent({ + approved: true, + }); + }); + + it('shows approved message', () => { + expect(wrapper.text()).toContain(APPROVED_MESSAGE); + }); + + it('renders avatar list for approvers', () => { + const avatars = findAvatars(); + + expect(avatars.exists()).toBe(true); + expect(avatars.props()).toEqual( + expect.objectContaining({ + items: testApprovers(), + }), + ); + }); + }); + + describe('when not approved', () => { + beforeEach(() => { + createComponent(); + }); + + it('render message', () => { + const names = toNounSeriesText(testRulesLeft()); + + expect(wrapper.text()).toContain( + `Requires ${TEST_APPROVALS_LEFT} more approvals from ${names}.`, + ); + }); + }); + + describe('when no rulesLeft', () => { + beforeEach(() => { + createComponent({ + rulesLeft: [], + }); + }); + + it('renders message', () => { + expect(wrapper.text()).toContain(`Requires ${TEST_APPROVALS_LEFT} more approvals.`); + }); + }); + + describe('when no approvers', () => { + beforeEach(() => { + createComponent({ + approvers: [], + }); + }); + + it('does not render avatar list', () => { + expect(wrapper.find(UserAvatarList).exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_author_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_author_spec.js index 05690aa1248..e7c10ab4c2d 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_author_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_author_spec.js @@ -1,39 +1,61 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { shallowMount } from '@vue/test-utils'; import MrWidgetAuthor from '~/vue_merge_request_widget/components/mr_widget_author.vue'; +window.gl = window.gl || {}; + describe('MrWidgetAuthor', () => { - let vm; + let wrapper; + let oldWindowGl; + const mockAuthor = { + name: 'Administrator', + username: 'root', + webUrl: 'http://localhost:3000/root', + avatarUrl: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }; beforeEach(() => { - const Component = Vue.extend(MrWidgetAuthor); - - vm = mountComponent(Component, { - author: { - name: 'Administrator', - username: 'root', - webUrl: 'http://localhost:3000/root', - avatarUrl: - 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + oldWindowGl = window.gl; + window.gl = { + mrWidgetData: { + defaultAvatarUrl: 'no_avatar.png', + }, + }; + wrapper = shallowMount(MrWidgetAuthor, { + propsData: { + author: mockAuthor, }, }); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + window.gl = oldWindowGl; }); it('renders link with the author web url', () => { - expect(vm.$el.getAttribute('href')).toEqual('http://localhost:3000/root'); + expect(wrapper.attributes('href')).toBe('http://localhost:3000/root'); }); it('renders image with avatar url', () => { - expect(vm.$el.querySelector('img').getAttribute('src')).toEqual( + expect(wrapper.find('img').attributes('src')).toBe( 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', ); }); + it('renders image with default avatar url when no avatarUrl is present in author', async () => { + wrapper.setProps({ + author: { + ...mockAuthor, + avatarUrl: null, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find('img').attributes('src')).toBe('no_avatar.png'); + }); + it('renders author name', () => { - expect(vm.$el.textContent.trim()).toEqual('Administrator'); + expect(wrapper.find('span').text()).toBe('Administrator'); }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_expandable_section_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_expandable_section_spec.js new file mode 100644 index 00000000000..69a50899d4d --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/mr_widget_expandable_section_spec.js @@ -0,0 +1,65 @@ +import { GlButton, GlCollapse, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import MrCollapsibleSection from '~/vue_merge_request_widget/components/mr_widget_expandable_section.vue'; + +describe('MrWidgetExpanableSection', () => { + let wrapper; + + const findButton = () => wrapper.find(GlButton); + const findCollapse = () => wrapper.find(GlCollapse); + + beforeEach(() => { + wrapper = shallowMount(MrCollapsibleSection, { + slots: { + content: '<span>Collapsable Content</span>', + header: '<span>Header Content</span>', + }, + }); + }); + + it('renders Icon', () => { + expect(wrapper.contains(GlIcon)).toBe(true); + }); + + it('renders header slot', () => { + expect(wrapper.text()).toContain('Header Content'); + }); + + it('renders content slot', () => { + expect(wrapper.text()).toContain('Collapsable Content'); + }); + + describe('when collapse section is closed', () => { + it('renders button with expand text', () => { + expect(findButton().text()).toBe('Expand'); + }); + + it('renders a collpased section with no visibility', () => { + const collapse = findCollapse(); + + expect(collapse.exists()).toBe(true); + expect(collapse.attributes('visible')).toBeUndefined(); + }); + }); + + describe('when collapse section is open', () => { + beforeEach(() => { + findButton().vm.$emit('click'); + return wrapper.vm.$nextTick(); + }); + + it('renders button with collapse text', () => { + const button = findButton(); + + expect(button.exists()).toBe(true); + expect(button.text()).toBe('Collapse'); + }); + + it('renders a collpased section with visible content', () => { + const collapse = findCollapse(); + + expect(collapse.exists()).toBe(true); + expect(collapse.attributes('visible')).toBe('true'); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js index b492a69fb3d..21058005d29 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js @@ -1,7 +1,13 @@ import Vue from 'vue'; +import Mousetrap from 'mousetrap'; import mountComponent from 'helpers/vue_mount_component_helper'; import headerComponent from '~/vue_merge_request_widget/components/mr_widget_header.vue'; +jest.mock('mousetrap', () => ({ + bind: jest.fn(), + unbind: jest.fn(), +})); + describe('MRWidgetHeader', () => { let vm; let Component; @@ -126,6 +132,35 @@ describe('MRWidgetHeader', () => { it('renders target branch', () => { expect(vm.$el.querySelector('.js-target-branch').textContent.trim()).toEqual('master'); }); + + describe('keyboard shortcuts', () => { + it('binds a keyboard shortcut handler to the "b" key', () => { + expect(Mousetrap.bind).toHaveBeenCalledWith('b', expect.any(Function)); + }); + + it('triggers a click on the "copy to clipboard" button when the handler is executed', () => { + const testClickHandler = jest.fn(); + vm.$refs.copyBranchNameButton.$el.addEventListener('click', testClickHandler); + + // Get a reference to the function that was assigned to the "b" shortcut key. + const shortcutHandler = Mousetrap.bind.mock.calls[0][1]; + + expect(testClickHandler).not.toHaveBeenCalled(); + + // Simulate Mousetrap calling the function. + shortcutHandler(); + + expect(testClickHandler).toHaveBeenCalledTimes(1); + }); + + it('unbinds the keyboard shortcut when the component is destroyed', () => { + expect(Mousetrap.unbind).not.toHaveBeenCalled(); + + vm.$destroy(); + + expect(Mousetrap.unbind).toHaveBeenCalledWith('b'); + }); + }); }); describe('with an open merge request', () => { diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js index 309aec179d9..6486826c3ec 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js @@ -1,189 +1,182 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { shallowMount, mount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; import { trimText } from 'helpers/text_helper'; -import pipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; +import { SUCCESS } from '~/vue_merge_request_widget/constants'; +import PipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; +import PipelineStage from '~/pipelines/components/pipelines_list/stage.vue'; import mockData from '../mock_data'; describe('MRWidgetPipeline', () => { - let vm; - let Component; - - beforeEach(() => { - Component = Vue.extend(pipelineComponent); - }); + let wrapper; + + const defaultProps = { + pipeline: mockData.pipeline, + ciStatus: SUCCESS, + hasCi: true, + mrTroubleshootingDocsPath: 'help', + ciTroubleshootingDocsPath: 'ci-help', + }; + + const ciErrorMessage = + 'Could not retrieve the pipeline status. For troubleshooting steps, read the documentation.'; + const monitoringMessage = 'Checking pipeline status.'; + + const findCIErrorMessage = () => wrapper.find('[data-testid="ci-error-message"]'); + const findPipelineID = () => wrapper.find('[data-testid="pipeline-id"]'); + const findPipelineInfoContainer = () => wrapper.find('[data-testid="pipeline-info-container"]'); + const findCommitLink = () => wrapper.find('[data-testid="commit-link"]'); + const findPipelineGraph = () => wrapper.find('[data-testid="widget-mini-pipeline-graph"]'); + const findAllPipelineStages = () => wrapper.findAll(PipelineStage); + const findPipelineCoverage = () => wrapper.find('[data-testid="pipeline-coverage"]'); + const findPipelineCoverageDelta = () => wrapper.find('[data-testid="pipeline-coverage-delta"]'); + const findMonitoringPipelineMessage = () => + wrapper.find('[data-testid="monitoring-pipeline-message"]'); + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + + const createWrapper = (props, mountFn = shallowMount) => { + wrapper = mountFn(PipelineComponent, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; afterEach(() => { - vm.$destroy(); + if (wrapper?.destroy) { + wrapper.destroy(); + wrapper = null; + } }); describe('computed', () => { describe('hasPipeline', () => { - it('should return true when there is a pipeline', () => { - vm = mountComponent(Component, { - pipeline: mockData.pipeline, - ciStatus: 'success', - hasCi: true, - troubleshootingDocsPath: 'help', - }); + beforeEach(() => { + createWrapper(); + }); - expect(vm.hasPipeline).toEqual(true); + it('should return true when there is a pipeline', () => { + expect(wrapper.vm.hasPipeline).toBe(true); }); - it('should return false when there is no pipeline', () => { - vm = mountComponent(Component, { - pipeline: {}, - troubleshootingDocsPath: 'help', - }); + it('should return false when there is no pipeline', async () => { + wrapper.setProps({ pipeline: {} }); - expect(vm.hasPipeline).toEqual(false); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.hasPipeline).toBe(false); }); }); describe('hasCIError', () => { - it('should return false when there is no CI error', () => { - vm = mountComponent(Component, { - pipeline: mockData.pipeline, - hasCi: true, - ciStatus: 'success', - troubleshootingDocsPath: 'help', - }); + beforeEach(() => { + createWrapper(); + }); - expect(vm.hasCIError).toEqual(false); + it('should return false when there is no CI error', () => { + expect(wrapper.vm.hasCIError).toBe(false); }); - it('should return true when there is a CI error', () => { - vm = mountComponent(Component, { - pipeline: mockData.pipeline, - hasCi: true, - ciStatus: null, - troubleshootingDocsPath: 'help', - }); + it('should return true when there is a pipeline, but no ci status', async () => { + wrapper.setProps({ ciStatus: null }); - expect(vm.hasCIError).toEqual(true); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.hasCIError).toBe(true); }); }); describe('coverageDeltaClass', () => { - it('should return no class if there is no coverage change', () => { - vm = mountComponent(Component, { - pipeline: mockData.pipeline, - pipelineCoverageDelta: '0', - troubleshootingDocsPath: 'help', - }); + beforeEach(() => { + createWrapper({ pipelineCoverageDelta: '0' }); + }); - expect(vm.coverageDeltaClass).toEqual(''); + it('should return no class if there is no coverage change', async () => { + expect(wrapper.vm.coverageDeltaClass).toBe(''); }); - it('should return text-success if the coverage increased', () => { - vm = mountComponent(Component, { - pipeline: mockData.pipeline, - pipelineCoverageDelta: '10', - troubleshootingDocsPath: 'help', - }); + it('should return text-success if the coverage increased', async () => { + wrapper.setProps({ pipelineCoverageDelta: '10' }); - expect(vm.coverageDeltaClass).toEqual('text-success'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.coverageDeltaClass).toBe('text-success'); }); - it('should return text-danger if the coverage decreased', () => { - vm = mountComponent(Component, { - pipeline: mockData.pipeline, - pipelineCoverageDelta: '-12', - troubleshootingDocsPath: 'help', - }); + it('should return text-danger if the coverage decreased', async () => { + wrapper.setProps({ pipelineCoverageDelta: '-12' }); - expect(vm.coverageDeltaClass).toEqual('text-danger'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.coverageDeltaClass).toBe('text-danger'); }); }); }); describe('rendered output', () => { - it('should render CI error', () => { - vm = mountComponent(Component, { - pipeline: mockData.pipeline, - hasCi: true, - troubleshootingDocsPath: 'help', - }); - - expect(vm.$el.querySelector('.media-body').textContent.trim()).toContain( - 'Could not retrieve the pipeline status. For troubleshooting steps, read the documentation.', - ); + beforeEach(() => { + createWrapper({ ciStatus: null }, mount); }); - it('should render CI error when no pipeline is provided', () => { - vm = mountComponent(Component, { - pipeline: {}, - hasCi: true, - ciStatus: 'success', - troubleshootingDocsPath: 'help', - }); - - expect(vm.$el.querySelector('.media-body').textContent.trim()).toContain( - 'Could not retrieve the pipeline status. For troubleshooting steps, read the documentation.', - ); + it('should render CI error if there is a pipeline, but no status', async () => { + expect(findCIErrorMessage().text()).toBe(ciErrorMessage); }); - it('should render CI error when no CI is provided and pipeline must succeed is turned on', () => { - vm = mountComponent(Component, { + it('should render a loading state when no pipeline is found', async () => { + wrapper.setProps({ pipeline: {}, hasCi: false, pipelineMustSucceed: true, - troubleshootingDocsPath: 'help', }); - expect(vm.$el.querySelector('.media-body').textContent.trim()).toContain( - 'No pipeline has been run for this commit.', - ); + await wrapper.vm.$nextTick(); + + expect(findMonitoringPipelineMessage().text()).toBe(monitoringMessage); + expect(findLoadingIcon().exists()).toBe(true); }); describe('with a pipeline', () => { beforeEach(() => { - vm = mountComponent(Component, { - pipeline: mockData.pipeline, - hasCi: true, - ciStatus: 'success', - pipelineCoverageDelta: mockData.pipelineCoverageDelta, - troubleshootingDocsPath: 'help', - }); + createWrapper( + { + pipelineCoverageDelta: mockData.pipelineCoverageDelta, + }, + mount, + ); }); it('should render pipeline ID', () => { - expect(vm.$el.querySelector('.pipeline-id').textContent.trim()).toEqual( - `#${mockData.pipeline.id}`, - ); + expect( + findPipelineID() + .text() + .trim(), + ).toBe(`#${mockData.pipeline.id}`); }); it('should render pipeline status and commit id', () => { - expect(vm.$el.querySelector('.media-body').textContent.trim()).toContain( - mockData.pipeline.details.status.label, - ); + expect(findPipelineInfoContainer().text()).toMatch(mockData.pipeline.details.status.label); - expect(vm.$el.querySelector('.js-commit-link').textContent.trim()).toEqual( - mockData.pipeline.commit.short_id, - ); + expect( + findCommitLink() + .text() + .trim(), + ).toBe(mockData.pipeline.commit.short_id); - expect(vm.$el.querySelector('.js-commit-link').getAttribute('href')).toEqual( - mockData.pipeline.commit.commit_path, - ); + expect(findCommitLink().attributes('href')).toBe(mockData.pipeline.commit.commit_path); }); it('should render pipeline graph', () => { - expect(vm.$el.querySelector('.mr-widget-pipeline-graph')).toBeDefined(); - expect(vm.$el.querySelectorAll('.stage-container').length).toEqual( - mockData.pipeline.details.stages.length, - ); + expect(findPipelineGraph().exists()).toBe(true); + expect(findAllPipelineStages().length).toBe(mockData.pipeline.details.stages.length); }); it('should render coverage information', () => { - expect(vm.$el.querySelector('.media-body').textContent).toContain( - `Coverage ${mockData.pipeline.coverage}`, - ); + expect(findPipelineCoverage().text()).toMatch(`Coverage ${mockData.pipeline.coverage}%`); }); it('should render pipeline coverage delta information', () => { - expect(vm.$el.querySelector('.js-pipeline-coverage-delta.text-danger')).toBeDefined(); - expect(vm.$el.querySelector('.js-pipeline-coverage-delta').textContent).toContain( - `(${mockData.pipelineCoverageDelta}%)`, - ); + expect(findPipelineCoverageDelta().exists()).toBe(true); + expect(findPipelineCoverageDelta().text()).toBe(`(${mockData.pipelineCoverageDelta}%)`); }); }); @@ -192,71 +185,61 @@ describe('MRWidgetPipeline', () => { const mockCopy = JSON.parse(JSON.stringify(mockData)); delete mockCopy.pipeline.commit; - vm = mountComponent(Component, { - pipeline: mockCopy.pipeline, - hasCi: true, - ciStatus: 'success', - troubleshootingDocsPath: 'help', - }); + createWrapper({}, mount); }); it('should render pipeline ID', () => { - expect(vm.$el.querySelector('.pipeline-id').textContent.trim()).toEqual( - `#${mockData.pipeline.id}`, - ); + expect( + findPipelineID() + .text() + .trim(), + ).toBe(`#${mockData.pipeline.id}`); }); it('should render pipeline status', () => { - expect(vm.$el.querySelector('.media-body').textContent.trim()).toContain( - mockData.pipeline.details.status.label, - ); - - expect(vm.$el.querySelector('.js-commit-link')).toBeNull(); + expect(findPipelineInfoContainer().text()).toMatch(mockData.pipeline.details.status.label); }); it('should render pipeline graph', () => { - expect(vm.$el.querySelector('.mr-widget-pipeline-graph')).toBeDefined(); - expect(vm.$el.querySelectorAll('.stage-container').length).toEqual( - mockData.pipeline.details.stages.length, - ); + expect(findPipelineGraph().exists()).toBe(true); + expect(findAllPipelineStages().length).toBe(mockData.pipeline.details.stages.length); }); it('should render coverage information', () => { - expect(vm.$el.querySelector('.media-body').textContent).toContain( - `Coverage ${mockData.pipeline.coverage}`, - ); + expect(findPipelineCoverage().text()).toMatch(`Coverage ${mockData.pipeline.coverage}%`); }); }); describe('without coverage', () => { - it('should not render a coverage', () => { + beforeEach(() => { const mockCopy = JSON.parse(JSON.stringify(mockData)); delete mockCopy.pipeline.coverage; - vm = mountComponent(Component, { - pipeline: mockCopy.pipeline, - hasCi: true, - ciStatus: 'success', - troubleshootingDocsPath: 'help', - }); + createWrapper( + { + pipeline: mockCopy.pipeline, + }, + mount, + ); + }); - expect(vm.$el.querySelector('.media-body').textContent).not.toContain('Coverage'); + it('should not render a coverage component', () => { + expect(findPipelineCoverage().exists()).toBe(false); }); }); describe('without a pipeline graph', () => { - it('should not render a pipeline graph', () => { + beforeEach(() => { const mockCopy = JSON.parse(JSON.stringify(mockData)); delete mockCopy.pipeline.details.stages; - vm = mountComponent(Component, { + createWrapper({ pipeline: mockCopy.pipeline, - hasCi: true, - ciStatus: 'success', - troubleshootingDocsPath: 'help', }); + }); - expect(vm.$el.querySelector('.js-mini-pipeline-graph')).toEqual(null); + it('should not render a pipeline graph', () => { + expect(findPipelineGraph().exists()).toBe(false); }); }); @@ -273,11 +256,8 @@ describe('MRWidgetPipeline', () => { }); const factory = () => { - vm = mountComponent(Component, { + createWrapper({ pipeline, - hasCi: true, - ciStatus: 'success', - troubleshootingDocsPath: 'help', sourceBranchLink: mockData.source_branch_link, }); }; @@ -289,7 +269,7 @@ describe('MRWidgetPipeline', () => { factory(); const expected = `Pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id} on ${mockData.source_branch_link}`; - const actual = trimText(vm.$el.querySelector('.js-pipeline-info-container').innerText); + const actual = trimText(findPipelineInfoContainer().text()); expect(actual).toBe(expected); }); @@ -302,7 +282,7 @@ describe('MRWidgetPipeline', () => { factory(); const expected = `Pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id}`; - const actual = trimText(vm.$el.querySelector('.js-pipeline-info-container').innerText); + const actual = trimText(findPipelineInfoContainer().text()); expect(actual).toBe(expected); }); @@ -316,7 +296,7 @@ describe('MRWidgetPipeline', () => { factory(); const expected = `Detached merge request pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id}`; - const actual = trimText(vm.$el.querySelector('.js-pipeline-info-container').innerText); + const actual = trimText(findPipelineInfoContainer().text()); expect(actual).toBe(expected); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_suggest_pipeline_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_suggest_pipeline_spec.js index 8b0253dc01a..d6c996f7501 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_suggest_pipeline_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_suggest_pipeline_spec.js @@ -1,37 +1,44 @@ import { mount } from '@vue/test-utils'; -import { GlLink } from '@gitlab/ui'; +import { GlLink, GlSprintf } from '@gitlab/ui'; import suggestPipelineComponent from '~/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue'; -import stubChildren from 'helpers/stub_children'; -import PipelineTourState from '~/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue'; import MrWidgetIcon from '~/vue_merge_request_widget/components/mr_widget_icon.vue'; import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper'; +import { popoverProps, iconName } from './pipeline_tour_mock_data'; -describe('MRWidgetHeader', () => { +describe('MRWidgetSuggestPipeline', () => { let wrapper; - const pipelinePath = '/foo/bar/add/pipeline/path'; - const pipelineSvgPath = '/foo/bar/pipeline/svg/path'; - const humanAccess = 'maintainer'; - const iconName = 'status_notfound'; + let trackingSpy; + + const mockTrackingOnWrapper = () => { + unmockTracking(); + trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); + }; beforeEach(() => { + document.body.dataset.page = 'projects:merge_requests:show'; + trackingSpy = mockTracking('_category_', undefined, jest.spyOn); + wrapper = mount(suggestPipelineComponent, { - propsData: { pipelinePath, pipelineSvgPath, humanAccess }, + propsData: popoverProps, stubs: { - ...stubChildren(PipelineTourState), + GlSprintf, }, }); }); afterEach(() => { wrapper.destroy(); + unmockTracking(); }); describe('template', () => { + const findOkBtn = () => wrapper.find('[data-testid="ok"]'); + it('renders add pipeline file link', () => { const link = wrapper.find(GlLink); expect(link.exists()).toBe(true); - expect(link.attributes().href).toBe(pipelinePath); + expect(link.attributes().href).toBe(popoverProps.pipelinePath); }); it('renders the expected text', () => { @@ -51,25 +58,60 @@ describe('MRWidgetHeader', () => { ); }); + it('renders the show me how button', () => { + const button = findOkBtn(); + + expect(button.exists()).toBe(true); + expect(button.classes('btn-info')).toEqual(true); + expect(button.attributes('href')).toBe(popoverProps.pipelinePath); + }); + + it('renders the help link', () => { + const link = wrapper.find('[data-testid="help"]'); + + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe(wrapper.vm.$options.helpURL); + }); + + it('renders the empty pipelines image', () => { + const image = wrapper.find('[data-testid="pipeline-image"]'); + + expect(image.exists()).toBe(true); + expect(image.attributes().src).toBe(popoverProps.pipelineSvgPath); + }); + describe('tracking', () => { - let spy; + it('send event for basic view of the suggest pipeline widget', () => { + const expectedCategory = undefined; + const expectedAction = undefined; - beforeEach(() => { - spy = mockTracking('_category_', wrapper.element, jest.spyOn); + expect(trackingSpy).toHaveBeenCalledWith(expectedCategory, expectedAction, { + label: wrapper.vm.$options.trackLabel, + property: popoverProps.humanAccess, + }); }); - afterEach(() => { - unmockTracking(); + it('send an event when add pipeline link is clicked', () => { + mockTrackingOnWrapper(); + const link = wrapper.find('[data-testid="add-pipeline-link"]'); + triggerEvent(link.element); + + expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_link', { + label: wrapper.vm.$options.trackLabel, + property: popoverProps.humanAccess, + value: '30', + }); }); it('send an event when ok button is clicked', () => { - const link = wrapper.find(GlLink); - triggerEvent(link.element); + mockTrackingOnWrapper(); + const okBtn = findOkBtn(); + triggerEvent(okBtn.element); - expect(spy).toHaveBeenCalledWith('_category_', 'click_link', { - label: 'no_pipeline_noticed', - property: humanAccess, - value: '30', + expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_button', { + label: wrapper.vm.$options.trackLabel, + property: popoverProps.humanAccess, + value: '10', }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_terraform_plan_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_terraform_plan_spec.js deleted file mode 100644 index 62c5c8e8531..00000000000 --- a/spec/frontend/vue_mr_widget/components/mr_widget_terraform_plan_spec.js +++ /dev/null @@ -1,107 +0,0 @@ -import { GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import axios from '~/lib/utils/axios_utils'; -import MockAdapter from 'axios-mock-adapter'; -import MrWidgetTerraformPlan from '~/vue_merge_request_widget/components/mr_widget_terraform_plan.vue'; -import Poll from '~/lib/utils/poll'; - -const plan = { - create: 10, - update: 20, - delete: 30, - job_path: '/path/to/ci/logs', -}; - -describe('MrWidgetTerraformPlan', () => { - let mock; - let wrapper; - - const propsData = { endpoint: '/path/to/terraform/report.json' }; - - const mockPollingApi = (response, body, header) => { - mock.onGet(propsData.endpoint).reply(response, body, header); - }; - - const mountWrapper = () => { - wrapper = shallowMount(MrWidgetTerraformPlan, { propsData }); - return axios.waitForAll(); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - wrapper.destroy(); - mock.restore(); - }); - - describe('loading poll', () => { - beforeEach(() => { - mockPollingApi(200, { '123': plan }, {}); - - return mountWrapper().then(() => { - wrapper.setData({ loading: true }); - return wrapper.vm.$nextTick(); - }); - }); - - it('Diplays loading icon when loading is true', () => { - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); - - expect(wrapper.find(GlSprintf).exists()).toBe(false); - - expect(wrapper.text()).not.toContain( - 'A terraform report was generated in your pipelines. Changes are unknown', - ); - }); - }); - - describe('successful poll', () => { - let pollRequest; - let pollStop; - - beforeEach(() => { - pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); - pollStop = jest.spyOn(Poll.prototype, 'stop'); - - mockPollingApi(200, { '123': plan }, {}); - - return mountWrapper(); - }); - - afterEach(() => { - pollRequest.mockRestore(); - pollStop.mockRestore(); - }); - - it('content change text', () => { - expect(wrapper.find(GlSprintf).exists()).toBe(true); - }); - - it('renders button when url is found', () => { - expect(wrapper.find(GlLink).exists()).toBe(true); - }); - - it('does not make additional requests after poll is successful', () => { - expect(pollRequest).toHaveBeenCalledTimes(1); - expect(pollStop).toHaveBeenCalledTimes(1); - }); - }); - - describe('polling fails', () => { - beforeEach(() => { - mockPollingApi(500, null, {}); - return mountWrapper(); - }); - - it('does not display changes text when api fails', () => { - expect(wrapper.text()).toContain( - 'A terraform report was generated in your pipelines. Changes are unknown', - ); - - expect(wrapper.find('.js-terraform-report-link').exists()).toBe(false); - expect(wrapper.find(GlLink).exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/vue_mr_widget/components/pipeline_tour_mock_data.js b/spec/frontend/vue_mr_widget/components/pipeline_tour_mock_data.js new file mode 100644 index 00000000000..c749c434079 --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/pipeline_tour_mock_data.js @@ -0,0 +1,7 @@ +export const popoverProps = { + pipelinePath: '/foo/bar/add/pipeline/path', + pipelineSvgPath: 'assets/illustrations/something.svg', + humanAccess: 'maintainer', +}; + +export const iconName = 'status_notfound'; diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js index e2caa6e8092..ae0f605c419 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js @@ -8,6 +8,7 @@ import { MWPS_MERGE_STRATEGY } from '~/vue_merge_request_widget/constants'; describe('MRWidgetAutoMergeEnabled', () => { let vm; + let oldWindowGl; const targetBranchPath = '/foo/bar'; const targetBranch = 'foo'; const sha = '1EA2EZ34'; @@ -16,6 +17,13 @@ describe('MRWidgetAutoMergeEnabled', () => { const Component = Vue.extend(autoMergeEnabledComponent); jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + oldWindowGl = window.gl; + window.gl = { + mrWidgetData: { + defaultAvatarUrl: 'no_avatar.png', + }, + }; + vm = mountComponent(Component, { mr: { shouldRemoveSourceBranch: false, @@ -35,6 +43,7 @@ describe('MRWidgetAutoMergeEnabled', () => { afterEach(() => { vm.$destroy(); + window.gl = oldWindowGl; }); describe('computed', () => { diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_checking_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_checking_spec.js index 56d55c9afac..afe6bd0e767 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_checking_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_checking_spec.js @@ -25,7 +25,7 @@ describe('MRWidgetChecking', () => { it('renders information about merging', () => { expect(vm.$el.querySelector('.media-body').textContent.trim()).toEqual( - 'Checking ability to merge automatically…', + 'Checking if merge request can be merged…', ); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_pipeline_tour_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_pipeline_tour_spec.js deleted file mode 100644 index e8f95e099cc..00000000000 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_pipeline_tour_spec.js +++ /dev/null @@ -1,143 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlPopover } from '@gitlab/ui'; -import Cookies from 'js-cookie'; -import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper'; -import pipelineTourState from '~/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue'; -import { popoverProps, cookieKey } from './pipeline_tour_mock_data'; - -describe('MRWidgetPipelineTour', () => { - let wrapper; - - afterEach(() => { - wrapper.destroy(); - }); - - describe('template', () => { - describe(`when ${cookieKey} cookie is set`, () => { - beforeEach(() => { - Cookies.set(cookieKey, true); - wrapper = shallowMount(pipelineTourState, { - propsData: popoverProps, - }); - }); - - it('does not render the popover', () => { - const popover = wrapper.find(GlPopover); - - expect(popover.exists()).toBe(false); - }); - - describe('tracking', () => { - let trackingSpy; - - beforeEach(() => { - trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); - }); - - afterEach(() => { - unmockTracking(); - }); - it('does not call tracking', () => { - expect(trackingSpy).not.toHaveBeenCalled(); - }); - }); - }); - - describe(`when ${cookieKey} cookie is not set`, () => { - const findOkBtn = () => wrapper.find({ ref: 'ok' }); - const findDismissBtn = () => wrapper.find({ ref: 'no-thanks' }); - - beforeEach(() => { - Cookies.remove(cookieKey); - wrapper = shallowMount(pipelineTourState, { - propsData: popoverProps, - }); - }); - - it('renders the popover', () => { - const popover = wrapper.find(GlPopover); - - expect(popover.exists()).toBe(true); - }); - - it('renders the show me how button', () => { - const button = findOkBtn(); - - expect(button.exists()).toBe(true); - expect(button.attributes().category).toBe('primary'); - }); - - it('renders the dismiss button', () => { - const button = findDismissBtn(); - - expect(button.exists()).toBe(true); - expect(button.attributes().category).toBe('secondary'); - }); - - it('renders the empty pipelines image', () => { - const image = wrapper.find('img'); - - expect(image.exists()).toBe(true); - expect(image.attributes().src).toBe(popoverProps.pipelineSvgPath); - }); - - describe('tracking', () => { - let trackingSpy; - - beforeEach(() => { - trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); - }); - - afterEach(() => { - unmockTracking(); - }); - - it('send event for basic view of popover', () => { - document.body.dataset.page = 'projects:merge_requests:show'; - - wrapper.vm.trackOnShow(); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, undefined, { - label: popoverProps.trackLabel, - property: popoverProps.humanAccess, - }); - }); - - it('send an event when ok button is clicked', () => { - const okBtn = findOkBtn(); - triggerEvent(okBtn.element); - - expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_button', { - label: popoverProps.trackLabel, - property: popoverProps.humanAccess, - value: '10', - }); - }); - - it('send an event when dismiss button is clicked', () => { - const dismissBtn = findDismissBtn(); - triggerEvent(dismissBtn.element); - - expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_button', { - label: popoverProps.trackLabel, - property: popoverProps.humanAccess, - value: '20', - }); - }); - }); - - describe('dismissPopover', () => { - it('updates popoverDismissed', () => { - const button = findDismissBtn(); - const popover = wrapper.find(GlPopover); - button.vm.$emit('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(Cookies.get(cookieKey)).toBe('true'); - expect(popover.exists()).toBe(false); - }); - }); - }); - }); - }); -}); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js index 1f0d6a7378c..5eb24315ca6 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -34,6 +34,9 @@ const createTestMr = customConfig => { ciStatus: null, sha: '12345678', squash: false, + squashIsEnabledByDefault: false, + squashIsReadonly: false, + squashIsSelected: false, commitMessage, squashCommitMessage, commitMessageWithDescription, @@ -694,6 +697,37 @@ describe('ReadyToMerge', () => { expect(findCheckboxElement().exists()).toBeFalsy(); }); + + describe('squash options', () => { + it.each` + squashState | state | prop | expectation + ${'squashIsReadonly'} | ${'enabled'} | ${'isDisabled'} | ${false} + ${'squashIsSelected'} | ${'selected'} | ${'value'} | ${false} + ${'squashIsSelected'} | ${'unselected'} | ${'value'} | ${false} + `( + 'is $state when squashIsReadonly returns $expectation ', + ({ squashState, prop, expectation }) => { + createLocalComponent({ + mr: { commitsCount: 2, enableSquashBeforeMerge: true, [squashState]: expectation }, + }); + + expect(findCheckboxElement().props(prop)).toBe(expectation); + }, + ); + + it('is not rendered for "Do not allow" option', () => { + createLocalComponent({ + mr: { + commitsCount: 2, + enableSquashBeforeMerge: true, + squashIsReadonly: true, + squashIsSelected: false, + }, + }); + + expect(findCheckboxElement().exists()).toBe(false); + }); + }); }); describe('commits count collapsible header', () => { @@ -709,7 +743,7 @@ describe('ReadyToMerge', () => { mr: { ffOnlyEnabled: true, enableSquashBeforeMerge: true, - squash: true, + squashIsSelected: true, commitsCount: 2, }, }); @@ -803,7 +837,7 @@ describe('ReadyToMerge', () => { createLocalComponent({ mr: { ffOnlyEnabled: true, - squash: true, + squashIsSelected: true, enableSquashBeforeMerge: true, commitsCount: 2, }, @@ -824,7 +858,7 @@ describe('ReadyToMerge', () => { createLocalComponent({ mr: { commitsCount: 2, - squash: true, + squashIsSelected: true, enableSquashBeforeMerge: true, }, }); @@ -854,7 +888,7 @@ describe('ReadyToMerge', () => { createLocalComponent({ mr: { commitsCount: 2, - squash: true, + squashIsSelected: true, enableSquashBeforeMerge: true, }, }); @@ -872,7 +906,7 @@ describe('ReadyToMerge', () => { it('should be rendered if squash is enabled and there is more than 1 commit', () => { createLocalComponent({ - mr: { enableSquashBeforeMerge: true, squash: true, commitsCount: 2 }, + mr: { enableSquashBeforeMerge: true, squashIsSelected: true, commitsCount: 2 }, }); expect(findCommitDropdownElement().exists()).toBeTruthy(); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js index b70d580ed04..1542b0939aa 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js @@ -19,6 +19,8 @@ describe('Squash before merge component', () => { wrapper.destroy(); }); + const findLabel = () => wrapper.find('[data-testid="squashLabel"]'); + describe('checkbox', () => { const findCheckbox = () => wrapper.find('.js-squash-checkbox'); @@ -63,6 +65,46 @@ describe('Squash before merge component', () => { }); }); + describe('label', () => { + describe.each` + isDisabled | expectation + ${true} | ${'grays out text if it is true'} + ${false} | ${'does not gray out text if it is false'} + `('isDisabled prop', ({ isDisabled, expectation }) => { + beforeEach(() => { + createComponent({ + value: false, + isDisabled, + }); + }); + + it(expectation, () => { + expect(findLabel().classes('gl-text-gray-600')).toBe(isDisabled); + }); + }); + }); + + describe('tooltip', () => { + const tooltipTitle = () => findLabel().element.dataset.title; + + it('does not render when isDisabled is false', () => { + createComponent({ + value: true, + isDisabled: false, + }); + expect(tooltipTitle()).toBeUndefined(); + }); + + it('display message when when isDisabled is true', () => { + createComponent({ + value: true, + isDisabled: true, + }); + + expect(tooltipTitle()).toBe('Required in this project.'); + }); + }); + describe('about link', () => { it('is not rendered if no help path is passed', () => { createComponent({ diff --git a/spec/frontend/vue_mr_widget/components/states/pipeline_tour_mock_data.js b/spec/frontend/vue_mr_widget/components/states/pipeline_tour_mock_data.js deleted file mode 100644 index 39bc89e459c..00000000000 --- a/spec/frontend/vue_mr_widget/components/states/pipeline_tour_mock_data.js +++ /dev/null @@ -1,10 +0,0 @@ -export const popoverProps = { - pipelinePath: '/foo/bar/add/pipeline/path', - pipelineSvgPath: 'assets/illustrations/something.svg', - humanAccess: 'maintainer', - popoverTarget: 'suggest-popover', - popoverContainer: 'suggest-pipeline', - trackLabel: 'some_tracking_label', -}; - -export const cookieKey = 'suggest_pipeline_dismissed'; diff --git a/spec/frontend/vue_mr_widget/components/terraform/mock_data.js b/spec/frontend/vue_mr_widget/components/terraform/mock_data.js new file mode 100644 index 00000000000..ae280146c22 --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/terraform/mock_data.js @@ -0,0 +1,31 @@ +export const invalidPlanWithName = { + job_name: 'Invalid Plan', + job_path: '/path/to/ci/logs/1', + tf_report_error: 'api_error', +}; + +export const invalidPlanWithoutName = { + tf_report_error: 'invalid_json_format', +}; + +export const validPlanWithName = { + create: 10, + update: 20, + delete: 30, + job_name: 'Valid Plan', + job_path: '/path/to/ci/logs/1', +}; + +export const validPlanWithoutName = { + create: 10, + update: 20, + delete: 30, + job_path: '/path/to/ci/logs/1', +}; + +export const plans = { + invalid_plan_one: invalidPlanWithName, + invalid_plan_two: invalidPlanWithName, + valid_plan_one: validPlanWithName, + valid_plan_two: validPlanWithoutName, +}; diff --git a/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js b/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js new file mode 100644 index 00000000000..be43f10c03e --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js @@ -0,0 +1,172 @@ +import { GlSkeletonLoading, GlSprintf } from '@gitlab/ui'; +import { invalidPlanWithName, plans, validPlanWithName } from './mock_data'; +import { shallowMount } from '@vue/test-utils'; +import axios from '~/lib/utils/axios_utils'; +import MockAdapter from 'axios-mock-adapter'; +import MrWidgetExpanableSection from '~/vue_merge_request_widget/components/mr_widget_expandable_section.vue'; +import MrWidgetTerraformContainer from '~/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue'; +import Poll from '~/lib/utils/poll'; +import TerraformPlan from '~/vue_merge_request_widget/components/terraform/terraform_plan.vue'; + +describe('MrWidgetTerraformConainer', () => { + let mock; + let wrapper; + + const propsData = { endpoint: '/path/to/terraform/report.json' }; + + const findHeader = () => wrapper.find('[data-testid="terraform-header-text"]'); + const findPlans = () => wrapper.findAll(TerraformPlan).wrappers.map(x => x.props('plan')); + + const mockPollingApi = (response, body, header) => { + mock.onGet(propsData.endpoint).reply(response, body, header); + }; + + const mountWrapper = () => { + wrapper = shallowMount(MrWidgetTerraformContainer, { + propsData, + stubs: { MrWidgetExpanableSection, GlSprintf }, + }); + return axios.waitForAll(); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + describe('when data is loading', () => { + beforeEach(() => { + mockPollingApi(200, plans, {}); + + return mountWrapper().then(() => { + wrapper.setData({ loading: true }); + return wrapper.vm.$nextTick(); + }); + }); + + it('diplays loading skeleton', () => { + expect(wrapper.contains(GlSkeletonLoading)).toBe(true); + expect(wrapper.contains(MrWidgetExpanableSection)).toBe(false); + }); + }); + + describe('when data has finished loading', () => { + beforeEach(() => { + mockPollingApi(200, plans, {}); + return mountWrapper(); + }); + + it('displays terraform content', () => { + expect(wrapper.contains(GlSkeletonLoading)).toBe(false); + expect(wrapper.contains(MrWidgetExpanableSection)).toBe(true); + expect(findPlans()).toEqual(Object.values(plans)); + }); + + describe('when data includes one invalid plan', () => { + beforeEach(() => { + const invalidPlanGroup = { bad_plan: invalidPlanWithName }; + mockPollingApi(200, invalidPlanGroup, {}); + return mountWrapper(); + }); + + it('displays header text for one invalid plan', () => { + expect(findHeader().text()).toBe('1 Terraform report failed to generate'); + }); + }); + + describe('when data includes multiple invalid plans', () => { + beforeEach(() => { + const invalidPlanGroup = { + bad_plan_one: invalidPlanWithName, + bad_plan_two: invalidPlanWithName, + }; + + mockPollingApi(200, invalidPlanGroup, {}); + return mountWrapper(); + }); + + it('displays header text for multiple invalid plans', () => { + expect(findHeader().text()).toBe('2 Terraform reports failed to generate'); + }); + }); + + describe('when data includes one valid plan', () => { + beforeEach(() => { + const validPlanGroup = { valid_plan: validPlanWithName }; + mockPollingApi(200, validPlanGroup, {}); + return mountWrapper(); + }); + + it('displays header text for one valid plans', () => { + expect(findHeader().text()).toBe('1 Terraform report was generated in your pipelines'); + }); + }); + + describe('when data includes multiple valid plans', () => { + beforeEach(() => { + const validPlanGroup = { + valid_plan_one: validPlanWithName, + valid_plan_two: validPlanWithName, + }; + mockPollingApi(200, validPlanGroup, {}); + return mountWrapper(); + }); + + it('displays header text for multiple valid plans', () => { + expect(findHeader().text()).toBe('2 Terraform reports were generated in your pipelines'); + }); + }); + }); + + describe('polling', () => { + let pollRequest; + let pollStop; + + beforeEach(() => { + pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); + pollStop = jest.spyOn(Poll.prototype, 'stop'); + }); + + afterEach(() => { + pollRequest.mockRestore(); + pollStop.mockRestore(); + }); + + describe('successful poll', () => { + beforeEach(() => { + mockPollingApi(200, plans, {}); + + return mountWrapper(); + }); + + it('does not make additional requests after poll is successful', () => { + expect(pollRequest).toHaveBeenCalledTimes(1); + expect(pollStop).toHaveBeenCalledTimes(1); + }); + }); + + describe('polling fails', () => { + beforeEach(() => { + mockPollingApi(500, null, {}); + return mountWrapper(); + }); + + it('stops loading', () => { + expect(wrapper.contains(GlSkeletonLoading)).toBe(false); + }); + + it('generates one broken plan', () => { + expect(findPlans()).toEqual([{ tf_report_error: 'api_error' }]); + }); + + it('does not make additional requests after poll is unsuccessful', () => { + expect(pollRequest).toHaveBeenCalledTimes(1); + expect(pollStop).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/terraform/terraform_plan_spec.js b/spec/frontend/vue_mr_widget/components/terraform/terraform_plan_spec.js new file mode 100644 index 00000000000..cc68ba0d9df --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/terraform/terraform_plan_spec.js @@ -0,0 +1,95 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import TerraformPlan from '~/vue_merge_request_widget/components/terraform/terraform_plan.vue'; +import { + invalidPlanWithName, + invalidPlanWithoutName, + validPlanWithName, + validPlanWithoutName, +} from './mock_data'; + +describe('TerraformPlan', () => { + let wrapper; + + const findIcon = () => wrapper.find('[data-testid="change-type-icon"]'); + const findLogButton = () => wrapper.find('[data-testid="terraform-report-link"]'); + + const mountWrapper = propsData => { + wrapper = shallowMount(TerraformPlan, { stubs: { GlLink, GlSprintf }, propsData }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('valid plan with job_name', () => { + beforeEach(() => { + mountWrapper({ plan: validPlanWithName }); + }); + + it('displays a document icon', () => { + expect(findIcon().attributes('name')).toBe('doc-changes'); + }); + + it('diplays the header text with a name', () => { + expect(wrapper.text()).toContain( + `The Terraform report ${validPlanWithName.job_name} was generated in your pipelines.`, + ); + }); + + it('diplays the reported changes', () => { + expect(wrapper.text()).toContain( + `Reported Resource Changes: ${validPlanWithName.create} to add, ${validPlanWithName.update} to change, ${validPlanWithName.delete} to delete`, + ); + }); + + it('renders button when url is found', () => { + expect(findLogButton().exists()).toBe(true); + expect(findLogButton().text()).toEqual('View full log'); + }); + }); + + describe('valid plan without job_name', () => { + beforeEach(() => { + mountWrapper({ plan: validPlanWithoutName }); + }); + + it('diplays the header text without a name', () => { + expect(wrapper.text()).toContain('A Terraform report was generated in your pipelines.'); + }); + }); + + describe('invalid plan with job_name', () => { + beforeEach(() => { + mountWrapper({ plan: invalidPlanWithName }); + }); + + it('displays a warning icon', () => { + expect(findIcon().attributes('name')).toBe('warning'); + }); + + it('diplays the header text with a name', () => { + expect(wrapper.text()).toContain( + `The Terraform report ${invalidPlanWithName.job_name} failed to generate.`, + ); + }); + + it('diplays generic error since report values are missing', () => { + expect(wrapper.text()).toContain('Generating the report caused an error.'); + }); + }); + + describe('invalid plan with out job_name', () => { + beforeEach(() => { + mountWrapper({ plan: invalidPlanWithoutName }); + }); + + it('diplays the header text without a name', () => { + expect(wrapper.text()).toContain('A Terraform report failed to generate.'); + }); + + it('does not render button because url is missing', () => { + expect(findLogButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/mock_data.js b/spec/frontend/vue_mr_widget/mock_data.js index 8ed153658fd..e00456a78b5 100644 --- a/spec/frontend/vue_mr_widget/mock_data.js +++ b/spec/frontend/vue_mr_widget/mock_data.js @@ -211,6 +211,15 @@ export default { can_revert_on_current_merge_request: true, can_cherry_pick_on_current_merge_request: true, }, + codeclimate: { + head_path: 'head.json', + base_path: 'base.json', + }, + blob_path: { + base_path: 'blob_path', + head_path: 'blob_path', + }, + codequality_help_path: 'code_quality.html', target_branch_path: '/root/acets-app/branches/master', source_branch_path: '/root/acets-app/branches/daaaa', conflict_resolution_ui_path: '/root/acets-app/-/merge_requests/22/conflicts', @@ -239,7 +248,8 @@ export default { commit_change_content_path: '/root/acets-app/-/merge_requests/22/commit_change_content', merge_commit_path: 'http://localhost:3000/root/acets-app/commit/53027d060246c8f47e4a9310fb332aa52f221775', - troubleshooting_docs_path: 'help', + mr_troubleshooting_docs_path: 'help', + ci_troubleshooting_docs_path: 'help2', merge_request_pipelines_docs_path: '/help/ci/merge_request_pipelines/index.md', merge_train_when_pipeline_succeeds_docs_path: '/help/ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/#startadd-to-merge-train-when-pipeline-succeeds', @@ -312,7 +322,8 @@ export const mockStore = { { id: 0, name: 'prod', status: SUCCESS }, { id: 1, name: 'prod-docs', status: SUCCESS }, ], - troubleshootingDocsPath: 'troubleshooting-docs-path', + mrTroubleshootingDocsPath: 'mr-troubleshooting-docs-path', + ciTroubleshootingDocsPath: 'ci-troubleshooting-docs-path', ciStatus: 'ci-status', hasCI: true, exposedArtifactsPath: 'exposed_artifacts.json', diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js index e022f68fdec..93659fa54fb 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -609,6 +609,12 @@ describe('mrWidgetOptions', () => { }); }); + describe('code quality widget', () => { + it('renders the component', () => { + expect(vm.$el.querySelector('.js-codequality-widget')).toExist(); + }); + }); + describe('pipeline for target branch after merge', () => { describe('with information for target branch pipeline', () => { beforeEach(done => { diff --git a/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js b/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js index e54cd345a37..1cb2c6c669b 100644 --- a/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js +++ b/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js @@ -49,14 +49,18 @@ describe('getStateKey', () => { expect(bound()).toEqual('unresolvedDiscussions'); + data.work_in_progress = true; + + expect(bound()).toEqual('workInProgress'); + context.onlyAllowMergeIfPipelineSucceeds = true; context.isPipelineFailed = true; expect(bound()).toEqual('pipelineFailed'); - data.work_in_progress = true; + context.shouldBeRebased = true; - expect(bound()).toEqual('workInProgress'); + expect(bound()).toEqual('rebase'); data.has_conflicts = true; diff --git a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap index df4b30f1cb8..19671d425a9 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap @@ -18,15 +18,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <gl-emoji - data-fallback-src="/assets/emoji/thumbsup-59ec2457ab33e8897261d01a495f6cf5c668d0004807dc541c3b1be5294b1e61.png" data-name="thumbsup" - data-unicode-version="6.0" - title="thumbs up sign" - > - - 👍 - - </gl-emoji> + /> </span> @@ -51,15 +44,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <gl-emoji - data-fallback-src="/assets/emoji/thumbsdown-5954334e2dae5357312b3d629f10a496c728029e02216f8c8b887f9b51561c61.png" data-name="thumbsdown" - data-unicode-version="6.0" - title="thumbs down sign" - > - - 👎 - - </gl-emoji> + /> </span> @@ -84,15 +70,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <gl-emoji - data-fallback-src="/assets/emoji/smile-14905c372d5bf7719bd727c9efae31a03291acec79801652a23710c6848c5d14.png" data-name="smile" - data-unicode-version="6.0" - title="smiling face with open mouth and smiling eyes" - > - - 😄 - - </gl-emoji> + /> </span> @@ -117,15 +96,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <gl-emoji - data-fallback-src="/assets/emoji/ok_hand-d63002dce3cc3655b67b8765b7c28d370edba0e3758b2329b60e0e61c4d8e78d.png" data-name="ok_hand" - data-unicode-version="6.0" - title="ok hand sign" - > - - 👌 - - </gl-emoji> + /> </span> @@ -150,15 +122,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <gl-emoji - data-fallback-src="/assets/emoji/cactus-2c5c4c35f26c7046fdc002b337e0d939729b33a26980e675950f9934c91e40fd.png" data-name="cactus" - data-unicode-version="6.0" - title="cactus" - > - - 🌵 - - </gl-emoji> + /> </span> @@ -183,15 +148,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <gl-emoji - data-fallback-src="/assets/emoji/a-bddbb39e8a1d35d42b7c08e7d47f63988cb4d8614b79f74e70b9c67c221896cc.png" data-name="a" - data-unicode-version="6.0" - title="negative squared latin capital letter a" - > - - 🅰 - - </gl-emoji> + /> </span> @@ -216,15 +174,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <gl-emoji - data-fallback-src="/assets/emoji/b-722f9db9442e7c0fc0d0ac0f5291fbf47c6a0ac4d8abd42e97957da705fb82bf.png" data-name="b" - data-unicode-version="6.0" - title="negative squared latin capital letter b" - > - - 🅱 - - </gl-emoji> + /> </span> diff --git a/spec/frontend/vue_shared/components/file_icon_spec.js b/spec/frontend/vue_shared/components/file_icon_spec.js index 5a385eee60c..adf0da21f9f 100644 --- a/spec/frontend/vue_shared/components/file_icon_spec.js +++ b/spec/frontend/vue_shared/components/file_icon_spec.js @@ -1,12 +1,14 @@ import { shallowMount } from '@vue/test-utils'; import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; import FileIcon from '~/vue_shared/components/file_icon.vue'; +import { FILE_SYMLINK_MODE } from '~/vue_shared/constants'; describe('File Icon component', () => { let wrapper; - const findIcon = () => wrapper.find('svg'); + const findSvgIcon = () => wrapper.find('svg'); + const findGlIcon = () => wrapper.find(GlIcon); const getIconName = () => - findIcon() + findSvgIcon() .find('use') .element.getAttribute('xlink:href') .replace(`${gon.sprite_file_icons}#`, ''); @@ -27,7 +29,7 @@ describe('File Icon component', () => { }); expect(wrapper.element.tagName).toEqual('SPAN'); - expect(findIcon().exists()).toBeDefined(); + expect(findSvgIcon().exists()).toBeDefined(); }); it.each` @@ -46,8 +48,8 @@ describe('File Icon component', () => { folder: true, }); - expect(findIcon().exists()).toBe(false); - expect(wrapper.find(GlIcon).classes()).toContain('folder-icon'); + expect(findSvgIcon().exists()).toBe(false); + expect(findGlIcon().classes()).toContain('folder-icon'); }); it('should render a loading icon', () => { @@ -66,8 +68,19 @@ describe('File Icon component', () => { cssClasses: 'extraclasses', size, }); + const classes = findSvgIcon().classes(); - expect(findIcon().classes()).toContain(`s${size}`); - expect(findIcon().classes()).toContain('extraclasses'); + expect(classes).toContain(`s${size}`); + expect(classes).toContain('extraclasses'); + }); + + it('should render a symlink icon', () => { + createComponent({ + fileName: 'anything', + fileMode: FILE_SYMLINK_MODE, + }); + + expect(findSvgIcon().exists()).toBe(false); + expect(findGlIcon().attributes('name')).toBe('symlink'); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js index eded5b87abc..05508d14209 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js @@ -13,7 +13,7 @@ import { SortDirection } from '~/vue_shared/components/filtered_search_bar/const import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; -import { mockAvailableTokens, mockSortOptions } from './mock_data'; +import { mockAvailableTokens, mockSortOptions, mockHistoryItems } from './mock_data'; const createComponent = ({ namespace = 'gitlab-org/gitlab-test', @@ -53,11 +53,17 @@ describe('FilteredSearchBarRoot', () => { describe('computed', () => { describe('tokenSymbols', () => { - it('returns array of map containing type and symbols from `tokens` prop', () => { + it('returns a map containing type and symbols from `tokens` prop', () => { expect(wrapper.vm.tokenSymbols).toEqual({ author_username: '@' }); }); }); + describe('tokenTitles', () => { + it('returns a map containing type and title from `tokens` prop', () => { + expect(wrapper.vm.tokenTitles).toEqual({ author_username: 'Author' }); + }); + }); + describe('sortDirectionIcon', () => { it('returns string "sort-lowest" when `selectedSortDirection` is "ascending"', () => { wrapper.setData({ @@ -133,14 +139,6 @@ describe('FilteredSearchBarRoot', () => { }); }); - describe('getRecentSearches', () => { - it('returns array of strings representing recent searches', () => { - wrapper.vm.recentSearchesStore.setRecentSearches(['foo']); - - expect(wrapper.vm.getRecentSearches()).toEqual(['foo']); - }); - }); - describe('handleSortOptionClick', () => { it('emits component event `onSort` with selected sort by value', () => { wrapper.vm.handleSortOptionClick(mockSortOptions[1]); @@ -172,6 +170,27 @@ describe('FilteredSearchBarRoot', () => { }); }); + describe('handleHistoryItemSelected', () => { + it('emits `onFilter` event with provided filters param', () => { + wrapper.vm.handleHistoryItemSelected(mockHistoryItems[0]); + + expect(wrapper.emitted('onFilter')[0]).toEqual([mockHistoryItems[0]]); + }); + }); + + describe('handleClearHistory', () => { + it('clears search history from recent searches store', () => { + jest.spyOn(wrapper.vm.recentSearchesStore, 'setRecentSearches').mockReturnValue([]); + jest.spyOn(wrapper.vm.recentSearchesService, 'save'); + + wrapper.vm.handleClearHistory(); + + expect(wrapper.vm.recentSearchesStore.setRecentSearches).toHaveBeenCalledWith([]); + expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([]); + expect(wrapper.vm.recentSearches).toEqual([]); + }); + }); + describe('handleFilterSubmit', () => { const mockFilters = [ { @@ -186,14 +205,11 @@ describe('FilteredSearchBarRoot', () => { it('calls `recentSearchesStore.addRecentSearch` with serialized value of provided `filters` param', () => { jest.spyOn(wrapper.vm.recentSearchesStore, 'addRecentSearch'); - // jest.spyOn(wrapper.vm.recentSearchesService, 'save'); wrapper.vm.handleFilterSubmit(mockFilters); return wrapper.vm.recentSearchesPromise.then(() => { - expect(wrapper.vm.recentSearchesStore.addRecentSearch).toHaveBeenCalledWith( - 'author_username:=@root foo', - ); + expect(wrapper.vm.recentSearchesStore.addRecentSearch).toHaveBeenCalledWith(mockFilters); }); }); @@ -203,9 +219,17 @@ describe('FilteredSearchBarRoot', () => { wrapper.vm.handleFilterSubmit(mockFilters); return wrapper.vm.recentSearchesPromise.then(() => { - expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([ - 'author_username:=@root foo', - ]); + expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([mockFilters]); + }); + }); + + it('sets `recentSearches` data prop with array of searches', () => { + jest.spyOn(wrapper.vm.recentSearchesService, 'save'); + + wrapper.vm.handleFilterSubmit(mockFilters); + + return wrapper.vm.recentSearchesPromise.then(() => { + expect(wrapper.vm.recentSearches).toEqual([mockFilters]); }); }); @@ -222,6 +246,7 @@ describe('FilteredSearchBarRoot', () => { wrapper.setData({ selectedSortOption: mockSortOptions[0], selectedSortDirection: SortDirection.descending, + recentSearches: mockHistoryItems, }); return wrapper.vm.$nextTick(); @@ -232,6 +257,7 @@ describe('FilteredSearchBarRoot', () => { expect(glFilteredSearchEl.props('placeholder')).toBe('Filter requirements'); expect(glFilteredSearchEl.props('availableTokens')).toEqual(mockAvailableTokens); + expect(glFilteredSearchEl.props('historyItems')).toEqual(mockHistoryItems); }); it('renders sort dropdown component', () => { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js index edc0f119262..7e28c4e11e1 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -44,6 +44,29 @@ export const mockAuthorToken = { export const mockAvailableTokens = [mockAuthorToken]; +export const mockHistoryItems = [ + [ + { + type: 'author_username', + value: { + data: 'toby', + operator: '=', + }, + }, + 'duo', + ], + [ + { + type: 'author_username', + value: { + data: 'root', + operator: '=', + }, + }, + 'si', + ], +]; + export const mockSortOptions = [ { id: 1, diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js index 3650ef79136..45294096eda 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js @@ -11,11 +11,12 @@ import { mockAuthorToken, mockAuthors } from '../mock_data'; jest.mock('~/flash'); -const createComponent = ({ config = mockAuthorToken, value = { data: '' } } = {}) => +const createComponent = ({ config = mockAuthorToken, value = { data: '' }, active = false } = {}) => mount(AuthorToken, { propsData: { config, value, + active, }, provide: { portalName: 'fake target', @@ -51,29 +52,23 @@ describe('AuthorToken', () => { describe('computed', () => { describe('currentValue', () => { it('returns lowercase string for `value.data`', () => { - wrapper.setProps({ - value: { data: 'FOO' }, - }); + wrapper = createComponent({ value: { data: 'FOO' } }); - return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.currentValue).toBe('foo'); - }); + expect(wrapper.vm.currentValue).toBe('foo'); }); }); describe('activeAuthor', () => { - it('returns object for currently present `value.data`', () => { + it('returns object for currently present `value.data`', async () => { + wrapper = createComponent({ value: { data: mockAuthors[0].username } }); + wrapper.setData({ authors: mockAuthors, }); - wrapper.setProps({ - value: { data: mockAuthors[0].username }, - }); + await wrapper.vm.$nextTick(); - return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.activeAuthor).toEqual(mockAuthors[0]); - }); + expect(wrapper.vm.activeAuthor).toEqual(mockAuthors[0]); }); }); }); diff --git a/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js b/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js index 8437e68d73c..93f4db5df18 100644 --- a/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js +++ b/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js @@ -38,6 +38,9 @@ describe('GlModalVuex', () => { localVue, store, propsData, + stubs: { + GlModal, + }, }); }; @@ -148,4 +151,29 @@ describe('GlModalVuex', () => { .then(done) .catch(done.fail); }); + + it.each(['ok', 'cancel'])( + 'passes an "%s" handler to the "modal-footer" slot scope', + handlerName => { + state.isVisible = true; + + const modalFooterSlotContent = jest.fn(); + + factory({ + scopedSlots: { + 'modal-footer': modalFooterSlotContent, + }, + }); + + const handler = modalFooterSlotContent.mock.calls[0][0][handlerName]; + + expect(wrapper.emitted(handlerName)).toBeFalsy(); + expect(actions.hide).not.toHaveBeenCalled(); + + handler(); + + expect(actions.hide).toHaveBeenCalledTimes(1); + expect(wrapper.emitted(handlerName)).toBeTruthy(); + }, + ); }); diff --git a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js index ca75c55df26..548d4476c0f 100644 --- a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; -import { mockAssigneesList } from '../../../../javascripts/boards/mock_data'; +import { mockAssigneesList } from 'jest/boards/mock_data'; const TEST_CSS_CLASSES = 'test-classes'; const TEST_MAX_VISIBLE = 4; diff --git a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js index 90c3fe54901..69d8c1a5918 100644 --- a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js @@ -4,7 +4,7 @@ import { shallowMount } from '@vue/test-utils'; import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue'; import Icon from '~/vue_shared/components/icon.vue'; -import { mockMilestone } from '../../../../javascripts/boards/mock_data'; +import { mockMilestone } from 'jest/boards/mock_data'; const createComponent = (milestone = mockMilestone) => { const Component = Vue.extend(IssueMilestone); diff --git a/spec/frontend/vue_shared/components/issue/issue_warning_spec.js b/spec/frontend/vue_shared/components/issue/issue_warning_spec.js deleted file mode 100644 index 891c70bcb5c..00000000000 --- a/spec/frontend/vue_shared/components/issue/issue_warning_spec.js +++ /dev/null @@ -1,105 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import IssueWarning from '~/vue_shared/components/issue/issue_warning.vue'; -import Icon from '~/vue_shared/components/icon.vue'; - -describe('Issue Warning Component', () => { - let wrapper; - - const findIcon = () => wrapper.find(Icon); - const findLockedBlock = () => wrapper.find({ ref: 'locked' }); - const findConfidentialBlock = () => wrapper.find({ ref: 'confidential' }); - const findLockedAndConfidentialBlock = () => wrapper.find({ ref: 'lockedAndConfidential' }); - - const createComponent = props => { - wrapper = shallowMount(IssueWarning, { - propsData: { - ...props, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('when issue is locked but not confidential', () => { - beforeEach(() => { - createComponent({ - isLocked: true, - lockedIssueDocsPath: 'locked-path', - isConfidential: false, - }); - }); - - it('renders information about locked issue', () => { - expect(findLockedBlock().exists()).toBe(true); - expect(findLockedBlock().element).toMatchSnapshot(); - }); - - it('renders warning icon', () => { - expect(findIcon().exists()).toBe(true); - }); - - it('does not render information about locked and confidential issue', () => { - expect(findLockedAndConfidentialBlock().exists()).toBe(false); - }); - - it('does not render information about confidential issue', () => { - expect(findConfidentialBlock().exists()).toBe(false); - }); - }); - - describe('when issue is confidential but not locked', () => { - beforeEach(() => { - createComponent({ - isLocked: false, - isConfidential: true, - confidentialIssueDocsPath: 'confidential-path', - }); - }); - - it('renders information about confidential issue', () => { - expect(findConfidentialBlock().exists()).toBe(true); - expect(findConfidentialBlock().element).toMatchSnapshot(); - }); - - it('renders warning icon', () => { - expect(wrapper.find(Icon).exists()).toBe(true); - }); - - it('does not render information about locked issue', () => { - expect(findLockedBlock().exists()).toBe(false); - }); - - it('does not render information about locked and confidential issue', () => { - expect(findLockedAndConfidentialBlock().exists()).toBe(false); - }); - }); - - describe('when issue is locked and confidential', () => { - beforeEach(() => { - createComponent({ - isLocked: true, - isConfidential: true, - }); - }); - - it('renders information about locked and confidential issue', () => { - expect(findLockedAndConfidentialBlock().exists()).toBe(true); - expect(findLockedAndConfidentialBlock().element).toMatchSnapshot(); - }); - - it('does not render warning icon', () => { - expect(wrapper.find(Icon).exists()).toBe(false); - }); - - it('does not render information about locked issue', () => { - expect(findLockedBlock().exists()).toBe(false); - }); - - it('does not render information about confidential issue', () => { - expect(findConfidentialBlock().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js index 9be0a67e4fa..fe9a5156539 100644 --- a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js +++ b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js @@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils'; import { formatDate } from '~/lib/utils/datetime_utility'; import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; import { defaultAssignees, defaultMilestone } from './related_issuable_mock_data'; +import { TEST_HOST } from 'jest/helpers/test_constants'; describe('RelatedIssuableItem', () => { let wrapper; @@ -19,7 +20,7 @@ describe('RelatedIssuableItem', () => { idKey: 1, displayReference: 'gitlab-org/gitlab-test#1', pathIdSeparator: '#', - path: `${gl.TEST_HOST}/path`, + path: `${TEST_HOST}/path`, title: 'title', confidential: true, dueDate: '1990-12-31', diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_mock_data.js b/spec/frontend/vue_shared/components/issue/related_issuable_mock_data.js index 5f69d761fdf..17813f2833d 100644 --- a/spec/frontend/vue_shared/components/issue/related_issuable_mock_data.js +++ b/spec/frontend/vue_shared/components/issue/related_issuable_mock_data.js @@ -1,3 +1,5 @@ +import { TEST_HOST } from 'jest/helpers/test_constants'; + export const defaultProps = { endpoint: '/foo/bar/issues/1/related_issues', currentNamespacePath: 'foo', @@ -83,8 +85,8 @@ export const defaultAssignees = [ name: 'Administrator', username: 'root', state: 'active', - avatar_url: `${gl.TEST_HOST}`, - web_url: `${gl.TEST_HOST}/root`, + avatar_url: `${TEST_HOST}`, + web_url: `${TEST_HOST}/root`, status_tooltip_html: null, path: '/root', }, @@ -93,8 +95,8 @@ export const defaultAssignees = [ name: 'Brooks Beatty', username: 'brynn_champlin', state: 'active', - avatar_url: `${gl.TEST_HOST}`, - web_url: `${gl.TEST_HOST}/brynn_champlin`, + avatar_url: `${TEST_HOST}`, + web_url: `${TEST_HOST}/brynn_champlin`, status_tooltip_html: null, path: '/brynn_champlin', }, @@ -103,8 +105,8 @@ export const defaultAssignees = [ name: 'Bryce Turcotte', username: 'melynda', state: 'active', - avatar_url: `${gl.TEST_HOST}`, - web_url: `${gl.TEST_HOST}/melynda`, + avatar_url: `${TEST_HOST}`, + web_url: `${TEST_HOST}/melynda`, status_tooltip_html: null, path: '/melynda', }, @@ -113,8 +115,8 @@ export const defaultAssignees = [ name: 'Conchita Eichmann', username: 'juliana_gulgowski', state: 'active', - avatar_url: `${gl.TEST_HOST}`, - web_url: `${gl.TEST_HOST}/juliana_gulgowski`, + avatar_url: `${TEST_HOST}`, + web_url: `${TEST_HOST}/juliana_gulgowski`, status_tooltip_html: null, path: '/juliana_gulgowski', }, diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js index 9a5b95b555f..c6e147899e4 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js @@ -69,11 +69,6 @@ describe('Suggestion Diff component', () => { expect(addToBatchBtn.html().includes('Add suggestion to batch')).toBe(true); }); - it('renders correct tooltip message for apply button', () => { - createComponent(); - expect(wrapper.vm.tooltipMessage).toBe('This also resolves the discussion'); - }); - describe('when apply suggestion is clicked', () => { beforeEach(() => { createComponent(); @@ -227,17 +222,23 @@ describe('Suggestion Diff component', () => { createComponent({ canApply: false }); }); - it('disables apply suggestion and add to batch buttons', () => { + it('disables apply suggestion and hides add to batch button', () => { expect(findApplyButton().exists()).toBe(true); - expect(findAddToBatchButton().exists()).toBe(true); + expect(findAddToBatchButton().exists()).toBe(false); expect(findApplyButton().attributes('disabled')).toBe('true'); - expect(findAddToBatchButton().attributes('disabled')).toBe('true'); + }); + }); + + describe('tooltip message for apply button', () => { + it('renders correct tooltip message when button is applicable', () => { + createComponent(); + expect(wrapper.vm.tooltipMessage).toBe('This also resolves this thread'); }); - it('renders correct tooltip message for apply button', () => { - expect(wrapper.vm.tooltipMessage).toBe( - "Can't apply as this line has changed or the suggestion already matches its content.", - ); + it('renders the inapplicable reason in the tooltip when button is not applicable', () => { + const inapplicableReason = 'lorem'; + createComponent({ canApply: false, inapplicableReason }); + expect(wrapper.vm.tooltipMessage).toBe(inapplicableReason); }); }); }); diff --git a/spec/frontend/vue_shared/components/issue/__snapshots__/issue_warning_spec.js.snap b/spec/frontend/vue_shared/components/notes/__snapshots__/noteable_warning_spec.js.snap index 49b18d3e106..573bc9abe4d 100644 --- a/spec/frontend/vue_shared/components/issue/__snapshots__/issue_warning_spec.js.snap +++ b/spec/frontend/vue_shared/components/notes/__snapshots__/noteable_warning_spec.js.snap @@ -1,6 +1,21 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Issue Warning Component when issue is confidential but not locked renders information about confidential issue 1`] = ` +exports[`Issue Warning Component when issue is locked but not confidential renders information about locked issue 1`] = ` +<span> + + This issue is locked. + Only project members can comment. + + <gl-link-stub + href="locked-path" + target="_blank" + > + Learn more + </gl-link-stub> +</span> +`; + +exports[`Issue Warning Component when noteable is confidential but not locked renders information about confidential issue 1`] = ` <span> This is a confidential issue. @@ -10,14 +25,12 @@ exports[`Issue Warning Component when issue is confidential but not locked rende href="confidential-path" target="_blank" > - - Learn more - + Learn more </gl-link-stub> </span> `; -exports[`Issue Warning Component when issue is locked and confidential renders information about locked and confidential issue 1`] = ` +exports[`Issue Warning Component when noteable is locked and confidential renders information about locked and confidential noteable 1`] = ` <span> <span> This issue is @@ -43,20 +56,3 @@ exports[`Issue Warning Component when issue is locked and confidential renders i </span> `; - -exports[`Issue Warning Component when issue is locked but not confidential renders information about locked issue 1`] = ` -<span> - - This issue is locked. - Only project members can comment. - - <gl-link-stub - href="locked-path" - target="_blank" - > - - Learn more - - </gl-link-stub> -</span> -`; diff --git a/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js b/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js new file mode 100644 index 00000000000..ae8c9a0928e --- /dev/null +++ b/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js @@ -0,0 +1,196 @@ +import { shallowMount } from '@vue/test-utils'; +import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue'; +import Icon from '~/vue_shared/components/icon.vue'; + +describe('Issue Warning Component', () => { + let wrapper; + + const findIcon = (w = wrapper) => w.find(Icon); + const findLockedBlock = (w = wrapper) => w.find({ ref: 'locked' }); + const findConfidentialBlock = (w = wrapper) => w.find({ ref: 'confidential' }); + const findLockedAndConfidentialBlock = (w = wrapper) => w.find({ ref: 'lockedAndConfidential' }); + + const createComponent = props => + shallowMount(NoteableWarning, { + propsData: { + ...props, + }, + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('when issue is locked but not confidential', () => { + beforeEach(() => { + wrapper = createComponent({ + isLocked: true, + lockedNoteableDocsPath: 'locked-path', + isConfidential: false, + }); + }); + + it('renders information about locked issue', () => { + expect(findLockedBlock().exists()).toBe(true); + expect(findLockedBlock().element).toMatchSnapshot(); + }); + + it('renders warning icon', () => { + expect(findIcon().exists()).toBe(true); + }); + + it('does not render information about locked and confidential issue', () => { + expect(findLockedAndConfidentialBlock().exists()).toBe(false); + }); + + it('does not render information about confidential issue', () => { + expect(findConfidentialBlock().exists()).toBe(false); + }); + }); + + describe('when noteable is confidential but not locked', () => { + beforeEach(() => { + wrapper = createComponent({ + isLocked: false, + isConfidential: true, + confidentialNoteableDocsPath: 'confidential-path', + }); + }); + + it('renders information about confidential issue', async () => { + expect(findConfidentialBlock().exists()).toBe(true); + expect(findConfidentialBlock().element).toMatchSnapshot(); + + await wrapper.vm.$nextTick(); + expect(findConfidentialBlock(wrapper).text()).toContain('This is a confidential issue.'); + }); + + it('renders warning icon', () => { + expect(wrapper.find(Icon).exists()).toBe(true); + }); + + it('does not render information about locked noteable', () => { + expect(findLockedBlock().exists()).toBe(false); + }); + + it('does not render information about locked and confidential noteable', () => { + expect(findLockedAndConfidentialBlock().exists()).toBe(false); + }); + }); + + describe('when noteable is locked and confidential', () => { + beforeEach(() => { + wrapper = createComponent({ + isLocked: true, + isConfidential: true, + }); + }); + + it('renders information about locked and confidential noteable', () => { + expect(findLockedAndConfidentialBlock().exists()).toBe(true); + expect(findLockedAndConfidentialBlock().element).toMatchSnapshot(); + }); + + it('does not render warning icon', () => { + expect(wrapper.find(Icon).exists()).toBe(false); + }); + + it('does not render information about locked noteable', () => { + expect(findLockedBlock().exists()).toBe(false); + }); + + it('does not render information about confidential noteable', () => { + expect(findConfidentialBlock().exists()).toBe(false); + }); + }); + + describe('when noteableType prop is defined', () => { + let wrapperLocked; + let wrapperConfidential; + let wrapperLockedAndConfidential; + + beforeEach(() => { + wrapperLocked = createComponent({ + isLocked: true, + isConfidential: false, + }); + wrapperConfidential = createComponent({ + isLocked: false, + isConfidential: true, + }); + wrapperLockedAndConfidential = createComponent({ + isLocked: true, + isConfidential: true, + }); + }); + + afterEach(() => { + wrapperLocked.destroy(); + wrapperConfidential.destroy(); + wrapperLockedAndConfidential.destroy(); + }); + + it('renders confidential & locked messages with noteable "issue"', () => { + expect(findLockedBlock(wrapperLocked).text()).toContain('This issue is locked.'); + expect(findConfidentialBlock(wrapperConfidential).text()).toContain( + 'This is a confidential issue.', + ); + expect(findLockedAndConfidentialBlock(wrapperLockedAndConfidential).text()).toContain( + 'This issue is confidential and locked.', + ); + }); + + it('renders confidential & locked messages with noteable "epic"', async () => { + wrapperLocked.setProps({ + noteableType: 'Epic', + }); + wrapperConfidential.setProps({ + noteableType: 'Epic', + }); + wrapperLockedAndConfidential.setProps({ + noteableType: 'Epic', + }); + + await wrapperLocked.vm.$nextTick(); + expect(findLockedBlock(wrapperLocked).text()).toContain('This epic is locked.'); + + await wrapperConfidential.vm.$nextTick(); + expect(findConfidentialBlock(wrapperConfidential).text()).toContain( + 'This is a confidential epic.', + ); + + await wrapperLockedAndConfidential.vm.$nextTick(); + expect(findLockedAndConfidentialBlock(wrapperLockedAndConfidential).text()).toContain( + 'This epic is confidential and locked.', + ); + }); + + it('renders confidential & locked messages with noteable "merge request"', async () => { + wrapperLocked.setProps({ + noteableType: 'MergeRequest', + }); + wrapperConfidential.setProps({ + noteableType: 'MergeRequest', + }); + wrapperLockedAndConfidential.setProps({ + noteableType: 'MergeRequest', + }); + + await wrapperLocked.vm.$nextTick(); + expect(findLockedBlock(wrapperLocked).text()).toContain('This merge request is locked.'); + + await wrapperConfidential.vm.$nextTick(); + expect(findConfidentialBlock(wrapperConfidential).text()).toContain( + 'This is a confidential merge request.', + ); + + await wrapperLockedAndConfidential.vm.$nextTick(); + expect(findLockedAndConfidentialBlock(wrapperLockedAndConfidential).text()).toContain( + 'This merge request is confidential and locked.', + ); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js index eb1d9e93634..385134c4a3f 100644 --- a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js +++ b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js @@ -74,6 +74,16 @@ describe('ProjectListItem component', () => { expect(renderedNamespace).toBe('a / ... / e /'); }); + it(`renders a simple namespace name of a GraphQL project`, () => { + options.propsData.project.name_with_namespace = undefined; + options.propsData.project.nameWithNamespace = 'test'; + + wrapper = shallowMount(Component, options); + const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text()); + + expect(renderedNamespace).toBe('test /'); + }); + it(`renders the project name`, () => { options.propsData.project.name = 'my-test-project'; diff --git a/spec/frontend/vue_shared/components/remove_member_modal_spec.js b/spec/frontend/vue_shared/components/remove_member_modal_spec.js new file mode 100644 index 00000000000..2d380b25a0a --- /dev/null +++ b/spec/frontend/vue_shared/components/remove_member_modal_spec.js @@ -0,0 +1,65 @@ +import { GlFormCheckbox, GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; + +describe('RemoveMemberModal', () => { + const memberPath = '/gitlab-org/gitlab-test/-/project_members/90'; + let wrapper; + + const findForm = () => wrapper.find({ ref: 'form' }); + const findGlModal = () => wrapper.find(GlModal); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe.each` + state | isAccessRequest | actionText | checkboxTestDescription | checkboxExpected | message + ${'removing a member'} | ${'false'} | ${'Remove member'} | ${'shows a checkbox to allow removal from related issues and MRs'} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} + ${'denying an access request'} | ${'true'} | ${'Deny access request'} | ${'does not show a checkbox'} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} + `( + 'when $state', + ({ actionText, isAccessRequest, message, checkboxTestDescription, checkboxExpected }) => { + beforeEach(() => { + wrapper = shallowMount(RemoveMemberModal, { + data() { + return { + modalData: { + isAccessRequest, + message, + memberPath, + }, + }; + }, + }); + }); + + it(`has the title ${actionText}`, () => { + expect(findGlModal().attributes('title')).toBe(actionText); + }); + + it('contains a form action', () => { + expect(findForm().attributes('action')).toBe(memberPath); + }); + + it('displays a message to the user', () => { + expect(wrapper.find('[data-testid=modal-message]').text()).toBe(message); + }); + + it(`${checkboxTestDescription}`, () => { + expect(wrapper.contains(GlFormCheckbox)).toBe(checkboxExpected); + }); + + it('submits the form when the modal is submitted', () => { + const spy = jest.spyOn(findForm().element, 'submit'); + + findGlModal().vm.$emit('primary'); + + expect(spy).toHaveBeenCalled(); + + spy.mockRestore(); + }); + }, + ); +}); diff --git a/spec/frontend/vue_shared/components/__snapshots__/resizable_chart_container_spec.js.snap b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/resizable_chart_container_spec.js.snap index add0c36a120..add0c36a120 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/resizable_chart_container_spec.js.snap +++ b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/resizable_chart_container_spec.js.snap diff --git a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap new file mode 100644 index 00000000000..103b53cb280 --- /dev/null +++ b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap @@ -0,0 +1,324 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Resizable Skeleton Loader default setup renders the bars, labels, and grid with correct position, size, and rx percentages 1`] = ` +<gl-skeleton-loader-stub + baseurl="" + height="130" + preserveaspectratio="xMidYMid meet" + width="400" +> + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="30%" + /> + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="60%" + /> + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="90%" + /> + + <rect + data-testid="skeleton-chart-bar" + height="5%" + rx="0.4%" + width="6%" + x="5.875%" + y="85%" + /> + <rect + data-testid="skeleton-chart-bar" + height="7%" + rx="0.4%" + width="6%" + x="17.625%" + y="83%" + /> + <rect + data-testid="skeleton-chart-bar" + height="9%" + rx="0.4%" + width="6%" + x="29.375%" + y="81%" + /> + <rect + data-testid="skeleton-chart-bar" + height="14%" + rx="0.4%" + width="6%" + x="41.125%" + y="76%" + /> + <rect + data-testid="skeleton-chart-bar" + height="21%" + rx="0.4%" + width="6%" + x="52.875%" + y="69%" + /> + <rect + data-testid="skeleton-chart-bar" + height="35%" + rx="0.4%" + width="6%" + x="64.625%" + y="55%" + /> + <rect + data-testid="skeleton-chart-bar" + height="50%" + rx="0.4%" + width="6%" + x="76.375%" + y="40%" + /> + <rect + data-testid="skeleton-chart-bar" + height="80%" + rx="0.4%" + width="6%" + x="88.125%" + y="10%" + /> + + <rect + data-testid="skeleton-chart-label" + height="5%" + rx="0.4%" + width="4%" + x="6.875%" + y="95%" + /> + <rect + data-testid="skeleton-chart-label" + height="5%" + rx="0.4%" + width="4%" + x="18.625%" + y="95%" + /> + <rect + data-testid="skeleton-chart-label" + height="5%" + rx="0.4%" + width="4%" + x="30.375%" + y="95%" + /> + <rect + data-testid="skeleton-chart-label" + height="5%" + rx="0.4%" + width="4%" + x="42.125%" + y="95%" + /> + <rect + data-testid="skeleton-chart-label" + height="5%" + rx="0.4%" + width="4%" + x="53.875%" + y="95%" + /> + <rect + data-testid="skeleton-chart-label" + height="5%" + rx="0.4%" + width="4%" + x="65.625%" + y="95%" + /> + <rect + data-testid="skeleton-chart-label" + height="5%" + rx="0.4%" + width="4%" + x="77.375%" + y="95%" + /> + <rect + data-testid="skeleton-chart-label" + height="5%" + rx="0.4%" + width="4%" + x="89.125%" + y="95%" + /> +</gl-skeleton-loader-stub> +`; + +exports[`Resizable Skeleton Loader with custom settings renders the correct position, and size percentages for bars and labels with different settings 1`] = ` +<gl-skeleton-loader-stub + baseurl="" + height="130" + preserveaspectratio="xMidYMid meet" + uniquekey="" + width="400" +> + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="30%" + /> + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="60%" + /> + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="90%" + /> + + <rect + data-testid="skeleton-chart-bar" + height="5%" + rx="0.6%" + width="3%" + x="6.0625%" + y="85%" + /> + <rect + data-testid="skeleton-chart-bar" + height="7%" + rx="0.6%" + width="3%" + x="18.1875%" + y="83%" + /> + <rect + data-testid="skeleton-chart-bar" + height="9%" + rx="0.6%" + width="3%" + x="30.3125%" + y="81%" + /> + <rect + data-testid="skeleton-chart-bar" + height="14%" + rx="0.6%" + width="3%" + x="42.4375%" + y="76%" + /> + <rect + data-testid="skeleton-chart-bar" + height="21%" + rx="0.6%" + width="3%" + x="54.5625%" + y="69%" + /> + <rect + data-testid="skeleton-chart-bar" + height="35%" + rx="0.6%" + width="3%" + x="66.6875%" + y="55%" + /> + <rect + data-testid="skeleton-chart-bar" + height="50%" + rx="0.6%" + width="3%" + x="78.8125%" + y="40%" + /> + <rect + data-testid="skeleton-chart-bar" + height="80%" + rx="0.6%" + width="3%" + x="90.9375%" + y="10%" + /> + + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="4.0625%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="16.1875%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="28.3125%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="40.4375%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="52.5625%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="64.6875%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="76.8125%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="88.9375%" + y="98%" + /> +</gl-skeleton-loader-stub> +`; diff --git a/spec/frontend/vue_shared/components/resizable_chart_container_spec.js b/spec/frontend/vue_shared/components/resizable_chart/resizable_chart_container_spec.js index 3a5514ef318..3a5514ef318 100644 --- a/spec/frontend/vue_shared/components/resizable_chart_container_spec.js +++ b/spec/frontend/vue_shared/components/resizable_chart/resizable_chart_container_spec.js diff --git a/spec/frontend/vue_shared/components/resizable_chart/skeleton_loader_spec.js b/spec/frontend/vue_shared/components/resizable_chart/skeleton_loader_spec.js new file mode 100644 index 00000000000..7facd02e596 --- /dev/null +++ b/spec/frontend/vue_shared/components/resizable_chart/skeleton_loader_spec.js @@ -0,0 +1,55 @@ +import { shallowMount } from '@vue/test-utils'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; + +describe('Resizable Skeleton Loader', () => { + let wrapper; + + const createComponent = (propsData = {}) => { + wrapper = shallowMount(ChartSkeletonLoader, { + propsData, + }); + }; + + const verifyElementsPresence = () => { + const gridItems = wrapper.findAll('[data-testid="skeleton-chart-grid"]').wrappers; + const barItems = wrapper.findAll('[data-testid="skeleton-chart-bar"]').wrappers; + const labelItems = wrapper.findAll('[data-testid="skeleton-chart-label"]').wrappers; + expect(gridItems.length).toBe(3); + expect(barItems.length).toBe(8); + expect(labelItems.length).toBe(8); + }; + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + }); + + describe('default setup', () => { + beforeEach(() => { + createComponent({ uniqueKey: null }); + }); + + it('renders the bars, labels, and grid with correct position, size, and rx percentages', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders the correct number of grid items, bars, and labels', () => { + verifyElementsPresence(); + }); + }); + + describe('with custom settings', () => { + beforeEach(() => { + createComponent({ uniqueKey: '', rx: 0.6, barWidth: 3, labelWidth: 7, labelHeight: 2 }); + }); + + it('renders the correct position, and size percentages for bars and labels with different settings', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders the correct number of grid items, bars, and labels', () => { + verifyElementsPresence(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js index faa32131fab..78f27c9948b 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js @@ -2,18 +2,35 @@ import { generateToolbarItem, addCustomEventListener, removeCustomEventListener, + registerHTMLToMarkdownRenderer, addImage, getMarkdown, -} from '~/vue_shared/components/rich_content_editor/editor_service'; +} from '~/vue_shared/components/rich_content_editor/services/editor_service'; +import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'; + +jest.mock('~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'); describe('Editor Service', () => { - const mockInstance = { - eventManager: { addEventType: jest.fn(), removeEventHandler: jest.fn(), listen: jest.fn() }, - editor: { exec: jest.fn() }, - invoke: jest.fn(), - }; - const event = 'someCustomEvent'; - const handler = jest.fn(); + let mockInstance; + let event; + let handler; + + beforeEach(() => { + mockInstance = { + eventManager: { addEventType: jest.fn(), removeEventHandler: jest.fn(), listen: jest.fn() }, + editor: { exec: jest.fn() }, + invoke: jest.fn(), + toMarkOptions: { + renderer: { + constructor: { + factory: jest.fn(), + }, + }, + }, + }; + event = 'someCustomEvent'; + handler = jest.fn(); + }); describe('generateToolbarItem', () => { const config = { @@ -74,4 +91,33 @@ describe('Editor Service', () => { expect(mockInstance.invoke).toHaveBeenCalledWith('getMarkdown'); }); }); + + describe('registerHTMLToMarkdownRenderer', () => { + let baseRenderer; + const htmlToMarkdownRenderer = {}; + const extendedRenderer = {}; + + beforeEach(() => { + baseRenderer = mockInstance.toMarkOptions.renderer; + buildHTMLToMarkdownRenderer.mockReturnValueOnce(htmlToMarkdownRenderer); + baseRenderer.constructor.factory.mockReturnValueOnce(extendedRenderer); + + registerHTMLToMarkdownRenderer(mockInstance); + }); + + it('builds a new instance of the HTML to Markdown renderer', () => { + expect(buildHTMLToMarkdownRenderer).toHaveBeenCalledWith(baseRenderer); + }); + + it('extends base renderer with the HTML to Markdown renderer', () => { + expect(baseRenderer.constructor.factory).toHaveBeenCalledWith( + baseRenderer, + htmlToMarkdownRenderer, + ); + }); + + it('replaces the default renderer with extended renderer', () => { + expect(mockInstance.toMarkOptions.renderer).toBe(extendedRenderer); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js new file mode 100644 index 00000000000..0c2ac53aa52 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js @@ -0,0 +1,76 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlModal, GlTabs } from '@gitlab/ui'; +import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue'; +import UploadImageTab from '~/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue'; +import { IMAGE_TABS } from '~/vue_shared/components/rich_content_editor/constants'; + +describe('Add Image Modal', () => { + let wrapper; + const propsData = { imageRoot: 'path/to/root/' }; + + const findModal = () => wrapper.find(GlModal); + const findTabs = () => wrapper.find(GlTabs); + const findUploadImageTab = () => wrapper.find(UploadImageTab); + const findUrlInput = () => wrapper.find({ ref: 'urlInput' }); + const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' }); + + beforeEach(() => { + wrapper = shallowMount(AddImageModal, { + provide: { glFeatures: { sseImageUploads: true } }, + propsData, + }); + }); + + describe('when content is loaded', () => { + it('renders a modal component', () => { + expect(findModal().exists()).toBe(true); + }); + + it('renders a Tabs component', () => { + expect(findTabs().exists()).toBe(true); + }); + + it('renders an upload image tab', () => { + expect(findUploadImageTab().exists()).toBe(true); + }); + + it('renders an input to add an image URL', () => { + expect(findUrlInput().exists()).toBe(true); + }); + + it('renders an input to add an image description', () => { + expect(findDescriptionInput().exists()).toBe(true); + }); + }); + + describe('add image', () => { + describe('Upload', () => { + it('validates the file', () => { + const preventDefault = jest.fn(); + const description = 'some description'; + const file = { name: 'some_file.png' }; + + wrapper.vm.$refs.uploadImageTab = { validateFile: jest.fn() }; + wrapper.setData({ file, description, tabIndex: IMAGE_TABS.UPLOAD_TAB }); + + findModal().vm.$emit('ok', { preventDefault }); + + expect(wrapper.vm.$refs.uploadImageTab.validateFile).toHaveBeenCalled(); + }); + }); + + describe('URL', () => { + it('emits an addImage event when a valid URL is specified', () => { + const preventDefault = jest.fn(); + const mockImage = { imageUrl: '/some/valid/url.png', description: 'some description' }; + wrapper.setData({ ...mockImage, tabIndex: IMAGE_TABS.URL_TAB }); + + findModal().vm.$emit('ok', { preventDefault }); + expect(preventDefault).not.toHaveBeenCalled(); + expect(wrapper.emitted('addImage')).toEqual([ + [{ imageUrl: mockImage.imageUrl, altText: mockImage.description }], + ]); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab_spec.js new file mode 100644 index 00000000000..ded490b2568 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab_spec.js @@ -0,0 +1,41 @@ +import { shallowMount } from '@vue/test-utils'; +import UploadImageTab from '~/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue'; + +describe('Upload Image Tab', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallowMount(UploadImageTab); + }); + + afterEach(() => wrapper.destroy()); + + const triggerInputEvent = size => { + const file = { size, name: 'file-name.png' }; + const mockEvent = new Event('input'); + + Object.defineProperty(mockEvent, 'target', { value: { files: [file] } }); + + wrapper.find({ ref: 'fileInput' }).element.dispatchEvent(mockEvent); + + return file; + }; + + describe('onInput', () => { + it.each` + size | fileError + ${2000000000} | ${'Maximum file size is 2MB. Please select a smaller file.'} + ${200} | ${null} + `('validates the file correctly', ({ size, fileError }) => { + triggerInputEvent(size); + + expect(wrapper.vm.fileError).toBe(fileError); + }); + }); + + it('emits input event when file is valid', () => { + const file = triggerInputEvent(200); + + expect(wrapper.emitted('input')).toEqual([[file]]); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image_modal_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image_modal_spec.js deleted file mode 100644 index 4889bc8538d..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image_modal_spec.js +++ /dev/null @@ -1,41 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlModal } from '@gitlab/ui'; -import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image_modal.vue'; - -describe('Add Image Modal', () => { - let wrapper; - - const findModal = () => wrapper.find(GlModal); - const findUrlInput = () => wrapper.find({ ref: 'urlInput' }); - const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' }); - - beforeEach(() => { - wrapper = shallowMount(AddImageModal); - }); - - describe('when content is loaded', () => { - it('renders a modal component', () => { - expect(findModal().exists()).toBe(true); - }); - - it('renders an input to add an image URL', () => { - expect(findUrlInput().exists()).toBe(true); - }); - - it('renders an input to add an image description', () => { - expect(findDescriptionInput().exists()).toBe(true); - }); - }); - - describe('add image', () => { - it('emits an addImage event when a valid URL is specified', () => { - const preventDefault = jest.fn(); - const mockImage = { imageUrl: '/some/valid/url.png', altText: 'some description' }; - wrapper.setData({ ...mockImage }); - - findModal().vm.$emit('ok', { preventDefault }); - expect(preventDefault).not.toHaveBeenCalled(); - expect(wrapper.emitted('addImage')).toEqual([[mockImage]]); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js index 0db10389df4..b6ff6aa767c 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; -import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image_modal.vue'; +import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue'; import { EDITOR_OPTIONS, EDITOR_TYPES, @@ -13,25 +13,28 @@ import { addCustomEventListener, removeCustomEventListener, addImage, -} from '~/vue_shared/components/rich_content_editor/editor_service'; + registerHTMLToMarkdownRenderer, +} from '~/vue_shared/components/rich_content_editor/services/editor_service'; -jest.mock('~/vue_shared/components/rich_content_editor/editor_service', () => ({ - ...jest.requireActual('~/vue_shared/components/rich_content_editor/editor_service'), +jest.mock('~/vue_shared/components/rich_content_editor/services/editor_service', () => ({ + ...jest.requireActual('~/vue_shared/components/rich_content_editor/services/editor_service'), addCustomEventListener: jest.fn(), removeCustomEventListener: jest.fn(), addImage: jest.fn(), + registerHTMLToMarkdownRenderer: jest.fn(), })); describe('Rich Content Editor', () => { let wrapper; - const value = '## Some Markdown'; + const content = '## Some Markdown'; + const imageRoot = 'path/to/root/'; const findEditor = () => wrapper.find({ ref: 'editor' }); const findAddImageModal = () => wrapper.find(AddImageModal); beforeEach(() => { wrapper = shallowMount(RichContentEditor, { - propsData: { value }, + propsData: { content, imageRoot }, }); }); @@ -41,7 +44,7 @@ describe('Rich Content Editor', () => { }); it('renders the correct content', () => { - expect(findEditor().props().initialValue).toBe(value); + expect(findEditor().props().initialValue).toBe(content); }); it('provides the correct editor options', () => { @@ -73,17 +76,37 @@ describe('Rich Content Editor', () => { }); }); + describe('when content is reset', () => { + it('should reset the content via setMarkdown', () => { + const newContent = 'Just the body content excluding the front matter for example'; + const mockInstance = { invoke: jest.fn() }; + wrapper.vm.$refs.editor = mockInstance; + + wrapper.vm.resetInitialValue(newContent); + + expect(mockInstance.invoke).toHaveBeenCalledWith('setMarkdown', newContent); + }); + }); + describe('when editor is loaded', () => { - it('adds the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { - const mockEditorApi = { eventManager: { addEventType: jest.fn(), listen: jest.fn() } }; + let mockEditorApi; + + beforeEach(() => { + mockEditorApi = { eventManager: { addEventType: jest.fn(), listen: jest.fn() } }; findEditor().vm.$emit('load', mockEditorApi); + }); + it('adds the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { expect(addCustomEventListener).toHaveBeenCalledWith( mockEditorApi, CUSTOM_EVENTS.openAddImageModal, wrapper.vm.onOpenAddImageModal, ); }); + + it('registers HTML to markdown renderer', () => { + expect(registerHTMLToMarkdownRenderer).toHaveBeenCalledWith(mockEditorApi); + }); }); describe('when editor is destroyed', () => { @@ -107,7 +130,7 @@ describe('Rich Content Editor', () => { }); it('calls the onAddImage method when the addImage event is emitted', () => { - const mockImage = { imageUrl: 'some/url.png', description: 'some description' }; + const mockImage = { imageUrl: 'some/url.png', altText: 'some description' }; const mockInstance = { exec: jest.fn() }; wrapper.vm.$refs.editor = mockInstance; diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js new file mode 100644 index 00000000000..cafe53e6bb2 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js @@ -0,0 +1,29 @@ +import buildCustomHTMLRenderer from '~/vue_shared/components/rich_content_editor/services/build_custom_renderer'; + +describe('Build Custom Renderer Service', () => { + describe('buildCustomHTMLRenderer', () => { + it('should return an object with the default renderer functions when lacking arguments', () => { + expect(buildCustomHTMLRenderer()).toEqual( + expect.objectContaining({ + list: expect.any(Function), + text: expect.any(Function), + }), + ); + }); + + it('should return an object with both custom and default renderer functions when passed customRenderers', () => { + const mockHtmlCustomRenderer = jest.fn(); + const customRenderers = { + html: [mockHtmlCustomRenderer], + }; + + expect(buildCustomHTMLRenderer(customRenderers)).toEqual( + expect.objectContaining({ + html: expect.any(Function), + list: expect.any(Function), + text: expect.any(Function), + }), + ); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js new file mode 100644 index 00000000000..0e8610a22f5 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js @@ -0,0 +1,50 @@ +import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'; + +describe('HTMLToMarkdownRenderer', () => { + let baseRenderer; + let htmlToMarkdownRenderer; + const NODE = { nodeValue: 'mock_node' }; + + beforeEach(() => { + baseRenderer = { + trim: jest.fn(input => `trimmed ${input}`), + getSpaceCollapsedText: jest.fn(input => `space collapsed ${input}`), + getSpaceControlled: jest.fn(input => `space controlled ${input}`), + convert: jest.fn(), + }; + }); + + describe('TEXT_NODE visitor', () => { + it('composes getSpaceControlled, getSpaceCollapsedText, and trim services', () => { + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + + expect(htmlToMarkdownRenderer.TEXT_NODE(NODE)).toBe( + `space controlled trimmed space collapsed ${NODE.nodeValue}`, + ); + }); + }); + + describe('LI OL, LI UL visitor', () => { + const oneLevelNestedList = '\n * List item 1\n * List item 2'; + const twoLevelNestedList = '\n * List item 1\n * List item 2'; + const spaceInContentList = '\n * List item 1\n * List item 2'; + + it.each` + list | indentSpaces | result + ${oneLevelNestedList} | ${2} | ${'\n * List item 1\n * List item 2'} + ${oneLevelNestedList} | ${3} | ${'\n * List item 1\n * List item 2'} + ${oneLevelNestedList} | ${6} | ${'\n * List item 1\n * List item 2'} + ${twoLevelNestedList} | ${4} | ${'\n * List item 1\n * List item 2'} + ${spaceInContentList} | ${1} | ${'\n * List item 1\n * List item 2'} + `('changes the list indentation to $indentSpaces spaces', ({ list, indentSpaces, result }) => { + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { + subListIndentSpaces: indentSpaces, + }); + + baseRenderer.convert.mockReturnValueOnce(list); + + expect(htmlToMarkdownRenderer['LI OL, LI UL'](NODE, list)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, list); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js new file mode 100644 index 00000000000..18dff0a39bb --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js @@ -0,0 +1,88 @@ +import { + buildTextToken, + buildUneditableOpenTokens, + buildUneditableCloseToken, + buildUneditableCloseTokens, + buildUneditableTokens, + buildUneditableInlineTokens, + buildUneditableHtmlAsTextTokens, +} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; + +import { + originInlineToken, + originToken, + uneditableOpenTokens, + uneditableCloseToken, + uneditableCloseTokens, + uneditableBlockTokens, + uneditableInlineTokens, + uneditableTokens, +} from './mock_data'; + +describe('Build Uneditable Token renderer helper', () => { + describe('buildTextToken', () => { + it('returns an object literal representing a text token', () => { + const text = originToken.content; + expect(buildTextToken(text)).toStrictEqual(originToken); + }); + }); + + describe('buildUneditableOpenTokens', () => { + it('returns a 2-item array of tokens with the originToken appended to an open token', () => { + const result = buildUneditableOpenTokens(originToken); + + expect(result).toHaveLength(2); + expect(result).toStrictEqual(uneditableOpenTokens); + }); + }); + + describe('buildUneditableCloseToken', () => { + it('returns an object literal representing the uneditable close token', () => { + expect(buildUneditableCloseToken()).toStrictEqual(uneditableCloseToken); + }); + }); + + describe('buildUneditableCloseTokens', () => { + it('returns a 2-item array of tokens with the originToken prepended to a close token', () => { + const result = buildUneditableCloseTokens(originToken); + + expect(result).toHaveLength(2); + expect(result).toStrictEqual(uneditableCloseTokens); + }); + }); + + describe('buildUneditableTokens', () => { + it('returns a 3-item array of tokens with the originToken wrapped in the middle of block tokens', () => { + const result = buildUneditableTokens(originToken); + + expect(result).toHaveLength(3); + expect(result).toStrictEqual(uneditableTokens); + }); + }); + + describe('buildUneditableInlineTokens', () => { + it('returns a 3-item array of tokens with the originInlineToken wrapped in the middle of inline tokens', () => { + const result = buildUneditableInlineTokens(originInlineToken); + + expect(result).toHaveLength(3); + expect(result).toStrictEqual(uneditableInlineTokens); + }); + }); + + describe('buildUneditableHtmlAsTextTokens', () => { + it('returns a 3-item array of tokens with the htmlBlockNode wrapped as a text token in the middle of block tokens', () => { + const htmlBlockNode = { + type: 'htmlBlock', + literal: '<div data-tomark-pass ><h1>Some header</h1><p>Some paragraph</p></div>', + }; + const result = buildUneditableHtmlAsTextTokens(htmlBlockNode); + const { type, content } = result[1]; + + expect(type).toBe('text'); + expect(content).not.toMatch(/ data-tomark-pass /); + + expect(result).toHaveLength(3); + expect(result).toStrictEqual(uneditableBlockTokens); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js new file mode 100644 index 00000000000..660c21281fd --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js @@ -0,0 +1,58 @@ +// Node spec helpers + +export const buildMockTextNode = literal => { + return { + firstChild: null, + literal, + type: 'text', + }; +}; + +export const normalTextNode = buildMockTextNode('This is just normal text.'); + +// Token spec helpers + +const buildMockUneditableOpenToken = type => { + return { + type: 'openTag', + tagName: type, + attributes: { contenteditable: false }, + classNames: [ + 'gl-px-4 gl-py-2 gl-my-5 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed', + ], + }; +}; + +const buildMockUneditableCloseToken = type => { + return { type: 'closeTag', tagName: type }; +}; + +export const originToken = { + type: 'text', + tagName: null, + content: '{:.no_toc .hidden-md .hidden-lg}', +}; +export const uneditableCloseToken = buildMockUneditableCloseToken('div'); +export const uneditableOpenTokens = [buildMockUneditableOpenToken('div'), originToken]; +export const uneditableCloseTokens = [originToken, uneditableCloseToken]; +export const uneditableTokens = [...uneditableOpenTokens, uneditableCloseToken]; + +export const originInlineToken = { + type: 'text', + content: '<i>Inline</i> content', +}; +export const uneditableInlineTokens = [ + buildMockUneditableOpenToken('a'), + originInlineToken, + buildMockUneditableCloseToken('a'), +]; + +export const uneditableBlockTokens = [ + buildMockUneditableOpenToken('div'), + { + type: 'text', + tagName: null, + content: '<div><h1>Some header</h1><p>Some paragraph</p></div>', + }, + buildMockUneditableCloseToken('div'), +]; diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_spec.js new file mode 100644 index 00000000000..b723ee8c8a0 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_spec.js @@ -0,0 +1,30 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text'; +import { buildUneditableTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; + +import { buildMockTextNode, normalTextNode } from './mock_data'; + +const embeddedRubyTextNode = buildMockTextNode('<%= partial("some/path") %>'); + +describe('Render Embedded Ruby Text renderer', () => { + describe('canRender', () => { + it('should return true when the argument `literal` has embedded ruby syntax', () => { + expect(renderer.canRender(embeddedRubyTextNode)).toBe(true); + }); + + it('should return false when the argument `literal` lacks embedded ruby syntax', () => { + expect(renderer.canRender(normalTextNode)).toBe(false); + }); + }); + + describe('render', () => { + const origin = jest.fn(); + + it('should return uneditable tokens', () => { + const context = { origin }; + + expect(renderer.render(embeddedRubyTextNode, context)).toStrictEqual( + buildUneditableTokens(origin()), + ); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js new file mode 100644 index 00000000000..d6bb01259bb --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js @@ -0,0 +1,33 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline'; +import { buildUneditableInlineTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; + +import { normalTextNode } from './mock_data'; + +const fontAwesomeInlineHtmlNode = { + firstChild: null, + literal: '<i class="far fa-paper-plane" id="biz-tech-icons">', + type: 'html', +}; + +describe('Render Font Awesome Inline HTML renderer', () => { + describe('canRender', () => { + it('should return true when the argument `literal` has font awesome inline html syntax', () => { + expect(renderer.canRender(fontAwesomeInlineHtmlNode)).toBe(true); + }); + + it('should return false when the argument `literal` lacks font awesome inline html syntax', () => { + expect(renderer.canRender(normalTextNode)).toBe(false); + }); + }); + + describe('render', () => { + it('should return uneditable inline tokens', () => { + const token = { type: 'text', tagName: null, content: fontAwesomeInlineHtmlNode.literal }; + const context = { origin: () => token }; + + expect(renderer.render(fontAwesomeInlineHtmlNode, context)).toStrictEqual( + buildUneditableInlineTokens(token), + ); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js new file mode 100644 index 00000000000..a6c712eeb31 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js @@ -0,0 +1,38 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_html_block'; +import { buildUneditableHtmlAsTextTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; + +import { normalTextNode } from './mock_data'; + +const htmlBlockNode = { + firstChild: null, + literal: '<div><h1>Heading</h1><p>Paragraph.</p></div>', + type: 'htmlBlock', +}; + +describe('Render HTML renderer', () => { + describe('canRender', () => { + it('should return true when the argument is an html block', () => { + expect(renderer.canRender(htmlBlockNode)).toBe(true); + }); + + it('should return false when the argument is not an html block', () => { + expect(renderer.canRender(normalTextNode)).toBe(false); + }); + }); + + describe('render', () => { + const htmlBlockNodeToMark = { + firstChild: null, + literal: '<div data-to-mark ></div>', + type: 'htmlBlock', + }; + + it.each` + node + ${htmlBlockNode} + ${htmlBlockNodeToMark} + `('should return uneditable tokens wrapping the $node as a token', ({ node }) => { + expect(renderer.render(node)).toStrictEqual(buildUneditableHtmlAsTextTokens(node)); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js new file mode 100644 index 00000000000..2897929f1bf --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js @@ -0,0 +1,55 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text'; +import { buildUneditableInlineTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; + +import { buildMockTextNode, normalTextNode } from './mock_data'; + +const mockTextStart = 'Majority example '; +const mockTextMiddle = '[environment terraform plans][terraform]'; +const mockTextEnd = '.'; +const identifierInstanceStartTextNode = buildMockTextNode(mockTextStart); +const identifierInstanceEndTextNode = buildMockTextNode(mockTextEnd); + +describe('Render Identifier Instance Text renderer', () => { + describe('canRender', () => { + it.each` + node | target + ${normalTextNode} | ${false} + ${identifierInstanceStartTextNode} | ${false} + ${identifierInstanceEndTextNode} | ${false} + ${buildMockTextNode(mockTextMiddle)} | ${true} + ${buildMockTextNode('Minority example [environment terraform plans][]')} | ${true} + ${buildMockTextNode('Minority example [environment terraform plans]')} | ${true} + `( + 'should return $target when the $node validates against identifier instance syntax', + ({ node, target }) => { + expect(renderer.canRender(node)).toBe(target); + }, + ); + }); + + describe('render', () => { + it.each` + start | middle | end + ${mockTextStart} | ${mockTextMiddle} | ${mockTextEnd} + ${mockTextStart} | ${'[environment terraform plans][]'} | ${mockTextEnd} + ${mockTextStart} | ${'[environment terraform plans]'} | ${mockTextEnd} + `( + 'should return inline editable, uneditable, and editable tokens in sequence', + ({ start, middle, end }) => { + const buildMockTextToken = content => ({ type: 'text', tagName: null, content }); + + const startToken = buildMockTextToken(start); + const middleToken = buildMockTextToken(middle); + const endToken = buildMockTextToken(end); + + const content = `${start}${middle}${end}`; + const contentToken = buildMockTextToken(content); + const contentNode = buildMockTextNode(content); + const context = { origin: jest.fn().mockReturnValueOnce(contentToken) }; + expect(renderer.render(contentNode, context)).toStrictEqual( + [startToken, buildUneditableInlineTokens(middleToken), endToken].flat(), + ); + }, + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js new file mode 100644 index 00000000000..320589e4de3 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js @@ -0,0 +1,65 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph'; +import { + buildUneditableOpenTokens, + buildUneditableCloseToken, +} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; + +import { buildMockTextNode } from './mock_data'; + +const buildMockParagraphNode = literal => { + return { + firstChild: buildMockTextNode(literal), + type: 'paragraph', + }; +}; + +const normalParagraphNode = buildMockParagraphNode( + 'This is just normal paragraph. It has multiple sentences.', +); +const identifierParagraphNode = buildMockParagraphNode( + `[another-identifier]: https://example.com "This example has a title" [identifier]: http://example1.com [this link]: http://example2.com`, +); + +describe('Render Identifier Paragraph renderer', () => { + describe('canRender', () => { + it.each` + node | paragraph | target + ${identifierParagraphNode} | ${'[Some text]: https://link.com'} | ${true} + ${normalParagraphNode} | ${'Normal non-identifier text. Another sentence.'} | ${false} + `( + 'should return $target when the $node matches $paragraph syntax', + ({ node, paragraph, target }) => { + const context = { + entering: true, + getChildrenText: jest.fn().mockReturnValueOnce(paragraph), + }; + + expect(renderer.canRender(node, context)).toBe(target); + }, + ); + }); + + describe('render', () => { + let origin; + + beforeEach(() => { + origin = jest.fn(); + }); + + it('should return uneditable open tokens when entering', () => { + const context = { entering: true, origin }; + + expect(renderer.render(identifierParagraphNode, context)).toStrictEqual( + buildUneditableOpenTokens(origin()), + ); + }); + + it('should return an uneditable close tokens when exiting', () => { + const context = { entering: false, origin }; + + expect(renderer.render(identifierParagraphNode, context)).toStrictEqual( + buildUneditableCloseToken(origin()), + ); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js new file mode 100644 index 00000000000..e60bf1c8c92 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js @@ -0,0 +1,55 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list'; +import { + buildUneditableOpenTokens, + buildUneditableCloseToken, +} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; + +import { buildMockTextNode } from './mock_data'; + +const buildMockListNode = literal => { + return { + firstChild: { + firstChild: { + firstChild: buildMockTextNode(literal), + type: 'paragraph', + }, + type: 'item', + }, + type: 'list', + }; +}; + +const normalListNode = buildMockListNode('Just another bullet point'); +const kramdownListNode = buildMockListNode('TOC'); + +describe('Render Kramdown List renderer', () => { + describe('canRender', () => { + it('should return true when the argument is a special kramdown TOC ordered/unordered list', () => { + expect(renderer.canRender(kramdownListNode)).toBe(true); + }); + + it('should return false when the argument is a normal ordered/unordered list', () => { + expect(renderer.canRender(normalListNode)).toBe(false); + }); + }); + + describe('render', () => { + const origin = jest.fn(); + + it('should return uneditable open tokens when entering', () => { + const context = { entering: true, origin }; + + expect(renderer.render(kramdownListNode, context)).toStrictEqual( + buildUneditableOpenTokens(origin()), + ); + }); + + it('should return an uneditable close tokens when exiting', () => { + const context = { entering: false, origin }; + + expect(renderer.render(kramdownListNode, context)).toStrictEqual( + buildUneditableCloseToken(origin()), + ); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text_spec.js new file mode 100644 index 00000000000..97ff9794e69 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text_spec.js @@ -0,0 +1,30 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text'; +import { buildUneditableTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; + +import { buildMockTextNode, normalTextNode } from './mock_data'; + +const kramdownTextNode = buildMockTextNode('{:toc}'); + +describe('Render Kramdown Text renderer', () => { + describe('canRender', () => { + it('should return true when the argument `literal` has kramdown syntax', () => { + expect(renderer.canRender(kramdownTextNode)).toBe(true); + }); + + it('should return false when the argument `literal` lacks kramdown syntax', () => { + expect(renderer.canRender(normalTextNode)).toBe(false); + }); + }); + + describe('render', () => { + const origin = jest.fn(); + + it('should return uneditable tokens', () => { + const context = { origin }; + + expect(renderer.render(kramdownTextNode, context)).toStrictEqual( + buildUneditableTokens(origin()), + ); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js index d02d924bd2b..79851e5db05 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js @@ -82,10 +82,9 @@ describe('DropdownButtonComponent', () => { }); it('renders dropdown button icon', () => { - const dropdownIconEl = vm.$el.querySelector('i.fa'); + const dropdownIconEl = vm.$el.querySelector('.dropdown-menu-toggle .gl-icon'); expect(dropdownIconEl).not.toBeNull(); - expect(dropdownIconEl.classList.contains('fa-chevron-down')).toBe(true); }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js index 035af946d75..510e537b1cd 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js @@ -29,13 +29,11 @@ describe('DropdownSearchInputComponent', () => { }); it('renders search icon element', () => { - expect(vm.$el.querySelector('.fa-search.dropdown-input-search')).not.toBeNull(); + expect(vm.$el.querySelector('.dropdown-input-search')).not.toBeNull(); }); it('renders clear search icon element', () => { - expect( - vm.$el.querySelector('.fa-times.dropdown-input-clear.js-dropdown-input-clear'), - ).not.toBeNull(); + expect(vm.$el.querySelector('.dropdown-input-clear.js-dropdown-input-clear')).not.toBeNull(); }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js index 214eb239432..68c9d26bb1a 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js @@ -1,18 +1,19 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlButton } from '@gitlab/ui'; import DropdownButton from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue'; import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; import { mockConfig } from './mock_data'; +let store; const localVue = createLocalVue(); localVue.use(Vuex); const createComponent = (initialState = mockConfig) => { - const store = new Vuex.Store(labelSelectModule()); + store = new Vuex.Store(labelSelectModule()); store.dispatch('setInitialState', initialState); @@ -33,26 +34,32 @@ describe('DropdownButton', () => { wrapper.destroy(); }); + const findDropdownButton = () => wrapper.find(GlButton); + const findDropdownText = () => wrapper.find('.dropdown-toggle-text'); + const findDropdownIcon = () => wrapper.find(GlIcon); + describe('methods', () => { describe('handleButtonClick', () => { - it('calls action `toggleDropdownContents` and stops event propagation when `state.variant` is "standalone"', () => { - const event = { - stopPropagation: jest.fn(), - }; - wrapper = createComponent({ - ...mockConfig, - variant: 'standalone', - }); - - jest.spyOn(wrapper.vm, 'toggleDropdownContents'); - - wrapper.vm.handleButtonClick(event); - - expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled(); - expect(event.stopPropagation).toHaveBeenCalled(); - - wrapper.destroy(); - }); + it.each` + variant + ${'standalone'} + ${'embedded'} + `( + 'toggles dropdown content and stops event propagation when `state.variant` is "$variant"', + ({ variant }) => { + const event = { stopPropagation: jest.fn() }; + + wrapper = createComponent({ + ...mockConfig, + variant, + }); + + findDropdownButton().vm.$emit('click', event); + + expect(store.state.showDropdownContents).toBe(true); + expect(event.stopPropagation).toHaveBeenCalled(); + }, + ); }); }); @@ -61,15 +68,24 @@ describe('DropdownButton', () => { expect(wrapper.is('gl-button-stub')).toBe(true); }); - it('renders button text element', () => { - const dropdownTextEl = wrapper.find('.dropdown-toggle-text'); + it('renders default button text element', () => { + const dropdownTextEl = findDropdownText(); expect(dropdownTextEl.exists()).toBe(true); expect(dropdownTextEl.text()).toBe('Label'); }); + it('renders provided button text element', () => { + store.state.dropdownButtonText = 'Custom label'; + const dropdownTextEl = findDropdownText(); + + return wrapper.vm.$nextTick().then(() => { + expect(dropdownTextEl.text()).toBe('Custom label'); + }); + }); + it('renders chevron icon element', () => { - const iconEl = wrapper.find(GlIcon); + const iconEl = findDropdownIcon(); expect(iconEl.exists()).toBe(true); expect(iconEl.props('name')).toBe('chevron-down'); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js index 1504e1521d3..9b01e0b9637 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -44,6 +44,7 @@ const createComponent = (initialState = mockConfig) => { describe('DropdownContentsLabelsView', () => { let wrapper; let wrapperStandalone; + let wrapperEmbedded; beforeEach(() => { wrapper = createComponent(); @@ -51,11 +52,16 @@ describe('DropdownContentsLabelsView', () => { ...mockConfig, variant: 'standalone', }); + wrapperEmbedded = createComponent({ + ...mockConfig, + variant: 'embedded', + }); }); afterEach(() => { wrapper.destroy(); wrapperStandalone.destroy(); + wrapperEmbedded.destroy(); }); describe('computed', () => { @@ -211,6 +217,10 @@ describe('DropdownContentsLabelsView', () => { expect(wrapperStandalone.find('.dropdown-title').exists()).toBe(false); }); + it('renders dropdown title element when `state.variant` is "embedded"', () => { + expect(wrapperEmbedded.find('.dropdown-title').exists()).toBe(true); + }); + it('renders dropdown close button element', () => { const closeButtonEl = wrapper.find('.dropdown-title').find(GlButton); @@ -291,5 +301,9 @@ describe('DropdownContentsLabelsView', () => { it('does not render footer list items when `state.variant` is "standalone"', () => { expect(wrapperStandalone.find('.dropdown-footer').exists()).toBe(false); }); + + it('renders footer list items when `state.variant` is "embedded"', () => { + expect(wrapperEmbedded.find('.dropdown-footer').exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js index ee4e9090e5d..6e97b046be2 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js @@ -89,18 +89,23 @@ describe('LabelsSelectRoot', () => { expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative'); }); - it('renders component root element with CSS class `is-standalone` when `state.variant` is "standalone"', () => { - const wrapperStandalone = createComponent({ - ...mockConfig, - variant: 'standalone', - }); - - return wrapperStandalone.vm.$nextTick(() => { - expect(wrapperStandalone.classes()).toContain('is-standalone'); - - wrapperStandalone.destroy(); - }); - }); + it.each` + variant | cssClass + ${'standalone'} | ${'is-standalone'} + ${'embedded'} | ${'is-embedded'} + `( + 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"', + ({ variant, cssClass }) => { + wrapper = createComponent({ + ...mockConfig, + variant, + }); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.classes()).toContain(cssClass); + }); + }, + ); it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', () => { expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js index b866117efcf..52116f757c5 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js @@ -2,13 +2,20 @@ import * as getters from '~/vue_shared/components/sidebar/labels_select_vue/stor describe('LabelsSelect Getters', () => { describe('dropdownButtonText', () => { - it('returns string "Label" when state.labels has no selected labels', () => { - const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - - expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe( - 'Label', - ); - }); + it.each` + labelType | dropdownButtonText | expected + ${'default'} | ${''} | ${'Label'} + ${'custom'} | ${'Custom label'} | ${'Custom label'} + `( + 'returns $labelType text when state.labels has no selected labels', + ({ dropdownButtonText, expected }) => { + const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + const selectedLabels = []; + const state = { labels, selectedLabels, dropdownButtonText }; + + expect(getters.dropdownButtonText(state, {})).toBe(expected); + }, + ); it('returns label title when state.labels has only 1 label', () => { const labels = [{ id: 1, title: 'Foobar', set: true }]; diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js index 2c7fce714f0..a4ff6ac0c16 100644 --- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js @@ -4,7 +4,6 @@ import UserPopover from '~/vue_shared/components/user_popover/user_popover.vue'; import Icon from '~/vue_shared/components/icon.vue'; const DEFAULT_PROPS = { - loaded: true, user: { username: 'root', name: 'Administrator', @@ -12,6 +11,7 @@ const DEFAULT_PROPS = { bio: null, workInformation: null, status: null, + loaded: true, }, }; @@ -46,28 +46,21 @@ describe('User Popover Component', () => { }); }; - describe('Empty', () => { - beforeEach(() => { - createWrapper( - {}, - { - propsData: { - target: findTarget(), - user: { - name: null, - username: null, - location: null, - bio: null, - workInformation: null, - status: null, - }, - }, + describe('when user is loading', () => { + it('displays skeleton loaders', () => { + createWrapper({ + user: { + name: null, + username: null, + location: null, + bio: null, + workInformation: null, + status: null, + loaded: false, }, - ); - }); + }); - it('should return skeleton loaders', () => { - expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true); + expect(wrapper.findAll(GlSkeletonLoading)).toHaveLength(4); }); }); @@ -90,9 +83,10 @@ describe('User Popover Component', () => { describe('job data', () => { const findWorkInformation = () => wrapper.find({ ref: 'workInformation' }); const findBio = () => wrapper.find({ ref: 'bio' }); + const bio = 'My super interesting bio'; it('should show only bio if work information is not available', () => { - const user = { ...DEFAULT_PROPS.user, bio: 'My super interesting bio' }; + const user = { ...DEFAULT_PROPS.user, bio, bioHtml: bio }; createWrapper({ user }); @@ -114,7 +108,8 @@ describe('User Popover Component', () => { it('should display bio and work information in separate lines', () => { const user = { ...DEFAULT_PROPS.user, - bio: 'My super interesting bio', + bio, + bioHtml: bio, workInformation: 'Frontend Engineer at GitLab', }; @@ -127,12 +122,13 @@ describe('User Popover Component', () => { it('should not encode special characters in bio', () => { const user = { ...DEFAULT_PROPS.user, - bio: 'I like <html> & CSS', + bio: 'I like CSS', + bioHtml: 'I like <b>CSS</b>', }; createWrapper({ user }); - expect(findBio().text()).toBe('I like <html> & CSS'); + expect(findBio().html()).toContain('I like <b>CSS</b>'); }); it('shows icon for bio', () => { diff --git a/spec/frontend/wikis_spec.js b/spec/frontend/wikis_spec.js index 8c68edafd16..3469be4da1c 100644 --- a/spec/frontend/wikis_spec.js +++ b/spec/frontend/wikis_spec.js @@ -1,4 +1,6 @@ +import { escape } from 'lodash'; import Wikis from '~/pages/shared/wikis/wikis'; +import Tracking from '~/tracking'; import { setHTMLFixture } from './helpers/fixtures'; describe('Wikis', () => { @@ -122,4 +124,32 @@ describe('Wikis', () => { }); }); }); + + describe('trackPageView', () => { + const trackingPage = 'projects:wikis:show'; + const trackingContext = { foo: 'bar' }; + const showPageHtmlFixture = ` + <div class="js-wiki-page-content" data-tracking-context="${escape( + JSON.stringify(trackingContext), + )}"></div> + `; + + beforeEach(() => { + setHTMLFixture(showPageHtmlFixture); + document.body.dataset.page = trackingPage; + jest.spyOn(Tracking, 'event').mockImplementation(); + + Wikis.trackPageView(); + }); + + it('sends the tracking event and context', () => { + expect(Tracking.event).toHaveBeenCalledWith(trackingPage, 'view_wiki_page', { + label: 'view_wiki_page', + context: { + schema: 'iglu:com.gitlab/wiki_page_context/jsonschema/1-0-0', + data: trackingContext, + }, + }); + }); + }); }); |