diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-05-20 14:34:42 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-05-20 14:34:42 +0000 |
commit | 9f46488805e86b1bc341ea1620b866016c2ce5ed (patch) | |
tree | f9748c7e287041e37d6da49e0a29c9511dc34768 /spec/frontend | |
parent | dfc92d081ea0332d69c8aca2f0e745cb48ae5e6d (diff) | |
download | gitlab-ce-9f46488805e86b1bc341ea1620b866016c2ce5ed.tar.gz |
Add latest changes from gitlab-org/gitlab@13-0-stable-ee
Diffstat (limited to 'spec/frontend')
500 files changed, 44257 insertions, 2188 deletions
diff --git a/spec/frontend/.eslintrc.yml b/spec/frontend/.eslintrc.yml index c8aacca5ef2..b9159191114 100644 --- a/spec/frontend/.eslintrc.yml +++ b/spec/frontend/.eslintrc.yml @@ -1,10 +1,6 @@ --- -env: - jest/globals: true -plugins: - - jest extends: - - 'plugin:jest/recommended' + - 'plugin:@gitlab/jest' settings: # We have to teach eslint-plugin-import what node modules we use # otherwise there is an error when it tries to resolve them @@ -14,9 +10,18 @@ settings: - path import/resolver: jest: - jestConfigFile: 'jest.config.js' + jestConfigFile: 'jest.config.unit.js' globals: getJSONFixture: false loadFixtures: false preloadFixtures: false setFixtures: false +rules: + jest/expect-expect: + - off + - assertFunctionNames: + - 'expect*' + - 'assert*' + - 'testAction' + jest/no-test-callback: + - off diff --git a/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js b/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js new file mode 100644 index 00000000000..726ed0fa030 --- /dev/null +++ b/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js @@ -0,0 +1,29 @@ +export const Editor = { + props: { + initialValue: { + type: String, + required: true, + }, + options: { + type: Object, + }, + initialEditType: { + type: String, + }, + height: { + type: String, + }, + previewStyle: { + type: String, + }, + }, + render(h) { + return h('div'); + }, +}; + +export const Viewer = { + render(h) { + return h('div'); + }, +}; diff --git a/spec/frontend/ajax_loading_spinner_spec.js b/spec/frontend/ajax_loading_spinner_spec.js new file mode 100644 index 00000000000..8ed2ee49ff8 --- /dev/null +++ b/spec/frontend/ajax_loading_spinner_spec.js @@ -0,0 +1,57 @@ +import $ from 'jquery'; +import AjaxLoadingSpinner from '~/ajax_loading_spinner'; + +describe('Ajax Loading Spinner', () => { + const fixtureTemplate = 'static/ajax_loading_spinner.html'; + preloadFixtures(fixtureTemplate); + + beforeEach(() => { + loadFixtures(fixtureTemplate); + AjaxLoadingSpinner.init(); + }); + + it('change current icon with spinner icon and disable link while waiting ajax response', done => { + jest.spyOn($, 'ajax').mockImplementation(req => { + const xhr = new XMLHttpRequest(); + const ajaxLoadingSpinner = document.querySelector('.js-ajax-loading-spinner'); + const icon = ajaxLoadingSpinner.querySelector('i'); + + req.beforeSend(xhr, { dataType: 'text/html' }); + + expect(icon).not.toHaveClass('fa-trash-o'); + expect(icon).toHaveClass('fa-spinner'); + expect(icon).toHaveClass('fa-spin'); + expect(icon.dataset.icon).toEqual('fa-trash-o'); + expect(ajaxLoadingSpinner.getAttribute('disabled')).toEqual(''); + + req.complete({}); + + done(); + const deferred = $.Deferred(); + return deferred.promise(); + }); + document.querySelector('.js-ajax-loading-spinner').click(); + }); + + it('use original icon again and enabled the link after complete the ajax request', done => { + jest.spyOn($, 'ajax').mockImplementation(req => { + const xhr = new XMLHttpRequest(); + const ajaxLoadingSpinner = document.querySelector('.js-ajax-loading-spinner'); + + req.beforeSend(xhr, { dataType: 'text/html' }); + req.complete({}); + + const icon = ajaxLoadingSpinner.querySelector('i'); + + expect(icon).toHaveClass('fa-trash-o'); + expect(icon).not.toHaveClass('fa-spinner'); + expect(icon).not.toHaveClass('fa-spin'); + expect(ajaxLoadingSpinner.getAttribute('disabled')).toEqual(null); + + done(); + const deferred = $.Deferred(); + return deferred.promise(); + }); + document.querySelector('.js-ajax-loading-spinner').click(); + }); +}); diff --git a/spec/frontend/alert_management/components/alert_management_detail_spec.js b/spec/frontend/alert_management/components/alert_management_detail_spec.js new file mode 100644 index 00000000000..1e4c2e24ccb --- /dev/null +++ b/spec/frontend/alert_management/components/alert_management_detail_spec.js @@ -0,0 +1,242 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import { GlAlert, GlLoadingIcon, GlDropdownItem, GlTable } from '@gitlab/ui'; +import AlertDetails from '~/alert_management/components/alert_details.vue'; +import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.graphql'; +import createFlash from '~/flash'; + +import mockAlerts from '../mocks/alerts.json'; + +const mockAlert = mockAlerts[0]; +jest.mock('~/flash'); + +describe('AlertDetails', () => { + let wrapper; + const newIssuePath = 'root/alerts/-/issues/new'; + const findStatusDropdownItem = () => wrapper.find(GlDropdownItem); + const findDetailsTable = () => wrapper.find(GlTable); + + function mountComponent({ + data, + createIssueFromAlertEnabled = false, + loading = false, + mountMethod = shallowMount, + stubs = {}, + } = {}) { + wrapper = mountMethod(AlertDetails, { + propsData: { + alertId: 'alertId', + projectPath: 'projectPath', + newIssuePath, + }, + data() { + return { alert: { ...mockAlert }, ...data }; + }, + provide: { + glFeatures: { createIssueFromAlertEnabled }, + }, + mocks: { + $apollo: { + mutate: jest.fn(), + queries: { + alert: { + loading, + }, + }, + }, + }, + stubs, + }); + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + const findCreatedIssueBtn = () => wrapper.find('[data-testid="createIssueBtn"]'); + + describe('Alert details', () => { + describe('when alert is null', () => { + beforeEach(() => { + mountComponent({ data: { alert: null } }); + }); + + it('shows an empty state', () => { + expect(wrapper.find('[data-testid="alertDetailsTabs"]').exists()).toBe(false); + }); + }); + + describe('when alert is present', () => { + beforeEach(() => { + mountComponent({ data: { alert: mockAlert } }); + }); + + it('renders a tab with overview information', () => { + expect(wrapper.find('[data-testid="overviewTab"]').exists()).toBe(true); + }); + + it('renders a tab with full alert information', () => { + expect(wrapper.find('[data-testid="fullDetailsTab"]').exists()).toBe(true); + }); + + it('renders a title', () => { + expect(wrapper.find('[data-testid="title"]').text()).toBe(mockAlert.title); + }); + + it('renders a start time', () => { + expect(wrapper.find('[data-testid="startTimeItem"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="startTimeItem"]').props().time).toBe( + mockAlert.startedAt, + ); + }); + }); + + describe('individual alert fields', () => { + describe.each` + field | data | isShown + ${'eventCount'} | ${1} | ${true} + ${'eventCount'} | ${undefined} | ${false} + ${'monitoringTool'} | ${'New Relic'} | ${true} + ${'monitoringTool'} | ${undefined} | ${false} + ${'service'} | ${'Prometheus'} | ${true} + ${'service'} | ${undefined} | ${false} + `(`$desc`, ({ field, data, isShown }) => { + beforeEach(() => { + mountComponent({ data: { alert: { ...mockAlert, [field]: data } } }); + }); + + it(`${field} is ${isShown ? 'displayed' : 'hidden'} correctly`, () => { + if (isShown) { + expect(wrapper.find(`[data-testid="${field}"]`).text()).toBe(data.toString()); + } else { + expect(wrapper.find(`[data-testid="${field}"]`).exists()).toBe(false); + } + }); + }); + }); + + describe('Create issue from alert', () => { + describe('createIssueFromAlertEnabled feature flag enabled', () => { + it('should display a button that links to new issue page', () => { + mountComponent({ createIssueFromAlertEnabled: true }); + expect(findCreatedIssueBtn().exists()).toBe(true); + expect(findCreatedIssueBtn().attributes('href')).toBe(newIssuePath); + }); + }); + + describe('createIssueFromAlertEnabled feature flag disabled', () => { + it('should display a button that links to a new issue page', () => { + mountComponent({ createIssueFromAlertEnabled: false }); + expect(findCreatedIssueBtn().exists()).toBe(false); + }); + }); + }); + + describe('View full alert details', () => { + beforeEach(() => { + mountComponent({ data: { alert: mockAlert } }); + }); + it('should display a table of raw alert details data', () => { + wrapper.find('[data-testid="fullDetailsTab"]').trigger('click'); + expect(findDetailsTable().exists()).toBe(true); + }); + }); + + describe('loading state', () => { + beforeEach(() => { + mountComponent({ loading: true }); + }); + + it('displays a loading state when loading', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + }); + + describe('error state', () => { + it('displays a error state correctly', () => { + mountComponent({ data: { errored: true } }); + expect(wrapper.find(GlAlert).exists()).toBe(true); + }); + + it('does not display an error when dismissed', () => { + mountComponent({ data: { errored: true, isErrorDismissed: true } }); + expect(wrapper.find(GlAlert).exists()).toBe(false); + }); + }); + + describe('header', () => { + const findHeader = () => wrapper.find('[data-testid="alert-header"]'); + const stubs = { TimeAgoTooltip: '<span>now</span>' }; + + describe('individual header fields', () => { + describe.each` + severity | createdAt | monitoringTool | result + ${'MEDIUM'} | ${'2020-04-17T23:18:14.996Z'} | ${null} | ${'Medium • Reported now'} + ${'INFO'} | ${'2020-04-17T23:18:14.996Z'} | ${'Datadog'} | ${'Info • Reported now by Datadog'} + `( + `When severity=$severity, createdAt=$createdAt, monitoringTool=$monitoringTool`, + ({ severity, createdAt, monitoringTool, result }) => { + beforeEach(() => { + mountComponent({ + data: { alert: { ...mockAlert, severity, createdAt, monitoringTool } }, + mountMethod: mount, + stubs, + }); + }); + + it('header text is shown correctly', () => { + expect(findHeader().text()).toBe(result); + }); + }, + ); + }); + }); + }); + + describe('updating the alert status', () => { + const mockUpdatedMutationResult = { + data: { + updateAlertStatus: { + errors: [], + alert: { + status: 'acknowledged', + }, + }, + }, + }; + + beforeEach(() => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alert: mockAlert }, + 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: 'alertId', + status: 'TRIGGERED', + projectPath: 'projectPath', + }, + }); + }); + + it('calls `createFlash` when request fails', () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); + findStatusDropdownItem().vm.$emit('click'); + + setImmediate(() => { + expect(createFlash).toHaveBeenCalledWith( + 'There was an error while updating the status of the alert. Please try again.', + ); + }); + }); + }); +}); diff --git a/spec/frontend/alert_management/components/alert_management_list_spec.js b/spec/frontend/alert_management/components/alert_management_list_spec.js new file mode 100644 index 00000000000..c4630ac57fe --- /dev/null +++ b/spec/frontend/alert_management/components/alert_management_list_spec.js @@ -0,0 +1,325 @@ +import { mount } from '@vue/test-utils'; +import { + GlEmptyState, + GlTable, + GlAlert, + GlLoadingIcon, + GlDropdown, + GlDropdownItem, + GlIcon, + GlTab, +} 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 } from '../../../../app/assets/javascripts/alert_management/constants'; +import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.graphql'; +import mockAlerts from '../mocks/alerts.json'; + +jest.mock('~/flash'); + +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn().mockName('visitUrlMock'), + joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths, +})); + +describe('AlertManagementList', () => { + let wrapper; + + const findAlertsTable = () => wrapper.find(GlTable); + const findAlerts = () => wrapper.findAll('table tbody tr'); + const findAlert = () => wrapper.find(GlAlert); + const findLoader = () => wrapper.find(GlLoadingIcon); + const findStatusDropdown = () => wrapper.find(GlDropdown); + const findStatusFilterTabs = () => wrapper.findAll(GlTab); + const findDateFields = () => wrapper.findAll(TimeAgo); + const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem); + const findSeverityFields = () => wrapper.findAll('[data-testid="severityField"]'); + + function mountComponent({ + props = { + alertManagementEnabled: false, + userCanEnableAlertManagement: false, + }, + data = {}, + loading = false, + alertListStatusFilteringEnabled = false, + stubs = {}, + } = {}) { + wrapper = mount(AlertManagementList, { + propsData: { + projectPath: 'gitlab-org/gitlab', + enableAlertManagementPath: '/link', + emptyAlertSvgPath: 'illustration/path', + ...props, + }, + provide: { + glFeatures: { + alertListStatusFilteringEnabled, + }, + }, + data() { + return data; + }, + mocks: { + $apollo: { + mutate: jest.fn(), + queries: { + alerts: { + loading, + }, + }, + }, + }, + stubs, + }); + } + + beforeEach(() => { + mountComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('alert management feature renders empty state', () => { + it('shows empty state', () => { + expect(wrapper.find(GlEmptyState).exists()).toBe(true); + }); + }); + + describe('Status Filter Tabs', () => { + describe('alertListStatusFilteringEnabled feature flag enabled', () => { + beforeEach(() => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: mockAlerts }, + loading: false, + alertListStatusFilteringEnabled: true, + stubs: { + GlTab: true, + }, + }); + }); + + it('should display filter tabs for all statuses', () => { + const tabs = findStatusFilterTabs().wrappers; + tabs.forEach((tab, i) => { + expect(tab.text()).toContain(ALERTS_STATUS_TABS[i].title); + }); + }); + }); + + describe('alertListStatusFilteringEnabled feature flag disabled', () => { + beforeEach(() => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: mockAlerts }, + loading: false, + alertListStatusFilteringEnabled: false, + stubs: { + GlTab: true, + }, + }); + }); + + it('should NOT display tabs', () => { + expect(findStatusFilterTabs()).not.toExist(); + }); + }); + }); + + describe('Alerts table', () => { + it('loading state', () => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: null }, + loading: true, + }); + expect(findAlertsTable().exists()).toBe(true); + expect(findLoader().exists()).toBe(true); + }); + + it('error state', () => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: null, errored: true }, + loading: false, + }); + expect(findAlertsTable().exists()).toBe(true); + expect(findAlertsTable().text()).toContain('No alerts to display'); + expect(findLoader().exists()).toBe(false); + expect(findAlert().props().variant).toBe('danger'); + }); + + it('empty state', () => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: [], errored: false }, + loading: false, + }); + expect(findAlertsTable().exists()).toBe(true); + expect(findAlertsTable().text()).toContain('No alerts to display'); + expect(findLoader().exists()).toBe(false); + expect(findAlert().props().variant).toBe('info'); + }); + + it('has data state', () => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: mockAlerts, errored: false }, + loading: false, + }); + expect(findLoader().exists()).toBe(false); + expect(findAlertsTable().exists()).toBe(true); + expect(findAlerts()).toHaveLength(mockAlerts.length); + }); + + it('displays status dropdown', () => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: mockAlerts, errored: false }, + loading: false, + }); + expect(findStatusDropdown().exists()).toBe(true); + }); + + it('shows correct severity icons', () => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: mockAlerts, errored: false }, + loading: false, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(GlTable).exists()).toBe(true); + expect( + findAlertsTable() + .find(GlIcon) + .classes('icon-critical'), + ).toBe(true); + }); + }); + + it('renders severity text', () => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: mockAlerts, errored: false }, + loading: false, + }); + + expect( + findSeverityFields() + .at(0) + .text(), + ).toBe('Critical'); + }); + + it('navigates to the detail page when alert row is clicked', () => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: mockAlerts, errored: false }, + loading: false, + }); + + findAlerts() + .at(0) + .trigger('click'); + expect(visitUrl).toHaveBeenCalledWith('/1527542/details'); + }); + + describe('handle date fields', () => { + it('should display time ago dates when values provided', () => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { + alerts: [ + { + iid: 1, + status: 'acknowledged', + startedAt: '2020-03-17T23:18:14.996Z', + endedAt: '2020-04-17T23:18:14.996Z', + severity: 'high', + }, + ], + errored: false, + }, + loading: false, + }); + expect(findDateFields().length).toBe(2); + }); + + it('should not display time ago dates when values not provided', () => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { + alerts: [ + { + iid: 1, + status: 'acknowledged', + startedAt: null, + endedAt: null, + severity: 'high', + }, + ], + errored: false, + }, + loading: false, + }); + expect(findDateFields().exists()).toBe(false); + }); + }); + }); + + describe('updating the alert status', () => { + const iid = '1527542'; + const mockUpdatedMutationResult = { + data: { + updateAlertStatus: { + errors: [], + alert: { + iid, + status: 'acknowledged', + }, + }, + }, + }; + + beforeEach(() => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: mockAlerts, errored: false }, + loading: false, + }); + }); + + it('calls `$apollo.mutate` with `updateAlertStatus` mutation and variables containing `iid`, `status`, & `projectPath`', () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); + findFirstStatusOption().vm.$emit('click'); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: updateAlertStatus, + variables: { + iid, + status: 'TRIGGERED', + projectPath: 'gitlab-org/gitlab', + }, + }); + }); + + it('calls `createFlash` when request fails', () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); + findFirstStatusOption().vm.$emit('click'); + + setImmediate(() => { + expect(createFlash).toHaveBeenCalledWith( + 'There was an error while updating the status of the alert. Please try again.', + ); + }); + }); + }); +}); diff --git a/spec/frontend/alert_management/mocks/alerts.json b/spec/frontend/alert_management/mocks/alerts.json new file mode 100644 index 00000000000..b67e2cfc52e --- /dev/null +++ b/spec/frontend/alert_management/mocks/alerts.json @@ -0,0 +1,29 @@ +[ + { + "iid": "1527542", + "title": "SyntaxError: Invalid or unexpected token", + "severity": "CRITICAL", + "eventCount": 7, + "startedAt": "2020-04-17T23:18:14.996Z", + "endedAt": "2020-04-17T23:18:14.996Z", + "status": "TRIGGERED" + }, + { + "iid": "1527543", + "title": "Some other alert Some other alert Some other alert Some other alert Some other alert Some other alert", + "severity": "MEDIUM", + "eventCount": 1, + "startedAt": "2020-04-17T23:18:14.996Z", + "endedAt": "2020-04-17T23:18:14.996Z", + "status": "ACKNOWLEDGED" + }, + { + "iid": "1527544", + "title": "SyntaxError: Invalid or unexpected token", + "severity": "LOW", + "eventCount": 4, + "startedAt": "2020-04-17T23:18:14.996Z", + "endedAt": "2020-04-17T23:18:14.996Z", + "status": "RESOLVED" + } + ] diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index f34c2fb69eb..d365048ab0b 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -15,7 +15,7 @@ describe('Api', () => { beforeEach(() => { mock = new MockAdapter(axios); originalGon = window.gon; - window.gon = Object.assign({}, dummyGon); + window.gon = { ...dummyGon }; }); afterEach(() => { diff --git a/spec/frontend/autosave_spec.js b/spec/frontend/autosave_spec.js index 3119477f385..bbdf3c6f91d 100644 --- a/spec/frontend/autosave_spec.js +++ b/spec/frontend/autosave_spec.js @@ -10,6 +10,8 @@ describe('Autosave', () => { const field = $('<textarea></textarea>'); const key = 'key'; const fallbackKey = 'fallbackKey'; + const lockVersionKey = 'lockVersionKey'; + const lockVersion = 1; describe('class constructor', () => { beforeEach(() => { @@ -30,6 +32,13 @@ describe('Autosave', () => { expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); expect(autosave.isLocalStorageAvailable).toBe(true); }); + + it('should set .isLocalStorageAvailable if lockVersion is passed', () => { + autosave = new Autosave(field, key, null, lockVersion); + + expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + expect(autosave.isLocalStorageAvailable).toBe(true); + }); }); describe('restore', () => { @@ -96,6 +105,40 @@ describe('Autosave', () => { }); }); + describe('getSavedLockVersion', () => { + beforeEach(() => { + autosave = { + field, + key, + lockVersionKey, + }; + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = false; + + Autosave.prototype.getSavedLockVersion.call(autosave); + }); + + it('should not call .getItem', () => { + expect(window.localStorage.getItem).not.toHaveBeenCalled(); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = true; + }); + + it('should call .getItem', () => { + Autosave.prototype.getSavedLockVersion.call(autosave); + + expect(window.localStorage.getItem).toHaveBeenCalledWith(lockVersionKey); + }); + }); + }); + describe('save', () => { beforeEach(() => { autosave = { reset: jest.fn() }; @@ -128,10 +171,51 @@ describe('Autosave', () => { }); }); + describe('save with lockVersion', () => { + beforeEach(() => { + autosave = { + field, + key, + lockVersionKey, + lockVersion, + isLocalStorageAvailable: true, + }; + }); + + describe('lockVersion is valid', () => { + it('should call .setItem', () => { + Autosave.prototype.save.call(autosave); + expect(window.localStorage.setItem).toHaveBeenCalledWith(lockVersionKey, lockVersion); + }); + + it('should call .setItem when version is 0', () => { + autosave.lockVersion = 0; + Autosave.prototype.save.call(autosave); + expect(window.localStorage.setItem).toHaveBeenCalledWith( + lockVersionKey, + autosave.lockVersion, + ); + }); + }); + + describe('lockVersion is invalid', () => { + it('should not call .setItem with lockVersion', () => { + delete autosave.lockVersion; + Autosave.prototype.save.call(autosave); + + expect(window.localStorage.setItem).not.toHaveBeenCalledWith( + lockVersionKey, + autosave.lockVersion, + ); + }); + }); + }); + describe('reset', () => { beforeEach(() => { autosave = { key, + lockVersionKey, }; }); @@ -156,6 +240,7 @@ describe('Autosave', () => { it('should call .removeItem', () => { expect(window.localStorage.removeItem).toHaveBeenCalledWith(key); + expect(window.localStorage.removeItem).toHaveBeenCalledWith(lockVersionKey); }); }); }); @@ -166,8 +251,8 @@ describe('Autosave', () => { field, key, fallbackKey, + isLocalStorageAvailable: true, }; - autosave.isLocalStorageAvailable = true; }); it('should call .getItem', () => { @@ -185,7 +270,8 @@ describe('Autosave', () => { it('should call .removeItem for key and fallbackKey', () => { Autosave.prototype.reset.call(autosave); - expect(window.localStorage.removeItem).toHaveBeenCalledTimes(2); + expect(window.localStorage.removeItem).toHaveBeenCalledWith(fallbackKey); + expect(window.localStorage.removeItem).toHaveBeenCalledWith(key); }); }); }); diff --git a/spec/frontend/avatar_helper_spec.js b/spec/frontend/avatar_helper_spec.js new file mode 100644 index 00000000000..c4da7189751 --- /dev/null +++ b/spec/frontend/avatar_helper_spec.js @@ -0,0 +1,110 @@ +import { TEST_HOST } from 'spec/test_constants'; +import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility'; +import { + DEFAULT_SIZE_CLASS, + IDENTICON_BG_COUNT, + renderAvatar, + renderIdenticon, + getIdenticonBackgroundClass, + getIdenticonTitle, +} from '~/helpers/avatar_helper'; + +function matchAll(str) { + return new RegExp(`^${str}$`); +} + +describe('avatar_helper', () => { + describe('getIdenticonBackgroundClass', () => { + it('returns identicon bg class from id that is a number', () => { + expect(getIdenticonBackgroundClass(1)).toEqual('bg2'); + }); + + it('returns identicon bg class from id that is a string', () => { + expect(getIdenticonBackgroundClass('1')).toEqual('bg2'); + }); + + it('returns identicon bg class from id that is a GraphQL string id', () => { + expect(getIdenticonBackgroundClass('gid://gitlab/Project/1')).toEqual('bg2'); + }); + + it('returns identicon bg class from unparsable string', () => { + expect(getIdenticonBackgroundClass('gid://gitlab/')).toEqual('bg1'); + }); + + it(`wraps around if id is bigger than ${IDENTICON_BG_COUNT}`, () => { + expect(getIdenticonBackgroundClass(IDENTICON_BG_COUNT + 4)).toEqual('bg5'); + expect(getIdenticonBackgroundClass(IDENTICON_BG_COUNT * 5 + 6)).toEqual('bg7'); + }); + }); + + describe('getIdenticonTitle', () => { + it('returns identicon title from name', () => { + expect(getIdenticonTitle('Lorem')).toEqual('L'); + expect(getIdenticonTitle('dolar-sit-amit')).toEqual('D'); + expect(getIdenticonTitle('%-with-special-chars')).toEqual('%'); + }); + + it('returns space if name is falsey', () => { + expect(getIdenticonTitle('')).toEqual(' '); + expect(getIdenticonTitle(null)).toEqual(' '); + }); + }); + + describe('renderIdenticon', () => { + it('renders with the first letter as title and bg based on id', () => { + const entity = { + id: IDENTICON_BG_COUNT + 3, + name: 'Xavior', + }; + const options = { + sizeClass: 's32', + }; + + const result = renderIdenticon(entity, options); + + expect(result).toHaveClass(`identicon ${options.sizeClass} bg4`); + expect(result).toHaveText(matchAll(getFirstCharacterCapitalized(entity.name))); + }); + + it('renders with defaults, if no options are given', () => { + const entity = { + id: 1, + name: 'tanuki', + }; + + const result = renderIdenticon(entity); + + expect(result).toHaveClass(`identicon ${DEFAULT_SIZE_CLASS} bg2`); + expect(result).toHaveText(matchAll(getFirstCharacterCapitalized(entity.name))); + }); + }); + + describe('renderAvatar', () => { + it('renders an image with the avatarUrl', () => { + const avatarUrl = `${TEST_HOST}/not-real-assets/test.png`; + + const result = renderAvatar({ + avatar_url: avatarUrl, + }); + + expect(result).toBeMatchedBy('img'); + expect(result).toHaveAttr('src', avatarUrl); + expect(result).toHaveClass(DEFAULT_SIZE_CLASS); + }); + + it('renders an identicon if no avatarUrl', () => { + const entity = { + id: 1, + name: 'walrus', + }; + const options = { + sizeClass: 's16', + }; + + const result = renderAvatar(entity, options); + + expect(result).toHaveClass(`identicon ${options.sizeClass} bg2`); + expect(result).toHaveText(matchAll(getFirstCharacterCapitalized(entity.name))); + }); + }); +}); diff --git a/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js b/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js index a98919e2113..eab805382bd 100644 --- a/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js +++ b/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js @@ -57,6 +57,18 @@ describe('PasteMarkdownTable', () => { expect(new PasteMarkdownTable(data).isTable()).toBe(false); }); + + it('returns false when the table copy comes from a diff', () => { + data.types = ['text/html', 'text/plain']; + data.getData = jest.fn().mockImplementation(mimeType => { + if (mimeType === 'text/html') { + return '<table class="diff-wrap-lines"><tr><td>First</td><td>Second</td></tr></table>'; + } + return 'First\tSecond'; + }); + + expect(new PasteMarkdownTable(data).isTable()).toBe(false); + }); }); describe('convertToTableMarkdown', () => { diff --git a/spec/frontend/behaviors/markdown/render_metrics_spec.js b/spec/frontend/behaviors/markdown/render_metrics_spec.js index 3f7beeb817b..ab81ed6b8f0 100644 --- a/spec/frontend/behaviors/markdown/render_metrics_spec.js +++ b/spec/frontend/behaviors/markdown/render_metrics_spec.js @@ -11,20 +11,20 @@ const getElements = () => Array.from(document.getElementsByClassName('js-render- describe('Render metrics for Gitlab Flavoured Markdown', () => { it('does nothing when no elements are found', () => { - renderMetrics([]); - - expect(mockEmbedGroup).not.toHaveBeenCalled(); + return renderMetrics([]).then(() => { + expect(mockEmbedGroup).not.toHaveBeenCalled(); + }); }); it('renders a vue component when elements are found', () => { document.body.innerHTML = `<div class="js-render-metrics" data-dashboard-url="${TEST_HOST}"></div>`; - renderMetrics(getElements()); - - expect(mockEmbedGroup).toHaveBeenCalledTimes(1); - expect(mockEmbedGroup).toHaveBeenCalledWith( - expect.objectContaining({ propsData: { urls: [`${TEST_HOST}`] } }), - ); + return renderMetrics(getElements()).then(() => { + expect(mockEmbedGroup).toHaveBeenCalledTimes(1); + expect(mockEmbedGroup).toHaveBeenCalledWith( + expect.objectContaining({ propsData: { urls: [`${TEST_HOST}`] } }), + ); + }); }); it('takes sibling metrics and groups them under a shared parent', () => { @@ -36,14 +36,14 @@ describe('Render metrics for Gitlab Flavoured Markdown', () => { <div class="js-render-metrics" data-dashboard-url="${TEST_HOST}/3"></div> `; - renderMetrics(getElements()); - - expect(mockEmbedGroup).toHaveBeenCalledTimes(2); - expect(mockEmbedGroup).toHaveBeenCalledWith( - expect.objectContaining({ propsData: { urls: [`${TEST_HOST}/1`, `${TEST_HOST}/2`] } }), - ); - expect(mockEmbedGroup).toHaveBeenCalledWith( - expect.objectContaining({ propsData: { urls: [`${TEST_HOST}/3`] } }), - ); + return renderMetrics(getElements()).then(() => { + expect(mockEmbedGroup).toHaveBeenCalledTimes(2); + expect(mockEmbedGroup).toHaveBeenCalledWith( + expect.objectContaining({ propsData: { urls: [`${TEST_HOST}/1`, `${TEST_HOST}/2`] } }), + ); + expect(mockEmbedGroup).toHaveBeenCalledWith( + expect.objectContaining({ propsData: { urls: [`${TEST_HOST}/3`] } }), + ); + }); }); }); diff --git a/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap index e47a7dcfa2a..1e639f91797 100644 --- a/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap +++ b/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap @@ -5,7 +5,7 @@ exports[`Blob Header Editing rendering matches the snapshot 1`] = ` class="js-file-title file-title-flex-parent" > <gl-form-input-stub - class="form-control js-snippet-file-name qa-snippet-file-name" + class="form-control js-snippet-file-name" id="snippet_file_name" name="snippet_file_name" placeholder="Give your file a name to add code highlighting, e.g. example.rb for Ruby" 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 7382a3a4cf7..2ac6e0d5d24 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,14 +8,15 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = ` <file-icon-stub aria-hidden="true" cssclasses="mr-2" - filename="dummy.md" + filename="foo/bar/dummy.md" size="18" /> <strong - class="file-title-name qa-file-title-name mr-1 js-blob-header-filepath" + class="file-title-name mr-1 js-blob-header-filepath" + data-qa-selector="file_title_name" > - dummy.md + foo/bar/dummy.md </strong> <small @@ -26,8 +27,8 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = ` <clipboard-button-stub cssclass="btn-clipboard btn-transparent lh-100 position-static" - gfm="\`dummy.md\`" - text="dummy.md" + gfm="\`foo/bar/dummy.md\`" + text="foo/bar/dummy.md" title="Copy file path" tooltipplacement="top" /> diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap index 2878ad492a4..7d868625956 100644 --- a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap +++ b/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap @@ -9,7 +9,7 @@ exports[`Blob Header Default Actions rendering matches the snapshot 1`] = ` /> <div - class="file-actions d-none d-sm-block" + class="file-actions d-none d-sm-flex" > <viewer-switcher-stub value="simple" diff --git a/spec/frontend/blob/components/blob_content_error_spec.js b/spec/frontend/blob/components/blob_content_error_spec.js index 58a9ee761df..6eb5cfb71aa 100644 --- a/spec/frontend/blob/components/blob_content_error_spec.js +++ b/spec/frontend/blob/components/blob_content_error_spec.js @@ -1,27 +1,60 @@ import { shallowMount } from '@vue/test-utils'; import BlobContentError from '~/blob/components/blob_content_error.vue'; +import { GlSprintf } from '@gitlab/ui'; + +import { BLOB_RENDER_ERRORS } from '~/blob/components/constants'; describe('Blob Content Error component', () => { let wrapper; - const viewerError = '<h1 id="error">Foo Error</h1>'; - function createComponent() { + function createComponent(props = {}) { wrapper = shallowMount(BlobContentError, { propsData: { - viewerError, + ...props, + }, + stubs: { + GlSprintf, }, }); } - beforeEach(() => { - createComponent(); - }); - afterEach(() => { wrapper.destroy(); }); - it('renders the passed error without transformations', () => { - expect(wrapper.html()).toContain(viewerError); + 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]} + `('renders correct reason for $error.id', ({ error, reason, options }) => { + createComponent({ + viewerError: error.id, + }); + expect(wrapper.text()).toContain(reason); + options.forEach(option => { + expect(wrapper.text()).toContain(option); + }); + }); + }); + + describe('external blob', () => { + it.each` + storageType | reason | options + ${'lfs'} | ${BLOB_RENDER_ERRORS.REASONS.EXTERNAL.text.lfs} | ${[BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]} + ${'build_artifact'} | ${BLOB_RENDER_ERRORS.REASONS.EXTERNAL.text.build_artifact} | ${[BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]} + ${'default'} | ${BLOB_RENDER_ERRORS.REASONS.EXTERNAL.text.default} | ${[BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]} + `('renders correct reason for $storageType blob', ({ storageType, reason, options }) => { + createComponent({ + viewerError: BLOB_RENDER_ERRORS.REASONS.EXTERNAL.id, + blob: { + externalStorage: storageType, + }, + }); + expect(wrapper.text()).toContain(reason); + options.forEach(option => { + expect(wrapper.text()).toContain(option); + }); + }); }); }); diff --git a/spec/frontend/blob/components/blob_content_spec.js b/spec/frontend/blob/components/blob_content_spec.js index 6a130c9c43d..244ed41869d 100644 --- a/spec/frontend/blob/components/blob_content_spec.js +++ b/spec/frontend/blob/components/blob_content_spec.js @@ -2,6 +2,12 @@ import { shallowMount } from '@vue/test-utils'; import BlobContent from '~/blob/components/blob_content.vue'; import BlobContentError from '~/blob/components/blob_content_error.vue'; import { + BLOB_RENDER_EVENT_LOAD, + BLOB_RENDER_EVENT_SHOW_SOURCE, + BLOB_RENDER_ERRORS, +} from '~/blob/components/constants'; +import { + Blob, RichViewerMock, SimpleViewerMock, RichBlobContentMock, @@ -38,7 +44,7 @@ describe('Blob Content component', () => { it('renders error if there is any in the viewer', () => { const renderError = 'Oops'; - const viewer = Object.assign({}, SimpleViewerMock, { renderError }); + const viewer = { ...SimpleViewerMock, renderError }; createComponent({}, viewer); expect(wrapper.contains(GlLoadingIcon)).toBe(false); expect(wrapper.contains(BlobContentError)).toBe(true); @@ -67,4 +73,32 @@ describe('Blob Content component', () => { expect(wrapper.find(viewer).html()).toContain(content); }); }); + + describe('functionality', () => { + describe('render error', () => { + const findErrorEl = () => wrapper.find(BlobContentError); + const renderError = BLOB_RENDER_ERRORS.REASONS.COLLAPSED.id; + const viewer = { ...SimpleViewerMock, renderError }; + + beforeEach(() => { + createComponent({ blob: Blob }, viewer); + }); + + it('correctly sets blob on the blob-content-error component', () => { + expect(findErrorEl().props('blob')).toEqual(Blob); + }); + + it(`properly proxies ${BLOB_RENDER_EVENT_LOAD} event`, () => { + expect(wrapper.emitted(BLOB_RENDER_EVENT_LOAD)).toBeUndefined(); + findErrorEl().vm.$emit(BLOB_RENDER_EVENT_LOAD); + expect(wrapper.emitted(BLOB_RENDER_EVENT_LOAD)).toBeTruthy(); + }); + + it(`properly proxies ${BLOB_RENDER_EVENT_SHOW_SOURCE} event`, () => { + expect(wrapper.emitted(BLOB_RENDER_EVENT_SHOW_SOURCE)).toBeUndefined(); + findErrorEl().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE); + expect(wrapper.emitted(BLOB_RENDER_EVENT_SHOW_SOURCE)).toBeTruthy(); + }); + }); + }); }); diff --git a/spec/frontend/blob/components/blob_header_filepath_spec.js b/spec/frontend/blob/components/blob_header_filepath_spec.js index d029ba2a7a4..3a53208f357 100644 --- a/spec/frontend/blob/components/blob_header_filepath_spec.js +++ b/spec/frontend/blob/components/blob_header_filepath_spec.js @@ -15,7 +15,7 @@ describe('Blob Header Filepath', () => { function createComponent(blobProps = {}, options = {}) { wrapper = shallowMount(BlobHeaderFilepath, { propsData: { - blob: Object.assign({}, MockBlob, blobProps), + blob: { ...MockBlob, ...blobProps }, }, ...options, }); @@ -38,12 +38,12 @@ describe('Blob Header Filepath', () => { .find('.js-blob-header-filepath') .text() .trim(), - ).toBe(MockBlob.name); + ).toBe(MockBlob.path); }); it('does not fail if the name is empty', () => { - const emptyName = ''; - createComponent({ name: emptyName }); + const emptyPath = ''; + createComponent({ path: emptyPath }); expect(wrapper.find('.js-blob-header-filepath').exists()).toBe(false); }); @@ -84,7 +84,7 @@ describe('Blob Header Filepath', () => { describe('functionality', () => { it('sets gfm value correctly on the clipboard-button', () => { createComponent(); - expect(wrapper.vm.gfmCopyText).toBe('`dummy.md`'); + expect(wrapper.vm.gfmCopyText).toBe(`\`${MockBlob.path}\``); }); }); }); diff --git a/spec/frontend/blob/components/blob_header_spec.js b/spec/frontend/blob/components/blob_header_spec.js index d410ef10fc9..0e7d2f6516a 100644 --- a/spec/frontend/blob/components/blob_header_spec.js +++ b/spec/frontend/blob/components/blob_header_spec.js @@ -13,7 +13,7 @@ describe('Blob Header Default Actions', () => { const method = shouldMount ? mount : shallowMount; wrapper = method.call(this, BlobHeader, { propsData: { - blob: Object.assign({}, Blob, blobProps), + blob: { ...Blob, ...blobProps }, ...propsData, }, ...options, diff --git a/spec/frontend/blob/components/mock_data.js b/spec/frontend/blob/components/mock_data.js index bfcca14324f..0f7193846ff 100644 --- a/spec/frontend/blob/components/mock_data.js +++ b/spec/frontend/blob/components/mock_data.js @@ -21,7 +21,7 @@ export const RichViewerMock = { export const Blob = { binary: false, name: 'dummy.md', - path: 'dummy.md', + path: 'foo/bar/dummy.md', rawPath: '/flightjs/flight/snippets/51/raw', size: 75, simpleViewer: { diff --git a/spec/frontend/blob/pipeline_tour_success_modal_spec.js b/spec/frontend/blob/pipeline_tour_success_modal_spec.js index 99940225652..6d4e5e46cb8 100644 --- a/spec/frontend/blob/pipeline_tour_success_modal_spec.js +++ b/spec/frontend/blob/pipeline_tour_success_modal_spec.js @@ -12,8 +12,8 @@ describe('PipelineTourSuccessModal', () => { beforeEach(() => { document.body.dataset.page = 'projects:blob:show'; - trackingSpy = mockTracking('_category_', undefined, jest.spyOn); + wrapper = shallowMount(pipelineTourSuccess, { propsData: modalProps, stubs: { diff --git a/spec/frontend/blob/suggest_gitlab_ci_yml/components/popover_spec.js b/spec/frontend/blob/suggest_gitlab_ci_yml/components/popover_spec.js index fb0964a3f32..3c03e6f04ab 100644 --- a/spec/frontend/blob/suggest_gitlab_ci_yml/components/popover_spec.js +++ b/spec/frontend/blob/suggest_gitlab_ci_yml/components/popover_spec.js @@ -69,8 +69,10 @@ describe('Suggest gitlab-ci.yml Popover', () => { let trackingSpy; beforeEach(() => { + document.body.dataset.page = 'projects:blob:new'; + trackingSpy = mockTracking('_category_', undefined, jest.spyOn); + createWrapper(commitTrackLabel); - trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); }); afterEach(() => { @@ -83,10 +85,6 @@ describe('Suggest gitlab-ci.yml Popover', () => { const expectedLabel = 'suggest_commit_first_project_gitlab_ci_yml'; const expectedProperty = 'owner'; - document.body.dataset.page = 'projects:blob:new'; - - wrapper.vm.trackOnShow(); - expect(trackingSpy).toHaveBeenCalledWith(expectedCategory, expectedAction, { label: expectedLabel, property: expectedProperty, @@ -99,6 +97,7 @@ describe('Suggest gitlab-ci.yml Popover', () => { const expectedProperty = 'owner'; const expectedValue = '10'; const dismissButton = wrapper.find(GlDeprecatedButton); + trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); triggerEvent(dismissButton.element); diff --git a/spec/frontend/blob/utils_spec.js b/spec/frontend/blob/utils_spec.js index 39a73aae444..119ed2dfe7a 100644 --- a/spec/frontend/blob/utils_spec.js +++ b/spec/frontend/blob/utils_spec.js @@ -8,11 +8,6 @@ jest.mock('~/editor/editor_lite', () => { }); }); -const mockCreateAceInstance = jest.fn(); -global.ace = { - edit: mockCreateAceInstance, -}; - describe('Blob utilities', () => { beforeEach(() => { Editor.mockClear(); @@ -29,21 +24,6 @@ describe('Blob utilities', () => { }); describe('Monaco editor', () => { - let origProp; - - beforeEach(() => { - origProp = window.gon; - window.gon = { - features: { - monacoSnippets: true, - }, - }; - }); - - afterEach(() => { - window.gon = origProp; - }); - it('initializes the Editor Lite', () => { utils.initEditorLite({ el: editorEl }); expect(Editor).toHaveBeenCalled(); @@ -69,27 +49,5 @@ describe('Blob utilities', () => { ]); }); }); - describe('ACE editor', () => { - let origProp; - - beforeEach(() => { - origProp = window.gon; - window.gon = { - features: { - monacoSnippets: false, - }, - }; - }); - - afterEach(() => { - window.gon = origProp; - }); - - it('does not initialize the Editor Lite', () => { - utils.initEditorLite({ el: editorEl }); - expect(Editor).not.toHaveBeenCalled(); - expect(mockCreateAceInstance).toHaveBeenCalledWith(editorEl); - }); - }); }); }); diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index 882310030f8..fa21053e2de 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -64,7 +64,7 @@ describe('Board list component', () => { let getIssues; function generateIssues(compWrapper) { for (let i = 1; i < 20; i += 1) { - const issue = Object.assign({}, compWrapper.list.issues[0]); + const issue = { ...compWrapper.list.issues[0] }; issue.id += i; compWrapper.list.issues.push(issue); } diff --git a/spec/frontend/boards/boards_store_spec.js b/spec/frontend/boards/boards_store_spec.js index 5c5315fd465..29cc8f981bd 100644 --- a/spec/frontend/boards/boards_store_spec.js +++ b/spec/frontend/boards/boards_store_spec.js @@ -214,6 +214,22 @@ describe('boardsStore', () => { }); }); + describe('getListIssues', () => { + let list; + + beforeEach(() => { + list = new List(listObj); + setupDefaultResponses(); + }); + + it('makes a request to get issues', () => { + const expectedResponse = expect.objectContaining({ issues: [createTestIssue()] }); + expect(list.issues).toEqual([]); + + return expect(boardsStore.getListIssues(list, true)).resolves.toEqual(expectedResponse); + }); + }); + describe('getIssuesForList', () => { const id = 'TOO-MUCH'; const url = `${endpoints.listsEndpoint}/${id}/issues?id=${id}`; @@ -1040,5 +1056,126 @@ describe('boardsStore', () => { }); }); }); + + describe('addListIssue', () => { + let list; + const issue1 = new ListIssue({ + title: 'Testing', + id: 2, + iid: 2, + confidential: false, + labels: [ + { + color: '#ff0000', + description: 'testing;', + id: 5000, + priority: undefined, + textColor: 'white', + title: 'Test', + }, + ], + assignees: [], + }); + const issue2 = new ListIssue({ + title: 'Testing', + id: 1, + iid: 1, + confidential: false, + labels: [ + { + id: 1, + title: 'test', + color: 'red', + description: 'testing', + }, + ], + assignees: [ + { + id: 1, + name: 'name', + username: 'username', + avatar_url: 'http://avatar_url', + }, + ], + real_path: 'path/to/issue', + }); + + beforeEach(() => { + list = new List(listObj); + list.addIssue(issue1); + setupDefaultResponses(); + }); + + it('adds issues that are not already on the list', () => { + expect(list.findIssue(issue2.id)).toBe(undefined); + expect(list.issues).toEqual([issue1]); + + boardsStore.addListIssue(list, issue2); + expect(list.findIssue(issue2.id)).toBe(issue2); + expect(list.issues.length).toBe(2); + expect(list.issues).toEqual([issue1, issue2]); + }); + }); + + describe('updateIssue', () => { + let issue; + let patchSpy; + + beforeEach(() => { + issue = new ListIssue({ + title: 'Testing', + id: 1, + iid: 1, + confidential: false, + labels: [ + { + id: 1, + title: 'test', + color: 'red', + description: 'testing', + }, + ], + assignees: [ + { + id: 1, + name: 'name', + username: 'username', + avatar_url: 'http://avatar_url', + }, + ], + real_path: 'path/to/issue', + }); + + patchSpy = jest.fn().mockReturnValue([200, { labels: [] }]); + axiosMock.onPatch(`path/to/issue.json`).reply(({ data }) => patchSpy(JSON.parse(data))); + }); + + it('passes assignee ids when there are assignees', () => { + boardsStore.updateIssue(issue); + return boardsStore.updateIssue(issue).then(() => { + expect(patchSpy).toHaveBeenCalledWith({ + issue: { + milestone_id: null, + assignee_ids: [1], + label_ids: [1], + }, + }); + }); + }); + + it('passes assignee ids of [0] when there are no assignees', () => { + issue.removeAllAssignees(); + + return boardsStore.updateIssue(issue).then(() => { + expect(patchSpy).toHaveBeenCalledWith({ + issue: { + milestone_id: null, + assignee_ids: [0], + label_ids: [1], + }, + }); + }); + }); + }); }); }); diff --git a/spec/frontend/boards/issue_spec.js b/spec/frontend/boards/issue_spec.js index ff72edaa695..412f20684f5 100644 --- a/spec/frontend/boards/issue_spec.js +++ b/spec/frontend/boards/issue_spec.js @@ -1,6 +1,5 @@ /* global ListIssue */ -import axios from '~/lib/utils/axios_utils'; import '~/boards/models/label'; import '~/boards/models/assignee'; import '~/boards/models/issue'; @@ -173,25 +172,12 @@ describe('Issue model', () => { }); describe('update', () => { - it('passes assignee ids when there are assignees', done => { - jest.spyOn(axios, 'patch').mockImplementation((url, data) => { - expect(data.issue.assignee_ids).toEqual([1]); - done(); - return Promise.resolve(); - }); - - issue.update('url'); - }); + it('passes update to boardsStore', () => { + jest.spyOn(boardsStore, 'updateIssue').mockImplementation(); - it('passes assignee ids of [0] when there are no assignees', done => { - jest.spyOn(axios, 'patch').mockImplementation((url, data) => { - expect(data.issue.assignee_ids).toEqual([0]); - done(); - return Promise.resolve(); - }); + issue.update(); - issue.removeAllAssignees(); - issue.update('url'); + expect(boardsStore.updateIssue).toHaveBeenCalledWith(issue); }); }); }); diff --git a/spec/frontend/bootstrap_linked_tabs_spec.js b/spec/frontend/bootstrap_linked_tabs_spec.js new file mode 100644 index 00000000000..2d8939e6480 --- /dev/null +++ b/spec/frontend/bootstrap_linked_tabs_spec.js @@ -0,0 +1,67 @@ +import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs'; + +describe('Linked Tabs', () => { + preloadFixtures('static/linked_tabs.html'); + + beforeEach(() => { + loadFixtures('static/linked_tabs.html'); + }); + + describe('when is initialized', () => { + beforeEach(() => { + jest.spyOn(window.history, 'replaceState').mockImplementation(() => {}); + }); + + it('should activate the tab correspondent to the given action', () => { + // eslint-disable-next-line no-new + new LinkedTabs({ + action: 'tab1', + defaultAction: 'tab1', + parentEl: '.linked-tabs', + }); + + expect(document.querySelector('#tab1').classList).toContain('active'); + }); + + it('should active the default tab action when the action is show', () => { + // eslint-disable-next-line no-new + new LinkedTabs({ + action: 'show', + defaultAction: 'tab1', + parentEl: '.linked-tabs', + }); + + expect(document.querySelector('#tab1').classList).toContain('active'); + }); + }); + + describe('on click', () => { + it('should change the url according to the clicked tab', () => { + const historySpy = jest.spyOn(window.history, 'replaceState').mockImplementation(() => {}); + + const linkedTabs = new LinkedTabs({ + action: 'show', + defaultAction: 'tab1', + parentEl: '.linked-tabs', + }); + + const secondTab = document.querySelector('.linked-tabs li:nth-child(2) a'); + const newState = + secondTab.getAttribute('href') + + linkedTabs.currentLocation.search + + linkedTabs.currentLocation.hash; + + secondTab.click(); + + if (historySpy) { + expect(historySpy).toHaveBeenCalledWith( + { + url: newState, + }, + document.title, + newState, + ); + } + }); + }); +}); diff --git a/spec/frontend/broadcast_notification_spec.js b/spec/frontend/broadcast_notification_spec.js new file mode 100644 index 00000000000..8d433946632 --- /dev/null +++ b/spec/frontend/broadcast_notification_spec.js @@ -0,0 +1,35 @@ +import Cookies from 'js-cookie'; +import initBroadcastNotifications from '~/broadcast_notification'; + +describe('broadcast message on dismiss', () => { + const dismiss = () => { + const button = document.querySelector('.js-dismiss-current-broadcast-notification'); + button.click(); + }; + const endsAt = '2020-01-01T00:00:00Z'; + + beforeEach(() => { + setFixtures(` + <div class="js-broadcast-notification-1"> + <button class="js-dismiss-current-broadcast-notification" data-id="1" data-expire-date="${endsAt}"></button> + </div> + `); + + initBroadcastNotifications(); + }); + + it('removes broadcast message', () => { + dismiss(); + + expect(document.querySelector('.js-broadcast-notification-1')).toBeNull(); + }); + + it('calls Cookies.set', () => { + jest.spyOn(Cookies, 'set'); + dismiss(); + + expect(Cookies.set).toHaveBeenCalledWith('hide_broadcast_message_1', true, { + expires: new Date(endsAt), + }); + }); +}); diff --git a/spec/frontend/ci_variable_list/ci_variable_list/ajax_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/ajax_variable_list_spec.js new file mode 100644 index 00000000000..93b185bd242 --- /dev/null +++ b/spec/frontend/ci_variable_list/ci_variable_list/ajax_variable_list_spec.js @@ -0,0 +1,203 @@ +import $ from 'jquery'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import AjaxFormVariableList from '~/ci_variable_list/ajax_variable_list'; + +const VARIABLE_PATCH_ENDPOINT = 'http://test.host/frontend-fixtures/builds-project/-/variables'; +const HIDE_CLASS = 'hide'; + +describe('AjaxFormVariableList', () => { + preloadFixtures('projects/ci_cd_settings.html'); + preloadFixtures('projects/ci_cd_settings_with_variables.html'); + + let container; + let saveButton; + let errorBox; + + let mock; + let ajaxVariableList; + + beforeEach(() => { + loadFixtures('projects/ci_cd_settings.html'); + container = document.querySelector('.js-ci-variable-list-section'); + + mock = new MockAdapter(axios); + + const ajaxVariableListEl = document.querySelector('.js-ci-variable-list-section'); + saveButton = ajaxVariableListEl.querySelector('.js-ci-variables-save-button'); + errorBox = container.querySelector('.js-ci-variable-error-box'); + ajaxVariableList = new AjaxFormVariableList({ + container, + formField: 'variables', + saveButton, + errorBox, + saveEndpoint: container.dataset.saveEndpoint, + maskableRegex: container.dataset.maskableRegex, + }); + + jest.spyOn(ajaxVariableList, 'updateRowsWithPersistedVariables'); + jest.spyOn(ajaxVariableList.variableList, 'toggleEnableRow'); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('onSaveClicked', () => { + it('shows loading spinner while waiting for the request', () => { + const loadingIcon = saveButton.querySelector('.js-ci-variables-save-loading-icon'); + + mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(() => { + expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(false); + + return [200, {}]; + }); + + expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(true); + + return ajaxVariableList.onSaveClicked().then(() => { + expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(true); + }); + }); + + it('calls `updateRowsWithPersistedVariables` with the persisted variables', () => { + const variablesResponse = [{ id: 1, key: 'foo', value: 'bar' }]; + mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200, { + variables: variablesResponse, + }); + + return ajaxVariableList.onSaveClicked().then(() => { + expect(ajaxVariableList.updateRowsWithPersistedVariables).toHaveBeenCalledWith( + variablesResponse, + ); + }); + }); + + it('hides any previous error box', () => { + mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200); + + expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true); + + return ajaxVariableList.onSaveClicked().then(() => { + expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true); + }); + }); + + it('disables remove buttons while waiting for the request', () => { + mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(() => { + expect(ajaxVariableList.variableList.toggleEnableRow).toHaveBeenCalledWith(false); + + return [200, {}]; + }); + + return ajaxVariableList.onSaveClicked().then(() => { + expect(ajaxVariableList.variableList.toggleEnableRow).toHaveBeenCalledWith(true); + }); + }); + + it('hides secret values', () => { + mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200, {}); + + const row = container.querySelector('.js-row'); + const valueInput = row.querySelector('.js-ci-variable-input-value'); + const valuePlaceholder = row.querySelector('.js-secret-value-placeholder'); + + valueInput.value = 'bar'; + $(valueInput).trigger('input'); + + expect(valuePlaceholder.classList.contains(HIDE_CLASS)).toBe(true); + expect(valueInput.classList.contains(HIDE_CLASS)).toBe(false); + + return ajaxVariableList.onSaveClicked().then(() => { + expect(valuePlaceholder.classList.contains(HIDE_CLASS)).toBe(false); + expect(valueInput.classList.contains(HIDE_CLASS)).toBe(true); + }); + }); + + it('shows error box with validation errors', () => { + const validationError = 'some validation error'; + mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(400, [validationError]); + + expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true); + + return ajaxVariableList.onSaveClicked().then(() => { + expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(false); + expect(errorBox.textContent.trim().replace(/\n+\s+/m, ' ')).toEqual( + `Validation failed ${validationError}`, + ); + }); + }); + + it('shows flash message when request fails', () => { + mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(500); + + expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true); + + return ajaxVariableList.onSaveClicked().then(() => { + expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true); + }); + }); + }); + + describe('updateRowsWithPersistedVariables', () => { + beforeEach(() => { + loadFixtures('projects/ci_cd_settings_with_variables.html'); + container = document.querySelector('.js-ci-variable-list-section'); + + const ajaxVariableListEl = document.querySelector('.js-ci-variable-list-section'); + saveButton = ajaxVariableListEl.querySelector('.js-ci-variables-save-button'); + errorBox = container.querySelector('.js-ci-variable-error-box'); + ajaxVariableList = new AjaxFormVariableList({ + container, + formField: 'variables', + saveButton, + errorBox, + saveEndpoint: container.dataset.saveEndpoint, + }); + }); + + it('removes variable that was removed', () => { + expect(container.querySelectorAll('.js-row').length).toBe(3); + + container.querySelector('.js-row-remove-button').click(); + + expect(container.querySelectorAll('.js-row').length).toBe(3); + + ajaxVariableList.updateRowsWithPersistedVariables([]); + + expect(container.querySelectorAll('.js-row').length).toBe(2); + }); + + it('updates new variable row with persisted ID', () => { + const row = container.querySelector('.js-row:last-child'); + const idInput = row.querySelector('.js-ci-variable-input-id'); + const keyInput = row.querySelector('.js-ci-variable-input-key'); + const valueInput = row.querySelector('.js-ci-variable-input-value'); + + keyInput.value = 'foo'; + $(keyInput).trigger('input'); + valueInput.value = 'bar'; + $(valueInput).trigger('input'); + + expect(idInput.value).toEqual(''); + + ajaxVariableList.updateRowsWithPersistedVariables([ + { + id: 3, + key: 'foo', + value: 'bar', + }, + ]); + + expect(idInput.value).toEqual('3'); + expect(row.dataset.isPersisted).toEqual('true'); + }); + }); + + describe('maskableRegex', () => { + it('takes in the regex provided by the data attribute', () => { + expect(container.dataset.maskableRegex).toBe('^[a-zA-Z0-9_+=/@:.-]{8,}$'); + expect(ajaxVariableList.maskableRegex).toBe(container.dataset.maskableRegex); + }); + }); +}); diff --git a/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js new file mode 100644 index 00000000000..9508203e5c2 --- /dev/null +++ b/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js @@ -0,0 +1,282 @@ +import $ from 'jquery'; +import waitForPromises from 'helpers/wait_for_promises'; +import VariableList from '~/ci_variable_list/ci_variable_list'; + +const HIDE_CLASS = 'hide'; + +describe('VariableList', () => { + preloadFixtures('pipeline_schedules/edit.html'); + preloadFixtures('pipeline_schedules/edit_with_variables.html'); + preloadFixtures('projects/ci_cd_settings.html'); + + let $wrapper; + let variableList; + + describe('with only key/value inputs', () => { + describe('with no variables', () => { + beforeEach(() => { + loadFixtures('pipeline_schedules/edit.html'); + $wrapper = $('.js-ci-variable-list-section'); + + variableList = new VariableList({ + container: $wrapper, + formField: 'schedule', + }); + variableList.init(); + }); + + it('should remove the row when clicking the remove button', () => { + $wrapper.find('.js-row-remove-button').trigger('click'); + + expect($wrapper.find('.js-row').length).toBe(0); + }); + + it('should add another row when editing the last rows key input', () => { + const $row = $wrapper.find('.js-row'); + $row + .find('.js-ci-variable-input-key') + .val('foo') + .trigger('input'); + + expect($wrapper.find('.js-row').length).toBe(2); + + // Check for the correct default in the new row + const $keyInput = $wrapper.find('.js-row:last-child').find('.js-ci-variable-input-key'); + + expect($keyInput.val()).toBe(''); + }); + + it('should add another row when editing the last rows value textarea', () => { + const $row = $wrapper.find('.js-row'); + $row + .find('.js-ci-variable-input-value') + .val('foo') + .trigger('input'); + + expect($wrapper.find('.js-row').length).toBe(2); + + // Check for the correct default in the new row + const $valueInput = $wrapper.find('.js-row:last-child').find('.js-ci-variable-input-key'); + + expect($valueInput.val()).toBe(''); + }); + + it('should remove empty row after blurring', () => { + const $row = $wrapper.find('.js-row'); + $row + .find('.js-ci-variable-input-key') + .val('foo') + .trigger('input'); + + expect($wrapper.find('.js-row').length).toBe(2); + + $row + .find('.js-ci-variable-input-key') + .val('') + .trigger('input') + .trigger('blur'); + + expect($wrapper.find('.js-row').length).toBe(1); + }); + }); + + describe('with persisted variables', () => { + beforeEach(() => { + loadFixtures('pipeline_schedules/edit_with_variables.html'); + $wrapper = $('.js-ci-variable-list-section'); + + variableList = new VariableList({ + container: $wrapper, + formField: 'schedule', + }); + variableList.init(); + }); + + it('should have "Reveal values" button initially when there are already variables', () => { + expect($wrapper.find('.js-secret-value-reveal-button').text()).toBe('Reveal values'); + }); + + it('should reveal hidden values', () => { + const $row = $wrapper.find('.js-row:first-child'); + const $inputValue = $row.find('.js-ci-variable-input-value'); + const $placeholder = $row.find('.js-secret-value-placeholder'); + + expect($placeholder.hasClass(HIDE_CLASS)).toBe(false); + expect($inputValue.hasClass(HIDE_CLASS)).toBe(true); + + // Reveal values + $wrapper.find('.js-secret-value-reveal-button').click(); + + expect($placeholder.hasClass(HIDE_CLASS)).toBe(true); + expect($inputValue.hasClass(HIDE_CLASS)).toBe(false); + }); + }); + }); + + describe('with all inputs(key, value, protected)', () => { + beforeEach(() => { + loadFixtures('projects/ci_cd_settings.html'); + $wrapper = $('.js-ci-variable-list-section'); + + $wrapper.find('.js-ci-variable-input-protected').attr('data-default', 'false'); + + variableList = new VariableList({ + container: $wrapper, + formField: 'variables', + }); + variableList.init(); + }); + + it('should not add another row when editing the last rows protected checkbox', () => { + const $row = $wrapper.find('.js-row:last-child'); + $row.find('.ci-variable-protected-item .js-project-feature-toggle').click(); + + return waitForPromises().then(() => { + expect($wrapper.find('.js-row').length).toBe(1); + }); + }); + + it('should not add another row when editing the last rows masked checkbox', () => { + jest.spyOn(variableList, 'checkIfRowTouched'); + const $row = $wrapper.find('.js-row:last-child'); + $row.find('.ci-variable-masked-item .js-project-feature-toggle').click(); + + return waitForPromises().then(() => { + // This validates that we are checking after the event listener has run + expect(variableList.checkIfRowTouched).toHaveBeenCalled(); + expect($wrapper.find('.js-row').length).toBe(1); + }); + }); + + describe('validateMaskability', () => { + let $row; + + const maskingErrorElement = '.js-row:last-child .masking-validation-error'; + const clickToggle = () => + $row.find('.ci-variable-masked-item .js-project-feature-toggle').click(); + + beforeEach(() => { + $row = $wrapper.find('.js-row:last-child'); + }); + + it('has a regex provided via a data attribute', () => { + clickToggle(); + + expect($wrapper.attr('data-maskable-regex')).toBe('^[a-zA-Z0-9_+=/@:.-]{8,}$'); + }); + + it('allows values that are 8 characters long', () => { + $row.find('.js-ci-variable-input-value').val('looooong'); + + clickToggle(); + + expect($wrapper.find(maskingErrorElement)).toHaveClass('hide'); + }); + + it('rejects values that are shorter than 8 characters', () => { + $row.find('.js-ci-variable-input-value').val('short'); + + clickToggle(); + + expect($wrapper.find(maskingErrorElement)).toBeVisible(); + }); + + it('allows values with base 64 characters', () => { + $row.find('.js-ci-variable-input-value').val('abcABC123_+=/-'); + + clickToggle(); + + expect($wrapper.find(maskingErrorElement)).toHaveClass('hide'); + }); + + it('rejects values with other special characters', () => { + $row.find('.js-ci-variable-input-value').val('1234567$'); + + clickToggle(); + + expect($wrapper.find(maskingErrorElement)).toBeVisible(); + }); + }); + }); + + describe('toggleEnableRow method', () => { + beforeEach(() => { + loadFixtures('pipeline_schedules/edit_with_variables.html'); + $wrapper = $('.js-ci-variable-list-section'); + + variableList = new VariableList({ + container: $wrapper, + formField: 'variables', + }); + variableList.init(); + }); + + it('should disable all key inputs', () => { + expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3); + + variableList.toggleEnableRow(false); + + expect($wrapper.find('.js-ci-variable-input-key[disabled]').length).toBe(3); + }); + + it('should disable all remove buttons', () => { + expect($wrapper.find('.js-row-remove-button:not([disabled])').length).toBe(3); + + variableList.toggleEnableRow(false); + + expect($wrapper.find('.js-row-remove-button[disabled]').length).toBe(3); + }); + + it('should enable all remove buttons', () => { + variableList.toggleEnableRow(false); + + expect($wrapper.find('.js-row-remove-button[disabled]').length).toBe(3); + + variableList.toggleEnableRow(true); + + expect($wrapper.find('.js-row-remove-button:not([disabled])').length).toBe(3); + }); + + it('should enable all key inputs', () => { + variableList.toggleEnableRow(false); + + expect($wrapper.find('.js-ci-variable-input-key[disabled]').length).toBe(3); + + variableList.toggleEnableRow(true); + + expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3); + }); + }); + + describe('hideValues', () => { + beforeEach(() => { + loadFixtures('projects/ci_cd_settings.html'); + $wrapper = $('.js-ci-variable-list-section'); + + variableList = new VariableList({ + container: $wrapper, + formField: 'variables', + }); + variableList.init(); + }); + + it('should hide value input and show placeholder stars', () => { + const $row = $wrapper.find('.js-row'); + const $inputValue = $row.find('.js-ci-variable-input-value'); + const $placeholder = $row.find('.js-secret-value-placeholder'); + + $row + .find('.js-ci-variable-input-value') + .val('foo') + .trigger('input'); + + expect($placeholder.hasClass(HIDE_CLASS)).toBe(true); + expect($inputValue.hasClass(HIDE_CLASS)).toBe(false); + + variableList.hideValues(); + + expect($placeholder.hasClass(HIDE_CLASS)).toBe(false); + expect($inputValue.hasClass(HIDE_CLASS)).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js new file mode 100644 index 00000000000..4982b68fa81 --- /dev/null +++ b/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js @@ -0,0 +1,37 @@ +import $ from 'jquery'; +import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list'; + +describe('NativeFormVariableList', () => { + preloadFixtures('pipeline_schedules/edit.html'); + + let $wrapper; + + beforeEach(() => { + loadFixtures('pipeline_schedules/edit.html'); + $wrapper = $('.js-ci-variable-list-section'); + + setupNativeFormVariableList({ + container: $wrapper, + formField: 'schedule', + }); + }); + + describe('onFormSubmit', () => { + it('should clear out the `name` attribute on the inputs for the last empty row on form submission (avoid BE validation)', () => { + const $row = $wrapper.find('.js-row'); + + expect($row.find('.js-ci-variable-input-key').attr('name')).toBe( + 'schedule[variables_attributes][][key]', + ); + + expect($row.find('.js-ci-variable-input-value').attr('name')).toBe( + 'schedule[variables_attributes][][secret_value]', + ); + + $wrapper.closest('form').trigger('trigger-submit'); + + expect($row.find('.js-ci-variable-input-key').attr('name')).toBe(''); + expect($row.find('.js-ci-variable-input-value').attr('name')).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 7b8d69df35e..9179302f786 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 @@ -96,6 +96,13 @@ describe('Ci variable modal', () => { findModal().vm.$emit('hidden'); expect(store.dispatch).toHaveBeenCalledWith('clearModal'); }); + + it('should dispatch setVariableProtected when admin settings are configured to protect variables', () => { + store.state.isProtectedByDefault = true; + findModal().vm.$emit('shown'); + + expect(store.dispatch).toHaveBeenCalledWith('setVariableProtected'); + }); }); describe('Editing a variable', () => { diff --git a/spec/frontend/ci_variable_list/services/mock_data.js b/spec/frontend/ci_variable_list/services/mock_data.js index 09c6cd9de21..7dab33050d9 100644 --- a/spec/frontend/ci_variable_list/services/mock_data.js +++ b/spec/frontend/ci_variable_list/services/mock_data.js @@ -8,7 +8,7 @@ export default { protected: false, secret_value: 'test_val', value: 'test_val', - variable_type: 'Var', + variable_type: 'Variable', }, ], @@ -44,7 +44,7 @@ export default { protected: false, secret_value: 'test_val', value: 'test_val', - variable_type: 'Var', + variable_type: 'Variable', }, { environment_scope: 'All (default)', @@ -104,7 +104,7 @@ export default { id: 28, key: 'goku_var', value: 'goku_val', - variable_type: 'Var', + variable_type: 'Variable', protected: true, masked: true, environment_scope: 'staging', @@ -114,7 +114,7 @@ export default { id: 25, key: 'test_var_4', value: 'test_val_4', - variable_type: 'Var', + variable_type: 'Variable', protected: false, masked: false, environment_scope: 'production', @@ -134,7 +134,7 @@ export default { id: 24, key: 'test_var_3', value: 'test_val_3', - variable_type: 'Var', + variable_type: 'Variable', protected: false, masked: false, environment_scope: 'All (default)', @@ -144,7 +144,7 @@ export default { id: 26, key: 'test_var_5', value: 'test_val_5', - variable_type: 'Var', + variable_type: 'Variable', protected: false, masked: false, environment_scope: 'production', diff --git a/spec/frontend/ci_variable_list/store/actions_spec.js b/spec/frontend/ci_variable_list/store/actions_spec.js index 84455612f0c..12b4311d0f5 100644 --- a/spec/frontend/ci_variable_list/store/actions_spec.js +++ b/spec/frontend/ci_variable_list/store/actions_spec.js @@ -75,6 +75,16 @@ describe('CI variable list store actions', () => { }); }); + describe('setVariableProtected', () => { + it('commits SET_VARIABLE_PROTECTED mutation', () => { + testAction(actions.setVariableProtected, {}, {}, [ + { + type: types.SET_VARIABLE_PROTECTED, + }, + ]); + }); + }); + describe('deleteVariable', () => { it('dispatch correct actions on successful deleted variable', done => { mock.onPatch(state.endpoint).reply(200); diff --git a/spec/frontend/ci_variable_list/store/mutations_spec.js b/spec/frontend/ci_variable_list/store/mutations_spec.js index 8652359f3df..1934d108957 100644 --- a/spec/frontend/ci_variable_list/store/mutations_spec.js +++ b/spec/frontend/ci_variable_list/store/mutations_spec.js @@ -47,7 +47,7 @@ describe('CI variable list mutations', () => { describe('CLEAR_MODAL', () => { it('should clear modal state ', () => { const modalState = { - variable_type: 'Var', + variable_type: 'Variable', key: '', secret_value: '', protected: false, @@ -97,4 +97,12 @@ describe('CI variable list mutations', () => { expect(stateCopy.environments).toEqual(['dev', 'production', 'staging']); }); }); + + describe('SET_VARIABLE_PROTECTED', () => { + it('should set protected value to true', () => { + mutations[types.SET_VARIABLE_PROTECTED](stateCopy); + + expect(stateCopy.variable.protected).toBe(true); + }); + }); }); diff --git a/spec/frontend/close_reopen_report_toggle_spec.js b/spec/frontend/close_reopen_report_toggle_spec.js new file mode 100644 index 00000000000..f6b5e4bed87 --- /dev/null +++ b/spec/frontend/close_reopen_report_toggle_spec.js @@ -0,0 +1,288 @@ +import CloseReopenReportToggle from '~/close_reopen_report_toggle'; +import DropLab from '~/droplab/drop_lab'; + +describe('CloseReopenReportToggle', () => { + describe('class constructor', () => { + const dropdownTrigger = {}; + const dropdownList = {}; + const button = {}; + let commentTypeToggle; + + beforeEach(() => { + commentTypeToggle = new CloseReopenReportToggle({ + dropdownTrigger, + dropdownList, + button, + }); + }); + + it('sets .dropdownTrigger', () => { + expect(commentTypeToggle.dropdownTrigger).toBe(dropdownTrigger); + }); + + it('sets .dropdownList', () => { + expect(commentTypeToggle.dropdownList).toBe(dropdownList); + }); + + it('sets .button', () => { + expect(commentTypeToggle.button).toBe(button); + }); + }); + + describe('initDroplab', () => { + let closeReopenReportToggle; + const dropdownList = { + querySelector: jest.fn(), + }; + const dropdownTrigger = {}; + const button = {}; + const reopenItem = {}; + const closeItem = {}; + const config = {}; + + beforeEach(() => { + jest.spyOn(DropLab.prototype, 'init').mockImplementation(() => {}); + dropdownList.querySelector.mockReturnValueOnce(reopenItem).mockReturnValueOnce(closeItem); + + closeReopenReportToggle = new CloseReopenReportToggle({ + dropdownTrigger, + dropdownList, + button, + }); + + jest.spyOn(closeReopenReportToggle, 'setConfig').mockReturnValue(config); + + closeReopenReportToggle.initDroplab(); + }); + + it('sets .reopenItem and .closeItem', () => { + expect(dropdownList.querySelector).toHaveBeenCalledWith('.reopen-item'); + expect(dropdownList.querySelector).toHaveBeenCalledWith('.close-item'); + expect(closeReopenReportToggle.reopenItem).toBe(reopenItem); + expect(closeReopenReportToggle.closeItem).toBe(closeItem); + }); + + it('sets .droplab', () => { + expect(closeReopenReportToggle.droplab).toEqual(expect.any(Object)); + }); + + it('calls .setConfig', () => { + expect(closeReopenReportToggle.setConfig).toHaveBeenCalled(); + }); + + it('calls droplab.init', () => { + expect(DropLab.prototype.init).toHaveBeenCalledWith( + dropdownTrigger, + dropdownList, + expect.any(Array), + config, + ); + }); + }); + + describe('updateButton', () => { + let closeReopenReportToggle; + const dropdownList = {}; + const dropdownTrigger = {}; + const button = { + blur: jest.fn(), + }; + const isClosed = true; + + beforeEach(() => { + closeReopenReportToggle = new CloseReopenReportToggle({ + dropdownTrigger, + dropdownList, + button, + }); + + jest.spyOn(closeReopenReportToggle, 'toggleButtonType').mockImplementation(() => {}); + + closeReopenReportToggle.updateButton(isClosed); + }); + + it('calls .toggleButtonType', () => { + expect(closeReopenReportToggle.toggleButtonType).toHaveBeenCalledWith(isClosed); + }); + + it('calls .button.blur', () => { + expect(closeReopenReportToggle.button.blur).toHaveBeenCalled(); + }); + }); + + describe('toggleButtonType', () => { + let closeReopenReportToggle; + const dropdownList = {}; + const dropdownTrigger = {}; + const button = {}; + const isClosed = true; + const showItem = { + click: jest.fn(), + }; + const hideItem = {}; + showItem.classList = { + add: jest.fn(), + remove: jest.fn(), + }; + hideItem.classList = { + add: jest.fn(), + remove: jest.fn(), + }; + + beforeEach(() => { + closeReopenReportToggle = new CloseReopenReportToggle({ + dropdownTrigger, + dropdownList, + button, + }); + + jest.spyOn(closeReopenReportToggle, 'getButtonTypes').mockReturnValue([showItem, hideItem]); + + closeReopenReportToggle.toggleButtonType(isClosed); + }); + + it('calls .getButtonTypes', () => { + expect(closeReopenReportToggle.getButtonTypes).toHaveBeenCalledWith(isClosed); + }); + + it('removes hide class and add selected class to showItem, opposite for hideItem', () => { + expect(showItem.classList.remove).toHaveBeenCalledWith('hidden'); + expect(showItem.classList.add).toHaveBeenCalledWith('droplab-item-selected'); + expect(hideItem.classList.add).toHaveBeenCalledWith('hidden'); + expect(hideItem.classList.remove).toHaveBeenCalledWith('droplab-item-selected'); + }); + + it('clicks the showItem', () => { + expect(showItem.click).toHaveBeenCalled(); + }); + }); + + describe('getButtonTypes', () => { + let closeReopenReportToggle; + const dropdownList = {}; + const dropdownTrigger = {}; + const button = {}; + const reopenItem = {}; + const closeItem = {}; + + beforeEach(() => { + closeReopenReportToggle = new CloseReopenReportToggle({ + dropdownTrigger, + dropdownList, + button, + }); + + closeReopenReportToggle.reopenItem = reopenItem; + closeReopenReportToggle.closeItem = closeItem; + }); + + it('returns reopenItem, closeItem if isClosed is true', () => { + const buttonTypes = closeReopenReportToggle.getButtonTypes(true); + + expect(buttonTypes).toEqual([reopenItem, closeItem]); + }); + + it('returns closeItem, reopenItem if isClosed is false', () => { + const buttonTypes = closeReopenReportToggle.getButtonTypes(false); + + expect(buttonTypes).toEqual([closeItem, reopenItem]); + }); + }); + + describe('setDisable', () => { + let closeReopenReportToggle; + const dropdownList = {}; + const dropdownTrigger = { + setAttribute: jest.fn(), + removeAttribute: jest.fn(), + }; + const button = { + setAttribute: jest.fn(), + removeAttribute: jest.fn(), + }; + + beforeEach(() => { + closeReopenReportToggle = new CloseReopenReportToggle({ + dropdownTrigger, + dropdownList, + button, + }); + }); + + it('disable .button and .dropdownTrigger if shouldDisable is true', () => { + closeReopenReportToggle.setDisable(true); + + expect(button.setAttribute).toHaveBeenCalledWith('disabled', 'true'); + expect(dropdownTrigger.setAttribute).toHaveBeenCalledWith('disabled', 'true'); + }); + + it('disable .button and .dropdownTrigger if shouldDisable is undefined', () => { + closeReopenReportToggle.setDisable(); + + expect(button.setAttribute).toHaveBeenCalledWith('disabled', 'true'); + expect(dropdownTrigger.setAttribute).toHaveBeenCalledWith('disabled', 'true'); + }); + + it('enable .button and .dropdownTrigger if shouldDisable is false', () => { + closeReopenReportToggle.setDisable(false); + + expect(button.removeAttribute).toHaveBeenCalledWith('disabled'); + expect(dropdownTrigger.removeAttribute).toHaveBeenCalledWith('disabled'); + }); + }); + + describe('setConfig', () => { + let closeReopenReportToggle; + const dropdownList = {}; + const dropdownTrigger = {}; + const button = {}; + let config; + + beforeEach(() => { + closeReopenReportToggle = new CloseReopenReportToggle({ + dropdownTrigger, + dropdownList, + button, + }); + + config = closeReopenReportToggle.setConfig(); + }); + + it('returns a config object', () => { + expect(config).toEqual({ + InputSetter: [ + { + input: button, + valueAttribute: 'data-text', + inputAttribute: 'data-value', + }, + { + input: button, + valueAttribute: 'data-text', + inputAttribute: 'title', + }, + { + input: button, + valueAttribute: 'data-button-class', + inputAttribute: 'class', + }, + { + input: dropdownTrigger, + valueAttribute: 'data-toggle-class', + inputAttribute: 'class', + }, + { + input: button, + valueAttribute: 'data-url', + inputAttribute: 'href', + }, + { + input: button, + valueAttribute: 'data-method', + inputAttribute: 'data-method', + }, + ], + }); + }); + }); +}); diff --git a/spec/frontend/clusters/components/applications_spec.js b/spec/frontend/clusters/components/applications_spec.js index 782e5215ad8..33b30891d5e 100644 --- a/spec/frontend/clusters/components/applications_spec.js +++ b/spec/frontend/clusters/components/applications_spec.js @@ -8,6 +8,7 @@ import eventHub from '~/clusters/event_hub'; import KnativeDomainEditor from '~/clusters/components/knative_domain_editor.vue'; import CrossplaneProviderStack from '~/clusters/components/crossplane_provider_stack.vue'; import IngressModsecuritySettings from '~/clusters/components/ingress_modsecurity_settings.vue'; +import FluentdOutputSettings from '~/clusters/components/fluentd_output_settings.vue'; describe('Applications', () => { let vm; @@ -67,6 +68,10 @@ describe('Applications', () => { it('renders a row for Elastic Stack', () => { expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull(); }); + + it('renders a row for Fluentd', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-fluentd')).not.toBeNull(); + }); }); describe('Group cluster applications', () => { @@ -112,6 +117,10 @@ describe('Applications', () => { it('renders a row for Elastic Stack', () => { expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull(); }); + + it('renders a row for Fluentd', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-fluentd')).not.toBeNull(); + }); }); describe('Instance cluster applications', () => { @@ -157,6 +166,10 @@ describe('Applications', () => { it('renders a row for Elastic Stack', () => { expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull(); }); + + it('renders a row for Fluentd', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-fluentd')).not.toBeNull(); + }); }); describe('Helm application', () => { @@ -240,6 +253,7 @@ describe('Applications', () => { jupyter: { title: 'JupyterHub', hostname: '' }, knative: { title: 'Knative', hostname: '' }, elastic_stack: { title: 'Elastic Stack' }, + fluentd: { title: 'Fluentd' }, }, }); @@ -539,4 +553,23 @@ describe('Applications', () => { }); }); }); + + describe('Fluentd application', () => { + const propsData = { + applications: { + ...APPLICATIONS_MOCK_STATE, + }, + }; + + let wrapper; + beforeEach(() => { + wrapper = shallowMount(Applications, { propsData }); + }); + afterEach(() => { + wrapper.destroy(); + }); + it('renders the correct Component', () => { + expect(wrapper.contains(FluentdOutputSettings)).toBe(true); + }); + }); }); diff --git a/spec/frontend/clusters/components/fluentd_output_settings_spec.js b/spec/frontend/clusters/components/fluentd_output_settings_spec.js new file mode 100644 index 00000000000..5e27cc49049 --- /dev/null +++ b/spec/frontend/clusters/components/fluentd_output_settings_spec.js @@ -0,0 +1,186 @@ +import { shallowMount } from '@vue/test-utils'; +import FluentdOutputSettings from '~/clusters/components/fluentd_output_settings.vue'; +import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants'; +import { GlAlert, GlDropdown, GlFormCheckbox } from '@gitlab/ui'; +import eventHub from '~/clusters/event_hub'; + +const { UPDATING } = APPLICATION_STATUS; + +describe('FluentdOutputSettings', () => { + let wrapper; + + const defaultSettings = { + protocol: 'tcp', + host: '127.0.0.1', + port: 514, + wafLogEnabled: true, + ciliumLogEnabled: false, + }; + const defaultProps = { + status: 'installable', + updateFailed: false, + ...defaultSettings, + }; + + const createComponent = (props = {}) => { + wrapper = shallowMount(FluentdOutputSettings, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + const updateComponentPropsFromEvent = () => { + const { isEditingSettings, ...props } = eventHub.$emit.mock.calls[0][1]; + wrapper.setProps(props); + }; + const findSaveButton = () => wrapper.find({ ref: 'saveBtn' }); + const findCancelButton = () => wrapper.find({ ref: 'cancelBtn' }); + const findProtocolDropdown = () => wrapper.find(GlDropdown); + const findCheckbox = name => + wrapper.findAll(GlFormCheckbox).wrappers.find(x => x.text() === name); + const findHost = () => wrapper.find('#fluentd-host'); + const findPort = () => wrapper.find('#fluentd-port'); + const changeCheckbox = checkbox => { + const currentValue = checkbox.attributes('checked')?.toString() === 'true'; + checkbox.vm.$emit('input', !currentValue); + }; + const changeInput = ({ element }, val) => { + element.value = val; + element.dispatchEvent(new Event('input')); + }; + const changePort = val => changeInput(findPort(), val); + const changeHost = val => changeInput(findHost(), val); + const changeProtocol = idx => findProtocolDropdown().vm.$children[idx].$emit('click'); + const toApplicationSettings = ({ wafLogEnabled, ciliumLogEnabled, ...settings }) => ({ + ...settings, + waf_log_enabled: wafLogEnabled, + cilium_log_enabled: ciliumLogEnabled, + }); + + describe('when fluentd is installed', () => { + beforeEach(() => { + createComponent({ status: 'installed' }); + jest.spyOn(eventHub, '$emit'); + }); + + it('does not render save and cancel buttons', () => { + expect(findSaveButton().exists()).toBe(false); + expect(findCancelButton().exists()).toBe(false); + }); + + describe.each` + desc | changeFn | key | value + ${'when protocol dropdown is triggered'} | ${() => changeProtocol(1)} | ${'protocol'} | ${'udp'} + ${'when host is changed'} | ${() => changeHost('test-host')} | ${'host'} | ${'test-host'} + ${'when port is changed'} | ${() => changePort(123)} | ${'port'} | ${123} + ${'when wafLogEnabled changes'} | ${() => changeCheckbox(findCheckbox('Send ModSecurity Logs'))} | ${'wafLogEnabled'} | ${!defaultSettings.wafLogEnabled} + ${'when ciliumLogEnabled changes'} | ${() => changeCheckbox(findCheckbox('Send Cilium Logs'))} | ${'ciliumLogEnabled'} | ${!defaultSettings.ciliumLogEnabled} + `('$desc', ({ changeFn, key, value }) => { + beforeEach(() => { + changeFn(); + }); + + it('triggers set event to be propagated with the current value', () => { + expect(eventHub.$emit).toHaveBeenCalledWith('setFluentdSettings', { + [key]: value, + isEditingSettings: true, + }); + }); + + describe('when value is updated from store', () => { + beforeEach(() => { + updateComponentPropsFromEvent(); + }); + + it('enables save and cancel buttons', () => { + expect(findSaveButton().exists()).toBe(true); + expect(findSaveButton().attributes().disabled).toBeUndefined(); + expect(findCancelButton().exists()).toBe(true); + expect(findCancelButton().attributes().disabled).toBeUndefined(); + }); + + describe('and the save changes button is clicked', () => { + beforeEach(() => { + eventHub.$emit.mockClear(); + findSaveButton().vm.$emit('click'); + }); + + it('triggers save event and pass current values', () => { + expect(eventHub.$emit).toHaveBeenCalledWith('updateApplication', { + id: FLUENTD, + params: toApplicationSettings({ + ...defaultSettings, + [key]: value, + }), + }); + }); + }); + + describe('and the cancel button is clicked', () => { + beforeEach(() => { + eventHub.$emit.mockClear(); + findCancelButton().vm.$emit('click'); + }); + + it('triggers reset event', () => { + expect(eventHub.$emit).toHaveBeenCalledWith('setFluentdSettings', { + ...defaultSettings, + isEditingSettings: false, + }); + }); + + describe('when value is updated from store', () => { + beforeEach(() => { + updateComponentPropsFromEvent(); + }); + + it('does not render save and cancel buttons', () => { + expect(findSaveButton().exists()).toBe(false); + expect(findCancelButton().exists()).toBe(false); + }); + }); + }); + }); + }); + + describe(`when fluentd status is ${UPDATING}`, () => { + beforeEach(() => { + createComponent({ installed: true, status: UPDATING }); + }); + + it('renders loading spinner in save button', () => { + expect(findSaveButton().props('loading')).toBe(true); + }); + + it('renders disabled save button', () => { + expect(findSaveButton().props('disabled')).toBe(true); + }); + + it('renders save button with "Saving" label', () => { + expect(findSaveButton().text()).toBe('Saving'); + }); + }); + + describe('when fluentd fails to update', () => { + beforeEach(() => { + createComponent({ updateFailed: true }); + }); + + it('displays a error message', () => { + expect(wrapper.contains(GlAlert)).toBe(true); + }); + }); + }); + + describe('when fluentd is not installed', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not render the save button', () => { + expect(findSaveButton().exists()).toBe(false); + expect(findCancelButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/clusters/components/knative_domain_editor_spec.js b/spec/frontend/clusters/components/knative_domain_editor_spec.js index 2de04f7da1f..73d08661199 100644 --- a/spec/frontend/clusters/components/knative_domain_editor_spec.js +++ b/spec/frontend/clusters/components/knative_domain_editor_spec.js @@ -93,7 +93,7 @@ describe('KnativeDomainEditor', () => { it('displays toast indicating a successful update', () => { wrapper.vm.$toast = { show: jest.fn() }; - wrapper.setProps({ knative: Object.assign({ updateSuccessful: true }, knative) }); + wrapper.setProps({ knative: { updateSuccessful: true, ...knative } }); return wrapper.vm.$nextTick(() => { expect(wrapper.vm.$toast.show).toHaveBeenCalledWith( diff --git a/spec/frontend/clusters/services/mock_data.js b/spec/frontend/clusters/services/mock_data.js index 52d78ea1176..c5ec3f6e6a8 100644 --- a/spec/frontend/clusters/services/mock_data.js +++ b/spec/frontend/clusters/services/mock_data.js @@ -159,6 +159,7 @@ const APPLICATIONS_MOCK_STATE = { jupyter: { title: 'JupyterHub', status: 'installable', hostname: '' }, knative: { title: 'Knative ', status: 'installable', hostname: '' }, elastic_stack: { title: 'Elastic Stack', status: 'installable' }, + fluentd: { title: 'Fluentd', status: 'installable' }, }; export { CLUSTERS_MOCK_DATA, DEFAULT_APPLICATION_STATE, APPLICATIONS_MOCK_STATE }; diff --git a/spec/frontend/clusters/stores/clusters_store_spec.js b/spec/frontend/clusters/stores/clusters_store_spec.js index 9fafc688af9..36e99c37be5 100644 --- a/spec/frontend/clusters/stores/clusters_store_spec.js +++ b/spec/frontend/clusters/stores/clusters_store_spec.js @@ -121,6 +121,24 @@ describe('Clusters Store', () => { uninstallFailed: false, validationError: null, }, + fluentd: { + title: 'Fluentd', + status: null, + statusReason: null, + requestReason: null, + port: null, + ciliumLogEnabled: null, + host: null, + protocol: null, + installed: false, + isEditingSettings: false, + installFailed: false, + uninstallable: false, + uninstallSuccessful: false, + uninstallFailed: false, + validationError: null, + wafLogEnabled: null, + }, jupyter: { title: 'JupyterHub', status: mockResponseData.applications[4].status, diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js index 85c86b2c0a9..e2d2e4b73b3 100644 --- a/spec/frontend/clusters_list/components/clusters_spec.js +++ b/spec/frontend/clusters_list/components/clusters_spec.js @@ -1,46 +1,68 @@ -import Vuex from 'vuex'; -import { createLocalVue, mount } from '@vue/test-utils'; -import { GlTable, GlLoadingIcon } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; import Clusters from '~/clusters_list/components/clusters.vue'; -import mockData from '../mock_data'; - -const localVue = createLocalVue(); -localVue.use(Vuex); +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'; describe('Clusters', () => { + let mock; + let store; let wrapper; - const findTable = () => wrapper.find(GlTable); + const endpoint = 'some/endpoint'; + const findLoader = () => wrapper.find(GlLoadingIcon); + const findPaginatedButtons = () => wrapper.find(GlPagination); + const findTable = () => wrapper.find(GlTable); const findStatuses = () => findTable().findAll('.js-status'); - const mountComponent = _state => { - const state = { clusters: mockData, endpoint: 'some/endpoint', ..._state }; - const store = new Vuex.Store({ - state, - }); + const mockPollingApi = (response, body, header) => { + mock.onGet(`${endpoint}?page=${header['x-page']}`).reply(response, body, header); + }; - wrapper = mount(Clusters, { localVue, store }); + const mountWrapper = () => { + store = ClusterStore({ endpoint }); + wrapper = mount(Clusters, { store }); + return axios.waitForAll(); }; beforeEach(() => { - mountComponent({ loading: false }); + mock = new MockAdapter(axios); + mockPollingApi(200, apiData, { + 'x-total': apiData.clusters.length, + 'x-per-page': 20, + 'x-page': 1, + }); + + return mountWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); }); describe('clusters table', () => { - it('displays a loader instead of the table while loading', () => { - mountComponent({ loading: true }); - expect(findLoader().exists()).toBe(true); - expect(findTable().exists()).toBe(false); + describe('when data is loading', () => { + beforeEach(() => { + wrapper.vm.$store.state.loading = true; + return wrapper.vm.$nextTick(); + }); + + it('displays a loader instead of the table while loading', () => { + expect(findLoader().exists()).toBe(true); + expect(findTable().exists()).toBe(false); + }); }); it('displays a table component', () => { expect(findTable().exists()).toBe(true); - expect(findTable().exists()).toBe(true); }); it('renders the correct table headers', () => { - const tableHeaders = wrapper.vm.$options.fields; + const tableHeaders = wrapper.vm.fields; const headers = findTable().findAll('th'); expect(headers.length).toBe(tableHeaders.length); @@ -62,7 +84,8 @@ describe('Clusters', () => { ${'unreachable'} | ${'bg-danger'} | ${1} ${'authentication_failure'} | ${'bg-warning'} | ${2} ${'deleting'} | ${null} | ${3} - ${'connected'} | ${'bg-success'} | ${4} + ${'created'} | ${'bg-success'} | ${4} + ${'default'} | ${'bg-white'} | ${5} `('renders a status for each cluster', ({ statusName, className, lineNumber }) => { const statuses = findStatuses(); const status = statuses.at(lineNumber); @@ -75,4 +98,47 @@ describe('Clusters', () => { } }); }); + + describe('pagination', () => { + const perPage = apiData.clusters.length; + const totalFirstPage = 100; + const totalSecondPage = 500; + + beforeEach(() => { + mockPollingApi(200, apiData, { + 'x-total': totalFirstPage, + 'x-per-page': perPage, + 'x-page': 1, + }); + return mountWrapper(); + }); + + it('should load to page 1 with header values', () => { + const buttons = findPaginatedButtons(); + + expect(buttons.props('perPage')).toBe(perPage); + expect(buttons.props('totalItems')).toBe(totalFirstPage); + expect(buttons.props('value')).toBe(1); + }); + + describe('when updating currentPage', () => { + beforeEach(() => { + mockPollingApi(200, apiData, { + 'x-total': totalSecondPage, + 'x-per-page': perPage, + 'x-page': 2, + }); + wrapper.setData({ currentPage: 2 }); + return axios.waitForAll(); + }); + + it('should change pagination when currentPage changes', () => { + const buttons = findPaginatedButtons(); + + expect(buttons.props('perPage')).toBe(perPage); + expect(buttons.props('totalItems')).toBe(totalSecondPage); + expect(buttons.props('value')).toBe(2); + }); + }); + }); }); diff --git a/spec/frontend/clusters_list/mock_data.js b/spec/frontend/clusters_list/mock_data.js index 5398975d81c..9a90a378f31 100644 --- a/spec/frontend/clusters_list/mock_data.js +++ b/spec/frontend/clusters_list/mock_data.js @@ -1,4 +1,4 @@ -export default [ +export const clusterList = [ { name: 'My Cluster 1', environmentScope: '*', @@ -40,8 +40,22 @@ export default [ environmentScope: 'development', size: '12', clusterType: 'project_type', - status: 'connected', + status: 'created', + cpu: '6 (100% free)', + memory: '20.12 (35% free)', + }, + { + name: 'My Cluster 6', + environmentScope: '*', + size: '1', + clusterType: 'project_type', + status: 'cleanup_ongoing', cpu: '6 (100% free)', memory: '20.12 (35% free)', }, ]; + +export const apiData = { + clusters: clusterList, + has_ancestor_clusters: false, +}; diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js index e903200bf1d..70766af3ec4 100644 --- a/spec/frontend/clusters_list/store/actions_spec.js +++ b/spec/frontend/clusters_list/store/actions_spec.js @@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import flashError from '~/flash'; import testAction from 'helpers/vuex_action_helper'; import axios from '~/lib/utils/axios_utils'; +import { apiData } from '../mock_data'; import * as types from '~/clusters_list/store/mutation_types'; import * as actions from '~/clusters_list/store/actions'; @@ -10,8 +11,6 @@ jest.mock('~/flash.js'); describe('Clusters store actions', () => { describe('fetchClusters', () => { let mock; - const endpoint = '/clusters'; - const clusters = [{ name: 'test' }]; beforeEach(() => { mock = new MockAdapter(axios); @@ -20,14 +19,29 @@ describe('Clusters store actions', () => { afterEach(() => mock.restore()); it('should commit SET_CLUSTERS_DATA with received response', done => { - mock.onGet().reply(200, clusters); + const headers = { + 'x-total': apiData.clusters.length, + 'x-per-page': 20, + 'x-page': 1, + }; + + const paginationInformation = { + nextPage: NaN, + page: 1, + perPage: 20, + previousPage: NaN, + total: apiData.clusters.length, + totalPages: NaN, + }; + + mock.onGet().reply(200, apiData, headers); testAction( actions.fetchClusters, - { endpoint }, + { endpoint: apiData.endpoint }, {}, [ - { type: types.SET_CLUSTERS_DATA, payload: clusters }, + { type: types.SET_CLUSTERS_DATA, payload: { data: apiData, paginationInformation } }, { type: types.SET_LOADING_STATE, payload: false }, ], [], @@ -38,13 +52,10 @@ describe('Clusters store actions', () => { it('should show flash on API error', done => { mock.onGet().reply(400, 'Not Found'); - testAction(actions.fetchClusters, { endpoint }, {}, [], [], () => { + testAction(actions.fetchClusters, { endpoint: apiData.endpoint }, {}, [], [], () => { expect(flashError).toHaveBeenCalledWith(expect.stringMatching('error')); done(); }); }); }); }); - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; 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 c1534022242..c9fdd388585 100644 --- a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap +++ b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap @@ -23,17 +23,20 @@ exports[`Code navigation popover component renders popover 1`] = ` <div class="popover-body" > - <gl-deprecated-button-stub + <gl-button-stub + category="tertiary" class="w-100" - href="http://test.com" - size="md" + data-testid="go-to-definition-btn" + href="http://gitlab.com/test.js#L20" + icon="" + size="medium" target="_blank" variant="default" > Go to definition - </gl-deprecated-button-stub> + </gl-button-stub> </div> </div> `; diff --git a/spec/frontend/code_navigation/components/app_spec.js b/spec/frontend/code_navigation/components/app_spec.js index d5693cc4173..6dfc81dcc40 100644 --- a/spec/frontend/code_navigation/components/app_spec.js +++ b/spec/frontend/code_navigation/components/app_spec.js @@ -48,6 +48,7 @@ describe('Code navigation app component', () => { factory({ currentDefinition: { hover: 'console' }, currentDefinitionPosition: { x: 0 }, + currentBlobPath: 'index.js', }); expect(wrapper.find(Popover).exists()).toBe(true); diff --git a/spec/frontend/code_navigation/components/popover_spec.js b/spec/frontend/code_navigation/components/popover_spec.js index df3bbc7c1c6..858e94cf155 100644 --- a/spec/frontend/code_navigation/components/popover_spec.js +++ b/spec/frontend/code_navigation/components/popover_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import Popover from '~/code_navigation/components/popover.vue'; -const DEFINITION_PATH_PREFIX = 'http:/'; +const DEFINITION_PATH_PREFIX = 'http://gitlab.com'; const MOCK_CODE_DATA = Object.freeze({ hover: [ @@ -10,7 +10,7 @@ const MOCK_CODE_DATA = Object.freeze({ value: 'console.log', }, ], - definition_path: 'test.com', + definition_path: 'test.js#L20', }); const MOCK_DOCS_DATA = Object.freeze({ @@ -20,13 +20,15 @@ const MOCK_DOCS_DATA = Object.freeze({ value: 'console.log', }, ], - definition_path: 'test.com', + definition_path: 'test.js#L20', }); let wrapper; -function factory(position, data, definitionPathPrefix) { - wrapper = shallowMount(Popover, { propsData: { position, data, definitionPathPrefix } }); +function factory({ position, data, definitionPathPrefix, blobPath = 'index.js' }) { + wrapper = shallowMount(Popover, { + propsData: { position, data, definitionPathPrefix, blobPath }, + }); } describe('Code navigation popover component', () => { @@ -35,14 +37,33 @@ describe('Code navigation popover component', () => { }); it('renders popover', () => { - factory({ x: 0, y: 0, height: 0 }, MOCK_CODE_DATA, DEFINITION_PATH_PREFIX); + factory({ + position: { x: 0, y: 0, height: 0 }, + data: MOCK_CODE_DATA, + definitionPathPrefix: DEFINITION_PATH_PREFIX, + }); expect(wrapper.element).toMatchSnapshot(); }); + it('renders link with hash to current file', () => { + factory({ + position: { x: 0, y: 0, height: 0 }, + data: MOCK_CODE_DATA, + definitionPathPrefix: DEFINITION_PATH_PREFIX, + blobPath: 'test.js', + }); + + expect(wrapper.find('[data-testid="go-to-definition-btn"]').attributes('href')).toBe('#L20'); + }); + describe('code output', () => { it('renders code output', () => { - factory({ x: 0, y: 0, height: 0 }, MOCK_CODE_DATA, DEFINITION_PATH_PREFIX); + factory({ + position: { x: 0, y: 0, height: 0 }, + data: MOCK_CODE_DATA, + definitionPathPrefix: DEFINITION_PATH_PREFIX, + }); expect(wrapper.find({ ref: 'code-output' }).exists()).toBe(true); expect(wrapper.find({ ref: 'doc-output' }).exists()).toBe(false); @@ -51,7 +72,11 @@ describe('Code navigation popover component', () => { describe('documentation output', () => { it('renders code output', () => { - factory({ x: 0, y: 0, height: 0 }, MOCK_DOCS_DATA, DEFINITION_PATH_PREFIX); + factory({ + position: { x: 0, y: 0, height: 0 }, + data: MOCK_DOCS_DATA, + definitionPathPrefix: DEFINITION_PATH_PREFIX, + }); expect(wrapper.find({ ref: 'code-output' }).exists()).toBe(false); expect(wrapper.find({ ref: 'doc-output' }).exists()).toBe(true); diff --git a/spec/frontend/code_navigation/store/actions_spec.js b/spec/frontend/code_navigation/store/actions_spec.js index 6d2ede6dda7..4cf77ed1be5 100644 --- a/spec/frontend/code_navigation/store/actions_spec.js +++ b/spec/frontend/code_navigation/store/actions_spec.js @@ -143,6 +143,16 @@ describe('Code navigation actions', () => { expect(addInteractionClass.mock.calls[0]).toEqual(['index.js', 'test']); expect(addInteractionClass.mock.calls[1]).toEqual(['index.js', 'console.log']); }); + + it('does not call addInteractionClass when no data exists', () => { + const state = { + data: null, + }; + + actions.showBlobInteractionZones({ state }, 'index.js'); + + expect(addInteractionClass).not.toHaveBeenCalled(); + }); }); describe('showDefinition', () => { @@ -173,7 +183,11 @@ describe('Code navigation actions', () => { [ { type: 'SET_CURRENT_DEFINITION', - payload: { definition: { hover: 'test' }, position: { height: 0, x: 0, y: 0 } }, + payload: { + blobPath: 'index.js', + definition: { hover: 'test' }, + position: { height: 0, x: 0, y: 0 }, + }, }, ], [], @@ -193,7 +207,11 @@ describe('Code navigation actions', () => { [ { type: 'SET_CURRENT_DEFINITION', - payload: { definition: { hover: 'test' }, position: { height: 0, x: 0, y: 0 } }, + payload: { + blobPath: 'index.js', + definition: { hover: 'test' }, + position: { height: 0, x: 0, y: 0 }, + }, }, ], [], @@ -214,7 +232,11 @@ describe('Code navigation actions', () => { [ { type: 'SET_CURRENT_DEFINITION', - payload: { definition: { hover: 'test' }, position: { height: 0, x: 0, y: 0 } }, + payload: { + blobPath: 'index.js', + definition: { hover: 'test' }, + position: { height: 0, x: 0, y: 0 }, + }, }, ], [], diff --git a/spec/frontend/commit/pipelines/pipelines_spec.js b/spec/frontend/commit/pipelines/pipelines_spec.js index b88cba90b87..86ae207e7b7 100644 --- a/spec/frontend/commit/pipelines/pipelines_spec.js +++ b/spec/frontend/commit/pipelines/pipelines_spec.js @@ -118,7 +118,7 @@ describe('Pipelines table in Commits and Merge requests', () => { let pipelineCopy; beforeEach(() => { - pipelineCopy = Object.assign({}, pipeline); + pipelineCopy = { ...pipeline }; }); describe('when latest pipeline has detached flag and canRunPipeline is true', () => { @@ -128,12 +128,7 @@ describe('Pipelines table in Commits and Merge requests', () => { mock.onGet('endpoint.json').reply(200, [pipelineCopy]); - vm = mountComponent( - PipelinesTable, - Object.assign({}, props, { - canRunPipeline: true, - }), - ); + vm = mountComponent(PipelinesTable, { ...props, canRunPipeline: true }); setImmediate(() => { expect(vm.$el.querySelector('.js-run-mr-pipeline')).not.toBeNull(); @@ -149,12 +144,7 @@ describe('Pipelines table in Commits and Merge requests', () => { mock.onGet('endpoint.json').reply(200, [pipelineCopy]); - vm = mountComponent( - PipelinesTable, - Object.assign({}, props, { - canRunPipeline: false, - }), - ); + vm = mountComponent(PipelinesTable, { ...props, canRunPipeline: false }); setImmediate(() => { expect(vm.$el.querySelector('.js-run-mr-pipeline')).toBeNull(); @@ -170,12 +160,7 @@ describe('Pipelines table in Commits and Merge requests', () => { mock.onGet('endpoint.json').reply(200, [pipelineCopy]); - vm = mountComponent( - PipelinesTable, - Object.assign({}, props, { - canRunPipeline: true, - }), - ); + vm = mountComponent(PipelinesTable, { ...props, canRunPipeline: true }); setImmediate(() => { expect(vm.$el.querySelector('.js-run-mr-pipeline')).toBeNull(); @@ -191,12 +176,7 @@ describe('Pipelines table in Commits and Merge requests', () => { mock.onGet('endpoint.json').reply(200, [pipelineCopy]); - vm = mountComponent( - PipelinesTable, - Object.assign({}, props, { - canRunPipeline: false, - }), - ); + vm = mountComponent(PipelinesTable, { ...props, canRunPipeline: false }); setImmediate(() => { expect(vm.$el.querySelector('.js-run-mr-pipeline')).toBeNull(); @@ -211,14 +191,12 @@ describe('Pipelines table in Commits and Merge requests', () => { mock.onGet('endpoint.json').reply(200, [pipelineCopy]); - vm = mountComponent( - PipelinesTable, - Object.assign({}, props, { - canRunPipeline: true, - projectId: '5', - mergeRequestId: 3, - }), - ); + vm = mountComponent(PipelinesTable, { + ...props, + canRunPipeline: true, + projectId: '5', + mergeRequestId: 3, + }); }); it('updates the loading state', done => { diff --git a/spec/frontend/commit_merge_requests_spec.js b/spec/frontend/commit_merge_requests_spec.js new file mode 100644 index 00000000000..82968e028d1 --- /dev/null +++ b/spec/frontend/commit_merge_requests_spec.js @@ -0,0 +1,69 @@ +import * as CommitMergeRequests from '~/commit_merge_requests'; + +describe('CommitMergeRequests', () => { + describe('createContent', () => { + it('should return created content', () => { + const content1 = CommitMergeRequests.createContent([ + { iid: 1, path: '/path1', title: 'foo' }, + { iid: 2, path: '/path2', title: 'baz' }, + ])[0]; + + expect(content1.tagName).toEqual('SPAN'); + expect(content1.childElementCount).toEqual(4); + + const content2 = CommitMergeRequests.createContent([])[0]; + + expect(content2.tagName).toEqual('SPAN'); + expect(content2.childElementCount).toEqual(0); + expect(content2.innerText).toEqual('No related merge requests found'); + }); + }); + + describe('getHeaderText', () => { + it('should return header text', () => { + expect(CommitMergeRequests.getHeaderText(0, 1)).toEqual('1 merge request'); + expect(CommitMergeRequests.getHeaderText(0, 2)).toEqual('2 merge requests'); + expect(CommitMergeRequests.getHeaderText(1, 1)).toEqual(','); + expect(CommitMergeRequests.getHeaderText(1, 2)).toEqual(','); + }); + }); + + describe('createHeader', () => { + it('should return created header', () => { + const header = CommitMergeRequests.createHeader(0, 1)[0]; + + expect(header.tagName).toEqual('SPAN'); + expect(header.innerText).toEqual('1 merge request'); + }); + }); + + describe('createItem', () => { + it('should return created item', () => { + const item = CommitMergeRequests.createItem({ iid: 1, path: '/path', title: 'foo' })[0]; + + expect(item.tagName).toEqual('SPAN'); + expect(item.childElementCount).toEqual(2); + expect(item.children[0].tagName).toEqual('A'); + expect(item.children[1].tagName).toEqual('SPAN'); + }); + }); + + describe('createLink', () => { + it('should return created link', () => { + const link = CommitMergeRequests.createLink({ iid: 1, path: '/path', title: 'foo' })[0]; + + expect(link.tagName).toEqual('A'); + expect(link.href).toMatch(/\/path$/); + expect(link.innerText).toEqual('!1'); + }); + }); + + describe('createTitle', () => { + it('should return created title', () => { + const title = CommitMergeRequests.createTitle({ iid: 1, path: '/path', title: 'foo' })[0]; + + expect(title.tagName).toEqual('SPAN'); + expect(title.innerText).toEqual('foo'); + }); + }); +}); diff --git a/spec/frontend/commits_spec.js b/spec/frontend/commits_spec.js new file mode 100644 index 00000000000..42bd37570b1 --- /dev/null +++ b/spec/frontend/commits_spec.js @@ -0,0 +1,98 @@ +import $ from 'jquery'; +import 'vendor/jquery.endless-scroll'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import CommitsList from '~/commits'; +import Pager from '~/pager'; + +describe('Commits List', () => { + let commitsList; + + beforeEach(() => { + setFixtures(` + <form class="commits-search-form" action="/h5bp/html5-boilerplate/commits/master"> + <input id="commits-search"> + </form> + <ol id="commits-list"></ol> + `); + jest.spyOn(Pager, 'init').mockImplementation(() => {}); + commitsList = new CommitsList(25); + }); + + it('should be defined', () => { + expect(CommitsList).toBeDefined(); + }); + + describe('processCommits', () => { + it('should join commit headers', () => { + commitsList.$contentList = $(` + <div> + <li class="commit-header" data-day="2016-09-20"> + <span class="day">20 Sep, 2016</span> + <span class="commits-count">1 commit</span> + </li> + <li class="commit"></li> + </div> + `); + + const data = ` + <li class="commit-header" data-day="2016-09-20"> + <span class="day">20 Sep, 2016</span> + <span class="commits-count">1 commit</span> + </li> + <li class="commit"></li> + `; + + // The last commit header should be removed + // since the previous one has the same data-day value. + expect(commitsList.processCommits(data).find('li.commit-header').length).toBe(0); + }); + }); + + describe('on entering input', () => { + let ajaxSpy; + let mock; + + beforeEach(() => { + commitsList.searchField.val(''); + + jest.spyOn(window.history, 'replaceState').mockImplementation(() => {}); + mock = new MockAdapter(axios); + + mock.onGet('/h5bp/html5-boilerplate/commits/master').reply(200, { + html: '<li>Result</li>', + }); + + ajaxSpy = jest.spyOn(axios, 'get'); + }); + + afterEach(() => { + mock.restore(); + }); + + it('should save the last search string', done => { + commitsList.searchField.val('GitLab'); + commitsList + .filterResults() + .then(() => { + expect(ajaxSpy).toHaveBeenCalled(); + expect(commitsList.lastSearch).toEqual('GitLab'); + + done(); + }) + .catch(done.fail); + }); + + it('should not make ajax call if the input does not change', done => { + commitsList + .filterResults() + .then(() => { + expect(ajaxSpy).not.toHaveBeenCalled(); + expect(commitsList.lastSearch).toEqual(''); + + done(); + }) + .catch(done.fail); + }); + }); +}); diff --git a/spec/frontend/contributors/store/actions_spec.js b/spec/frontend/contributors/store/actions_spec.js index fe3e2132d9d..55437da837c 100644 --- a/spec/frontend/contributors/store/actions_spec.js +++ b/spec/frontend/contributors/store/actions_spec.js @@ -55,6 +55,3 @@ describe('Contributors store actions', () => { }); }); }); - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/spec/frontend/contributors/store/getters_spec.js b/spec/frontend/contributors/store/getters_spec.js index e6342a669b7..a4202e0ef4b 100644 --- a/spec/frontend/contributors/store/getters_spec.js +++ b/spec/frontend/contributors/store/getters_spec.js @@ -74,6 +74,3 @@ describe('Contributors Store Getters', () => { }); }); }); - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js b/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js index 490a2775b67..0ef09b4b87e 100644 --- a/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js @@ -75,7 +75,7 @@ describe('awsServicesFacade', () => { }); it('return list of regions where each item has a name and value', () => { - expect(fetchRoles()).resolves.toEqual(rolesOutput); + return expect(fetchRoles()).resolves.toEqual(rolesOutput); }); }); @@ -91,7 +91,7 @@ describe('awsServicesFacade', () => { }); it('return list of roles where each item has a name and value', () => { - expect(fetchRegions()).resolves.toEqual(regionsOutput); + return expect(fetchRegions()).resolves.toEqual(regionsOutput); }); }); @@ -112,7 +112,7 @@ describe('awsServicesFacade', () => { }); it('return list of key pairs where each item has a name and value', () => { - expect(fetchKeyPairs({ region })).resolves.toEqual(keyPairsOutput); + return expect(fetchKeyPairs({ region })).resolves.toEqual(keyPairsOutput); }); }); @@ -133,7 +133,7 @@ describe('awsServicesFacade', () => { }); it('return list of vpcs where each item has a name and value', () => { - expect(fetchVpcs({ region })).resolves.toEqual(vpcsOutput); + return expect(fetchVpcs({ region })).resolves.toEqual(vpcsOutput); }); }); @@ -151,7 +151,7 @@ describe('awsServicesFacade', () => { }); it('uses name tag value as the vpc name', () => { - expect(fetchVpcs({ region })).resolves.toEqual(vpcsOutput); + return expect(fetchVpcs({ region })).resolves.toEqual(vpcsOutput); }); }); @@ -167,7 +167,7 @@ describe('awsServicesFacade', () => { }); it('return list of subnets where each item has a name and value', () => { - expect(fetchSubnets({ region, vpc })).resolves.toEqual(subnetsOutput); + return expect(fetchSubnets({ region, vpc })).resolves.toEqual(subnetsOutput); }); }); @@ -189,7 +189,7 @@ describe('awsServicesFacade', () => { }); it('return list of security groups where each item has a name and value', () => { - expect(fetchSecurityGroups({ region, vpc })).resolves.toEqual(securityGroupsOutput); + return expect(fetchSecurityGroups({ region, vpc })).resolves.toEqual(securityGroupsOutput); }); }); }); diff --git a/spec/frontend/create_item_dropdown_spec.js b/spec/frontend/create_item_dropdown_spec.js new file mode 100644 index 00000000000..a814952faab --- /dev/null +++ b/spec/frontend/create_item_dropdown_spec.js @@ -0,0 +1,195 @@ +import $ from 'jquery'; +import CreateItemDropdown from '~/create_item_dropdown'; + +const DROPDOWN_ITEM_DATA = [ + { + title: 'one', + id: 'one', + text: 'one', + }, + { + title: 'two', + id: 'two', + text: 'two', + }, + { + title: 'three', + id: 'three', + text: 'three', + }, +]; + +describe('CreateItemDropdown', () => { + preloadFixtures('static/create_item_dropdown.html'); + + let $wrapperEl; + let createItemDropdown; + + function createItemAndClearInput(text) { + // Filter for the new item + $wrapperEl + .find('.dropdown-input-field') + .val(text) + .trigger('input'); + + // Create the new item + const $createButton = $wrapperEl.find('.js-dropdown-create-new-item'); + $createButton.click(); + + // Clear out the filter + $wrapperEl + .find('.dropdown-input-field') + .val('') + .trigger('input'); + } + + beforeEach(() => { + loadFixtures('static/create_item_dropdown.html'); + $wrapperEl = $('.js-create-item-dropdown-fixture-root'); + }); + + afterEach(() => { + $wrapperEl.remove(); + }); + + describe('items', () => { + beforeEach(() => { + createItemDropdown = new CreateItemDropdown({ + $dropdown: $wrapperEl.find('.js-dropdown-menu-toggle'), + defaultToggleLabel: 'All variables', + fieldName: 'variable[environment]', + getData: (term, callback) => { + callback(DROPDOWN_ITEM_DATA); + }, + }); + }); + + it('should have a dropdown item for each piece of data', () => { + // Get the data in the dropdown + $('.js-dropdown-menu-toggle').click(); + + const $itemEls = $wrapperEl.find('.js-dropdown-content a'); + + expect($itemEls.length).toEqual(DROPDOWN_ITEM_DATA.length); + }); + }); + + describe('created items', () => { + const NEW_ITEM_TEXT = 'foobarbaz'; + + beforeEach(() => { + createItemDropdown = new CreateItemDropdown({ + $dropdown: $wrapperEl.find('.js-dropdown-menu-toggle'), + defaultToggleLabel: 'All variables', + fieldName: 'variable[environment]', + getData: (term, callback) => { + callback(DROPDOWN_ITEM_DATA); + }, + }); + + // Open the dropdown + $('.js-dropdown-menu-toggle').click(); + + // Filter for the new item + $wrapperEl + .find('.dropdown-input-field') + .val(NEW_ITEM_TEXT) + .trigger('input'); + }); + + it('create new item button should include the filter text', () => { + expect($wrapperEl.find('.js-dropdown-create-new-item code').text()).toEqual(NEW_ITEM_TEXT); + }); + + it('should update the dropdown with the newly created item', () => { + // Create the new item + const $createButton = $wrapperEl.find('.js-dropdown-create-new-item'); + $createButton.click(); + + expect($wrapperEl.find('.dropdown-toggle-text').text()).toEqual(NEW_ITEM_TEXT); + expect($wrapperEl.find('input[name="variable[environment]"]').val()).toEqual(NEW_ITEM_TEXT); + }); + + it('should include newly created item in dropdown list', () => { + createItemAndClearInput(NEW_ITEM_TEXT); + + const $itemEls = $wrapperEl.find('.js-dropdown-content a'); + + expect($itemEls.length).toEqual(1 + DROPDOWN_ITEM_DATA.length); + expect($($itemEls.get(DROPDOWN_ITEM_DATA.length)).text()).toEqual(NEW_ITEM_TEXT); + }); + + it('should not duplicate an item when trying to create an existing item', () => { + createItemAndClearInput(DROPDOWN_ITEM_DATA[0].text); + + const $itemEls = $wrapperEl.find('.js-dropdown-content a'); + + expect($itemEls.length).toEqual(DROPDOWN_ITEM_DATA.length); + }); + }); + + describe('clearDropdown()', () => { + beforeEach(() => { + createItemDropdown = new CreateItemDropdown({ + $dropdown: $wrapperEl.find('.js-dropdown-menu-toggle'), + defaultToggleLabel: 'All variables', + fieldName: 'variable[environment]', + getData: (term, callback) => { + callback(DROPDOWN_ITEM_DATA); + }, + }); + }); + + it('should clear all data and filter input', () => { + const filterInput = $wrapperEl.find('.dropdown-input-field'); + + // Get the data in the dropdown + $('.js-dropdown-menu-toggle').click(); + + // Filter for an item + filterInput.val('one').trigger('input'); + + const $itemElsAfterFilter = $wrapperEl.find('.js-dropdown-content a'); + + expect($itemElsAfterFilter.length).toEqual(1); + + createItemDropdown.clearDropdown(); + + const $itemElsAfterClear = $wrapperEl.find('.js-dropdown-content a'); + + expect($itemElsAfterClear.length).toEqual(0); + expect(filterInput.val()).toEqual(''); + }); + }); + + describe('createNewItemFromValue option', () => { + beforeEach(() => { + createItemDropdown = new CreateItemDropdown({ + $dropdown: $wrapperEl.find('.js-dropdown-menu-toggle'), + defaultToggleLabel: 'All variables', + fieldName: 'variable[environment]', + getData: (term, callback) => { + callback(DROPDOWN_ITEM_DATA); + }, + createNewItemFromValue: newValue => ({ + title: `${newValue}-title`, + id: `${newValue}-id`, + text: `${newValue}-text`, + }), + }); + }); + + it('all items go through createNewItemFromValue', () => { + // Get the data in the dropdown + $('.js-dropdown-menu-toggle').click(); + + createItemAndClearInput('new-item'); + + const $itemEls = $wrapperEl.find('.js-dropdown-content a'); + + expect($itemEls.length).toEqual(1 + DROPDOWN_ITEM_DATA.length); + expect($($itemEls[3]).text()).toEqual('new-item-text'); + expect($wrapperEl.find('.dropdown-toggle-text').text()).toEqual('new-item-title'); + }); + }); +}); diff --git a/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js b/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js index 61cbef0c557..79c37293fe5 100644 --- a/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js +++ b/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js @@ -152,7 +152,6 @@ describe('custom metrics form fields component', () => { describe('when query validation is in flight', () => { beforeEach(() => { - jest.useFakeTimers(); mountComponent( { metricPersisted: true, ...makeFormData({ query: 'validQuery' }) }, { diff --git a/spec/frontend/deploy_keys/components/action_btn_spec.js b/spec/frontend/deploy_keys/components/action_btn_spec.js new file mode 100644 index 00000000000..b8211b02464 --- /dev/null +++ b/spec/frontend/deploy_keys/components/action_btn_spec.js @@ -0,0 +1,54 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import eventHub from '~/deploy_keys/eventhub'; +import actionBtn from '~/deploy_keys/components/action_btn.vue'; + +describe('Deploy keys action btn', () => { + const data = getJSONFixture('deploy_keys/keys.json'); + const deployKey = data.enabled_keys[0]; + let wrapper; + + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + + beforeEach(() => { + wrapper = shallowMount(actionBtn, { + propsData: { + deployKey, + type: 'enable', + }, + slots: { + default: 'Enable', + }, + }); + }); + + it('renders the default slot', () => { + expect(wrapper.text()).toBe('Enable'); + }); + + it('sends eventHub event with btn type', () => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + wrapper.trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('enable.key', deployKey, expect.anything()); + }); + }); + + it('shows loading spinner after click', () => { + wrapper.trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + it('disables button after click', () => { + wrapper.trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.attributes('disabled')).toBe('disabled'); + }); + }); +}); diff --git a/spec/frontend/deploy_keys/components/app_spec.js b/spec/frontend/deploy_keys/components/app_spec.js new file mode 100644 index 00000000000..291502c9ed7 --- /dev/null +++ b/spec/frontend/deploy_keys/components/app_spec.js @@ -0,0 +1,142 @@ +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'spec/test_constants'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import eventHub from '~/deploy_keys/eventhub'; +import deployKeysApp from '~/deploy_keys/components/app.vue'; + +const TEST_ENDPOINT = `${TEST_HOST}/dummy/`; + +describe('Deploy keys app component', () => { + const data = getJSONFixture('deploy_keys/keys.json'); + let wrapper; + let mock; + + const mountComponent = () => { + wrapper = mount(deployKeysApp, { + propsData: { + endpoint: TEST_ENDPOINT, + projectId: '8', + }, + }); + + return waitForPromises(); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet(TEST_ENDPOINT).reply(200, data); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + const findLoadingIcon = () => wrapper.find('.gl-spinner'); + const findKeyPanels = () => wrapper.findAll('.deploy-keys .nav-links li'); + + it('renders loading icon while waiting for request', () => { + mock.onGet(TEST_ENDPOINT).reply(() => new Promise()); + + mountComponent(); + + return wrapper.vm.$nextTick().then(() => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + it('renders keys panels', () => { + return mountComponent().then(() => { + expect(findKeyPanels().length).toBe(3); + }); + }); + + it.each` + selector | label | count + ${'.js-deployKeys-tab-enabled_keys'} | ${'Enabled deploy keys'} | ${1} + ${'.js-deployKeys-tab-available_project_keys'} | ${'Privately accessible deploy keys'} | ${0} + ${'.js-deployKeys-tab-public_keys'} | ${'Publicly accessible deploy keys'} | ${1} + `('$selector title is $label with keys count equal to $count', ({ selector, label, count }) => { + return mountComponent().then(() => { + const element = wrapper.find(selector); + expect(element.exists()).toBe(true); + expect(element.text().trim()).toContain(label); + + expect( + element + .find('.badge') + .text() + .trim(), + ).toBe(count.toString()); + }); + }); + + it('does not render key panels when keys object is empty', () => { + mock.onGet(TEST_ENDPOINT).reply(200, []); + + return mountComponent().then(() => { + expect(findKeyPanels().length).toBe(0); + }); + }); + + it('re-fetches deploy keys when enabling a key', () => { + const key = data.public_keys[0]; + return mountComponent() + .then(() => { + jest.spyOn(wrapper.vm.service, 'getKeys').mockImplementation(() => {}); + jest.spyOn(wrapper.vm.service, 'enableKey').mockImplementation(() => Promise.resolve()); + + eventHub.$emit('enable.key', key); + + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(wrapper.vm.service.enableKey).toHaveBeenCalledWith(key.id); + expect(wrapper.vm.service.getKeys).toHaveBeenCalled(); + }); + }); + + it('re-fetches deploy keys when disabling a key', () => { + const key = data.public_keys[0]; + return mountComponent() + .then(() => { + jest.spyOn(window, 'confirm').mockReturnValue(true); + jest.spyOn(wrapper.vm.service, 'getKeys').mockImplementation(() => {}); + jest.spyOn(wrapper.vm.service, 'disableKey').mockImplementation(() => Promise.resolve()); + + eventHub.$emit('disable.key', key); + + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(wrapper.vm.service.disableKey).toHaveBeenCalledWith(key.id); + expect(wrapper.vm.service.getKeys).toHaveBeenCalled(); + }); + }); + + it('calls disableKey when removing a key', () => { + const key = data.public_keys[0]; + return mountComponent() + .then(() => { + jest.spyOn(window, 'confirm').mockReturnValue(true); + jest.spyOn(wrapper.vm.service, 'getKeys').mockImplementation(() => {}); + jest.spyOn(wrapper.vm.service, 'disableKey').mockImplementation(() => Promise.resolve()); + + eventHub.$emit('remove.key', key); + + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(wrapper.vm.service.disableKey).toHaveBeenCalledWith(key.id); + expect(wrapper.vm.service.getKeys).toHaveBeenCalled(); + }); + }); + + it('hasKeys returns true when there are keys', () => { + return mountComponent().then(() => { + expect(wrapper.vm.hasKeys).toEqual(3); + }); + }); +}); diff --git a/spec/frontend/deploy_keys/components/key_spec.js b/spec/frontend/deploy_keys/components/key_spec.js new file mode 100644 index 00000000000..7d942d969bb --- /dev/null +++ b/spec/frontend/deploy_keys/components/key_spec.js @@ -0,0 +1,161 @@ +import { mount } from '@vue/test-utils'; +import DeployKeysStore from '~/deploy_keys/store'; +import key from '~/deploy_keys/components/key.vue'; +import { getTimeago } from '~/lib/utils/datetime_utility'; + +describe('Deploy keys key', () => { + let wrapper; + let store; + + const data = getJSONFixture('deploy_keys/keys.json'); + + const findTextAndTrim = selector => + wrapper + .find(selector) + .text() + .trim(); + + const createComponent = propsData => { + wrapper = mount(key, { + propsData: { + store, + endpoint: 'https://test.host/dummy/endpoint', + ...propsData, + }, + }); + }; + + beforeEach(() => { + store = new DeployKeysStore(); + store.keys = data; + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('enabled key', () => { + const deployKey = data.enabled_keys[0]; + + it('renders the keys title', () => { + createComponent({ deployKey }); + + expect(findTextAndTrim('.title')).toContain('My title'); + }); + + it('renders human friendly formatted created date', () => { + createComponent({ deployKey }); + + expect(findTextAndTrim('.key-created-at')).toBe( + `${getTimeago().format(deployKey.created_at)}`, + ); + }); + + it('shows pencil button for editing', () => { + createComponent({ deployKey }); + + expect(wrapper.find('.btn .ic-pencil')).toExist(); + }); + + it('shows disable button when the project is not deletable', () => { + createComponent({ deployKey }); + + expect(wrapper.find('.btn .ic-cancel')).toExist(); + }); + + it('shows remove button when the project is deletable', () => { + createComponent({ + deployKey: { ...deployKey, destroyed_when_orphaned: true, almost_orphaned: true }, + }); + expect(wrapper.find('.btn .ic-remove')).toExist(); + }); + }); + + describe('deploy key labels', () => { + const deployKey = data.enabled_keys[0]; + const deployKeysProjects = [...deployKey.deploy_keys_projects]; + it('shows write access title when key has write access', () => { + deployKeysProjects[0] = { ...deployKeysProjects[0], can_push: true }; + createComponent({ deployKey: { ...deployKey, deploy_keys_projects: deployKeysProjects } }); + + expect(wrapper.find('.deploy-project-label').attributes('data-original-title')).toBe( + 'Write access allowed', + ); + }); + + it('does not show write access title when key has write access', () => { + deployKeysProjects[0] = { ...deployKeysProjects[0], can_push: false }; + createComponent({ deployKey: { ...deployKey, deploy_keys_projects: deployKeysProjects } }); + + expect(wrapper.find('.deploy-project-label').attributes('data-original-title')).toBe( + 'Read access only', + ); + }); + + it('shows expandable button if more than two projects', () => { + createComponent({ deployKey }); + const labels = wrapper.findAll('.deploy-project-label'); + + expect(labels.length).toBe(2); + expect(labels.at(1).text()).toContain('others'); + expect(labels.at(1).attributes('data-original-title')).toContain('Expand'); + }); + + it('expands all project labels after click', () => { + createComponent({ deployKey }); + const { length } = deployKey.deploy_keys_projects; + wrapper + .findAll('.deploy-project-label') + .at(1) + .trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + const labels = wrapper.findAll('.deploy-project-label'); + + expect(labels.length).toBe(length); + expect(labels.at(1).text()).not.toContain(`+${length} others`); + expect(labels.at(1).attributes('data-original-title')).not.toContain('Expand'); + }); + }); + + it('shows two projects', () => { + createComponent({ + deployKey: { ...deployKey, deploy_keys_projects: [...deployKeysProjects].slice(0, 2) }, + }); + + const labels = wrapper.findAll('.deploy-project-label'); + + expect(labels.length).toBe(2); + expect(labels.at(1).text()).toContain(deployKey.deploy_keys_projects[1].project.full_name); + }); + }); + + describe('public keys', () => { + const deployKey = data.public_keys[0]; + + it('renders deploy keys without any enabled projects', () => { + createComponent({ deployKey: { ...deployKey, deploy_keys_projects: [] } }); + + expect(findTextAndTrim('.deploy-project-list')).toBe('None'); + }); + + it('shows enable button', () => { + createComponent({ deployKey }); + expect(findTextAndTrim('.btn')).toBe('Enable'); + }); + + it('shows pencil button for editing', () => { + createComponent({ deployKey }); + expect(wrapper.find('.btn .ic-pencil')).toExist(); + }); + + it('shows disable button when key is enabled', () => { + store.keys.enabled_keys.push(deployKey); + + createComponent({ deployKey }); + + expect(wrapper.find('.btn .ic-cancel')).toExist(); + }); + }); +}); diff --git a/spec/frontend/deploy_keys/components/keys_panel_spec.js b/spec/frontend/deploy_keys/components/keys_panel_spec.js new file mode 100644 index 00000000000..53c8ba073bc --- /dev/null +++ b/spec/frontend/deploy_keys/components/keys_panel_spec.js @@ -0,0 +1,63 @@ +import { mount } from '@vue/test-utils'; +import DeployKeysStore from '~/deploy_keys/store'; +import deployKeysPanel from '~/deploy_keys/components/keys_panel.vue'; + +describe('Deploy keys panel', () => { + const data = getJSONFixture('deploy_keys/keys.json'); + let wrapper; + + const findTableRowHeader = () => wrapper.find('.table-row-header'); + + const mountComponent = props => { + const store = new DeployKeysStore(); + store.keys = data; + wrapper = mount(deployKeysPanel, { + propsData: { + title: 'test', + keys: data.enabled_keys, + showHelpBox: true, + store, + endpoint: 'https://test.host/dummy/endpoint', + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders list of keys', () => { + mountComponent(); + expect(wrapper.findAll('.deploy-key').length).toBe(wrapper.vm.keys.length); + }); + + it('renders table header', () => { + mountComponent(); + const tableHeader = findTableRowHeader(); + + expect(tableHeader).toExist(); + expect(tableHeader.text()).toContain('Deploy key'); + expect(tableHeader.text()).toContain('Project usage'); + expect(tableHeader.text()).toContain('Created'); + }); + + it('renders help box if keys are empty', () => { + mountComponent({ keys: [] }); + + expect(wrapper.find('.settings-message').exists()).toBe(true); + + expect( + wrapper + .find('.settings-message') + .text() + .trim(), + ).toBe('No deploy keys found. Create one with the form above.'); + }); + + it('renders no table header if keys are empty', () => { + mountComponent({ keys: [] }); + expect(findTableRowHeader().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap new file mode 100644 index 00000000000..4828e8cb3c2 --- /dev/null +++ b/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design discussions component should match the snapshot of note when repositioning 1`] = ` +<button + aria-label="Comment form position" + class="position-absolute btn-transparent comment-indicator" + style="left: 10px; top: 10px; cursor: move;" + type="button" +> + <icon-stub + name="image-comment-dark" + size="16" + /> +</button> +`; + +exports[`Design discussions component should match the snapshot of note with index 1`] = ` +<button + aria-label="Comment '1' position" + class="position-absolute js-image-badge badge badge-pill" + style="left: 10px; top: 10px;" + type="button" +> + + 1 + +</button> +`; + +exports[`Design discussions component should match the snapshot of note without index 1`] = ` +<button + aria-label="Comment form position" + class="position-absolute 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/components/__snapshots__/design_presentation_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap new file mode 100644 index 00000000000..189962c5b2e --- /dev/null +++ b/spec/frontend/design_management/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/components/__snapshots__/design_scaler_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap new file mode 100644 index 00000000000..cb4575cbd11 --- /dev/null +++ b/spec/frontend/design_management/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/components/__snapshots__/image_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/image_spec.js.snap new file mode 100644 index 00000000000..acaa62b11eb --- /dev/null +++ b/spec/frontend/design_management/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/components/delete_button_spec.js b/spec/frontend/design_management/components/delete_button_spec.js new file mode 100644 index 00000000000..9d3bcd98e44 --- /dev/null +++ b/spec/frontend/design_management/components/delete_button_spec.js @@ -0,0 +1,51 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlDeprecatedButton, GlModal, GlModalDirective } from '@gitlab/ui'; +import BatchDeleteButton from '~/design_management/components/delete_button.vue'; + +describe('Batch delete button component', () => { + let wrapper; + + const findButton = () => wrapper.find(GlDeprecatedButton); + 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/components/design_note_pin_spec.js b/spec/frontend/design_management/components/design_note_pin_spec.js new file mode 100644 index 00000000000..4f7260b1363 --- /dev/null +++ b/spec/frontend/design_management/components/design_note_pin_spec.js @@ -0,0 +1,49 @@ +import { shallowMount } from '@vue/test-utils'; +import DesignNotePin from '~/design_management/components/design_note_pin.vue'; + +describe('Design discussions 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/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap new file mode 100644 index 00000000000..e071274cc81 --- /dev/null +++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap @@ -0,0 +1,61 @@ +// 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> + + <div + class="note-text js-note-text md" + data-qa-selector="note_content" + /> +</timeline-entry-item-stub> +`; diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap new file mode 100644 index 00000000000..e01c79e3520 --- /dev/null +++ b/spec/frontend/design_management/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/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js new file mode 100644 index 00000000000..b16b26ff82f --- /dev/null +++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js @@ -0,0 +1,133 @@ +import { shallowMount } from '@vue/test-utils'; +import { ApolloMutation } from 'vue-apollo'; +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 ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; + +describe('Design discussions component', () => { + let wrapper; + + const findReplyPlaceholder = () => wrapper.find(ReplyPlaceholder); + const findReplyForm = () => wrapper.find(DesignReplyForm); + + 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 = shallowMount(DesignDiscussion, { + propsData: { + discussion: { + id: '0', + notes: [ + { + id: '1', + }, + { + id: '2', + }, + ], + }, + noteableId: 'noteable-id', + designId: 'design-id', + discussionIndex: 1, + ...props, + }, + data() { + return { + ...data, + }; + }, + stubs: { + ReplyPlaceholder, + ApolloMutation, + }, + mocks: { $apollo }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders correct amount of discussion notes', () => { + createComponent(); + expect(wrapper.findAll(DesignNote)).toHaveLength(2); + }); + + it('renders reply placeholder by default', () => { + createComponent(); + expect(findReplyPlaceholder().exists()).toBe(true); + }); + + it('hides reply placeholder and opens form on placeholder click', () => { + createComponent(); + findReplyPlaceholder().trigger('click'); + + 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({}, { 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({}, { 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: '1', + source: 'pin', + }, + }, + ); + + expect(wrapper.findAll(DesignNote).wrappers.every(note => note.classes('gl-bg-blue-50'))).toBe( + true, + ); + }); +}); diff --git a/spec/frontend/design_management/components/design_notes/design_note_spec.js b/spec/frontend/design_management/components/design_notes/design_note_spec.js new file mode 100644 index 00000000000..8b32d3022ee --- /dev/null +++ b/spec/frontend/design_management/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/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/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/components/design_notes/design_reply_form_spec.js b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js new file mode 100644 index 00000000000..34b8f1f9fa8 --- /dev/null +++ b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js @@ -0,0 +1,182 @@ +import { mount } from '@vue/test-utils'; +import DesignReplyForm from '~/design_management/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 = {}) { + wrapper = mount(DesignReplyForm, { + propsData: { + value: '', + isSaving: false, + ...props, + }, + stubs: { GlModal }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('textarea has focus after component mount', () => { + createComponent(); + + 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/components/design_overlay_spec.js b/spec/frontend/design_management/components/design_overlay_spec.js new file mode 100644 index 00000000000..1c9b130aca6 --- /dev/null +++ b/spec/frontend/design_management/components/design_overlay_spec.js @@ -0,0 +1,393 @@ +import { mount } from '@vue/test-utils'; +import DesignOverlay from '~/design_management/components/design_overlay.vue'; +import updateActiveDiscussion from '~/design_management/graphql/mutations/update_active_discussion.mutation.graphql'; +import notes from '../mock_data/notes'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '~/design_management/constants'; + +const mutate = jest.fn(() => Promise.resolve()); + +describe('Design overlay component', () => { + let wrapper; + + const mockDimensions = { width: 100, height: 100 }; + const mockNoteNotAuthorised = { + id: 'note-not-authorised', + discussion: { id: 'discussion-not-authorised' }, + position: { + x: 1, + y: 80, + ...mockDimensions, + }, + userPermissions: {}, + }; + + 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', + }, + ...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', () => { + beforeEach(() => { + createComponent({ + notes, + }); + }); + + it('should render a correct amount of notes', () => { + expect(findAllNotes()).toHaveLength(notes.length); + }); + + it('should have a correct style for each note badge', () => { + expect(findFirstBadge().attributes().style).toBe('left: 10px; top: 15px;'); + expect(findSecondBadge().attributes().style).toBe('left: 50px; top: 50px;'); + }); + + 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); + }); + }); + + 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'); + }); + }); + }); + + 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, + }, + ], + ]); + }); + }); + + it('should do nothing if [adminNote] permission is not present', () => { + createComponent({ + dimensions: mockDimensions, + notes: [mockNoteNotAuthorised], + }); + + const badge = findAllNotes().at(0); + return clickAndDragBadge( + badge, + { x: mockNoteNotAuthorised.x, y: mockNoteNotAuthorised.y }, + { x: 20, y: 20 }, + ).then(() => { + expect(wrapper.vm.movingNoteStartPosition).toBeNull(); + expect(findFirstBadge().attributes().style).toBe('left: 1px; top: 80px;'); + }); + }); + }); + + 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/components/design_presentation_spec.js b/spec/frontend/design_management/components/design_presentation_spec.js new file mode 100644 index 00000000000..8a709393d92 --- /dev/null +++ b/spec/frontend/design_management/components/design_presentation_spec.js @@ -0,0 +1,546 @@ +import { shallowMount } from '@vue/test-utils'; +import DesignPresentation from '~/design_management/components/design_presentation.vue'; +import DesignOverlay from '~/design_management/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 } = {}, + data = {}, + stubs = {}, + ) { + wrapper = shallowMount(DesignPresentation, { + propsData: { + image, + imageName, + discussions, + isAnnotating, + }, + 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/components/design_scaler_spec.js b/spec/frontend/design_management/components/design_scaler_spec.js new file mode 100644 index 00000000000..b06d2f924df --- /dev/null +++ b/spec/frontend/design_management/components/design_scaler_spec.js @@ -0,0 +1,67 @@ +import { shallowMount } from '@vue/test-utils'; +import DesignScaler from '~/design_management/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/components/image_spec.js b/spec/frontend/design_management/components/image_spec.js new file mode 100644 index 00000000000..52d60b04a8a --- /dev/null +++ b/spec/frontend/design_management/components/image_spec.js @@ -0,0 +1,133 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import DesignImage from '~/design_management/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/components/list/__snapshots__/item_spec.js.snap b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap new file mode 100644 index 00000000000..9cd427f6aae --- /dev/null +++ b/spec/frontend/design_management/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" + 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" + 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" + 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" + 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" + 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" + 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" + 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/components/list/item_spec.js b/spec/frontend/design_management/components/list/item_spec.js new file mode 100644 index 00000000000..705b532454f --- /dev/null +++ b/spec/frontend/design_management/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/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/components/toolbar/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap new file mode 100644 index 00000000000..e55cff8de3d --- /dev/null +++ b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap @@ -0,0 +1,61 @@ +// 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" + > + <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="" + buttonvariant="danger" + hasselecteddesigns="true" + > + <icon-stub + name="remove" + size="18" + /> + </delete-button-stub> +</header> +`; diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_button_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_button_spec.js.snap new file mode 100644 index 00000000000..08662a04f15 --- /dev/null +++ b/spec/frontend/design_management/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/components/toolbar/__snapshots__/pagination_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_spec.js.snap new file mode 100644 index 00000000000..0197b4bff79 --- /dev/null +++ b/spec/frontend/design_management/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/components/toolbar/index_spec.js b/spec/frontend/design_management/components/toolbar/index_spec.js new file mode 100644 index 00000000000..2910b2f62ba --- /dev/null +++ b/spec/frontend/design_management/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/components/toolbar/index.vue'; +import DeleteButton from '~/design_management/components/delete_button.vue'; +import { DESIGNS_ROUTE_NAME } from '~/design_management/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/components/toolbar/pagination_button_spec.js b/spec/frontend/design_management/components/toolbar/pagination_button_spec.js new file mode 100644 index 00000000000..b7df201795b --- /dev/null +++ b/spec/frontend/design_management/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/components/toolbar/pagination_button.vue'; +import { DESIGN_ROUTE_NAME } from '~/design_management/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/components/toolbar/pagination_spec.js b/spec/frontend/design_management/components/toolbar/pagination_spec.js new file mode 100644 index 00000000000..db5a36dadf6 --- /dev/null +++ b/spec/frontend/design_management/components/toolbar/pagination_spec.js @@ -0,0 +1,79 @@ +/* global Mousetrap */ +import 'mousetrap'; +import { shallowMount } from '@vue/test-utils'; +import Pagination from '~/design_management/components/toolbar/pagination.vue'; +import { DESIGN_ROUTE_NAME } from '~/design_management/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/components/upload/__snapshots__/button_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap new file mode 100644 index 00000000000..185bf4a48f7 --- /dev/null +++ b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap @@ -0,0 +1,79 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management upload button component renders inverted upload design button 1`] = ` +<div + isinverted="true" +> + <gl-deprecated-button-stub + size="md" + title="Adding a design with the same filename replaces the file in a new version." + variant="success" + > + + Add designs + + <!----> + </gl-deprecated-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-deprecated-button-stub + disabled="true" + size="md" + title="Adding a design with the same filename replaces the file in a new version." + variant="success" + > + + Add designs + + <gl-loading-icon-stub + class="ml-1" + color="orange" + inline="true" + label="Loading" + size="sm" + /> + </gl-deprecated-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-deprecated-button-stub + size="md" + title="Adding a design with the same filename replaces the file in a new version." + variant="success" + > + + Add designs + + <!----> + </gl-deprecated-button-stub> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="design_file" + type="file" + /> +</div> +`; diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap new file mode 100644 index 00000000000..0737b9729a2 --- /dev/null +++ b/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap @@ -0,0 +1,455 @@ +// 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 d-flex-center p-3" + > + <div + class="d-flex-center flex-column text-center" + > + <gl-icon-stub + class="mb-4" + name="doc-new" + size="48" + /> + + <p> + <gl-sprintf-stub + message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." + /> + </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> + 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> + 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 d-flex-center p-3" + > + <div + class="d-flex-center flex-column text-center" + > + <gl-icon-stub + class="mb-4" + name="doc-new" + size="48" + /> + + <p> + <gl-sprintf-stub + message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." + /> + </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> + 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> + 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 d-flex-center p-3" + > + <div + class="d-flex-center flex-column text-center" + > + <gl-icon-stub + class="mb-4" + name="doc-new" + size="48" + /> + + <p> + <gl-sprintf-stub + message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." + /> + </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> + 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> + 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 d-flex-center p-3" + > + <div + class="d-flex-center flex-column text-center" + > + <gl-icon-stub + class="mb-4" + name="doc-new" + size="48" + /> + + <p> + <gl-sprintf-stub + message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." + /> + </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> + 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> + 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 d-flex-center p-3" + > + <div + class="d-flex-center flex-column text-center" + > + <gl-icon-stub + class="mb-4" + name="doc-new" + size="48" + /> + + <p> + <gl-sprintf-stub + message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." + /> + </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> + 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> + 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 d-flex-center p-3" + > + <div + class="d-flex-center flex-column text-center" + > + <gl-icon-stub + class="mb-4" + name="doc-new" + size="48" + /> + + <p> + <gl-sprintf-stub + message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." + /> + </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> + 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> + 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> + 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> + Incoming! + </h3> + + <span> + Drop your designs to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap new file mode 100644 index 00000000000..00f1a40dfb2 --- /dev/null +++ b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap @@ -0,0 +1,111 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management design version dropdown component renders design version dropdown button 1`] = ` +<gl-dropdown-stub + class="design-version-dropdown" + issueiid="" + projectpath="" + text="Showing Latest Version" + variant="link" +> + <gl-dropdown-item-stub> + <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-dropdown-item-stub> + <gl-dropdown-item-stub> + <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-dropdown-item-stub> +</gl-dropdown-stub> +`; + +exports[`Design management design version dropdown component renders design version list 1`] = ` +<gl-dropdown-stub + class="design-version-dropdown" + issueiid="" + projectpath="" + text="Showing Latest Version" + variant="link" +> + <gl-dropdown-item-stub> + <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-dropdown-item-stub> + <gl-dropdown-item-stub> + <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-dropdown-item-stub> +</gl-dropdown-stub> +`; diff --git a/spec/frontend/design_management/components/upload/button_spec.js b/spec/frontend/design_management/components/upload/button_spec.js new file mode 100644 index 00000000000..c0a9693dc37 --- /dev/null +++ b/spec/frontend/design_management/components/upload/button_spec.js @@ -0,0 +1,59 @@ +import { shallowMount } from '@vue/test-utils'; +import UploadButton from '~/design_management/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/components/upload/design_dropzone_spec.js b/spec/frontend/design_management/components/upload/design_dropzone_spec.js new file mode 100644 index 00000000000..9b86b5b2878 --- /dev/null +++ b/spec/frontend/design_management/components/upload/design_dropzone_spec.js @@ -0,0 +1,132 @@ +import { shallowMount } from '@vue/test-utils'; +import DesignDropzone from '~/design_management/components/upload/design_dropzone.vue'; +import createFlash from '~/flash'; + +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'); + + function createComponent({ slots = {}, data = {} } = {}) { + wrapper = shallowMount(DesignDropzone, { + slots, + 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); + }); + }); + }); +}); diff --git a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js new file mode 100644 index 00000000000..7521b9fad2a --- /dev/null +++ b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js @@ -0,0 +1,114 @@ +import { shallowMount } from '@vue/test-utils'; +import DesignVersionDropdown from '~/design_management/components/upload/design_version_dropdown.vue'; +import { GlDropdown, GlDropdownItem } 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(GlDropdown).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(GlDropdown).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(GlDropdown).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(GlDropdown).attributes('text')).toBe('Showing Latest Version'); + }); + }); + + it('should have the same length as apollo query', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.findAll(GlDropdownItem)).toHaveLength(wrapper.vm.allVersions.length); + }); + }); + }); +}); diff --git a/spec/frontend/design_management/components/upload/mock_data/all_versions.js b/spec/frontend/design_management/components/upload/mock_data/all_versions.js new file mode 100644 index 00000000000..e76bbd261bd --- /dev/null +++ b/spec/frontend/design_management/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/mock_data/all_versions.js b/spec/frontend/design_management/mock_data/all_versions.js new file mode 100644 index 00000000000..c389fdb8747 --- /dev/null +++ b/spec/frontend/design_management/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/mock_data/design.js b/spec/frontend/design_management/mock_data/design.js new file mode 100644 index 00000000000..34e3077f4a2 --- /dev/null +++ b/spec/frontend/design_management/mock_data/design.js @@ -0,0 +1,54 @@ +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', + notes: { + nodes: [ + { + id: 'note-id', + 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/mock_data/designs.js b/spec/frontend/design_management/mock_data/designs.js new file mode 100644 index 00000000000..07f5c1b7457 --- /dev/null +++ b/spec/frontend/design_management/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/mock_data/no_designs.js b/spec/frontend/design_management/mock_data/no_designs.js new file mode 100644 index 00000000000..9db0ffcade2 --- /dev/null +++ b/spec/frontend/design_management/mock_data/no_designs.js @@ -0,0 +1,11 @@ +export default { + project: { + issue: { + designCollection: { + designs: { + edges: [], + }, + }, + }, + }, +}; diff --git a/spec/frontend/design_management/mock_data/notes.js b/spec/frontend/design_management/mock_data/notes.js new file mode 100644 index 00000000000..db4624c8524 --- /dev/null +++ b/spec/frontend/design_management/mock_data/notes.js @@ -0,0 +1,32 @@ +export default [ + { + id: 'note-id-1', + position: { + height: 100, + width: 100, + x: 10, + y: 15, + }, + userPermissions: { + adminNote: true, + }, + discussion: { + id: 'discussion-id-1', + }, + }, + { + id: 'note-id-2', + position: { + height: 50, + width: 50, + x: 25, + y: 25, + }, + userPermissions: { + adminNote: true, + }, + discussion: { + id: 'discussion-id-2', + }, + }, +]; diff --git a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap new file mode 100644 index 00000000000..3ba63fd14f0 --- /dev/null +++ b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap @@ -0,0 +1,263 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management index page designs does not render toolbar when there is no permission 1`] = ` +<div> + <!----> + + <div + class="mt-4" + > + <ol + class="list-unstyled row" + > + <li + class="col-md-6 col-lg-4 mb-3" + > + <design-dropzone-stub + class="design-list-item" + /> + </li> + + <li + class="col-md-6 col-lg-4 mb-3" + > + <design-dropzone-stub> + <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-4 mb-3" + > + <design-dropzone-stub> + <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-4 mb-3" + > + <design-dropzone-stub> + <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> + <header + class="row-content-block border-top-0 p-2 d-flex" + > + <div + class="d-flex justify-content-between align-items-center w-100" + > + <design-version-dropdown-stub /> + + <div + class="qa-selector-toolbar d-flex" + > + <gl-deprecated-button-stub + class="mr-2 js-select-all" + size="md" + variant="link" + > + Select all + </gl-deprecated-button-stub> + + <div> + <delete-button-stub + buttonclass="btn-danger btn-inverted mr-2" + buttonvariant="" + > + + Delete selected + + <!----> + </delete-button-stub> + </div> + + <upload-button-stub /> + </div> + </div> + </header> + + <div + class="mt-4" + > + <ol + class="list-unstyled row" + > + <li + class="col-md-6 col-lg-4 mb-3" + > + <design-dropzone-stub + class="design-list-item" + /> + </li> + + <li + class="col-md-6 col-lg-4 mb-3" + > + <design-dropzone-stub> + <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-4 mb-3" + > + <design-dropzone-stub> + <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-4 mb-3" + > + <design-dropzone-stub> + <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> + <!----> + + <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> + <!----> + + <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 empty text 1`] = ` +<div> + <!----> + + <div + class="mt-4" + > + <ol + class="list-unstyled row" + > + <li + class="col-md-6 col-lg-4 mb-3" + > + <design-dropzone-stub + class="design-list-item" + /> + </li> + + </ol> + </div> + + <router-view-stub + name="default" + /> +</div> +`; diff --git a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap new file mode 100644 index 00000000000..76e481ee518 --- /dev/null +++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap @@ -0,0 +1,184 @@ +// 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" + projectpath="" + /> + + <!----> + + <design-presentation-stub + discussions="[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-size-20-deprecated-no-really-do-not-use-me font-weight-bold mt-0" + > + + My precious issue + + </h2> + + <a + class="text-tertiary text-decoration-none mb-3 d-block" + href="full-issue-url" + > + ull-issue-path + </a> + + <participants-stub + class="mb-4" + numberoflessparticipants="7" + participants="[object Object]" + /> + + <div + class="design-discussion-wrapper" + > + <div + class="badge badge-pill" + type="button" + > + 1 + </div> + + <div + class="design-discussion bordered-box position-relative" + data-qa-selector="design_discussion_content" + > + <design-note-stub + class="" + markdownpreviewpath="//preview_markdown?target_type=Issue" + note="[object Object]" + /> + + <div + class="reply-wrapper" + > + <reply-placeholder-stub + buttontext="Reply..." + class="qa-discussion-reply" + /> + </div> + </div> + </div> + + <!----> + </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" + projectpath="" + /> + + <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-size-20-deprecated-no-really-do-not-use-me font-weight-bold mt-0" + > + + My precious issue + + </h2> + + <a + class="text-tertiary text-decoration-none mb-3 d-block" + href="full-issue-url" + > + ull-issue-path + </a> + + <participants-stub + class="mb-4" + numberoflessparticipants="7" + participants="[object Object]" + /> + + <h2 + class="new-discussion-disclaimer gl-font-base m-0" + > + + Click the image where you'd like to start a new discussion + + </h2> + </div> +</div> +`; diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js new file mode 100644 index 00000000000..9e2f071a983 --- /dev/null +++ b/spec/frontend/design_management/pages/design/index_spec.js @@ -0,0 +1,301 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlAlert } from '@gitlab/ui'; +import { ApolloMutation } from 'vue-apollo'; +import createFlash from '~/flash'; +import DesignIndex from '~/design_management/pages/design/index.vue'; +import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue'; +import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue'; +import Participants from '~/sidebar/components/participants/participants.vue'; +import createImageDiffNoteMutation from '~/design_management/graphql/mutations/createImageDiffNote.mutation.graphql'; +import updateActiveDiscussionMutation from '~/design_management/graphql/mutations/update_active_discussion.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/utils/error_messages'; +import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants'; + +jest.mock('~/flash'); +jest.mock('mousetrap', () => ({ + bind: jest.fn(), + unbind: jest.fn(), +})); + +describe('Design management design index page', () => { + let wrapper; + 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 updateActiveDiscussionMutationVariables = { + mutation: updateActiveDiscussionMutation, + variables: { + id: design.discussions.nodes[0].notes.nodes[0].id, + source: 'discussion', + }, + }; + + const mutate = jest.fn().mockResolvedValue(); + const routerPush = jest.fn(); + + const findDiscussions = () => wrapper.findAll(DesignDiscussion); + const findDiscussionForm = () => wrapper.find(DesignReplyForm); + const findParticipants = () => wrapper.find(Participants); + const findDiscussionsWrapper = () => wrapper.find('.image-notes'); + + function createComponent(loading = false, data = {}, { routeQuery = {} } = {}) { + const $apollo = { + queries: { + design: { + loading, + }, + }, + mutate, + }; + + const $router = { + push: routerPush, + }; + + const $route = { + query: routeQuery, + }; + + wrapper = shallowMount(DesignIndex, { + propsData: { id: '1' }, + mocks: { $apollo, $router, $route }, + stubs: { + ApolloMutation, + DesignDiscussion, + }, + data() { + return { + issueIid: '1', + activeDiscussion: { + id: null, + source: null, + }, + ...data, + }; + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + 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('renders participants', () => { + createComponent(false, { design }); + + expect(findParticipants().exists()).toBe(true); + }); + + it('passes the correct amount of participants to the Participants component', () => { + createComponent(false, { design }); + + expect(findParticipants().props('participants')).toHaveLength(1); + }); + + describe('when has no discussions', () => { + beforeEach(() => { + createComponent(false, { + 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(wrapper.find('.new-discussion-disclaimer').exists()).toBe(true); + }); + }); + + describe('when has discussions', () => { + beforeEach(() => { + createComponent(false, { design }); + }); + + it('renders correct amount of discussions', () => { + expect(findDiscussions()).toHaveLength(1); + }); + + it('sends a mutation to set an active discussion when clicking on a discussion', () => { + findDiscussions() + .at(0) + .trigger('click'); + + expect(mutate).toHaveBeenCalledWith(updateActiveDiscussionMutationVariables); + }); + + it('sends a mutation to reset an active discussion when clicking outside of discussion', () => { + findDiscussionsWrapper().trigger('click'); + + expect(mutate).toHaveBeenCalledWith({ + ...updateActiveDiscussionMutationVariables, + variables: { id: undefined, source: 'discussion' }, + }); + }); + }); + + it('opens a new discussion form', () => { + createComponent(false, { + design: { + ...design, + discussions: { + nodes: [], + }, + }, + }); + + wrapper.vm.openCommentForm({ x: 0, y: 0 }); + + return wrapper.vm.$nextTick().then(() => { + expect(findDiscussionForm().exists()).toBe(true); + }); + }); + + 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); + + wrapper.vm.onDesignQueryResult({ data: mockResponseNoDesigns, loading: false }); + return wrapper.vm.$nextTick().then(() => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith(DESIGN_NOT_FOUND_ERROR); + expect(routerPush).toHaveBeenCalledTimes(1); + expect(routerPush).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME }); + }); + }); + }); + + describe('when no design exists for given version', () => { + it('redirects to /designs', () => { + // attempt to query for a version of the design that doesn't exist + createComponent(true, {}, { routeQuery: { version: '999' } }); + wrapper.setData({ + allVersions: mockAllVersions, + }); + + 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(routerPush).toHaveBeenCalledTimes(1); + expect(routerPush).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js new file mode 100644 index 00000000000..2299b858da9 --- /dev/null +++ b/spec/frontend/design_management/pages/index_spec.js @@ -0,0 +1,533 @@ +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/pages/index.vue'; +import uploadDesignQuery from '~/design_management/graphql/mutations/uploadDesign.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'; +import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants'; +import { + EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE, + EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE, +} from '~/design_management/utils/error_messages'; +import createFlash from '~/flash'; + +const localVue = createLocalVue(); +localVue.use(VueRouter); +const router = new VueRouter({ + routes: [ + { + name: DESIGNS_ROUTE_NAME, + path: '/designs', + component: Index, + }, + ], +}); + +jest.mock('~/flash.js'); + +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 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, { + mocks: { $apollo }, + localVue, + router, + stubs: { DesignDestroyer, ApolloMutation, ...stubs }, + attachToDocument: true, + }); + + wrapper.setData({ + designs, + allVersions, + issueIid: '1', + permissions: { + createDesign, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('designs', () => { + it('renders loading icon', () => { + createComponent({ loading: true }); + + return wrapper.vm.$nextTick().then(() => { + 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] }); + + return wrapper.vm.$nextTick().then(() => { + expect(findToolbar().exists()).toBe(true); + }); + }); + + it('renders designs list and header with upload button', () => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('does not render toolbar when there is no permission', () => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion], createDesign: false }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + }); + + describe('when has no designs', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders empty text', () => + wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + })); + + 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: '', + 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(); + }); + }); + + 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().classes()).toContain('d-flex'); + expect(findToolbar().classes()).not.toContain('d-none'); + }); + + 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 does not render toolbar buttons', () => { + createComponent({ designs: [], allVersions: [mockVersion] }); + expect(findToolbar().exists()).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(); + }); + }); +}); diff --git a/spec/frontend/design_management/router_spec.js b/spec/frontend/design_management/router_spec.js new file mode 100644 index 00000000000..0f4afa5e288 --- /dev/null +++ b/spec/frontend/design_management/router_spec.js @@ -0,0 +1,81 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import VueRouter from 'vue-router'; +import App from '~/design_management/components/app.vue'; +import Designs from '~/design_management/pages/index.vue'; +import DesignDetail from '~/design_management/pages/design/index.vue'; +import createRouter from '~/design_management/router'; +import { + ROOT_ROUTE_NAME, + DESIGNS_ROUTE_NAME, + DESIGN_ROUTE_NAME, +} from '~/design_management/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 }, + }, + }, + }, + }); +} + +jest.mock('mousetrap', () => ({ + bind: jest.fn(), + unbind: jest.fn(), +})); + +describe('Design management router', () => { + afterEach(() => { + window.location.hash = ''; + }); + + describe.each([['/'], [{ name: ROOT_ROUTE_NAME }]])('root route', routeArg => { + it('pushes home component', () => { + const wrapper = factory(routeArg); + + expect(wrapper.find(Designs).exists()).toBe(true); + }); + }); + + describe.each([['/designs'], [{ name: DESIGNS_ROUTE_NAME }]])('designs route', routeArg => { + it('pushes designs root 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/utils/cache_update_spec.js b/spec/frontend/design_management/utils/cache_update_spec.js new file mode 100644 index 00000000000..641d35ff9ff --- /dev/null +++ b/spec/frontend/design_management/utils/cache_update_spec.js @@ -0,0 +1,44 @@ +import { InMemoryCache } from 'apollo-cache-inmemory'; +import { + updateStoreAfterDesignsDelete, + updateStoreAfterAddDiscussionComment, + updateStoreAfterAddImageDiffNote, + updateStoreAfterUploadDesign, + updateStoreAfterUpdateImageDiffNote, +} from '~/design_management/utils/cache_update'; +import { + designDeletionError, + ADD_DISCUSSION_COMMENT_ERROR, + ADD_IMAGE_DIFF_NOTE_ERROR, + UPDATE_IMAGE_DIFF_NOTE_ERROR, +} from '~/design_management/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/utils/design_management_utils_spec.js b/spec/frontend/design_management/utils/design_management_utils_spec.js new file mode 100644 index 00000000000..af631073df6 --- /dev/null +++ b/spec/frontend/design_management/utils/design_management_utils_spec.js @@ -0,0 +1,176 @@ +import { + extractCurrentDiscussion, + extractDiscussions, + findVersionId, + designUploadOptimisticResponse, + updateImageDiffNoteOptimisticResponse, + isValidDesignFile, + extractDesign, +} from '~/design_management/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'] }, + { id: 2, notes: ['b'] }, + { id: 3, notes: ['c'] }, + { id: 4, notes: ['d'] }, + ]); + }); +}); + +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/utils/error_messages_spec.js b/spec/frontend/design_management/utils/error_messages_spec.js new file mode 100644 index 00000000000..635ff931d7d --- /dev/null +++ b/spec/frontend/design_management/utils/error_messages_spec.js @@ -0,0 +1,62 @@ +import { + designDeletionError, + designUploadSkippedWarning, +} from '~/design_management/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/utils/tracking_spec.js b/spec/frontend/design_management/utils/tracking_spec.js new file mode 100644 index 00000000000..9fa5eae55b3 --- /dev/null +++ b/spec/frontend/design_management/utils/tracking_spec.js @@ -0,0 +1,53 @@ +import { mockTracking } from 'helpers/tracking_helper'; +import { trackDesignDetailView } from '~/design_management/utils/tracking'; + +function getTrackingSpy(key) { + return mockTracking(key, undefined, jest.spyOn); +} + +describe('Tracking Events', () => { + describe('trackDesignDetailView', () => { + const eventKey = 'projects:issues:design'; + const eventName = 'design_viewed'; + + it('trackDesignDetailView fires a tracking event when called', () => { + const trackingSpy = getTrackingSpy(eventKey); + + trackDesignDetailView(); + + expect(trackingSpy).toHaveBeenCalledWith( + eventKey, + eventName, + expect.objectContaining({ + label: eventName, + value: { + 'internal-object-refrerer': '', + 'design-collection-owner': '', + 'design-version-number': 1, + 'design-is-current-version': false, + }, + }), + ); + }); + + 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, + value: { + 'internal-object-refrerer': 'from-a-test', + 'design-collection-owner': 'test', + 'design-version-number': 100, + 'design-is-current-version': true, + }, + }), + ); + }); + }); +}); diff --git a/spec/frontend/diff_comments_store_spec.js b/spec/frontend/diff_comments_store_spec.js new file mode 100644 index 00000000000..6f25c9dd3bc --- /dev/null +++ b/spec/frontend/diff_comments_store_spec.js @@ -0,0 +1,136 @@ +/* global CommentsStore */ + +import '~/diff_notes/models/discussion'; +import '~/diff_notes/models/note'; +import '~/diff_notes/stores/comments'; + +function createDiscussion(noteId = 1, resolved = true) { + CommentsStore.create({ + discussionId: 'a', + noteId, + canResolve: true, + resolved, + resolvedBy: 'test', + authorName: 'test', + authorAvatar: 'test', + noteTruncated: 'test...', + }); +} + +beforeEach(() => { + CommentsStore.state = {}; +}); + +describe('New discussion', () => { + it('creates new discussion', () => { + expect(Object.keys(CommentsStore.state).length).toBe(0); + createDiscussion(); + + expect(Object.keys(CommentsStore.state).length).toBe(1); + }); + + it('creates new note in discussion', () => { + createDiscussion(); + createDiscussion(2); + + const discussion = CommentsStore.state.a; + + expect(Object.keys(discussion.notes).length).toBe(2); + }); +}); + +describe('Get note', () => { + beforeEach(() => { + createDiscussion(); + }); + + it('gets note by ID', () => { + const note = CommentsStore.get('a', 1); + + expect(note).toBeDefined(); + expect(note.id).toBe(1); + }); +}); + +describe('Delete discussion', () => { + beforeEach(() => { + createDiscussion(); + }); + + it('deletes discussion by ID', () => { + CommentsStore.delete('a', 1); + + expect(Object.keys(CommentsStore.state).length).toBe(0); + }); + + it('deletes discussion when no more notes', () => { + createDiscussion(); + createDiscussion(2); + + expect(Object.keys(CommentsStore.state).length).toBe(1); + expect(Object.keys(CommentsStore.state.a.notes).length).toBe(2); + + CommentsStore.delete('a', 1); + CommentsStore.delete('a', 2); + + expect(Object.keys(CommentsStore.state).length).toBe(0); + }); +}); + +describe('Update note', () => { + beforeEach(() => { + createDiscussion(); + }); + + it('updates note to be unresolved', () => { + CommentsStore.update('a', 1, false, 'test'); + + const note = CommentsStore.get('a', 1); + + expect(note.resolved).toBe(false); + }); +}); + +describe('Discussion resolved', () => { + beforeEach(() => { + createDiscussion(); + }); + + it('is resolved with single note', () => { + const discussion = CommentsStore.state.a; + + expect(discussion.isResolved()).toBe(true); + }); + + it('is unresolved with 2 notes', () => { + const discussion = CommentsStore.state.a; + createDiscussion(2, false); + + expect(discussion.isResolved()).toBe(false); + }); + + it('is resolved with 2 notes', () => { + const discussion = CommentsStore.state.a; + createDiscussion(2); + + expect(discussion.isResolved()).toBe(true); + }); + + it('resolve all notes', () => { + const discussion = CommentsStore.state.a; + createDiscussion(2, false); + + discussion.resolveAllNotes(); + + expect(discussion.isResolved()).toBe(true); + }); + + it('unresolve all notes', () => { + const discussion = CommentsStore.state.a; + createDiscussion(2); + + discussion.unResolveAllNotes(); + + expect(discussion.isResolved()).toBe(false); + }); +}); diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index 3a0354205f8..57e3a93c6f4 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -14,10 +14,13 @@ import TreeList from '~/diffs/components/tree_list.vue'; import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '~/diffs/constants'; import createDiffsStore from '../create_diffs_store'; import axios from '~/lib/utils/axios_utils'; +import * as urlUtils from '~/lib/utils/url_utility'; import diffsMockData from '../mock_data/merge_request_diffs'; const mergeRequestDiff = { version_index: 1 }; const TEST_ENDPOINT = `${TEST_HOST}/diff/endpoint`; +const COMMIT_URL = '[BASE URL]/OLD'; +const UPDATED_COMMIT_URL = '[BASE URL]/NEW'; describe('diffs/components/app', () => { const oldMrTabs = window.mrTabs; @@ -25,8 +28,14 @@ describe('diffs/components/app', () => { let wrapper; let mock; - function createComponent(props = {}, extendStore = () => {}) { + function createComponent(props = {}, extendStore = () => {}, provisions = {}) { const localVue = createLocalVue(); + const provide = { + ...provisions, + glFeatures: { + ...(provisions.glFeatures || {}), + }, + }; localVue.use(Vuex); @@ -49,6 +58,7 @@ describe('diffs/components/app', () => { showSuggestPopover: true, ...props, }, + provide, store, methods: { isLatestVersion() { @@ -79,7 +89,10 @@ describe('diffs/components/app', () => { window.mrTabs = oldMrTabs; // reset component - wrapper.destroy(); + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } mock.restore(); }); @@ -452,76 +465,109 @@ describe('diffs/components/app', () => { }); describe('keyboard shortcut navigation', () => { - const mappings = { - '[': -1, - k: -1, - ']': +1, - j: +1, - }; - let spy; + let spies = []; + let jumpSpy; + let moveSpy; + + function setup(componentProps, featureFlags) { + createComponent( + componentProps, + ({ state }) => { + state.diffs.commit = { id: 'SHA123' }; + }, + { glFeatures: { mrCommitNeighborNav: true, ...featureFlags } }, + ); + + moveSpy = jest.spyOn(wrapper.vm, 'moveToNeighboringCommit').mockImplementation(() => {}); + jumpSpy = jest.fn(); + spies = [jumpSpy, moveSpy]; + wrapper.setMethods({ + jumpToFile: jumpSpy, + }); + } describe('visible app', () => { - beforeEach(() => { - spy = jest.fn(); + it.each` + key | name | spy | args | featureFlags + ${'['} | ${'jumpToFile'} | ${0} | ${[-1]} | ${{}} + ${'k'} | ${'jumpToFile'} | ${0} | ${[-1]} | ${{}} + ${']'} | ${'jumpToFile'} | ${0} | ${[+1]} | ${{}} + ${'j'} | ${'jumpToFile'} | ${0} | ${[+1]} | ${{}} + ${'x'} | ${'moveToNeighboringCommit'} | ${1} | ${[{ direction: 'previous' }]} | ${{ mrCommitNeighborNav: true }} + ${'c'} | ${'moveToNeighboringCommit'} | ${1} | ${[{ direction: 'next' }]} | ${{ mrCommitNeighborNav: true }} + `( + 'calls `$name()` with correct parameters whenever the "$key" key is pressed', + ({ key, spy, args, featureFlags }) => { + setup({ shouldShow: true }, featureFlags); - createComponent({ - shouldShow: true, - }); - wrapper.setMethods({ - jumpToFile: spy, - }); - }); + return wrapper.vm.$nextTick().then(() => { + expect(spies[spy]).not.toHaveBeenCalled(); + + Mousetrap.trigger(key); + + expect(spies[spy]).toHaveBeenCalledWith(...args); + }); + }, + ); + + it.each` + key | name | spy | featureFlags + ${'x'} | ${'moveToNeighboringCommit'} | ${1} | ${{ mrCommitNeighborNav: false }} + ${'c'} | ${'moveToNeighboringCommit'} | ${1} | ${{ mrCommitNeighborNav: false }} + `( + 'does not call `$name()` even when the correct key is pressed if the feature flag is disabled', + ({ key, spy, featureFlags }) => { + setup({ shouldShow: true }, featureFlags); - it.each(Object.keys(mappings))( - 'calls `jumpToFile()` with correct parameter whenever pre-defined %s is pressed', - key => { return wrapper.vm.$nextTick().then(() => { - expect(spy).not.toHaveBeenCalled(); + expect(spies[spy]).not.toHaveBeenCalled(); Mousetrap.trigger(key); - expect(spy).toHaveBeenCalledWith(mappings[key]); + expect(spies[spy]).not.toHaveBeenCalled(); }); }, ); - it('does not call `jumpToFile()` when unknown key is pressed', done => { - wrapper.vm - .$nextTick() - .then(() => { - Mousetrap.trigger('d'); + it.each` + key | name | spy | allowed + ${'d'} | ${'jumpToFile'} | ${0} | ${['[', ']', 'j', 'k']} + ${'r'} | ${'moveToNeighboringCommit'} | ${1} | ${['x', 'c']} + `( + `does not call \`$name()\` when a key that is not one of \`$allowed\` is pressed`, + ({ key, spy }) => { + setup({ shouldShow: true }, { mrCommitNeighborNav: true }); - expect(spy).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); - }); + return wrapper.vm.$nextTick().then(() => { + Mousetrap.trigger(key); + + expect(spies[spy]).not.toHaveBeenCalled(); + }); + }, + ); }); - describe('hideen app', () => { + describe('hidden app', () => { beforeEach(() => { - spy = jest.fn(); + setup({ shouldShow: false }, { mrCommitNeighborNav: true }); - createComponent({ - shouldShow: false, - }); - wrapper.setMethods({ - jumpToFile: spy, + return wrapper.vm.$nextTick().then(() => { + Mousetrap.reset(); }); }); - it('stops calling `jumpToFile()` when application is hidden', done => { - wrapper.vm - .$nextTick() - .then(() => { - Object.keys(mappings).forEach(key => { - Mousetrap.trigger(key); + it.each` + key | name | spy + ${'['} | ${'jumpToFile'} | ${0} + ${'k'} | ${'jumpToFile'} | ${0} + ${']'} | ${'jumpToFile'} | ${0} + ${'j'} | ${'jumpToFile'} | ${0} + ${'x'} | ${'moveToNeighboringCommit'} | ${1} + ${'c'} | ${'moveToNeighboringCommit'} | ${1} + `('stops calling `$name()` when the app is hidden', ({ key, spy }) => { + Mousetrap.trigger(key); - expect(spy).not.toHaveBeenCalled(); - }); - }) - .then(done) - .catch(done.fail); + expect(spies[spy]).not.toHaveBeenCalled(); }); }); }); @@ -602,6 +648,70 @@ describe('diffs/components/app', () => { }); }); + describe('commit watcher', () => { + const spy = () => { + jest.spyOn(wrapper.vm, 'refetchDiffData').mockImplementation(() => {}); + jest.spyOn(wrapper.vm, 'adjustView').mockImplementation(() => {}); + }; + let location; + + beforeAll(() => { + location = window.location; + delete window.location; + window.location = COMMIT_URL; + document.title = 'My Title'; + }); + + beforeEach(() => { + jest.spyOn(urlUtils, 'updateHistory'); + }); + + afterAll(() => { + window.location = location; + }); + + it('when the commit changes and the app is not loading it should update the history, refetch the diff data, and update the view', () => { + createComponent({}, ({ state }) => { + state.diffs.commit = { ...state.diffs.commit, id: 'OLD' }; + }); + spy(); + + store.state.diffs.commit = { id: 'NEW' }; + + return wrapper.vm.$nextTick().then(() => { + expect(urlUtils.updateHistory).toHaveBeenCalledWith({ + title: document.title, + url: UPDATED_COMMIT_URL, + }); + expect(wrapper.vm.refetchDiffData).toHaveBeenCalled(); + expect(wrapper.vm.adjustView).toHaveBeenCalled(); + }); + }); + + it.each` + isLoading | oldSha | newSha + ${true} | ${'OLD'} | ${'NEW'} + ${false} | ${'NEW'} | ${'NEW'} + `( + 'given `{ "isLoading": $isLoading, "oldSha": "$oldSha", "newSha": "$newSha" }`, nothing should happen', + ({ isLoading, oldSha, newSha }) => { + createComponent({}, ({ state }) => { + state.diffs.isLoading = isLoading; + state.diffs.commit = { ...state.diffs.commit, id: oldSha }; + }); + spy(); + + store.state.diffs.commit = { id: newSha }; + + return wrapper.vm.$nextTick().then(() => { + expect(urlUtils.updateHistory).not.toHaveBeenCalled(); + expect(wrapper.vm.refetchDiffData).not.toHaveBeenCalled(); + expect(wrapper.vm.adjustView).not.toHaveBeenCalled(); + }); + }, + ); + }); + describe('diffs', () => { it('should render compare versions component', () => { createComponent({}, ({ state }) => { diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js index 6bb3a0dcf21..0df951d43a7 100644 --- a/spec/frontend/diffs/components/commit_item_spec.js +++ b/spec/frontend/diffs/components/commit_item_spec.js @@ -13,6 +13,8 @@ const TEST_AUTHOR_EMAIL = 'test+test@gitlab.com'; const TEST_AUTHOR_GRAVATAR = `${TEST_HOST}/avatar/test?s=40`; const TEST_SIGNATURE_HTML = '<a>Legit commit</a>'; const TEST_PIPELINE_STATUS_PATH = `${TEST_HOST}/pipeline/status`; +const NEXT_COMMIT_URL = `${TEST_HOST}/?commit_id=next`; +const PREV_COMMIT_URL = `${TEST_HOST}/?commit_id=prev`; describe('diffs/components/commit_item', () => { let wrapper; @@ -30,12 +32,24 @@ describe('diffs/components/commit_item', () => { const getCommitActionsElement = () => wrapper.find('.commit-actions'); const getCommitPipelineStatus = () => wrapper.find(CommitPipelineStatus); - const mountComponent = propsData => { + const getCommitNavButtonsElement = () => wrapper.find('.commit-nav-buttons'); + const getNextCommitNavElement = () => + getCommitNavButtonsElement().find('.btn-group > *:last-child'); + const getPrevCommitNavElement = () => + getCommitNavButtonsElement().find('.btn-group > *:first-child'); + + const mountComponent = (propsData, featureFlags = {}) => { wrapper = mount(Component, { propsData: { commit, ...propsData, }, + provide: { + glFeatures: { + mrCommitNeighborNav: true, + ...featureFlags, + }, + }, stubs: { CommitPipelineStatus: true, }, @@ -173,4 +187,132 @@ describe('diffs/components/commit_item', () => { expect(getCommitPipelineStatus().exists()).toBe(true); }); }); + + describe('without neighbor commits', () => { + beforeEach(() => { + mountComponent({ commit: { ...commit, prev_commit_id: null, next_commit_id: null } }); + }); + + it('does not render any navigation buttons', () => { + expect(getCommitNavButtonsElement().exists()).toEqual(false); + }); + }); + + describe('with neighbor commits', () => { + let mrCommit; + + beforeEach(() => { + mrCommit = { + ...commit, + next_commit_id: 'next', + prev_commit_id: 'prev', + }; + + mountComponent({ commit: mrCommit }); + }); + + it('renders the commit navigation buttons', () => { + expect(getCommitNavButtonsElement().exists()).toEqual(true); + + mountComponent({ + commit: { ...mrCommit, next_commit_id: null }, + }); + expect(getCommitNavButtonsElement().exists()).toEqual(true); + + mountComponent({ + commit: { ...mrCommit, prev_commit_id: null }, + }); + expect(getCommitNavButtonsElement().exists()).toEqual(true); + }); + + it('does not render the commit navigation buttons if the `mrCommitNeighborNav` feature flag is disabled', () => { + mountComponent({ commit: mrCommit }, { mrCommitNeighborNav: false }); + + expect(getCommitNavButtonsElement().exists()).toEqual(false); + }); + + describe('prev commit', () => { + const { location } = window; + + beforeAll(() => { + delete window.location; + window.location = { href: `${TEST_HOST}?commit_id=${mrCommit.id}` }; + }); + + beforeEach(() => { + jest.spyOn(wrapper.vm, 'moveToNeighboringCommit').mockImplementation(() => {}); + }); + + afterAll(() => { + window.location = location; + }); + + it('uses the correct href', () => { + const link = getPrevCommitNavElement(); + + expect(link.element.getAttribute('href')).toEqual(PREV_COMMIT_URL); + }); + + it('triggers the correct Vuex action on click', () => { + const link = getPrevCommitNavElement(); + + link.trigger('click'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.moveToNeighboringCommit).toHaveBeenCalledWith({ + direction: 'previous', + }); + }); + }); + + it('renders a disabled button when there is no prev commit', () => { + mountComponent({ commit: { ...mrCommit, prev_commit_id: null } }); + + const button = getPrevCommitNavElement(); + + expect(button.element.tagName).toEqual('BUTTON'); + expect(button.element.hasAttribute('disabled')).toEqual(true); + }); + }); + + describe('next commit', () => { + const { location } = window; + + beforeAll(() => { + delete window.location; + window.location = { href: `${TEST_HOST}?commit_id=${mrCommit.id}` }; + }); + + beforeEach(() => { + jest.spyOn(wrapper.vm, 'moveToNeighboringCommit').mockImplementation(() => {}); + }); + + afterAll(() => { + window.location = location; + }); + + it('uses the correct href', () => { + const link = getNextCommitNavElement(); + + expect(link.element.getAttribute('href')).toEqual(NEXT_COMMIT_URL); + }); + + it('triggers the correct Vuex action on click', () => { + const link = getNextCommitNavElement(); + + link.trigger('click'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.moveToNeighboringCommit).toHaveBeenCalledWith({ direction: 'next' }); + }); + }); + + it('renders a disabled button when there is no next commit', () => { + mountComponent({ commit: { ...mrCommit, next_commit_id: null } }); + + const button = getNextCommitNavElement(); + + expect(button.element.tagName).toEqual('BUTTON'); + expect(button.element.hasAttribute('disabled')).toEqual(true); + }); + }); + }); }); diff --git a/spec/frontend/diffs/components/diff_content_spec.js b/spec/frontend/diffs/components/diff_content_spec.js index 979c67787f7..b78895f9e55 100644 --- a/spec/frontend/diffs/components/diff_content_spec.js +++ b/spec/frontend/diffs/components/diff_content_spec.js @@ -10,7 +10,7 @@ import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; import NoteForm from '~/notes/components/note_form.vue'; import DiffDiscussions from '~/diffs/components/diff_discussions.vue'; import { IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants'; -import diffFileMockData from '../../../javascripts/diffs/mock_data/diff_file'; +import diffFileMockData from '../mock_data/diff_file'; import { diffViewerModes } from '~/ide/constants'; const localVue = createLocalVue(); diff --git a/spec/frontend/diffs/components/diff_discussions_spec.js b/spec/frontend/diffs/components/diff_discussions_spec.js index ba5a4f96204..83becc7a20a 100644 --- a/spec/frontend/diffs/components/diff_discussions_spec.js +++ b/spec/frontend/diffs/components/diff_discussions_spec.js @@ -13,7 +13,7 @@ const localVue = createLocalVue(); describe('DiffDiscussions', () => { let store; let wrapper; - const getDiscussionsMockData = () => [Object.assign({}, discussionsMockData)]; + const getDiscussionsMockData = () => [{ ...discussionsMockData }]; const createComponent = props => { store = createStore(); diff --git a/spec/frontend/diffs/components/diff_expansion_cell_spec.js b/spec/frontend/diffs/components/diff_expansion_cell_spec.js index 31c6a4d5b60..0504f3933e0 100644 --- a/spec/frontend/diffs/components/diff_expansion_cell_spec.js +++ b/spec/frontend/diffs/components/diff_expansion_cell_spec.js @@ -81,7 +81,7 @@ describe('DiffExpansionCell', () => { isTop: false, isBottom: false, }; - const props = Object.assign({}, defaults, options); + const props = { ...defaults, ...options }; vm = createComponentWithStore(cmp, store, props).$mount(); }; diff --git a/spec/frontend/diffs/components/diff_gutter_avatars_spec.js b/spec/frontend/diffs/components/diff_gutter_avatars_spec.js index 4d8345d494d..da18d8e7894 100644 --- a/spec/frontend/diffs/components/diff_gutter_avatars_spec.js +++ b/spec/frontend/diffs/components/diff_gutter_avatars_spec.js @@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue'; import discussionsMockData from '../mock_data/diff_discussions'; -const getDiscussionsMockData = () => [Object.assign({}, discussionsMockData)]; +const getDiscussionsMockData = () => [{ ...discussionsMockData }]; describe('DiffGutterAvatars', () => { let wrapper; 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 9b032d10fdc..3e0acd0dace 100644 --- a/spec/frontend/diffs/components/diff_line_note_form_spec.js +++ b/spec/frontend/diffs/components/diff_line_note_form_spec.js @@ -9,7 +9,7 @@ describe('DiffLineNoteForm', () => { let wrapper; let diffFile; let diffLines; - const getDiffFileMock = () => Object.assign({}, diffFileMockData); + const getDiffFileMock = () => ({ ...diffFileMockData }); beforeEach(() => { diffFile = getDiffFileMock(); diff --git a/spec/frontend/diffs/components/edit_button_spec.js b/spec/frontend/diffs/components/edit_button_spec.js index f9a1d4a84a8..71512c1c4af 100644 --- a/spec/frontend/diffs/components/edit_button_spec.js +++ b/spec/frontend/diffs/components/edit_button_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { GlDeprecatedButton } from '@gitlab/ui'; import EditButton from '~/diffs/components/edit_button.vue'; const editPath = 'test-path'; @@ -22,7 +23,7 @@ describe('EditButton', () => { canCurrentUserFork: false, }); - expect(wrapper.attributes('href')).toBe(editPath); + expect(wrapper.find(GlDeprecatedButton).attributes('href')).toBe(editPath); }); it('emits a show fork message event if current user can fork', () => { @@ -30,7 +31,7 @@ describe('EditButton', () => { editPath, canCurrentUserFork: true, }); - wrapper.trigger('click'); + wrapper.find(GlDeprecatedButton).trigger('click'); return wrapper.vm.$nextTick().then(() => { expect(wrapper.emitted('showForkMessage')).toBeTruthy(); @@ -42,7 +43,7 @@ describe('EditButton', () => { editPath, canCurrentUserFork: false, }); - wrapper.trigger('click'); + wrapper.find(GlDeprecatedButton).trigger('click'); return wrapper.vm.$nextTick().then(() => { expect(wrapper.emitted('showForkMessage')).toBeFalsy(); @@ -55,10 +56,20 @@ describe('EditButton', () => { canCurrentUserFork: true, canModifyBlob: true, }); - wrapper.trigger('click'); + wrapper.find(GlDeprecatedButton).trigger('click'); return wrapper.vm.$nextTick().then(() => { expect(wrapper.emitted('showForkMessage')).toBeFalsy(); }); }); + + it('disables button if editPath is empty', () => { + createComponent({ + editPath: '', + canCurrentUserFork: true, + canModifyBlob: true, + }); + + expect(wrapper.find(GlDeprecatedButton).attributes('disabled')).toBe('true'); + }); }); diff --git a/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js b/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js index f423c3b111e..90f012fbafe 100644 --- a/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js +++ b/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js @@ -16,7 +16,7 @@ describe('InlineDiffExpansionRow', () => { isTop: false, isBottom: false, }; - const props = Object.assign({}, defaults, options); + const props = { ...defaults, ...options }; return createComponentWithStore(cmp, createStore(), props).$mount(); }; diff --git a/spec/frontend/diffs/components/inline_diff_view_spec.js b/spec/frontend/diffs/components/inline_diff_view_spec.js index a63c13fb271..9b0cf6a84d9 100644 --- a/spec/frontend/diffs/components/inline_diff_view_spec.js +++ b/spec/frontend/diffs/components/inline_diff_view_spec.js @@ -8,8 +8,8 @@ import discussionsMockData from '../mock_data/diff_discussions'; describe('InlineDiffView', () => { let component; - const getDiffFileMock = () => Object.assign({}, diffFileMockData); - const getDiscussionsMockData = () => [Object.assign({}, discussionsMockData)]; + const getDiffFileMock = () => ({ ...diffFileMockData }); + const getDiscussionsMockData = () => [{ ...discussionsMockData }]; const notesLength = getDiscussionsMockData()[0].notes.length; beforeEach(done => { diff --git a/spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js b/spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js index 15b2a824697..38112445e8d 100644 --- a/spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js +++ b/spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js @@ -16,7 +16,7 @@ describe('ParallelDiffExpansionRow', () => { isTop: false, isBottom: false, }; - const props = Object.assign({}, defaults, options); + const props = { ...defaults, ...options }; return createComponentWithStore(cmp, createStore(), props).$mount(); }; diff --git a/spec/frontend/diffs/components/parallel_diff_view_spec.js b/spec/frontend/diffs/components/parallel_diff_view_spec.js index 0eefbc7ec08..03cf1b72b62 100644 --- a/spec/frontend/diffs/components/parallel_diff_view_spec.js +++ b/spec/frontend/diffs/components/parallel_diff_view_spec.js @@ -7,7 +7,7 @@ import diffFileMockData from '../mock_data/diff_file'; describe('ParallelDiffView', () => { let component; - const getDiffFileMock = () => Object.assign({}, diffFileMockData); + const getDiffFileMock = () => ({ ...diffFileMockData }); beforeEach(() => { const diffFile = getDiffFileMock(); diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index ceccce6312f..3fba661da44 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -40,9 +40,12 @@ import { receiveFullDiffError, fetchFullDiff, toggleFullDiff, + switchToFullDiffFromRenamedFile, setFileCollapsed, setExpandedDiffLines, setSuggestPopoverDismissed, + changeCurrentCommit, + moveToNeighboringCommit, } from '~/diffs/store/actions'; import eventHub from '~/notes/event_hub'; import * as types from '~/diffs/store/mutation_types'; @@ -312,7 +315,7 @@ describe('DiffsStoreActions', () => { describe('fetchDiffFilesMeta', () => { it('should fetch diff meta information', done => { - const endpointMetadata = '/fetch/diffs_meta?'; + const endpointMetadata = '/fetch/diffs_meta'; const mock = new MockAdapter(axios); const data = { diff_files: [] }; const res = { data }; @@ -1250,6 +1253,87 @@ describe('DiffsStoreActions', () => { }); }); + describe('switchToFullDiffFromRenamedFile', () => { + const SUCCESS_URL = 'fakehost/context.success'; + const ERROR_URL = 'fakehost/context.error'; + const testFilePath = 'testpath'; + const updatedViewerName = 'testviewer'; + const preparedLine = { prepared: 'in-a-test' }; + const testFile = { + file_path: testFilePath, + file_hash: 'testhash', + alternate_viewer: { name: updatedViewerName }, + }; + const updatedViewer = { name: updatedViewerName, collapsed: false }; + const testData = [{ rich_text: 'test' }, { rich_text: 'file2' }]; + let renamedFile; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + jest.spyOn(utils, 'prepareLineForRenamedFile').mockImplementation(() => preparedLine); + }); + + afterEach(() => { + renamedFile = null; + mock.restore(); + }); + + describe('success', () => { + beforeEach(() => { + renamedFile = { ...testFile, context_lines_path: SUCCESS_URL }; + mock.onGet(SUCCESS_URL).replyOnce(200, testData); + }); + + it.each` + diffViewType + ${INLINE_DIFF_VIEW_TYPE} + ${PARALLEL_DIFF_VIEW_TYPE} + `( + 'performs the correct mutations and starts a render queue for view type $diffViewType', + ({ diffViewType }) => { + return testAction( + switchToFullDiffFromRenamedFile, + { diffFile: renamedFile }, + { diffViewType }, + [ + { + type: types.SET_DIFF_FILE_VIEWER, + payload: { filePath: testFilePath, viewer: updatedViewer }, + }, + { + type: types.SET_CURRENT_VIEW_DIFF_FILE_LINES, + payload: { filePath: testFilePath, lines: [preparedLine, preparedLine] }, + }, + ], + [{ type: 'startRenderDiffsQueue' }], + ); + }, + ); + }); + + describe('error', () => { + beforeEach(() => { + renamedFile = { ...testFile, context_lines_path: ERROR_URL }; + mock.onGet(ERROR_URL).reply(500); + }); + + it('dispatches the error handling action', () => { + const rejected = testAction( + switchToFullDiffFromRenamedFile, + { diffFile: renamedFile }, + null, + [], + [{ type: 'receiveFullDiffError', payload: testFilePath }], + ); + + return rejected.catch(error => + expect(error).toEqual(new Error('Request failed with status code 500')), + ); + }); + }); + }); + describe('setFileCollapsed', () => { it('commits SET_FILE_COLLAPSED', done => { testAction( @@ -1347,4 +1431,102 @@ describe('DiffsStoreActions', () => { ); }); }); + + describe('changeCurrentCommit', () => { + it('commits the new commit information and re-requests the diff metadata for the commit', () => { + return testAction( + changeCurrentCommit, + { commitId: 'NEW' }, + { + commit: { + id: 'OLD', + }, + endpoint: 'URL/OLD', + endpointBatch: 'URL/OLD', + endpointMetadata: 'URL/OLD', + }, + [ + { type: types.SET_DIFF_FILES, payload: [] }, + { + type: types.SET_BASE_CONFIG, + payload: { + commit: { + id: 'OLD', // Not a typo: the action fired next will overwrite all of the `commit` in state + }, + endpoint: 'URL/NEW', + endpointBatch: 'URL/NEW', + endpointMetadata: 'URL/NEW', + }, + }, + ], + [{ type: 'fetchDiffFilesMeta' }], + ); + }); + + it.each` + commitId | commit | msg + ${undefined} | ${{ id: 'OLD' }} | ${'`commitId` is a required argument'} + ${'NEW'} | ${null} | ${'`state` must already contain a valid `commit`'} + ${undefined} | ${null} | ${'`commitId` is a required argument'} + `( + 'returns a rejected promise with the error message $msg given `{ "commitId": $commitId, "state.commit": $commit }`', + ({ commitId, commit, msg }) => { + const err = new Error(msg); + const actionReturn = testAction( + changeCurrentCommit, + { commitId }, + { + endpoint: 'URL/OLD', + endpointBatch: 'URL/OLD', + endpointMetadata: 'URL/OLD', + commit, + }, + [], + [], + ); + + return expect(actionReturn).rejects.toStrictEqual(err); + }, + ); + }); + + describe('moveToNeighboringCommit', () => { + it.each` + direction | expected | currentCommit + ${'next'} | ${'NEXTSHA'} | ${{ next_commit_id: 'NEXTSHA' }} + ${'previous'} | ${'PREVIOUSSHA'} | ${{ prev_commit_id: 'PREVIOUSSHA' }} + `( + 'for the direction "$direction", dispatches the action to move to the SHA "$expected"', + ({ direction, expected, currentCommit }) => { + return testAction( + moveToNeighboringCommit, + { direction }, + { commit: currentCommit }, + [], + [{ type: 'changeCurrentCommit', payload: { commitId: expected } }], + ); + }, + ); + + it.each` + direction | diffsAreLoading | currentCommit + ${'next'} | ${false} | ${{ prev_commit_id: 'PREVIOUSSHA' }} + ${'next'} | ${true} | ${{ prev_commit_id: 'PREVIOUSSHA' }} + ${'next'} | ${false} | ${undefined} + ${'previous'} | ${false} | ${{ next_commit_id: 'NEXTSHA' }} + ${'previous'} | ${true} | ${{ next_commit_id: 'NEXTSHA' }} + ${'previous'} | ${false} | ${undefined} + `( + 'given `{ "isloading": $diffsAreLoading, "commit": $currentCommit }` in state, no actions are dispatched', + ({ direction, diffsAreLoading, currentCommit }) => { + return testAction( + moveToNeighboringCommit, + { direction }, + { commit: currentCommit, isLoading: diffsAreLoading }, + [], + [], + ); + }, + ); + }); }); diff --git a/spec/frontend/diffs/store/getters_spec.js b/spec/frontend/diffs/store/getters_spec.js index ca47f51cb15..dac5be2d656 100644 --- a/spec/frontend/diffs/store/getters_spec.js +++ b/spec/frontend/diffs/store/getters_spec.js @@ -14,10 +14,10 @@ describe('Diffs Module Getters', () => { beforeEach(() => { localState = state(); - discussionMock = Object.assign({}, discussion); + discussionMock = { ...discussion }; discussionMock.diff_file.file_hash = diffFileMock.fileHash; - discussionMock1 = Object.assign({}, discussion); + discussionMock1 = { ...discussion }; discussionMock1.diff_file.file_hash = diffFileMock.fileHash; }); diff --git a/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js b/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js index eb0f2364a50..0343ef75732 100644 --- a/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js +++ b/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js @@ -18,7 +18,6 @@ describe('Compare diff version dropdowns', () => { }; localState.targetBranchName = 'baseVersion'; localState.mergeRequestDiffs = diffsMockData; - gon.features = { diffCompareWithHead: true }; }); describe('selectedTargetIndex', () => { @@ -129,14 +128,6 @@ describe('Compare diff version dropdowns', () => { }); assertVersions(targetVersions); }); - - it('does not list head version if feature flag is not enabled', () => { - gon.features = { diffCompareWithHead: false }; - setupTest(); - const targetVersions = getters.diffCompareDropdownTargetVersions(localState, getters); - - expect(targetVersions.find(version => version.isHead)).toBeUndefined(); - }); }); it('diffCompareDropdownSourceVersions', () => { diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js index 858ab5be167..c24d406fef3 100644 --- a/spec/frontend/diffs/store/mutations_spec.js +++ b/spec/frontend/diffs/store/mutations_spec.js @@ -1041,6 +1041,36 @@ describe('DiffsStoreMutations', () => { }); }); + describe('SET_DIFF_FILE_VIEWER', () => { + it("should update the correct diffFile's viewer property", () => { + const state = { + diffFiles: [ + { file_path: 'SearchString', viewer: 'OLD VIEWER' }, + { file_path: 'OtherSearchString' }, + { file_path: 'SomeOtherString' }, + ], + }; + + mutations[types.SET_DIFF_FILE_VIEWER](state, { + filePath: 'SearchString', + viewer: 'NEW VIEWER', + }); + + expect(state.diffFiles[0].viewer).toEqual('NEW VIEWER'); + expect(state.diffFiles[1].viewer).not.toBeDefined(); + expect(state.diffFiles[2].viewer).not.toBeDefined(); + + mutations[types.SET_DIFF_FILE_VIEWER](state, { + filePath: 'OtherSearchString', + viewer: 'NEW VIEWER', + }); + + expect(state.diffFiles[0].viewer).toEqual('NEW VIEWER'); + expect(state.diffFiles[1].viewer).toEqual('NEW VIEWER'); + expect(state.diffFiles[2].viewer).not.toBeDefined(); + }); + }); + describe('SET_SHOW_SUGGEST_POPOVER', () => { it('sets showSuggestPopover to false', () => { const state = { showSuggestPopover: true }; diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index 1adcdab272a..641373e666f 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -361,6 +361,72 @@ describe('DiffsStoreUtils', () => { }); }); + describe('prepareLineForRenamedFile', () => { + const diffFile = { + file_hash: 'file-hash', + }; + const lineIndex = 4; + const sourceLine = { + foo: 'test', + rich_text: ' <p>rich</p>', // Note the leading space + }; + const correctLine = { + foo: 'test', + line_code: 'file-hash_5_5', + old_line: 5, + new_line: 5, + rich_text: '<p>rich</p>', // Note no leading space + discussionsExpanded: true, + discussions: [], + hasForm: false, + text: undefined, + alreadyPrepared: true, + }; + let preppedLine; + + beforeEach(() => { + preppedLine = utils.prepareLineForRenamedFile({ + diffViewType: INLINE_DIFF_VIEW_TYPE, + line: sourceLine, + index: lineIndex, + diffFile, + }); + }); + + it('copies over the original line object to the new prepared line', () => { + expect(preppedLine).toEqual( + expect.objectContaining({ + foo: correctLine.foo, + rich_text: correctLine.rich_text, + }), + ); + }); + + it('correctly sets the old and new lines, plus a line code', () => { + expect(preppedLine.old_line).toEqual(correctLine.old_line); + expect(preppedLine.new_line).toEqual(correctLine.new_line); + expect(preppedLine.line_code).toEqual(correctLine.line_code); + }); + + it('returns a single object with the correct structure for `inline` lines', () => { + expect(preppedLine).toEqual(correctLine); + }); + + it('returns a nested object with "left" and "right" lines + the line code for `parallel` lines', () => { + preppedLine = utils.prepareLineForRenamedFile({ + diffViewType: PARALLEL_DIFF_VIEW_TYPE, + line: sourceLine, + index: lineIndex, + diffFile, + }); + + expect(Object.keys(preppedLine)).toEqual(['left', 'right', 'line_code']); + expect(preppedLine.left).toEqual(correctLine); + expect(preppedLine.right).toEqual(correctLine); + expect(preppedLine.line_code).toEqual(correctLine.line_code); + }); + }); + describe('prepareDiffData', () => { let mock; let preparedDiff; @@ -372,13 +438,13 @@ describe('DiffsStoreUtils', () => { mock = getDiffFileMock(); preparedDiff = { diff_files: [mock] }; splitInlineDiff = { - diff_files: [Object.assign({}, mock, { parallel_diff_lines: undefined })], + diff_files: [{ ...mock, parallel_diff_lines: undefined }], }; splitParallelDiff = { - diff_files: [Object.assign({}, mock, { highlighted_diff_lines: undefined })], + diff_files: [{ ...mock, highlighted_diff_lines: undefined }], }; completedDiff = { - diff_files: [Object.assign({}, mock, { highlighted_diff_lines: undefined })], + diff_files: [{ ...mock, highlighted_diff_lines: undefined }], }; preparedDiff.diff_files = utils.prepareDiffData(preparedDiff); @@ -503,11 +569,16 @@ describe('DiffsStoreUtils', () => { }, }; + // When multi line comments are fully implemented `line_code` will be + // included in all requests. Until then we need to ensure the logic does + // not change when it is included only in the "comparison" argument. + const lineRange = { start_line_code: 'abc_1_1', end_line_code: 'abc_1_2' }; + it('returns true when the discussion is up to date', () => { expect( utils.isDiscussionApplicableToLine({ discussion: discussions.upToDateDiscussion1, - diffPosition, + diffPosition: { ...diffPosition, line_range: lineRange }, latestDiff: true, }), ).toBe(true); @@ -517,7 +588,7 @@ describe('DiffsStoreUtils', () => { expect( utils.isDiscussionApplicableToLine({ discussion: discussions.outDatedDiscussion1, - diffPosition, + diffPosition: { ...diffPosition, line_range: lineRange }, latestDiff: true, }), ).toBe(false); @@ -534,6 +605,7 @@ describe('DiffsStoreUtils', () => { diffPosition: { ...diffPosition, lineCode: 'ABC_1', + line_range: lineRange, }, latestDiff: true, }), @@ -551,6 +623,7 @@ describe('DiffsStoreUtils', () => { diffPosition: { ...diffPosition, line_code: 'ABC_1', + line_range: lineRange, }, latestDiff: true, }), @@ -568,6 +641,7 @@ describe('DiffsStoreUtils', () => { diffPosition: { ...diffPosition, lineCode: 'ABC_1', + line_range: lineRange, }, latestDiff: false, }), diff --git a/spec/frontend/dirty_submit/dirty_submit_collection_spec.js b/spec/frontend/dirty_submit/dirty_submit_collection_spec.js new file mode 100644 index 00000000000..170d581be23 --- /dev/null +++ b/spec/frontend/dirty_submit/dirty_submit_collection_spec.js @@ -0,0 +1,22 @@ +import DirtySubmitCollection from '~/dirty_submit/dirty_submit_collection'; +import { setInputValue, createForm } from './helper'; + +jest.mock('lodash/throttle', () => jest.fn(fn => fn)); + +describe('DirtySubmitCollection', () => { + const testElementsCollection = [createForm(), createForm()]; + const forms = testElementsCollection.map(testElements => testElements.form); + + new DirtySubmitCollection(forms); // eslint-disable-line no-new + + it.each(testElementsCollection)('disables submits until there are changes', testElements => { + const { input, submit } = testElements; + const originalValue = input.value; + + expect(submit.disabled).toBe(true); + setInputValue(input, `${originalValue} changes`); + expect(submit.disabled).toBe(false); + setInputValue(input, originalValue); + expect(submit.disabled).toBe(true); + }); +}); diff --git a/spec/frontend/dirty_submit/dirty_submit_factory_spec.js b/spec/frontend/dirty_submit/dirty_submit_factory_spec.js new file mode 100644 index 00000000000..40843a68582 --- /dev/null +++ b/spec/frontend/dirty_submit/dirty_submit_factory_spec.js @@ -0,0 +1,18 @@ +import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; +import DirtySubmitForm from '~/dirty_submit/dirty_submit_form'; +import DirtySubmitCollection from '~/dirty_submit/dirty_submit_collection'; +import { createForm } from './helper'; + +describe('DirtySubmitCollection', () => { + it('returns a DirtySubmitForm instance for single form elements', () => { + const { form } = createForm(); + + expect(dirtySubmitFactory(form) instanceof DirtySubmitForm).toBe(true); + }); + + it('returns a DirtySubmitCollection instance for a collection of form elements', () => { + const forms = [createForm().form, createForm().form]; + + expect(dirtySubmitFactory(forms) instanceof DirtySubmitCollection).toBe(true); + }); +}); diff --git a/spec/frontend/dirty_submit/dirty_submit_form_spec.js b/spec/frontend/dirty_submit/dirty_submit_form_spec.js new file mode 100644 index 00000000000..d7f690df1f3 --- /dev/null +++ b/spec/frontend/dirty_submit/dirty_submit_form_spec.js @@ -0,0 +1,97 @@ +import { range as rge, throttle } from 'lodash'; +import DirtySubmitForm from '~/dirty_submit/dirty_submit_form'; +import { getInputValue, setInputValue, createForm } from './helper'; + +jest.mock('lodash/throttle', () => jest.fn(fn => fn)); +const lodash = jest.requireActual('lodash'); + +function expectToToggleDisableOnDirtyUpdate(submit, input) { + const originalValue = getInputValue(input); + + expect(submit.disabled).toBe(true); + + setInputValue(input, `${originalValue} changes`); + expect(submit.disabled).toBe(false); + setInputValue(input, originalValue); + expect(submit.disabled).toBe(true); +} + +describe('DirtySubmitForm', () => { + describe('submit button tests', () => { + it('disables submit until there are changes', () => { + const { form, input, submit } = createForm(); + + new DirtySubmitForm(form); // eslint-disable-line no-new + + expectToToggleDisableOnDirtyUpdate(submit, input); + }); + + it('disables submit until there are changes when initializing with a falsy value', () => { + const { form, input, submit } = createForm(); + input.value = ''; + + new DirtySubmitForm(form); // eslint-disable-line no-new + + expectToToggleDisableOnDirtyUpdate(submit, input); + }); + + it('disables submit until there are changes for radio inputs', () => { + const { form, input, submit } = createForm('radio'); + + new DirtySubmitForm(form); // eslint-disable-line no-new + + expectToToggleDisableOnDirtyUpdate(submit, input); + }); + + it('disables submit until there are changes for checkbox inputs', () => { + const { form, input, submit } = createForm('checkbox'); + + new DirtySubmitForm(form); // eslint-disable-line no-new + + expectToToggleDisableOnDirtyUpdate(submit, input); + }); + }); + + describe('throttling tests', () => { + beforeEach(() => { + throttle.mockImplementation(lodash.throttle); + jest.useFakeTimers(); + }); + + afterEach(() => { + throttle.mockReset(); + }); + + it('throttles updates when rapid changes are made to a single form element', () => { + const { form, input } = createForm(); + const updateDirtyInputSpy = jest.spyOn(new DirtySubmitForm(form), 'updateDirtyInput'); + + rge(10).forEach(i => { + setInputValue(input, `change ${i}`, false); + }); + + jest.runOnlyPendingTimers(); + + expect(updateDirtyInputSpy).toHaveBeenCalledTimes(1); + }); + + it('does not throttle updates when rapid changes are made to different form elements', () => { + const form = document.createElement('form'); + const range = rge(10); + range.forEach(i => { + form.innerHTML += `<input type="text" name="input-${i}" class="js-input-${i}"/>`; + }); + + const updateDirtyInputSpy = jest.spyOn(new DirtySubmitForm(form), 'updateDirtyInput'); + + range.forEach(i => { + const input = form.querySelector(`.js-input-${i}`); + setInputValue(input, `change`, false); + }); + + jest.runOnlyPendingTimers(); + + expect(updateDirtyInputSpy).toHaveBeenCalledTimes(range.length); + }); + }); +}); diff --git a/spec/frontend/dirty_submit/helper.js b/spec/frontend/dirty_submit/helper.js new file mode 100644 index 00000000000..c02512b7671 --- /dev/null +++ b/spec/frontend/dirty_submit/helper.js @@ -0,0 +1,43 @@ +function isCheckableType(type) { + return /^(radio|checkbox)$/.test(type); +} + +export function setInputValue(element, value) { + const { type } = element; + let eventType; + + if (isCheckableType(type)) { + element.checked = !element.checked; + eventType = 'change'; + } else { + element.value = value; + eventType = 'input'; + } + + element.dispatchEvent( + new Event(eventType, { + bubbles: true, + }), + ); +} + +export function getInputValue(input) { + return isCheckableType(input.type) ? input.checked : input.value; +} + +export function createForm(type = 'text') { + const form = document.createElement('form'); + form.innerHTML = ` + <input type="${type}" name="${type}" class="js-input"/> + <button type="submit" class="js-dirty-submit"></button> + `; + + const input = form.querySelector('.js-input'); + const submit = form.querySelector('.js-dirty-submit'); + + return { + form, + input, + submit, + }; +} diff --git a/spec/frontend/editor/editor_lite_spec.js b/spec/frontend/editor/editor_lite_spec.js new file mode 100644 index 00000000000..cb07bcf8f28 --- /dev/null +++ b/spec/frontend/editor/editor_lite_spec.js @@ -0,0 +1,177 @@ +import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor'; +import Editor from '~/editor/editor_lite'; +import { DEFAULT_THEME, themes } from '~/ide/lib/themes'; + +describe('Base editor', () => { + let editorEl; + let editor; + const blobContent = 'Foo Bar'; + const blobPath = 'test.md'; + const uri = new Uri('gitlab', false, blobPath); + const fakeModel = { foo: 'bar' }; + + beforeEach(() => { + setFixtures('<div id="editor" data-editor-loading></div>'); + editorEl = document.getElementById('editor'); + editor = new Editor(); + }); + + afterEach(() => { + editor.dispose(); + editorEl.remove(); + }); + + it('initializes Editor with basic properties', () => { + expect(editor).toBeDefined(); + expect(editor.editorEl).toBe(null); + expect(editor.blobContent).toEqual(''); + expect(editor.blobPath).toEqual(''); + }); + + it('removes `editor-loading` data attribute from the target DOM element', () => { + editor.createInstance({ el: editorEl }); + + expect(editorEl.dataset.editorLoading).toBeUndefined(); + }); + + describe('instance of the Editor', () => { + let modelSpy; + let instanceSpy; + let setModel; + let dispose; + + beforeEach(() => { + setModel = jest.fn(); + dispose = jest.fn(); + modelSpy = jest.spyOn(monacoEditor, 'createModel').mockImplementation(() => fakeModel); + instanceSpy = jest.spyOn(monacoEditor, 'create').mockImplementation(() => ({ + setModel, + dispose, + })); + }); + + it('does nothing if no dom element is supplied', () => { + editor.createInstance(); + + expect(editor.editorEl).toBe(null); + expect(editor.blobContent).toEqual(''); + expect(editor.blobPath).toEqual(''); + + expect(modelSpy).not.toHaveBeenCalled(); + expect(instanceSpy).not.toHaveBeenCalled(); + expect(setModel).not.toHaveBeenCalled(); + }); + + it('creates model to be supplied to Monaco editor', () => { + editor.createInstance({ el: editorEl, blobPath, blobContent }); + + expect(modelSpy).toHaveBeenCalledWith(blobContent, undefined, uri); + expect(setModel).toHaveBeenCalledWith(fakeModel); + }); + + it('initializes the instance on a supplied DOM node', () => { + editor.createInstance({ el: editorEl }); + + expect(editor.editorEl).not.toBe(null); + expect(instanceSpy).toHaveBeenCalledWith(editorEl, expect.anything()); + }); + }); + + describe('implementation', () => { + beforeEach(() => { + editor.createInstance({ el: editorEl, blobPath, blobContent }); + }); + + afterEach(() => { + editor.model.dispose(); + }); + + it('correctly proxies value from the model', () => { + expect(editor.getValue()).toEqual(blobContent); + }); + + it('is capable of changing the language of the model', () => { + // ignore warnings and errors Monaco posts during setup + // (due to being called from Jest/Node.js environment) + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + + const blobRenamedPath = 'test.js'; + + expect(editor.model.getLanguageIdentifier().language).toEqual('markdown'); + editor.updateModelLanguage(blobRenamedPath); + + expect(editor.model.getLanguageIdentifier().language).toEqual('javascript'); + }); + + it('falls back to plaintext if there is no language associated with an extension', () => { + const blobRenamedPath = 'test.myext'; + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + editor.updateModelLanguage(blobRenamedPath); + + expect(spy).not.toHaveBeenCalled(); + expect(editor.model.getLanguageIdentifier().language).toEqual('plaintext'); + }); + }); + + describe('languages', () => { + it('registers custom languages defined with Monaco', () => { + expect(monacoLanguages.getLanguages()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'vue', + }), + ]), + ); + }); + }); + + describe('syntax highlighting theme', () => { + let themeDefineSpy; + let themeSetSpy; + let defaultScheme; + + beforeEach(() => { + themeDefineSpy = jest.spyOn(monacoEditor, 'defineTheme').mockImplementation(() => {}); + themeSetSpy = jest.spyOn(monacoEditor, 'setTheme').mockImplementation(() => {}); + defaultScheme = window.gon.user_color_scheme; + }); + + afterEach(() => { + window.gon.user_color_scheme = defaultScheme; + }); + + it('sets default syntax highlighting theme', () => { + const expectedTheme = themes.find(t => t.name === DEFAULT_THEME); + + editor = new Editor(); + + expect(themeDefineSpy).toHaveBeenCalledWith(DEFAULT_THEME, expectedTheme.data); + expect(themeSetSpy).toHaveBeenCalledWith(DEFAULT_THEME); + }); + + it('sets correct theme if it is set in users preferences', () => { + const expectedTheme = themes.find(t => t.name !== DEFAULT_THEME); + + expect(expectedTheme.name).not.toBe(DEFAULT_THEME); + + window.gon.user_color_scheme = expectedTheme.name; + editor = new Editor(); + + expect(themeDefineSpy).toHaveBeenCalledWith(expectedTheme.name, expectedTheme.data); + expect(themeSetSpy).toHaveBeenCalledWith(expectedTheme.name); + }); + + it('falls back to default theme if a selected one is not supported yet', () => { + const name = 'non-existent-theme'; + const nonExistentTheme = { name }; + + window.gon.user_color_scheme = nonExistentTheme.name; + editor = new Editor(); + + expect(themeDefineSpy).not.toHaveBeenCalled(); + expect(themeSetSpy).toHaveBeenCalledWith(DEFAULT_THEME); + }); + }); +}); diff --git a/spec/frontend/emoji_spec.js b/spec/frontend/emoji_spec.js new file mode 100644 index 00000000000..25bc95e0dd6 --- /dev/null +++ b/spec/frontend/emoji_spec.js @@ -0,0 +1,485 @@ +import { glEmojiTag } from '~/emoji'; +import isEmojiUnicodeSupported, { + isFlagEmoji, + isRainbowFlagEmoji, + isKeycapEmoji, + isSkinToneComboEmoji, + isHorceRacingSkinToneComboEmoji, + isPersonZwjEmoji, +} from '~/emoji/support/is_emoji_unicode_supported'; + +const emptySupportMap = { + personZwj: false, + horseRacing: false, + flag: false, + skinToneModifier: false, + '9.0': false, + '8.0': false, + '7.0': false, + 6.1: false, + '6.0': false, + 5.2: false, + 5.1: false, + 4.1: false, + '4.0': false, + 3.2: false, + '3.0': false, + 1.1: false, +}; + +const emojiFixtureMap = { + bomb: { + name: 'bomb', + moji: '💣', + unicodeVersion: '6.0', + }, + construction_worker_tone5: { + name: 'construction_worker_tone5', + moji: '👷🏿', + unicodeVersion: '8.0', + }, + five: { + name: 'five', + moji: '5️⃣', + unicodeVersion: '3.0', + }, + grey_question: { + name: 'grey_question', + moji: '❔', + unicodeVersion: '6.0', + }, +}; + +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, +}; + +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); + } +} + +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, + }, + ); + }); + + it('bomb emoji with sprite fallback readiness', () => { + const emojiKey = 'bomb'; + 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, + }, + ); + }); + }); + + describe('isFlagEmoji', () => { + it('should gracefully handle empty string', () => { + expect(isFlagEmoji('')).toBeFalsy(); + }); + + it('should detect flag_ac', () => { + expect(isFlagEmoji('🇦🇨')).toBeTruthy(); + }); + + it('should detect flag_us', () => { + expect(isFlagEmoji('🇺🇸')).toBeTruthy(); + }); + + it('should detect flag_zw', () => { + expect(isFlagEmoji('🇿🇼')).toBeTruthy(); + }); + + it('should not detect flags', () => { + expect(isFlagEmoji('🎏')).toBeFalsy(); + }); + + it('should not detect triangular_flag_on_post', () => { + expect(isFlagEmoji('🚩')).toBeFalsy(); + }); + + it('should not detect single letter', () => { + expect(isFlagEmoji('🇦')).toBeFalsy(); + }); + + it('should not detect >2 letters', () => { + expect(isFlagEmoji('🇦🇧🇨')).toBeFalsy(); + }); + }); + + describe('isRainbowFlagEmoji', () => { + it('should gracefully handle empty string', () => { + expect(isRainbowFlagEmoji('')).toBeFalsy(); + }); + + it('should detect rainbow_flag', () => { + expect(isRainbowFlagEmoji('🏳🌈')).toBeTruthy(); + }); + + it("should not detect flag_white on its' own", () => { + expect(isRainbowFlagEmoji('🏳')).toBeFalsy(); + }); + + it("should not detect rainbow on its' own", () => { + expect(isRainbowFlagEmoji('🌈')).toBeFalsy(); + }); + + it('should not detect flag_white with something else', () => { + expect(isRainbowFlagEmoji('🏳🔵')).toBeFalsy(); + }); + }); + + describe('isKeycapEmoji', () => { + it('should gracefully handle empty string', () => { + expect(isKeycapEmoji('')).toBeFalsy(); + }); + + it('should detect one(keycap)', () => { + expect(isKeycapEmoji('1️⃣')).toBeTruthy(); + }); + + it('should detect nine(keycap)', () => { + expect(isKeycapEmoji('9️⃣')).toBeTruthy(); + }); + + it('should not detect ten(keycap)', () => { + expect(isKeycapEmoji('🔟')).toBeFalsy(); + }); + + it('should not detect hash(keycap)', () => { + expect(isKeycapEmoji('#⃣')).toBeFalsy(); + }); + }); + + describe('isSkinToneComboEmoji', () => { + it('should gracefully handle empty string', () => { + expect(isSkinToneComboEmoji('')).toBeFalsy(); + }); + + it('should detect hand_splayed_tone5', () => { + expect(isSkinToneComboEmoji('🖐🏿')).toBeTruthy(); + }); + + it('should not detect hand_splayed', () => { + expect(isSkinToneComboEmoji('🖐')).toBeFalsy(); + }); + + it('should detect lifter_tone1', () => { + expect(isSkinToneComboEmoji('🏋🏻')).toBeTruthy(); + }); + + it('should not detect lifter', () => { + expect(isSkinToneComboEmoji('🏋')).toBeFalsy(); + }); + + it('should detect rowboat_tone4', () => { + expect(isSkinToneComboEmoji('🚣🏾')).toBeTruthy(); + }); + + it('should not detect rowboat', () => { + expect(isSkinToneComboEmoji('🚣')).toBeFalsy(); + }); + + it('should not detect individual tone emoji', () => { + expect(isSkinToneComboEmoji('🏻')).toBeFalsy(); + }); + }); + + describe('isHorceRacingSkinToneComboEmoji', () => { + it('should gracefully handle empty string', () => { + expect(isHorceRacingSkinToneComboEmoji('')).toBeFalsy(); + }); + + it('should detect horse_racing_tone2', () => { + expect(isHorceRacingSkinToneComboEmoji('🏇🏼')).toBeTruthy(); + }); + + it('should not detect horse_racing', () => { + expect(isHorceRacingSkinToneComboEmoji('🏇')).toBeFalsy(); + }); + }); + + describe('isPersonZwjEmoji', () => { + it('should gracefully handle empty string', () => { + expect(isPersonZwjEmoji('')).toBeFalsy(); + }); + + it('should detect couple_mm', () => { + expect(isPersonZwjEmoji('👨❤️👨')).toBeTruthy(); + }); + + it('should not detect couple_with_heart', () => { + expect(isPersonZwjEmoji('💑')).toBeFalsy(); + }); + + it('should not detect couplekiss', () => { + expect(isPersonZwjEmoji('💏')).toBeFalsy(); + }); + + it('should detect family_mmb', () => { + expect(isPersonZwjEmoji('👨👨👦')).toBeTruthy(); + }); + + it('should detect family_mwgb', () => { + expect(isPersonZwjEmoji('👨👩👧👦')).toBeTruthy(); + }); + + it('should not detect family', () => { + expect(isPersonZwjEmoji('👪')).toBeFalsy(); + }); + + it('should detect kiss_ww', () => { + expect(isPersonZwjEmoji('👩❤️💋👩')).toBeTruthy(); + }); + + it('should not detect girl', () => { + expect(isPersonZwjEmoji('👧')).toBeFalsy(); + }); + + it('should not detect girl_tone5', () => { + expect(isPersonZwjEmoji('👧🏿')).toBeFalsy(); + }); + + it('should not detect man', () => { + expect(isPersonZwjEmoji('👨')).toBeFalsy(); + }); + + it('should not detect woman', () => { + expect(isPersonZwjEmoji('👩')).toBeFalsy(); + }); + }); + + describe('isEmojiUnicodeSupported', () => { + it('should gracefully handle empty string with unicode support', () => { + const isSupported = isEmojiUnicodeSupported({ '1.0': true }, '', '1.0'); + + expect(isSupported).toBeTruthy(); + }); + + it('should gracefully handle empty string without unicode support', () => { + const isSupported = isEmojiUnicodeSupported({}, '', '1.0'); + + expect(isSupported).toBeFalsy(); + }); + + it('bomb(6.0) with 6.0 support', () => { + const emojiKey = 'bomb'; + const unicodeSupportMap = { ...emptySupportMap, '6.0': true }; + const isSupported = isEmojiUnicodeSupported( + unicodeSupportMap, + emojiFixtureMap[emojiKey].moji, + emojiFixtureMap[emojiKey].unicodeVersion, + ); + + expect(isSupported).toBeTruthy(); + }); + + it('bomb(6.0) without 6.0 support', () => { + const emojiKey = 'bomb'; + const unicodeSupportMap = emptySupportMap; + const isSupported = isEmojiUnicodeSupported( + unicodeSupportMap, + emojiFixtureMap[emojiKey].moji, + emojiFixtureMap[emojiKey].unicodeVersion, + ); + + expect(isSupported).toBeFalsy(); + }); + + it('bomb(6.0) without 6.0 but with 9.0 support', () => { + const emojiKey = 'bomb'; + const unicodeSupportMap = { ...emptySupportMap, '9.0': true }; + const isSupported = isEmojiUnicodeSupported( + unicodeSupportMap, + emojiFixtureMap[emojiKey].moji, + emojiFixtureMap[emojiKey].unicodeVersion, + ); + + expect(isSupported).toBeFalsy(); + }); + + it('construction_worker_tone5(8.0) without skin tone modifier support', () => { + const emojiKey = 'construction_worker_tone5'; + const unicodeSupportMap = { + ...emptySupportMap, + skinToneModifier: false, + '9.0': true, + '8.0': true, + '7.0': true, + 6.1: true, + '6.0': true, + 5.2: true, + 5.1: true, + 4.1: true, + '4.0': true, + 3.2: true, + '3.0': true, + 1.1: true, + }; + const isSupported = isEmojiUnicodeSupported( + unicodeSupportMap, + emojiFixtureMap[emojiKey].moji, + emojiFixtureMap[emojiKey].unicodeVersion, + ); + + expect(isSupported).toBeFalsy(); + }); + + it('use native keycap on >=57 chrome', () => { + const emojiKey = 'five'; + const unicodeSupportMap = { + ...emptySupportMap, + '3.0': true, + meta: { + isChrome: true, + chromeVersion: 57, + }, + }; + const isSupported = isEmojiUnicodeSupported( + unicodeSupportMap, + emojiFixtureMap[emojiKey].moji, + emojiFixtureMap[emojiKey].unicodeVersion, + ); + + expect(isSupported).toBeTruthy(); + }); + + it('fallback keycap on <57 chrome', () => { + const emojiKey = 'five'; + const unicodeSupportMap = { + ...emptySupportMap, + '3.0': true, + meta: { + isChrome: true, + chromeVersion: 50, + }, + }; + const isSupported = isEmojiUnicodeSupported( + unicodeSupportMap, + emojiFixtureMap[emojiKey].moji, + emojiFixtureMap[emojiKey].unicodeVersion, + ); + + expect(isSupported).toBeFalsy(); + }); + }); +}); diff --git a/spec/frontend/feature_highlight/feature_highlight_helper_spec.js b/spec/frontend/feature_highlight/feature_highlight_helper_spec.js new file mode 100644 index 00000000000..2c3c3e3267a --- /dev/null +++ b/spec/frontend/feature_highlight/feature_highlight_helper_spec.js @@ -0,0 +1,62 @@ +import $ from 'jquery'; +import axios from '~/lib/utils/axios_utils'; +import { getSelector, dismiss, inserted } from '~/feature_highlight/feature_highlight_helper'; +import { togglePopover } from '~/shared/popover'; + +describe('feature highlight helper', () => { + describe('getSelector', () => { + it('returns js-feature-highlight selector', () => { + const highlightId = 'highlightId'; + + expect(getSelector(highlightId)).toEqual( + `.js-feature-highlight[data-highlight=${highlightId}]`, + ); + }); + }); + + describe('dismiss', () => { + const context = { + hide: () => {}, + attr: () => '/-/callouts/dismiss', + }; + + beforeEach(() => { + jest.spyOn(axios, 'post').mockResolvedValue(); + jest.spyOn(togglePopover, 'call').mockImplementation(() => {}); + jest.spyOn(context, 'hide').mockImplementation(() => {}); + dismiss.call(context); + }); + + it('calls persistent dismissal endpoint', () => { + expect(axios.post).toHaveBeenCalledWith( + '/-/callouts/dismiss', + expect.objectContaining({ feature_name: undefined }), + ); + }); + + it('calls hide popover', () => { + expect(togglePopover.call).toHaveBeenCalledWith(context, false); + }); + + it('calls hide', () => { + expect(context.hide).toHaveBeenCalled(); + }); + }); + + describe('inserted', () => { + it('registers click event callback', done => { + const context = { + getAttribute: () => 'popoverId', + dataset: { + highlight: 'some-feature', + }, + }; + + jest.spyOn($.fn, 'on').mockImplementation(event => { + expect(event).toEqual('click'); + done(); + }); + inserted.call(context); + }); + }); +}); diff --git a/spec/frontend/feature_highlight/feature_highlight_options_spec.js b/spec/frontend/feature_highlight/feature_highlight_options_spec.js index 8b75c46fd4c..f82f984cb7f 100644 --- a/spec/frontend/feature_highlight/feature_highlight_options_spec.js +++ b/spec/frontend/feature_highlight/feature_highlight_options_spec.js @@ -3,34 +3,20 @@ import domContentLoaded from '~/feature_highlight/feature_highlight_options'; describe('feature highlight options', () => { describe('domContentLoaded', () => { - it('should not call highlightFeatures when breakpoint is xs', () => { - jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xs'); - - expect(domContentLoaded()).toBe(false); - }); - - it('should not call highlightFeatures when breakpoint is sm', () => { - jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('sm'); - - expect(domContentLoaded()).toBe(false); - }); - - it('should not call highlightFeatures when breakpoint is md', () => { - jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('md'); - - expect(domContentLoaded()).toBe(false); - }); - - it('should not call highlightFeatures when breakpoint is not xl', () => { - jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('lg'); - - expect(domContentLoaded()).toBe(false); - }); - - it('should call highlightFeatures when breakpoint is xl', () => { - jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xl'); - - expect(domContentLoaded()).toBe(true); - }); + it.each` + breakPoint | shouldCall + ${'xs'} | ${false} + ${'sm'} | ${false} + ${'md'} | ${false} + ${'lg'} | ${false} + ${'xl'} | ${true} + `( + 'when breakpoint is $breakPoint should call highlightFeatures is $shouldCall', + ({ breakPoint, shouldCall }) => { + jest.spyOn(bp, 'getBreakpointSize').mockReturnValue(breakPoint); + + expect(domContentLoaded()).toBe(shouldCall); + }, + ); }); }); diff --git a/spec/frontend/feature_highlight/feature_highlight_spec.js b/spec/frontend/feature_highlight/feature_highlight_spec.js new file mode 100644 index 00000000000..79c4050c8c4 --- /dev/null +++ b/spec/frontend/feature_highlight/feature_highlight_spec.js @@ -0,0 +1,120 @@ +import $ from 'jquery'; +import MockAdapter from 'axios-mock-adapter'; +import * as featureHighlight from '~/feature_highlight/feature_highlight'; +import * as popover from '~/shared/popover'; +import axios from '~/lib/utils/axios_utils'; + +jest.mock('~/shared/popover'); + +describe('feature highlight', () => { + beforeEach(() => { + setFixtures(` + <div> + <div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" data-dismiss-endpoint="/test" disabled> + Trigger + </div> + </div> + <div class="feature-highlight-popover-content"> + Content + <div class="dismiss-feature-highlight"> + Dismiss + </div> + </div> + `); + }); + + describe('setupFeatureHighlightPopover', () => { + let mock; + const selector = '.js-feature-highlight[data-highlight=test]'; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet('/test').reply(200); + jest.spyOn(window, 'addEventListener').mockImplementation(() => {}); + featureHighlight.setupFeatureHighlightPopover('test', 0); + }); + + afterEach(() => { + mock.restore(); + }); + + it('setup popover content', () => { + const $popoverContent = $('.feature-highlight-popover-content'); + const outerHTML = $popoverContent.prop('outerHTML'); + + expect($(selector).data('content')).toEqual(outerHTML); + }); + + it('setup mouseenter', () => { + $(selector).trigger('mouseenter'); + + expect(popover.mouseenter).toHaveBeenCalledWith(expect.any(Object)); + }); + + it('setup debounced mouseleave', () => { + $(selector).trigger('mouseleave'); + + expect(popover.debouncedMouseleave).toHaveBeenCalled(); + }); + + it('setup show.bs.popover', () => { + $(selector).trigger('show.bs.popover'); + + expect(window.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function), { + once: true, + }); + }); + + it('removes disabled attribute', () => { + expect($('.js-feature-highlight').is(':disabled')).toEqual(false); + }); + }); + + describe('findHighestPriorityFeature', () => { + beforeEach(() => { + setFixtures(` + <div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" disabled></div> + <div class="js-feature-highlight" data-highlight="test-high-priority" data-highlight-priority="20" disabled></div> + <div class="js-feature-highlight" data-highlight="test-low-priority" data-highlight-priority="0" disabled></div> + `); + }); + + it('should pick the highest priority feature highlight', () => { + setFixtures(` + <div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" disabled></div> + <div class="js-feature-highlight" data-highlight="test-high-priority" data-highlight-priority="20" disabled></div> + <div class="js-feature-highlight" data-highlight="test-low-priority" data-highlight-priority="0" disabled></div> + `); + + expect($('.js-feature-highlight').length).toBeGreaterThan(1); + expect(featureHighlight.findHighestPriorityFeature()).toEqual('test-high-priority'); + }); + + it('should work when no priority is set', () => { + setFixtures(` + <div class="js-feature-highlight" data-highlight="test" disabled></div> + `); + + expect(featureHighlight.findHighestPriorityFeature()).toEqual('test'); + }); + + it('should pick the highest priority feature highlight when some have no priority set', () => { + setFixtures(` + <div class="js-feature-highlight" data-highlight="test-no-priority1" disabled></div> + <div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" disabled></div> + <div class="js-feature-highlight" data-highlight="test-no-priority2" disabled></div> + <div class="js-feature-highlight" data-highlight="test-high-priority" data-highlight-priority="20" disabled></div> + <div class="js-feature-highlight" data-highlight="test-low-priority" data-highlight-priority="0" disabled></div> + `); + + expect($('.js-feature-highlight').length).toBeGreaterThan(1); + expect(featureHighlight.findHighestPriorityFeature()).toEqual('test-high-priority'); + }); + }); + + describe('highlightFeatures', () => { + it('calls setupFeatureHighlightPopover', () => { + expect(featureHighlight.highlightFeatures()).toEqual('test'); + }); + }); +}); diff --git a/spec/frontend/filtered_search/dropdown_utils_spec.js b/spec/frontend/filtered_search/dropdown_utils_spec.js new file mode 100644 index 00000000000..3320b6b0942 --- /dev/null +++ b/spec/frontend/filtered_search/dropdown_utils_spec.js @@ -0,0 +1,374 @@ +import DropdownUtils from '~/filtered_search/dropdown_utils'; +import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager'; +import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper'; + +describe('Dropdown Utils', () => { + const issueListFixture = 'issues/issue_list.html'; + preloadFixtures(issueListFixture); + + describe('getEscapedText', () => { + it('should return same word when it has no space', () => { + const escaped = DropdownUtils.getEscapedText('textWithoutSpace'); + + expect(escaped).toBe('textWithoutSpace'); + }); + + it('should escape with double quotes', () => { + let escaped = DropdownUtils.getEscapedText('text with space'); + + expect(escaped).toBe('"text with space"'); + + escaped = DropdownUtils.getEscapedText("won't fix"); + + expect(escaped).toBe('"won\'t fix"'); + }); + + it('should escape with single quotes', () => { + const escaped = DropdownUtils.getEscapedText('won"t fix'); + + expect(escaped).toBe("'won\"t fix'"); + }); + + it('should escape with single quotes by default', () => { + const escaped = DropdownUtils.getEscapedText('won"t\' fix'); + + expect(escaped).toBe("'won\"t' fix'"); + }); + }); + + describe('filterWithSymbol', () => { + let input; + const item = { + title: '@root', + }; + + beforeEach(() => { + setFixtures(` + <input type="text" id="test" /> + `); + + input = document.getElementById('test'); + }); + + it('should filter without symbol', () => { + input.value = 'roo'; + + const updatedItem = DropdownUtils.filterWithSymbol('@', input, item); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with symbol', () => { + input.value = '@roo'; + + const updatedItem = DropdownUtils.filterWithSymbol('@', input, item); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + describe('filters multiple word title', () => { + const multipleWordItem = { + title: 'Community Contributions', + }; + + it('should filter with double quote', () => { + input.value = '"'; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with double quote and symbol', () => { + input.value = '~"'; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with double quote and multiple words', () => { + input.value = '"community con'; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with double quote, symbol and multiple words', () => { + input.value = '~"community con'; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with single quote', () => { + input.value = "'"; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with single quote and symbol', () => { + input.value = "~'"; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with single quote and multiple words', () => { + input.value = "'community con"; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with single quote, symbol and multiple words', () => { + input.value = "~'community con"; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + }); + }); + + describe('filterHint', () => { + let input; + let allowedKeys; + + beforeEach(() => { + setFixtures(` + <ul class="tokens-container"> + <li class="input-token"> + <input class="filtered-search" type="text" id="test" /> + </li> + </ul> + `); + + input = document.getElementById('test'); + allowedKeys = IssuableFilteredSearchTokenKeys.getKeys(); + }); + + function config() { + return { + input, + allowedKeys, + }; + } + + it('should filter', () => { + input.value = 'l'; + let updatedItem = DropdownUtils.filterHint(config(), { + hint: 'label', + }); + + expect(updatedItem.droplab_hidden).toBe(false); + + input.value = 'o'; + updatedItem = DropdownUtils.filterHint(config(), { + hint: 'label', + }); + + expect(updatedItem.droplab_hidden).toBe(true); + }); + + it('should return droplab_hidden false when item has no hint', () => { + const updatedItem = DropdownUtils.filterHint(config(), {}, ''); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should allow multiple if item.type is array', () => { + input.value = 'label:~first la'; + const updatedItem = DropdownUtils.filterHint(config(), { + hint: 'label', + type: 'array', + }); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should prevent multiple if item.type is not array', () => { + input.value = 'milestone:~first mile'; + let updatedItem = DropdownUtils.filterHint(config(), { + hint: 'milestone', + }); + + expect(updatedItem.droplab_hidden).toBe(true); + + updatedItem = DropdownUtils.filterHint(config(), { + hint: 'milestone', + type: 'string', + }); + + expect(updatedItem.droplab_hidden).toBe(true); + }); + }); + + describe('setDataValueIfSelected', () => { + beforeEach(() => { + jest.spyOn(FilteredSearchDropdownManager, 'addWordToInput').mockImplementation(() => {}); + }); + + it('calls addWordToInput when dataValue exists', () => { + const selected = { + getAttribute: () => 'value', + hasAttribute: () => false, + }; + + DropdownUtils.setDataValueIfSelected(null, '=', selected); + + expect(FilteredSearchDropdownManager.addWordToInput.mock.calls.length).toEqual(1); + }); + + it('returns true when dataValue exists', () => { + const selected = { + getAttribute: () => 'value', + hasAttribute: () => false, + }; + + const result = DropdownUtils.setDataValueIfSelected(null, '=', selected); + const result2 = DropdownUtils.setDataValueIfSelected(null, '!=', selected); + + expect(result).toBe(true); + expect(result2).toBe(true); + }); + + it('returns false when dataValue does not exist', () => { + const selected = { + getAttribute: () => null, + }; + + const result = DropdownUtils.setDataValueIfSelected(null, '=', selected); + const result2 = DropdownUtils.setDataValueIfSelected(null, '!=', selected); + + expect(result).toBe(false); + expect(result2).toBe(false); + }); + }); + + describe('getInputSelectionPosition', () => { + describe('word with trailing spaces', () => { + const value = 'label:none '; + + it('should return selectionStart when cursor is at the trailing space', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 11, + value, + }); + + expect(left).toBe(11); + expect(right).toBe(11); + }); + + it('should return input when cursor is at the start of input', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 0, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(10); + }); + + it('should return input when cursor is at the middle of input', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 7, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(10); + }); + + it('should return input when cursor is at the end of input', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 10, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(10); + }); + }); + + describe('multiple words', () => { + const value = 'label:~"Community Contribution"'; + + it('should return input when cursor is after the first word', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 17, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(31); + }); + + it('should return input when cursor is before the second word', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 18, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(31); + }); + }); + + describe('incomplete multiple words', () => { + const value = 'label:~"Community Contribution'; + + it('should return entire input when cursor is at the start of input', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 0, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(30); + }); + + it('should return entire input when cursor is at the end of input', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 30, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(30); + }); + }); + }); + + describe('getSearchQuery', () => { + let authorToken; + + beforeEach(() => { + loadFixtures(issueListFixture); + + authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user'); + const searchTermToken = FilteredSearchSpecHelper.createSearchVisualToken('search term'); + + const tokensContainer = document.querySelector('.tokens-container'); + tokensContainer.appendChild(searchTermToken); + tokensContainer.appendChild(authorToken); + }); + + it('uses original value if present', () => { + const originalValue = 'original dance'; + const valueContainer = authorToken.querySelector('.value-container'); + valueContainer.dataset.originalValue = originalValue; + + const searchQuery = DropdownUtils.getSearchQuery(); + + expect(searchQuery).toBe(' search term author:=original dance'); + }); + }); +}); diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js new file mode 100644 index 00000000000..ef87662a1ef --- /dev/null +++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js @@ -0,0 +1,587 @@ +import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; +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'; +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'; + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + visitUrl: jest.fn(), +})); + +describe('Filtered Search Manager', () => { + let input; + let manager; + let tokensContainer; + const page = 'issues'; + const placeholder = 'Search or filter results...'; + + function dispatchBackspaceEvent(element, eventType) { + const event = new Event(eventType); + event.keyCode = BACKSPACE_KEY_CODE; + element.dispatchEvent(event); + } + + function dispatchDeleteEvent(element, eventType) { + const event = new Event(eventType); + event.keyCode = DELETE_KEY_CODE; + element.dispatchEvent(event); + } + + function dispatchAltBackspaceEvent(element, eventType) { + const event = new Event(eventType); + event.altKey = true; + event.keyCode = BACKSPACE_KEY_CODE; + element.dispatchEvent(event); + } + + function dispatchCtrlBackspaceEvent(element, eventType) { + const event = new Event(eventType); + event.ctrlKey = true; + event.keyCode = BACKSPACE_KEY_CODE; + element.dispatchEvent(event); + } + + function dispatchMetaBackspaceEvent(element, eventType) { + const event = new Event(eventType); + event.metaKey = true; + event.keyCode = BACKSPACE_KEY_CODE; + element.dispatchEvent(event); + } + + function getVisualTokens() { + return tokensContainer.querySelectorAll('.js-visual-token'); + } + + beforeEach(() => { + setFixtures(` + <div class="filtered-search-box"> + <form> + <ul class="tokens-container list-unstyled"> + ${FilteredSearchSpecHelper.createInputHTML(placeholder)} + </ul> + <button class="clear-search" type="button"> + <i class="fa fa-times"></i> + </button> + </form> + </div> + `); + + jest.spyOn(FilteredSearchDropdownManager.prototype, 'setDropdown').mockImplementation(); + }); + + const initializeManager = () => { + jest.spyOn(FilteredSearchManager.prototype, 'loadSearchParamsFromURL').mockImplementation(); + jest.spyOn(FilteredSearchManager.prototype, 'tokenChange').mockImplementation(); + jest + .spyOn(FilteredSearchDropdownManager.prototype, 'updateDropdownOffset') + .mockImplementation(); + jest.spyOn(gl.utils, 'getParameterByName').mockReturnValue(null); + jest.spyOn(FilteredSearchVisualTokens, 'unselectTokens'); + + input = document.querySelector('.filtered-search'); + tokensContainer = document.querySelector('.tokens-container'); + manager = new FilteredSearchManager({ page }); + manager.setup(); + }; + + afterEach(() => { + manager.cleanup(); + }); + + describe('class constructor', () => { + const isLocalStorageAvailable = 'isLocalStorageAvailable'; + + beforeEach(() => { + jest.spyOn(RecentSearchesService, 'isAvailable').mockReturnValue(isLocalStorageAvailable); + jest.spyOn(RecentSearchesRoot.prototype, 'render').mockImplementation(); + }); + + it('should instantiate RecentSearchesStore with isLocalStorageAvailable', () => { + manager = new FilteredSearchManager({ page }); + + expect(RecentSearchesService.isAvailable).toHaveBeenCalled(); + expect(manager.recentSearchesStore.state).toEqual( + expect.objectContaining({ + isLocalStorageAvailable, + allowedKeys: IssuableFilteredSearchTokenKeys.getKeys(), + }), + ); + }); + }); + + describe('setup', () => { + beforeEach(() => { + manager = new FilteredSearchManager({ page }); + }); + + it('should not instantiate Flash if an RecentSearchesServiceError is caught', () => { + jest + .spyOn(RecentSearchesService.prototype, 'fetch') + .mockImplementation(() => Promise.reject(new RecentSearchesServiceError())); + jest.spyOn(window, 'Flash').mockImplementation(); + + manager.setup(); + + expect(window.Flash).not.toHaveBeenCalled(); + }); + }); + + describe('searchState', () => { + beforeEach(() => { + jest.spyOn(FilteredSearchManager.prototype, 'search').mockImplementation(); + initializeManager(); + }); + + it('should blur button', () => { + const e = { + preventDefault: () => {}, + currentTarget: { + blur: () => {}, + }, + }; + jest.spyOn(e.currentTarget, 'blur'); + manager.searchState(e); + + expect(e.currentTarget.blur).toHaveBeenCalled(); + }); + + it('should not call search if there is no state', () => { + const e = { + preventDefault: () => {}, + currentTarget: { + blur: () => {}, + }, + }; + + manager.searchState(e); + + expect(FilteredSearchManager.prototype.search).not.toHaveBeenCalled(); + }); + + it('should call search when there is state', () => { + const e = { + preventDefault: () => {}, + currentTarget: { + blur: () => {}, + dataset: { + state: 'opened', + }, + }, + }; + + manager.searchState(e); + + expect(FilteredSearchManager.prototype.search).toHaveBeenCalledWith('opened'); + }); + }); + + describe('search', () => { + const defaultParams = '?scope=all&utf8=%E2%9C%93&state=opened'; + + beforeEach(() => { + initializeManager(); + }); + + it('should search with a single word', done => { + input.value = 'searchTerm'; + + visitUrl.mockImplementation(url => { + expect(url).toEqual(`${defaultParams}&search=searchTerm`); + done(); + }); + + manager.search(); + }); + + it('should search with multiple words', done => { + input.value = 'awesome search terms'; + + visitUrl.mockImplementation(url => { + expect(url).toEqual(`${defaultParams}&search=awesome+search+terms`); + done(); + }); + + manager.search(); + }); + + it('should search with special characters', done => { + input.value = '~!@#$%^&*()_+{}:<>,.?/'; + + visitUrl.mockImplementation(url => { + expect(url).toEqual( + `${defaultParams}&search=~!%40%23%24%25%5E%26*()_%2B%7B%7D%3A%3C%3E%2C.%3F%2F`, + ); + done(); + }); + + manager.search(); + }); + + it('removes duplicated tokens', done => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')} + `); + + visitUrl.mockImplementation(url => { + expect(url).toEqual(`${defaultParams}&label_name[]=bug`); + done(); + }); + + manager.search(); + }); + }); + + describe('handleInputPlaceholder', () => { + beforeEach(() => { + initializeManager(); + }); + + it('should render placeholder when there is no input', () => { + expect(input.placeholder).toEqual(placeholder); + }); + + it('should not render placeholder when there is input', () => { + input.value = 'test words'; + + const event = new Event('input'); + input.dispatchEvent(event); + + expect(input.placeholder).toEqual(''); + }); + + it('should not render placeholder when there are tokens and no input', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'), + ); + + const event = new Event('input'); + input.dispatchEvent(event); + + expect(input.placeholder).toEqual(''); + }); + }); + + describe('checkForBackspace', () => { + beforeEach(() => { + initializeManager(); + }); + + describe('tokens and no input', () => { + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'), + ); + }); + + it('removes last token', () => { + jest.spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial'); + dispatchBackspaceEvent(input, 'keyup'); + dispatchBackspaceEvent(input, 'keyup'); + + expect(FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled(); + }); + + it('sets the input', () => { + jest.spyOn(FilteredSearchVisualTokens, 'getLastTokenPartial'); + dispatchDeleteEvent(input, 'keyup'); + dispatchDeleteEvent(input, 'keyup'); + + expect(FilteredSearchVisualTokens.getLastTokenPartial).toHaveBeenCalled(); + expect(input.value).toEqual('~bug'); + }); + }); + + it('does not remove token or change input when there is existing input', () => { + jest.spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial'); + jest.spyOn(FilteredSearchVisualTokens, 'getLastTokenPartial'); + + input.value = 'text'; + dispatchDeleteEvent(input, 'keyup'); + + expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled(); + expect(FilteredSearchVisualTokens.getLastTokenPartial).not.toHaveBeenCalled(); + expect(input.value).toEqual('text'); + }); + + it('does not remove previous token on single backspace press', () => { + jest.spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial'); + jest.spyOn(FilteredSearchVisualTokens, 'getLastTokenPartial'); + + input.value = 't'; + dispatchDeleteEvent(input, 'keyup'); + + expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled(); + expect(FilteredSearchVisualTokens.getLastTokenPartial).not.toHaveBeenCalled(); + expect(input.value).toEqual('t'); + }); + }); + + describe('checkForAltOrCtrlBackspace', () => { + beforeEach(() => { + initializeManager(); + jest.spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial'); + }); + + describe('tokens and no input', () => { + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'), + ); + }); + + it('removes last token via alt-backspace', () => { + dispatchAltBackspaceEvent(input, 'keydown'); + + expect(FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled(); + }); + + it('removes last token via ctrl-backspace', () => { + dispatchCtrlBackspaceEvent(input, 'keydown'); + + expect(FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled(); + }); + }); + + describe('tokens and input', () => { + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'), + ); + }); + + it('does not remove token or change input via alt-backspace when there is existing input', () => { + input = manager.filteredSearchInput; + input.value = 'text'; + dispatchAltBackspaceEvent(input, 'keydown'); + + expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled(); + expect(input.value).toEqual('text'); + }); + + it('does not remove token or change input via ctrl-backspace when there is existing input', () => { + input = manager.filteredSearchInput; + input.value = 'text'; + dispatchCtrlBackspaceEvent(input, 'keydown'); + + expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled(); + expect(input.value).toEqual('text'); + }); + }); + }); + + describe('checkForMetaBackspace', () => { + beforeEach(() => { + initializeManager(); + }); + + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'), + ); + }); + + it('removes all tokens and input', () => { + jest.spyOn(FilteredSearchManager.prototype, 'clearSearch'); + dispatchMetaBackspaceEvent(input, 'keydown'); + + expect(manager.clearSearch).toHaveBeenCalled(); + expect(manager.filteredSearchInput.value).toEqual(''); + expect(DropdownUtils.getSearchQuery()).toEqual(''); + }); + }); + + describe('removeToken', () => { + beforeEach(() => { + initializeManager(); + }); + + it('removes token even when it is already selected', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true), + ); + + tokensContainer.querySelector('.js-visual-token .remove-token').click(); + + expect(tokensContainer.querySelector('.js-visual-token')).toEqual(null); + }); + + describe('unselected token', () => { + beforeEach(() => { + jest.spyOn(FilteredSearchManager.prototype, 'removeSelectedToken'); + + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none'), + ); + tokensContainer.querySelector('.js-visual-token .remove-token').click(); + }); + + it('removes token when remove button is selected', () => { + expect(tokensContainer.querySelector('.js-visual-token')).toEqual(null); + }); + + it('calls removeSelectedToken', () => { + expect(manager.removeSelectedToken).toHaveBeenCalled(); + }); + }); + }); + + describe('removeSelectedTokenKeydown', () => { + beforeEach(() => { + initializeManager(); + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true), + ); + }); + + it('removes selected token when the backspace key is pressed', () => { + expect(getVisualTokens().length).toEqual(1); + + dispatchBackspaceEvent(document, 'keydown'); + + expect(getVisualTokens().length).toEqual(0); + }); + + it('removes selected token when the delete key is pressed', () => { + expect(getVisualTokens().length).toEqual(1); + + dispatchDeleteEvent(document, 'keydown'); + + expect(getVisualTokens().length).toEqual(0); + }); + + it('updates the input placeholder after removal', () => { + manager.handleInputPlaceholder(); + + expect(input.placeholder).toEqual(''); + expect(getVisualTokens().length).toEqual(1); + + dispatchBackspaceEvent(document, 'keydown'); + + expect(input.placeholder).not.toEqual(''); + expect(getVisualTokens().length).toEqual(0); + }); + + it('updates the clear button after removal', () => { + manager.toggleClearSearchButton(); + + const clearButton = document.querySelector('.clear-search'); + + expect(clearButton.classList.contains('hidden')).toEqual(false); + expect(getVisualTokens().length).toEqual(1); + + dispatchBackspaceEvent(document, 'keydown'); + + expect(clearButton.classList.contains('hidden')).toEqual(true); + expect(getVisualTokens().length).toEqual(0); + }); + }); + + describe('removeSelectedToken', () => { + beforeEach(() => { + jest.spyOn(FilteredSearchVisualTokens, 'removeSelectedToken'); + jest.spyOn(FilteredSearchManager.prototype, 'handleInputPlaceholder'); + jest.spyOn(FilteredSearchManager.prototype, 'toggleClearSearchButton'); + initializeManager(); + }); + + it('calls FilteredSearchVisualTokens.removeSelectedToken', () => { + manager.removeSelectedToken(); + + expect(FilteredSearchVisualTokens.removeSelectedToken).toHaveBeenCalled(); + }); + + it('calls handleInputPlaceholder', () => { + manager.removeSelectedToken(); + + expect(manager.handleInputPlaceholder).toHaveBeenCalled(); + }); + + it('calls toggleClearSearchButton', () => { + manager.removeSelectedToken(); + + expect(manager.toggleClearSearchButton).toHaveBeenCalled(); + }); + + it('calls update dropdown offset', () => { + manager.removeSelectedToken(); + + expect(manager.dropdownManager.updateDropdownOffset).toHaveBeenCalled(); + }); + }); + + describe('Clearing search', () => { + beforeEach(() => { + initializeManager(); + }); + + it('Clicking the "x" clear button, clears the input', () => { + const inputValue = 'label:=~bug'; + manager.filteredSearchInput.value = inputValue; + manager.filteredSearchInput.dispatchEvent(new Event('input')); + + expect(DropdownUtils.getSearchQuery()).toEqual(inputValue); + + manager.clearSearchButton.click(); + + expect(manager.filteredSearchInput.value).toEqual(''); + expect(DropdownUtils.getSearchQuery()).toEqual(''); + }); + }); + + describe('toggleInputContainerFocus', () => { + beforeEach(() => { + initializeManager(); + }); + + it('toggles on focus', () => { + input.focus(); + + expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual( + true, + ); + }); + + it('toggles on blur', () => { + input.blur(); + + expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual( + false, + ); + }); + }); + + describe('getAllParams', () => { + let paramsArr; + beforeEach(() => { + paramsArr = ['key=value', 'otherkey=othervalue']; + + initializeManager(); + }); + + it('correctly modifies params when custom modifier is passed', () => { + const modifedParams = manager.getAllParams.call( + { + modifyUrlParams: params => params.reverse(), + }, + [].concat(paramsArr), + ); + + expect(modifedParams[0]).toBe(paramsArr[1]); + }); + + it('does not modify params when no custom modifier is passed', () => { + const modifedParams = manager.getAllParams.call({}, paramsArr); + + expect(modifedParams[1]).toBe(paramsArr[1]); + }); + }); +}); diff --git a/spec/frontend/filtered_search/filtered_search_tokenizer_spec.js b/spec/frontend/filtered_search/filtered_search_tokenizer_spec.js new file mode 100644 index 00000000000..dec03e5ab93 --- /dev/null +++ b/spec/frontend/filtered_search/filtered_search_tokenizer_spec.js @@ -0,0 +1,152 @@ +import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import FilteredSearchTokenizer from '~/filtered_search/filtered_search_tokenizer'; + +describe('Filtered Search Tokenizer', () => { + const allowedKeys = IssuableFilteredSearchTokenKeys.getKeys(); + + describe('processTokens', () => { + it('returns for input containing only search value', () => { + const results = FilteredSearchTokenizer.processTokens('searchTerm', allowedKeys); + + expect(results.searchToken).toBe('searchTerm'); + expect(results.tokens.length).toBe(0); + expect(results.lastToken).toBe(results.searchToken); + }); + + it('returns for input containing only tokens', () => { + const results = FilteredSearchTokenizer.processTokens( + 'author:@root label:~"Very Important" milestone:%v1.0 assignee:none', + allowedKeys, + ); + + expect(results.searchToken).toBe(''); + expect(results.tokens.length).toBe(4); + expect(results.tokens[3]).toBe(results.lastToken); + + expect(results.tokens[0].key).toBe('author'); + expect(results.tokens[0].value).toBe('root'); + expect(results.tokens[0].symbol).toBe('@'); + + expect(results.tokens[1].key).toBe('label'); + expect(results.tokens[1].value).toBe('"Very Important"'); + expect(results.tokens[1].symbol).toBe('~'); + + expect(results.tokens[2].key).toBe('milestone'); + expect(results.tokens[2].value).toBe('v1.0'); + expect(results.tokens[2].symbol).toBe('%'); + + expect(results.tokens[3].key).toBe('assignee'); + expect(results.tokens[3].value).toBe('none'); + expect(results.tokens[3].symbol).toBe(''); + }); + + it('returns for input starting with search value and ending with tokens', () => { + const results = FilteredSearchTokenizer.processTokens( + 'searchTerm anotherSearchTerm milestone:none', + allowedKeys, + ); + + expect(results.searchToken).toBe('searchTerm anotherSearchTerm'); + expect(results.tokens.length).toBe(1); + expect(results.tokens[0]).toBe(results.lastToken); + expect(results.tokens[0].key).toBe('milestone'); + expect(results.tokens[0].value).toBe('none'); + expect(results.tokens[0].symbol).toBe(''); + }); + + it('returns for input starting with tokens and ending with search value', () => { + const results = FilteredSearchTokenizer.processTokens( + 'assignee:@user searchTerm', + allowedKeys, + ); + + expect(results.searchToken).toBe('searchTerm'); + expect(results.tokens.length).toBe(1); + expect(results.tokens[0].key).toBe('assignee'); + expect(results.tokens[0].value).toBe('user'); + expect(results.tokens[0].symbol).toBe('@'); + expect(results.lastToken).toBe(results.searchToken); + }); + + it('returns for input containing search value wrapped between tokens', () => { + const results = FilteredSearchTokenizer.processTokens( + 'author:@root label:~"Won\'t fix" searchTerm anotherSearchTerm milestone:none', + allowedKeys, + ); + + expect(results.searchToken).toBe('searchTerm anotherSearchTerm'); + expect(results.tokens.length).toBe(3); + expect(results.tokens[2]).toBe(results.lastToken); + + expect(results.tokens[0].key).toBe('author'); + expect(results.tokens[0].value).toBe('root'); + expect(results.tokens[0].symbol).toBe('@'); + + expect(results.tokens[1].key).toBe('label'); + expect(results.tokens[1].value).toBe('"Won\'t fix"'); + expect(results.tokens[1].symbol).toBe('~'); + + expect(results.tokens[2].key).toBe('milestone'); + expect(results.tokens[2].value).toBe('none'); + expect(results.tokens[2].symbol).toBe(''); + }); + + it('returns for input containing search value in between tokens', () => { + const results = FilteredSearchTokenizer.processTokens( + 'author:@root searchTerm assignee:none anotherSearchTerm label:~Doing', + allowedKeys, + ); + + expect(results.searchToken).toBe('searchTerm anotherSearchTerm'); + expect(results.tokens.length).toBe(3); + expect(results.tokens[2]).toBe(results.lastToken); + + expect(results.tokens[0].key).toBe('author'); + expect(results.tokens[0].value).toBe('root'); + expect(results.tokens[0].symbol).toBe('@'); + + expect(results.tokens[1].key).toBe('assignee'); + expect(results.tokens[1].value).toBe('none'); + expect(results.tokens[1].symbol).toBe(''); + + expect(results.tokens[2].key).toBe('label'); + expect(results.tokens[2].value).toBe('Doing'); + expect(results.tokens[2].symbol).toBe('~'); + }); + + it('returns search value for invalid tokens', () => { + const results = FilteredSearchTokenizer.processTokens('fake:token', allowedKeys); + + expect(results.lastToken).toBe('fake:token'); + expect(results.searchToken).toBe('fake:token'); + expect(results.tokens.length).toEqual(0); + }); + + it('returns search value and token for mix of valid and invalid tokens', () => { + const results = FilteredSearchTokenizer.processTokens('label:real fake:token', allowedKeys); + + expect(results.tokens.length).toEqual(1); + expect(results.tokens[0].key).toBe('label'); + expect(results.tokens[0].value).toBe('real'); + expect(results.tokens[0].symbol).toBe(''); + expect(results.lastToken).toBe('fake:token'); + expect(results.searchToken).toBe('fake:token'); + }); + + it('returns search value for invalid symbols', () => { + const results = FilteredSearchTokenizer.processTokens('std::includes', allowedKeys); + + expect(results.lastToken).toBe('std::includes'); + expect(results.searchToken).toBe('std::includes'); + }); + + it('removes duplicated values', () => { + const results = FilteredSearchTokenizer.processTokens('label:~foo label:~foo', allowedKeys); + + expect(results.tokens.length).toBe(1); + expect(results.tokens[0].key).toBe('label'); + expect(results.tokens[0].value).toBe('foo'); + expect(results.tokens[0].symbol).toBe('~'); + }); + }); +}); diff --git a/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js b/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js new file mode 100644 index 00000000000..c7be900ba2c --- /dev/null +++ b/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js @@ -0,0 +1,148 @@ +import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; + +describe('Issues Filtered Search Token Keys', () => { + describe('get', () => { + let tokenKeys; + + beforeEach(() => { + tokenKeys = IssuableFilteredSearchTokenKeys.get(); + }); + + it('should return tokenKeys', () => { + expect(tokenKeys).not.toBeNull(); + }); + + it('should return tokenKeys as an array', () => { + expect(tokenKeys instanceof Array).toBe(true); + }); + + it('should always return the same array', () => { + const tokenKeys2 = IssuableFilteredSearchTokenKeys.get(); + + expect(tokenKeys).toEqual(tokenKeys2); + }); + + it('should return assignee as a string', () => { + const assignee = tokenKeys.find(tokenKey => tokenKey.key === 'assignee'); + + expect(assignee.type).toEqual('string'); + }); + }); + + describe('getKeys', () => { + it('should return keys', () => { + const getKeys = IssuableFilteredSearchTokenKeys.getKeys(); + const keys = IssuableFilteredSearchTokenKeys.get().map(i => i.key); + + keys.forEach((key, i) => { + expect(key).toEqual(getKeys[i]); + }); + }); + }); + + describe('getConditions', () => { + let conditions; + + beforeEach(() => { + conditions = IssuableFilteredSearchTokenKeys.getConditions(); + }); + + it('should return conditions', () => { + expect(conditions).not.toBeNull(); + }); + + it('should return conditions as an array', () => { + expect(conditions instanceof Array).toBe(true); + }); + }); + + describe('searchByKey', () => { + it('should return null when key not found', () => { + const tokenKey = IssuableFilteredSearchTokenKeys.searchByKey('notakey'); + + expect(tokenKey).toBeNull(); + }); + + it('should return tokenKey when found by key', () => { + const tokenKeys = IssuableFilteredSearchTokenKeys.get(); + const result = IssuableFilteredSearchTokenKeys.searchByKey(tokenKeys[0].key); + + expect(result).toEqual(tokenKeys[0]); + }); + }); + + describe('searchBySymbol', () => { + it('should return null when symbol not found', () => { + const tokenKey = IssuableFilteredSearchTokenKeys.searchBySymbol('notasymbol'); + + expect(tokenKey).toBeNull(); + }); + + it('should return tokenKey when found by symbol', () => { + const tokenKeys = IssuableFilteredSearchTokenKeys.get(); + const result = IssuableFilteredSearchTokenKeys.searchBySymbol(tokenKeys[0].symbol); + + expect(result).toEqual(tokenKeys[0]); + }); + }); + + describe('searchByKeyParam', () => { + it('should return null when key param not found', () => { + const tokenKey = IssuableFilteredSearchTokenKeys.searchByKeyParam('notakeyparam'); + + expect(tokenKey).toBeNull(); + }); + + it('should return tokenKey when found by key param', () => { + const tokenKeys = IssuableFilteredSearchTokenKeys.get(); + const result = IssuableFilteredSearchTokenKeys.searchByKeyParam( + `${tokenKeys[0].key}_${tokenKeys[0].param}`, + ); + + expect(result).toEqual(tokenKeys[0]); + }); + + it('should return alternative tokenKey when found by key param', () => { + const tokenKeys = IssuableFilteredSearchTokenKeys.getAlternatives(); + const result = IssuableFilteredSearchTokenKeys.searchByKeyParam( + `${tokenKeys[0].key}_${tokenKeys[0].param}`, + ); + + expect(result).toEqual(tokenKeys[0]); + }); + }); + + describe('searchByConditionUrl', () => { + it('should return null when condition url not found', () => { + const condition = IssuableFilteredSearchTokenKeys.searchByConditionUrl(null); + + expect(condition).toBeNull(); + }); + + it('should return condition when found by url', () => { + const conditions = IssuableFilteredSearchTokenKeys.getConditions(); + const result = IssuableFilteredSearchTokenKeys.searchByConditionUrl(conditions[0].url); + + expect(result).toBe(conditions[0]); + }); + }); + + describe('searchByConditionKeyValue', () => { + it('should return null when condition tokenKey and value not found', () => { + const condition = IssuableFilteredSearchTokenKeys.searchByConditionKeyValue(null, null); + + expect(condition).toBeNull(); + }); + + it('should return condition when found by tokenKey and value', () => { + const conditions = IssuableFilteredSearchTokenKeys.getConditions(); + const result = IssuableFilteredSearchTokenKeys.searchByConditionKeyValue( + conditions[0].tokenKey, + conditions[0].operator, + conditions[0].value, + ); + + expect(result).toEqual(conditions[0]); + }); + }); +}); diff --git a/spec/frontend/filtered_search/recent_searches_root_spec.js b/spec/frontend/filtered_search/recent_searches_root_spec.js new file mode 100644 index 00000000000..281d406e013 --- /dev/null +++ b/spec/frontend/filtered_search/recent_searches_root_spec.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import RecentSearchesRoot from '~/filtered_search/recent_searches_root'; + +jest.mock('vue'); + +describe('RecentSearchesRoot', () => { + describe('render', () => { + let recentSearchesRoot; + let data; + let template; + + beforeEach(() => { + recentSearchesRoot = { + store: { + state: 'state', + }, + }; + + Vue.mockImplementation(options => { + ({ data, template } = options); + }); + + RecentSearchesRoot.prototype.render.call(recentSearchesRoot); + }); + + it('should instantiate Vue', () => { + expect(Vue).toHaveBeenCalled(); + expect(data()).toBe(recentSearchesRoot.store.state); + expect(template).toContain(':is-local-storage-available="isLocalStorageAvailable"'); + }); + }); +}); diff --git a/spec/frontend/filtered_search/services/recent_searches_service_spec.js b/spec/frontend/filtered_search/services/recent_searches_service_spec.js new file mode 100644 index 00000000000..a89d38b7a20 --- /dev/null +++ b/spec/frontend/filtered_search/services/recent_searches_service_spec.js @@ -0,0 +1,161 @@ +import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; +import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; +import AccessorUtilities from '~/lib/utils/accessor'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; + +useLocalStorageSpy(); + +describe('RecentSearchesService', () => { + let service; + + beforeEach(() => { + service = new RecentSearchesService(); + localStorage.removeItem(service.localStorageKey); + }); + + describe('fetch', () => { + beforeEach(() => { + jest.spyOn(RecentSearchesService, 'isAvailable').mockReturnValue(true); + }); + + it('should default to empty array', done => { + const fetchItemsPromise = service.fetch(); + + fetchItemsPromise + .then(items => { + expect(items).toEqual([]); + }) + .then(done) + .catch(done.fail); + }); + + it('should reject when unable to parse', done => { + jest.spyOn(localStorage, 'getItem').mockReturnValue('fail'); + const fetchItemsPromise = service.fetch(); + + fetchItemsPromise + .then(done.fail) + .catch(error => { + expect(error).toEqual(expect.any(SyntaxError)); + }) + .then(done) + .catch(done.fail); + }); + + it('should reject when service is unavailable', done => { + RecentSearchesService.isAvailable.mockReturnValue(false); + + service + .fetch() + .then(done.fail) + .catch(error => { + expect(error).toEqual(expect.any(Error)); + }) + .then(done) + .catch(done.fail); + }); + + it('should return items from localStorage', done => { + jest.spyOn(localStorage, 'getItem').mockReturnValue('["foo", "bar"]'); + const fetchItemsPromise = service.fetch(); + + fetchItemsPromise + .then(items => { + expect(items).toEqual(['foo', 'bar']); + }) + .then(done) + .catch(done.fail); + }); + + describe('if .isAvailable returns `false`', () => { + beforeEach(() => { + RecentSearchesService.isAvailable.mockReturnValue(false); + + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {}); + }); + + it('should not call .getItem', done => { + RecentSearchesService.prototype + .fetch() + .then(done.fail) + .catch(err => { + expect(err).toEqual(new RecentSearchesServiceError()); + expect(localStorage.getItem).not.toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + }); + }); + + describe('setRecentSearches', () => { + beforeEach(() => { + jest.spyOn(RecentSearchesService, 'isAvailable').mockReturnValue(true); + }); + + it('should save things in localStorage', () => { + jest.spyOn(localStorage, 'setItem'); + const items = ['foo', 'bar']; + service.save(items); + + expect(localStorage.setItem).toHaveBeenCalledWith(expect.any(String), JSON.stringify(items)); + }); + }); + + describe('save', () => { + beforeEach(() => { + jest.spyOn(localStorage, 'setItem'); + jest.spyOn(RecentSearchesService, 'isAvailable').mockImplementation(() => {}); + }); + + describe('if .isAvailable returns `true`', () => { + const searchesString = 'searchesString'; + const localStorageKey = 'localStorageKey'; + const recentSearchesService = { + localStorageKey, + }; + + beforeEach(() => { + RecentSearchesService.isAvailable.mockReturnValue(true); + + jest.spyOn(JSON, 'stringify').mockReturnValue(searchesString); + }); + + it('should call .setItem', () => { + RecentSearchesService.prototype.save.call(recentSearchesService); + + expect(localStorage.setItem).toHaveBeenCalledWith(localStorageKey, searchesString); + }); + }); + + describe('if .isAvailable returns `false`', () => { + beforeEach(() => { + RecentSearchesService.isAvailable.mockReturnValue(false); + }); + + it('should not call .setItem', () => { + RecentSearchesService.prototype.save(); + + expect(localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + }); + + describe('isAvailable', () => { + let isAvailable; + + beforeEach(() => { + jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe'); + + isAvailable = RecentSearchesService.isAvailable(); + }); + + it('should call .isLocalStorageAccessSafe', () => { + expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + }); + + it('should return a boolean', () => { + expect(typeof isAvailable).toBe('boolean'); + }); + }); +}); diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js new file mode 100644 index 00000000000..ea501423403 --- /dev/null +++ b/spec/frontend/filtered_search/visual_token_value_spec.js @@ -0,0 +1,389 @@ +import { escape } from 'lodash'; +import VisualTokenValue from '~/filtered_search/visual_token_value'; +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'; + +describe('Filtered Search Visual Tokens', () => { + const findElements = tokenElement => { + const tokenNameElement = tokenElement.querySelector('.name'); + const tokenValueContainer = tokenElement.querySelector('.value-container'); + const tokenValueElement = tokenValueContainer.querySelector('.value'); + const tokenOperatorElement = tokenElement.querySelector('.operator'); + const tokenType = tokenNameElement.innerText.toLowerCase(); + const tokenValue = tokenValueElement.innerText; + const tokenOperator = tokenOperatorElement.innerText; + const subject = new VisualTokenValue(tokenValue, tokenType, tokenOperator); + return { subject, tokenValueContainer, tokenValueElement }; + }; + + let tokensContainer; + let authorToken; + let bugLabelToken; + + beforeEach(() => { + setFixtures(` + <ul class="tokens-container"> + ${FilteredSearchSpecHelper.createInputHTML()} + </ul> + `); + tokensContainer = document.querySelector('.tokens-container'); + + authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user'); + bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '=', '~bug'); + }); + + describe('updateUserTokenAppearance', () => { + let usersCacheSpy; + + beforeEach(() => { + jest.spyOn(UsersCache, 'retrieve').mockImplementation(username => usersCacheSpy(username)); + }); + + it('ignores error if UsersCache throws', done => { + jest.spyOn(window, 'Flash').mockImplementation(() => {}); + const dummyError = new Error('Earth rotated backwards'); + const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); + const tokenValue = tokenValueElement.innerText; + usersCacheSpy = username => { + expect(`@${username}`).toBe(tokenValue); + return Promise.reject(dummyError); + }; + + subject + .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) + .then(() => { + expect(window.Flash.mock.calls.length).toBe(0); + }) + .then(done) + .catch(done.fail); + }); + + it('does nothing if user cannot be found', done => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); + const tokenValue = tokenValueElement.innerText; + usersCacheSpy = username => { + expect(`@${username}`).toBe(tokenValue); + return Promise.resolve(undefined); + }; + + subject + .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) + .then(() => { + expect(tokenValueElement.innerText).toBe(tokenValue); + }) + .then(done) + .catch(done.fail); + }); + + it('replaces author token with avatar and display name', done => { + const dummyUser = { + name: 'Important Person', + avatar_url: 'https://host.invalid/mypics/avatar.png', + }; + const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); + const tokenValue = tokenValueElement.innerText; + usersCacheSpy = username => { + expect(`@${username}`).toBe(tokenValue); + return Promise.resolve(dummyUser); + }; + + subject + .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) + .then(() => { + expect(tokenValueContainer.dataset.originalValue).toBe(tokenValue); + expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name); + const avatar = tokenValueElement.querySelector('img.avatar'); + + expect(avatar.getAttribute('src')).toBe(dummyUser.avatar_url); + expect(avatar.getAttribute('alt')).toBe(''); + }) + .then(done) + .catch(done.fail); + }); + + it('escapes user name when creating token', done => { + const dummyUser = { + name: '<script>', + avatar_url: `${gl.TEST_HOST}/mypics/avatar.png`, + }; + const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); + const tokenValue = tokenValueElement.innerText; + usersCacheSpy = username => { + expect(`@${username}`).toBe(tokenValue); + return Promise.resolve(dummyUser); + }; + + subject + .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) + .then(() => { + expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name); + tokenValueElement.querySelector('.avatar').remove(); + + expect(tokenValueElement.innerHTML.trim()).toBe(escape(dummyUser.name)); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('updateLabelTokenColor', () => { + const jsonFixtureName = 'labels/project_labels.json'; + const dummyEndpoint = '/dummy/endpoint'; + + preloadFixtures(jsonFixtureName); + + let labelData; + + beforeAll(() => { + labelData = getJSONFixture(jsonFixtureName); + }); + + const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken( + 'label', + '=', + '~doesnotexist', + ); + const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken( + 'label', + '=', + '~"some space"', + ); + + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${bugLabelToken.outerHTML} + ${missingLabelToken.outerHTML} + ${spaceLabelToken.outerHTML} + `); + + const filteredSearchInput = document.querySelector('.filtered-search'); + filteredSearchInput.dataset.runnerTagsEndpoint = `${dummyEndpoint}/admin/runners/tag_list`; + filteredSearchInput.dataset.labelsEndpoint = `${dummyEndpoint}/-/labels`; + filteredSearchInput.dataset.milestonesEndpoint = `${dummyEndpoint}/-/milestones`; + + AjaxCache.internalStorage = {}; + AjaxCache.internalStorage[`${filteredSearchInput.dataset.labelsEndpoint}.json`] = labelData; + }); + + const parseColor = color => { + const dummyElement = document.createElement('div'); + dummyElement.style.color = color; + return dummyElement.style.color; + }; + + const expectValueContainerStyle = (tokenValueContainer, label) => { + expect(tokenValueContainer.getAttribute('style')).not.toBe(null); + expect(tokenValueContainer.style.backgroundColor).toBe(parseColor(label.color)); + expect(tokenValueContainer.style.color).toBe(parseColor(label.text_color)); + }; + + const findLabel = tokenValue => + labelData.find(label => tokenValue === `~${DropdownUtils.getEscapedText(label.title)}`); + + it('updates the color of a label token', done => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken); + const tokenValue = tokenValueElement.innerText; + const matchingLabel = findLabel(tokenValue); + + subject + .updateLabelTokenColor(tokenValueContainer, tokenValue) + .then(() => { + expectValueContainerStyle(tokenValueContainer, matchingLabel); + }) + .then(done) + .catch(done.fail); + }); + + it('updates the color of a label token with spaces', done => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(spaceLabelToken); + const tokenValue = tokenValueElement.innerText; + const matchingLabel = findLabel(tokenValue); + + subject + .updateLabelTokenColor(tokenValueContainer, tokenValue) + .then(() => { + expectValueContainerStyle(tokenValueContainer, matchingLabel); + }) + .then(done) + .catch(done.fail); + }); + + it('does not change color of a missing label', done => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(missingLabelToken); + const tokenValue = tokenValueElement.innerText; + const matchingLabel = findLabel(tokenValue); + + expect(matchingLabel).toBe(undefined); + + subject + .updateLabelTokenColor(tokenValueContainer, tokenValue) + .then(() => { + expect(tokenValueContainer.getAttribute('style')).toBe(null); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('setTokenStyle', () => { + let originalTextColor; + + beforeEach(() => { + originalTextColor = bugLabelToken.style.color; + }); + + it('should set backgroundColor', () => { + const originalBackgroundColor = bugLabelToken.style.backgroundColor; + const token = VisualTokenValue.setTokenStyle(bugLabelToken, 'blue', 'white'); + + expect(token.style.backgroundColor).toEqual('blue'); + expect(token.style.backgroundColor).not.toEqual(originalBackgroundColor); + }); + + it('should set textColor', () => { + const token = VisualTokenValue.setTokenStyle(bugLabelToken, 'white', 'black'); + + expect(token.style.color).toEqual('black'); + expect(token.style.color).not.toEqual(originalTextColor); + }); + + it('should add inverted class when textColor is #FFFFFF', () => { + const token = VisualTokenValue.setTokenStyle(bugLabelToken, 'black', '#FFFFFF'); + + expect(token.style.color).toEqual('rgb(255, 255, 255)'); + expect(token.style.color).not.toEqual(originalTextColor); + expect(token.querySelector('.remove-token').classList.contains('inverted')).toEqual(true); + }); + }); + + describe('render', () => { + const setupSpies = subject => { + jest.spyOn(subject, 'updateLabelTokenColor').mockImplementation(() => {}); + const updateLabelTokenColorSpy = subject.updateLabelTokenColor; + + jest.spyOn(subject, 'updateUserTokenAppearance').mockImplementation(() => {}); + const updateUserTokenAppearanceSpy = subject.updateUserTokenAppearance; + + return { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy }; + }; + + const keywordToken = FilteredSearchSpecHelper.createFilterVisualToken('search'); + const milestoneToken = FilteredSearchSpecHelper.createFilterVisualToken( + 'milestone', + 'upcoming', + ); + + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${authorToken.outerHTML} + ${bugLabelToken.outerHTML} + ${keywordToken.outerHTML} + ${milestoneToken.outerHTML} + `); + }); + + it('renders a author token value element', () => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); + + const { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy } = setupSpies(subject); + subject.render(tokenValueContainer, tokenValueElement); + + expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(1); + const expectedArgs = [tokenValueContainer, tokenValueElement]; + + expect(updateUserTokenAppearanceSpy.mock.calls[0]).toEqual(expectedArgs); + expect(updateLabelTokenColorSpy.mock.calls.length).toBe(0); + }); + + it('renders a label token value element', () => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken); + + const { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy } = setupSpies(subject); + subject.render(tokenValueContainer, tokenValueElement); + + expect(updateLabelTokenColorSpy.mock.calls.length).toBe(1); + const expectedArgs = [tokenValueContainer]; + + expect(updateLabelTokenColorSpy.mock.calls[0]).toEqual(expectedArgs); + expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(0); + }); + + it('renders a milestone token value element', () => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(milestoneToken); + + const { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy } = setupSpies(subject); + subject.render(tokenValueContainer, tokenValueElement); + + expect(updateLabelTokenColorSpy.mock.calls.length).toBe(0); + expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(0); + }); + + it('does not update user token appearance for `none` filter', () => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); + + subject.tokenValue = 'none'; + + const { updateUserTokenAppearanceSpy } = setupSpies(subject); + subject.render(tokenValueContainer, tokenValueElement); + + expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(0); + }); + + it('does not update user token appearance for `None` filter', () => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); + + subject.tokenValue = 'None'; + + const { updateUserTokenAppearanceSpy } = setupSpies(subject); + subject.render(tokenValueContainer, tokenValueElement); + + expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(0); + }); + + it('does not update user token appearance for `any` filter', () => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); + + subject.tokenValue = 'any'; + + const { updateUserTokenAppearanceSpy } = setupSpies(subject); + subject.render(tokenValueContainer, tokenValueElement); + + expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(0); + }); + + it('does not update label token color for `None` filter', () => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken); + + subject.tokenValue = 'None'; + + const { updateLabelTokenColorSpy } = setupSpies(subject); + subject.render(tokenValueContainer, tokenValueElement); + + expect(updateLabelTokenColorSpy.mock.calls.length).toBe(0); + }); + + it('does not update label token color for `none` filter', () => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken); + + subject.tokenValue = 'none'; + + const { updateLabelTokenColorSpy } = setupSpies(subject); + subject.render(tokenValueContainer, tokenValueElement); + + expect(updateLabelTokenColorSpy.mock.calls.length).toBe(0); + }); + + it('does not update label token color for `any` filter', () => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken); + + subject.tokenValue = 'any'; + + const { updateLabelTokenColorSpy } = setupSpies(subject); + subject.render(tokenValueContainer, tokenValueElement); + + expect(updateLabelTokenColorSpy.mock.calls.length).toBe(0); + }); + }); +}); diff --git a/spec/frontend/fixtures/test_report.rb b/spec/frontend/fixtures/test_report.rb index d26bba9b9d0..d0ecaf11994 100644 --- a/spec/frontend/fixtures/test_report.rb +++ b/spec/frontend/fixtures/test_report.rb @@ -15,7 +15,7 @@ describe Projects::PipelinesController, "(JavaScript fixtures)", type: :controll before do sign_in(user) - stub_feature_flags(junit_pipeline_view: true) + stub_feature_flags(junit_pipeline_view: project) end it "pipelines/test_report.json" do diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js new file mode 100644 index 00000000000..fa7c1904339 --- /dev/null +++ b/spec/frontend/flash_spec.js @@ -0,0 +1,233 @@ +import flash, { createFlashEl, createAction, hideFlash, removeFlashClickListener } from '~/flash'; + +describe('Flash', () => { + describe('createFlashEl', () => { + let el; + + beforeEach(() => { + el = document.createElement('div'); + }); + + afterEach(() => { + el.innerHTML = ''; + }); + + it('creates flash element with type', () => { + el.innerHTML = createFlashEl('testing', 'alert'); + + expect(el.querySelector('.flash-alert')).not.toBeNull(); + }); + + it('escapes text', () => { + el.innerHTML = createFlashEl('<script>alert("a");</script>', 'alert'); + + expect(el.querySelector('.flash-text').textContent.trim()).toBe( + '<script>alert("a");</script>', + ); + }); + }); + + describe('hideFlash', () => { + let el; + + beforeEach(() => { + el = document.createElement('div'); + el.className = 'js-testing'; + }); + + it('sets transition style', () => { + hideFlash(el); + + expect(el.style.transition).toBe('opacity 0.15s'); + }); + + it('sets opacity style', () => { + hideFlash(el); + + expect(el.style.opacity).toBe('0'); + }); + + it('does not set styles when fadeTransition is false', () => { + hideFlash(el, false); + + expect(el.style.opacity).toBe(''); + expect(el.style.transition).toBeFalsy(); + }); + + it('removes element after transitionend', () => { + document.body.appendChild(el); + + hideFlash(el); + el.dispatchEvent(new Event('transitionend')); + + expect(document.querySelector('.js-testing')).toBeNull(); + }); + + it('calls event listener callback once', () => { + jest.spyOn(el, 'remove'); + document.body.appendChild(el); + + hideFlash(el); + + el.dispatchEvent(new Event('transitionend')); + el.dispatchEvent(new Event('transitionend')); + + expect(el.remove.mock.calls.length).toBe(1); + }); + }); + + describe('createAction', () => { + let el; + + beforeEach(() => { + el = document.createElement('div'); + }); + + it('creates link with href', () => { + el.innerHTML = createAction({ + href: 'testing', + title: 'test', + }); + + expect(el.querySelector('.flash-action').href).toContain('testing'); + }); + + it('uses hash as href when no href is present', () => { + el.innerHTML = createAction({ + title: 'test', + }); + + expect(el.querySelector('.flash-action').href).toContain('#'); + }); + + it('adds role when no href is present', () => { + el.innerHTML = createAction({ + title: 'test', + }); + + expect(el.querySelector('.flash-action').getAttribute('role')).toBe('button'); + }); + + it('escapes the title text', () => { + el.innerHTML = createAction({ + title: '<script>alert("a")</script>', + }); + + expect(el.querySelector('.flash-action').textContent.trim()).toBe( + '<script>alert("a")</script>', + ); + }); + }); + + describe('createFlash', () => { + describe('no flash-container', () => { + it('does not add to the DOM', () => { + const flashEl = flash('testing'); + + expect(flashEl).toBeNull(); + + expect(document.querySelector('.flash-alert')).toBeNull(); + }); + }); + + describe('with flash-container', () => { + beforeEach(() => { + document.body.innerHTML += ` + <div class="content-wrapper js-content-wrapper"> + <div class="flash-container"></div> + </div> + `; + }); + + afterEach(() => { + document.querySelector('.js-content-wrapper').remove(); + }); + + it('adds flash element into container', () => { + flash('test', 'alert', document, null, false, true); + + expect(document.querySelector('.flash-alert')).not.toBeNull(); + + expect(document.body.className).toContain('flash-shown'); + }); + + it('adds flash into specified parent', () => { + flash('test', 'alert', document.querySelector('.content-wrapper')); + + expect(document.querySelector('.content-wrapper .flash-alert')).not.toBeNull(); + }); + + it('adds container classes when inside content-wrapper', () => { + flash('test'); + + expect(document.querySelector('.flash-text').className).toBe('flash-text'); + }); + + it('does not add container when outside of content-wrapper', () => { + document.querySelector('.content-wrapper').className = 'js-content-wrapper'; + flash('test'); + + expect(document.querySelector('.flash-text').className.trim()).toContain('flash-text'); + }); + + it('removes element after clicking', () => { + flash('test', 'alert', document, null, false, true); + + document.querySelector('.flash-alert .js-close-icon').click(); + + expect(document.querySelector('.flash-alert')).toBeNull(); + + expect(document.body.className).not.toContain('flash-shown'); + }); + + describe('with actionConfig', () => { + it('adds action link', () => { + flash('test', 'alert', document, { + title: 'test', + }); + + expect(document.querySelector('.flash-action')).not.toBeNull(); + }); + + it('calls actionConfig clickHandler on click', () => { + const actionConfig = { + title: 'test', + clickHandler: jest.fn(), + }; + + flash('test', 'alert', document, actionConfig); + + document.querySelector('.flash-action').click(); + + expect(actionConfig.clickHandler).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('removeFlashClickListener', () => { + beforeEach(() => { + document.body.innerHTML += ` + <div class="flash-container"> + <div class="flash"> + <div class="close-icon js-close-icon"></div> + </div> + </div> + `; + }); + + it('removes global flash on click', done => { + const flashEl = document.querySelector('.flash'); + + removeFlashClickListener(flashEl, false); + + flashEl.querySelector('.js-close-icon').click(); + + setImmediate(() => { + expect(document.querySelector('.flash')).toBeNull(); + + done(); + }); + }); + }); +}); diff --git a/spec/frontend/frequent_items/components/app_spec.js b/spec/frontend/frequent_items/components/app_spec.js new file mode 100644 index 00000000000..7c54a48aa41 --- /dev/null +++ b/spec/frontend/frequent_items/components/app_spec.js @@ -0,0 +1,251 @@ +import MockAdapter from 'axios-mock-adapter'; +import Vue from 'vue'; +import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import axios from '~/lib/utils/axios_utils'; +import appComponent from '~/frequent_items/components/app.vue'; +import eventHub from '~/frequent_items/event_hub'; +import store from '~/frequent_items/store'; +import { FREQUENT_ITEMS, HOUR_IN_MS } from '~/frequent_items/constants'; +import { getTopFrequentItems } from '~/frequent_items/utils'; +import { currentSession, mockFrequentProjects, mockSearchedProjects } from '../mock_data'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +useLocalStorageSpy(); + +let session; +const createComponentWithStore = (namespace = 'projects') => { + session = currentSession[namespace]; + gon.api_version = session.apiVersion; + const Component = Vue.extend(appComponent); + + return mountComponentWithStore(Component, { + store, + props: { + namespace, + currentUserName: session.username, + currentItem: session.project || session.group, + }, + }); +}; + +describe('Frequent Items App Component', () => { + let vm; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + vm = createComponentWithStore(); + }); + + afterEach(() => { + mock.restore(); + vm.$destroy(); + }); + + describe('methods', () => { + describe('dropdownOpenHandler', () => { + it('should fetch frequent items when no search has been previously made on desktop', () => { + jest.spyOn(vm, 'fetchFrequentItems').mockImplementation(() => {}); + + vm.dropdownOpenHandler(); + + expect(vm.fetchFrequentItems).toHaveBeenCalledWith(); + }); + }); + + describe('logItemAccess', () => { + let storage; + + beforeEach(() => { + storage = {}; + + localStorage.setItem.mockImplementation((storageKey, value) => { + storage[storageKey] = value; + }); + + localStorage.getItem.mockImplementation(storageKey => { + if (storage[storageKey]) { + return storage[storageKey]; + } + + return null; + }); + }); + + it('should create a project store if it does not exist and adds a project', () => { + vm.logItemAccess(session.storageKey, session.project); + + const projects = JSON.parse(storage[session.storageKey]); + + expect(projects.length).toBe(1); + expect(projects[0].frequency).toBe(1); + expect(projects[0].lastAccessedOn).toBeDefined(); + }); + + it('should prevent inserting same report multiple times into store', () => { + vm.logItemAccess(session.storageKey, session.project); + vm.logItemAccess(session.storageKey, session.project); + + const projects = JSON.parse(storage[session.storageKey]); + + expect(projects.length).toBe(1); + }); + + it('should increase frequency of report if it was logged multiple times over the course of an hour', () => { + let projects; + const newTimestamp = Date.now() + HOUR_IN_MS + 1; + + vm.logItemAccess(session.storageKey, session.project); + projects = JSON.parse(storage[session.storageKey]); + + expect(projects[0].frequency).toBe(1); + + vm.logItemAccess(session.storageKey, { + ...session.project, + lastAccessedOn: newTimestamp, + }); + projects = JSON.parse(storage[session.storageKey]); + + expect(projects[0].frequency).toBe(2); + expect(projects[0].lastAccessedOn).not.toBe(session.project.lastAccessedOn); + }); + + it('should always update project metadata', () => { + let projects; + const oldProject = { + ...session.project, + }; + + const newProject = { + ...session.project, + name: 'New Name', + avatarUrl: 'new/avatar.png', + namespace: 'New / Namespace', + webUrl: 'http://localhost/new/web/url', + }; + + vm.logItemAccess(session.storageKey, oldProject); + projects = JSON.parse(storage[session.storageKey]); + + expect(projects[0].name).toBe(oldProject.name); + expect(projects[0].avatarUrl).toBe(oldProject.avatarUrl); + expect(projects[0].namespace).toBe(oldProject.namespace); + expect(projects[0].webUrl).toBe(oldProject.webUrl); + + vm.logItemAccess(session.storageKey, newProject); + projects = JSON.parse(storage[session.storageKey]); + + expect(projects[0].name).toBe(newProject.name); + expect(projects[0].avatarUrl).toBe(newProject.avatarUrl); + expect(projects[0].namespace).toBe(newProject.namespace); + expect(projects[0].webUrl).toBe(newProject.webUrl); + }); + + it('should not add more than 20 projects in store', () => { + for (let id = 0; id < FREQUENT_ITEMS.MAX_COUNT; id += 1) { + const project = { + ...session.project, + id, + }; + vm.logItemAccess(session.storageKey, project); + } + + const projects = JSON.parse(storage[session.storageKey]); + + expect(projects.length).toBe(FREQUENT_ITEMS.MAX_COUNT); + }); + }); + }); + + describe('created', () => { + it('should bind event listeners on eventHub', done => { + jest.spyOn(eventHub, '$on').mockImplementation(() => {}); + + createComponentWithStore().$mount(); + + Vue.nextTick(() => { + expect(eventHub.$on).toHaveBeenCalledWith('projects-dropdownOpen', expect.any(Function)); + done(); + }); + }); + }); + + describe('beforeDestroy', () => { + it('should unbind event listeners on eventHub', done => { + jest.spyOn(eventHub, '$off').mockImplementation(() => {}); + + vm.$mount(); + vm.$destroy(); + + Vue.nextTick(() => { + expect(eventHub.$off).toHaveBeenCalledWith('projects-dropdownOpen', expect.any(Function)); + done(); + }); + }); + }); + + describe('template', () => { + it('should render search input', () => { + expect(vm.$el.querySelector('.search-input-container')).toBeDefined(); + }); + + it('should render loading animation', done => { + vm.$store.dispatch('fetchSearchedItems'); + + Vue.nextTick(() => { + const loadingEl = vm.$el.querySelector('.loading-animation'); + + expect(loadingEl).toBeDefined(); + expect(loadingEl.classList.contains('prepend-top-20')).toBe(true); + expect(loadingEl.querySelector('span').getAttribute('aria-label')).toBe('Loading projects'); + done(); + }); + }); + + it('should render frequent projects list header', done => { + Vue.nextTick(() => { + const sectionHeaderEl = vm.$el.querySelector('.section-header'); + + expect(sectionHeaderEl).toBeDefined(); + expect(sectionHeaderEl.innerText.trim()).toBe('Frequently visited'); + done(); + }); + }); + + it('should render frequent projects list', done => { + const expectedResult = getTopFrequentItems(mockFrequentProjects); + localStorage.getItem.mockImplementation(() => JSON.stringify(mockFrequentProjects)); + + expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(1); + + vm.fetchFrequentItems(); + Vue.nextTick(() => { + expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe( + expectedResult.length, + ); + done(); + }); + }); + + it('should render searched projects list', done => { + mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects); + + expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(1); + + vm.$store.dispatch('setSearchQuery', 'gitlab'); + vm.$nextTick() + .then(() => { + expect(vm.$el.querySelector('.loading-animation')).toBeDefined(); + }) + .then(waitForPromises) + .then(() => { + expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe( + mockSearchedProjects.data.length, + ); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/frontend/frequent_items/mock_data.js b/spec/frontend/frequent_items/mock_data.js index 5cd4cddd877..8c3c66f67ff 100644 --- a/spec/frontend/frequent_items/mock_data.js +++ b/spec/frontend/frequent_items/mock_data.js @@ -1,5 +1,94 @@ import { TEST_HOST } from 'helpers/test_constants'; +export const currentSession = { + groups: { + username: 'root', + storageKey: 'root/frequent-groups', + apiVersion: 'v4', + group: { + id: 1, + name: 'dummy-group', + full_name: 'dummy-parent-group', + webUrl: `${TEST_HOST}/dummy-group`, + avatarUrl: null, + lastAccessedOn: Date.now(), + }, + }, + projects: { + username: 'root', + storageKey: 'root/frequent-projects', + apiVersion: 'v4', + project: { + id: 1, + name: 'dummy-project', + namespace: 'SampleGroup / Dummy-Project', + webUrl: `${TEST_HOST}/samplegroup/dummy-project`, + avatarUrl: null, + lastAccessedOn: Date.now(), + }, + }, +}; + +export const mockNamespace = 'projects'; + +export const mockStorageKey = 'test-user/frequent-projects'; + +export const mockGroup = { + id: 1, + name: 'Sub451', + namespace: 'Commit451 / Sub451', + webUrl: `${TEST_HOST}/Commit451/Sub451`, + avatarUrl: null, +}; + +export const mockRawGroup = { + id: 1, + name: 'Sub451', + full_name: 'Commit451 / Sub451', + web_url: `${TEST_HOST}/Commit451/Sub451`, + avatar_url: null, +}; + +export const mockFrequentGroups = [ + { + id: 3, + name: 'Subgroup451', + full_name: 'Commit451 / Subgroup451', + webUrl: '/Commit451/Subgroup451', + avatarUrl: null, + frequency: 7, + lastAccessedOn: 1497979281815, + }, + { + id: 1, + name: 'Commit451', + full_name: 'Commit451', + webUrl: '/Commit451', + avatarUrl: null, + frequency: 3, + lastAccessedOn: 1497979281815, + }, +]; + +export const mockSearchedGroups = [mockRawGroup]; +export const mockProcessedSearchedGroups = [mockGroup]; + +export const mockProject = { + id: 1, + name: 'GitLab Community Edition', + namespace: 'gitlab-org / gitlab-ce', + webUrl: `${TEST_HOST}/gitlab-org/gitlab-foss`, + avatarUrl: null, +}; + +export const mockRawProject = { + id: 1, + name: 'GitLab Community Edition', + name_with_namespace: 'gitlab-org / gitlab-ce', + web_url: `${TEST_HOST}/gitlab-org/gitlab-foss`, + avatar_url: null, +}; + export const mockFrequentProjects = [ { id: 1, @@ -48,10 +137,34 @@ export const mockFrequentProjects = [ }, ]; -export const mockProject = { - id: 1, - name: 'GitLab Community Edition', - namespace: 'gitlab-org / gitlab-ce', - webUrl: `${TEST_HOST}/gitlab-org/gitlab-foss`, - avatarUrl: null, -}; +export const mockSearchedProjects = { data: [mockRawProject] }; +export const mockProcessedSearchedProjects = [mockProject]; + +export const unsortedFrequentItems = [ + { id: 1, frequency: 12, lastAccessedOn: 1491400843391 }, + { id: 2, frequency: 14, lastAccessedOn: 1488240890738 }, + { id: 3, frequency: 44, lastAccessedOn: 1497675908472 }, + { id: 4, frequency: 8, lastAccessedOn: 1497979281815 }, + { id: 5, frequency: 34, lastAccessedOn: 1488089211943 }, + { id: 6, frequency: 14, lastAccessedOn: 1493517292488 }, + { id: 7, frequency: 42, lastAccessedOn: 1486815299875 }, + { id: 8, frequency: 33, lastAccessedOn: 1500762279114 }, + { id: 10, frequency: 46, lastAccessedOn: 1483251641543 }, +]; + +/** + * This const has a specific order which tests authenticity + * of `getTopFrequentItems` method so + * DO NOT change order of items in this const. + */ +export const sortedFrequentItems = [ + { id: 10, frequency: 46, lastAccessedOn: 1483251641543 }, + { id: 3, frequency: 44, lastAccessedOn: 1497675908472 }, + { id: 7, frequency: 42, lastAccessedOn: 1486815299875 }, + { id: 5, frequency: 34, lastAccessedOn: 1488089211943 }, + { id: 8, frequency: 33, lastAccessedOn: 1500762279114 }, + { id: 6, frequency: 14, lastAccessedOn: 1493517292488 }, + { id: 2, frequency: 14, lastAccessedOn: 1488240890738 }, + { id: 1, frequency: 12, lastAccessedOn: 1491400843391 }, + { id: 4, frequency: 8, lastAccessedOn: 1497979281815 }, +]; diff --git a/spec/frontend/frequent_items/store/actions_spec.js b/spec/frontend/frequent_items/store/actions_spec.js new file mode 100644 index 00000000000..304098e85f1 --- /dev/null +++ b/spec/frontend/frequent_items/store/actions_spec.js @@ -0,0 +1,228 @@ +import testAction from 'helpers/vuex_action_helper'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import AccessorUtilities from '~/lib/utils/accessor'; +import * as actions from '~/frequent_items/store/actions'; +import * as types from '~/frequent_items/store/mutation_types'; +import state from '~/frequent_items/store/state'; +import { + mockNamespace, + mockStorageKey, + mockFrequentProjects, + mockSearchedProjects, +} from '../mock_data'; + +describe('Frequent Items Dropdown Store Actions', () => { + let mockedState; + let mock; + + beforeEach(() => { + mockedState = state(); + mock = new MockAdapter(axios); + + mockedState.namespace = mockNamespace; + mockedState.storageKey = mockStorageKey; + }); + + afterEach(() => { + mock.restore(); + }); + + describe('setNamespace', () => { + it('should set namespace', done => { + testAction( + actions.setNamespace, + mockNamespace, + mockedState, + [{ type: types.SET_NAMESPACE, payload: mockNamespace }], + [], + done, + ); + }); + }); + + describe('setStorageKey', () => { + it('should set storage key', done => { + testAction( + actions.setStorageKey, + mockStorageKey, + mockedState, + [{ type: types.SET_STORAGE_KEY, payload: mockStorageKey }], + [], + done, + ); + }); + }); + + describe('requestFrequentItems', () => { + it('should request frequent items', done => { + testAction( + actions.requestFrequentItems, + null, + mockedState, + [{ type: types.REQUEST_FREQUENT_ITEMS }], + [], + done, + ); + }); + }); + + describe('receiveFrequentItemsSuccess', () => { + it('should set frequent items', done => { + testAction( + actions.receiveFrequentItemsSuccess, + mockFrequentProjects, + mockedState, + [{ type: types.RECEIVE_FREQUENT_ITEMS_SUCCESS, payload: mockFrequentProjects }], + [], + done, + ); + }); + }); + + describe('receiveFrequentItemsError', () => { + it('should set frequent items error state', done => { + testAction( + actions.receiveFrequentItemsError, + null, + mockedState, + [{ type: types.RECEIVE_FREQUENT_ITEMS_ERROR }], + [], + done, + ); + }); + }); + + describe('fetchFrequentItems', () => { + it('should dispatch `receiveFrequentItemsSuccess`', done => { + mockedState.namespace = mockNamespace; + mockedState.storageKey = mockStorageKey; + + testAction( + actions.fetchFrequentItems, + null, + mockedState, + [], + [{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsSuccess', payload: [] }], + done, + ); + }); + + it('should dispatch `receiveFrequentItemsError`', done => { + jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(false); + mockedState.namespace = mockNamespace; + mockedState.storageKey = mockStorageKey; + + testAction( + actions.fetchFrequentItems, + null, + mockedState, + [], + [{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsError' }], + done, + ); + }); + }); + + describe('requestSearchedItems', () => { + it('should request searched items', done => { + testAction( + actions.requestSearchedItems, + null, + mockedState, + [{ type: types.REQUEST_SEARCHED_ITEMS }], + [], + done, + ); + }); + }); + + describe('receiveSearchedItemsSuccess', () => { + it('should set searched items', done => { + testAction( + actions.receiveSearchedItemsSuccess, + mockSearchedProjects, + mockedState, + [{ type: types.RECEIVE_SEARCHED_ITEMS_SUCCESS, payload: mockSearchedProjects }], + [], + done, + ); + }); + }); + + describe('receiveSearchedItemsError', () => { + it('should set searched items error state', done => { + testAction( + actions.receiveSearchedItemsError, + null, + mockedState, + [{ type: types.RECEIVE_SEARCHED_ITEMS_ERROR }], + [], + done, + ); + }); + }); + + describe('fetchSearchedItems', () => { + beforeEach(() => { + gon.api_version = 'v4'; + }); + + it('should dispatch `receiveSearchedItemsSuccess`', done => { + mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects, {}); + + testAction( + actions.fetchSearchedItems, + null, + mockedState, + [], + [ + { type: 'requestSearchedItems' }, + { + type: 'receiveSearchedItemsSuccess', + payload: { data: mockSearchedProjects, headers: {} }, + }, + ], + done, + ); + }); + + it('should dispatch `receiveSearchedItemsError`', done => { + gon.api_version = 'v4'; + mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(500); + + testAction( + actions.fetchSearchedItems, + null, + mockedState, + [], + [{ type: 'requestSearchedItems' }, { type: 'receiveSearchedItemsError' }], + done, + ); + }); + }); + + describe('setSearchQuery', () => { + it('should commit query and dispatch `fetchSearchedItems` when query is present', done => { + testAction( + actions.setSearchQuery, + { query: 'test' }, + mockedState, + [{ type: types.SET_SEARCH_QUERY, payload: { query: 'test' } }], + [{ type: 'fetchSearchedItems', payload: { query: 'test' } }], + done, + ); + }); + + it('should commit query and dispatch `fetchFrequentItems` when query is empty', done => { + testAction( + actions.setSearchQuery, + null, + mockedState, + [{ type: types.SET_SEARCH_QUERY, payload: null }], + [{ type: 'fetchFrequentItems' }], + done, + ); + }); + }); +}); diff --git a/spec/frontend/frequent_items/store/mutations_spec.js b/spec/frontend/frequent_items/store/mutations_spec.js new file mode 100644 index 00000000000..d36964b2600 --- /dev/null +++ b/spec/frontend/frequent_items/store/mutations_spec.js @@ -0,0 +1,117 @@ +import state from '~/frequent_items/store/state'; +import mutations from '~/frequent_items/store/mutations'; +import * as types from '~/frequent_items/store/mutation_types'; +import { + mockNamespace, + mockStorageKey, + mockFrequentProjects, + mockSearchedProjects, + mockProcessedSearchedProjects, + mockSearchedGroups, + mockProcessedSearchedGroups, +} from '../mock_data'; + +describe('Frequent Items dropdown mutations', () => { + let stateCopy; + + beforeEach(() => { + stateCopy = state(); + }); + + describe('SET_NAMESPACE', () => { + it('should set namespace', () => { + mutations[types.SET_NAMESPACE](stateCopy, mockNamespace); + + expect(stateCopy.namespace).toEqual(mockNamespace); + }); + }); + + describe('SET_STORAGE_KEY', () => { + it('should set storage key', () => { + mutations[types.SET_STORAGE_KEY](stateCopy, mockStorageKey); + + expect(stateCopy.storageKey).toEqual(mockStorageKey); + }); + }); + + describe('SET_SEARCH_QUERY', () => { + it('should set search query', () => { + const searchQuery = 'gitlab-ce'; + + mutations[types.SET_SEARCH_QUERY](stateCopy, searchQuery); + + expect(stateCopy.searchQuery).toEqual(searchQuery); + }); + }); + + describe('REQUEST_FREQUENT_ITEMS', () => { + it('should set view states when requesting frequent items', () => { + mutations[types.REQUEST_FREQUENT_ITEMS](stateCopy); + + expect(stateCopy.isLoadingItems).toEqual(true); + expect(stateCopy.hasSearchQuery).toEqual(false); + }); + }); + + describe('RECEIVE_FREQUENT_ITEMS_SUCCESS', () => { + it('should set view states when receiving frequent items', () => { + mutations[types.RECEIVE_FREQUENT_ITEMS_SUCCESS](stateCopy, mockFrequentProjects); + + expect(stateCopy.items).toEqual(mockFrequentProjects); + expect(stateCopy.isLoadingItems).toEqual(false); + expect(stateCopy.hasSearchQuery).toEqual(false); + expect(stateCopy.isFetchFailed).toEqual(false); + }); + }); + + describe('RECEIVE_FREQUENT_ITEMS_ERROR', () => { + it('should set items and view states when error occurs retrieving frequent items', () => { + mutations[types.RECEIVE_FREQUENT_ITEMS_ERROR](stateCopy); + + expect(stateCopy.items).toEqual([]); + expect(stateCopy.isLoadingItems).toEqual(false); + expect(stateCopy.hasSearchQuery).toEqual(false); + expect(stateCopy.isFetchFailed).toEqual(true); + }); + }); + + describe('REQUEST_SEARCHED_ITEMS', () => { + it('should set view states when requesting searched items', () => { + mutations[types.REQUEST_SEARCHED_ITEMS](stateCopy); + + expect(stateCopy.isLoadingItems).toEqual(true); + expect(stateCopy.hasSearchQuery).toEqual(true); + }); + }); + + describe('RECEIVE_SEARCHED_ITEMS_SUCCESS', () => { + it('should set items and view states when receiving searched items', () => { + mutations[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](stateCopy, mockSearchedProjects); + + expect(stateCopy.items).toEqual(mockProcessedSearchedProjects); + expect(stateCopy.isLoadingItems).toEqual(false); + expect(stateCopy.hasSearchQuery).toEqual(true); + expect(stateCopy.isFetchFailed).toEqual(false); + }); + + it('should also handle the different `full_name` key for namespace in groups payload', () => { + mutations[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](stateCopy, mockSearchedGroups); + + expect(stateCopy.items).toEqual(mockProcessedSearchedGroups); + expect(stateCopy.isLoadingItems).toEqual(false); + expect(stateCopy.hasSearchQuery).toEqual(true); + expect(stateCopy.isFetchFailed).toEqual(false); + }); + }); + + describe('RECEIVE_SEARCHED_ITEMS_ERROR', () => { + it('should set view states when error occurs retrieving searched items', () => { + mutations[types.RECEIVE_SEARCHED_ITEMS_ERROR](stateCopy); + + expect(stateCopy.items).toEqual([]); + expect(stateCopy.isLoadingItems).toEqual(false); + expect(stateCopy.hasSearchQuery).toEqual(true); + expect(stateCopy.isFetchFailed).toEqual(true); + }); + }); +}); diff --git a/spec/frontend/frequent_items/utils_spec.js b/spec/frontend/frequent_items/utils_spec.js new file mode 100644 index 00000000000..181dd9268dc --- /dev/null +++ b/spec/frontend/frequent_items/utils_spec.js @@ -0,0 +1,130 @@ +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +import { + isMobile, + getTopFrequentItems, + updateExistingFrequentItem, + sanitizeItem, +} from '~/frequent_items/utils'; +import { HOUR_IN_MS, FREQUENT_ITEMS } from '~/frequent_items/constants'; +import { mockProject, unsortedFrequentItems, sortedFrequentItems } from './mock_data'; + +describe('Frequent Items utils spec', () => { + describe('isMobile', () => { + it('returns true when the screen is medium ', () => { + jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('md'); + + expect(isMobile()).toBe(true); + }); + + it('returns true when the screen is small ', () => { + jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('sm'); + + expect(isMobile()).toBe(true); + }); + + it('returns true when the screen is extra-small ', () => { + jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xs'); + + expect(isMobile()).toBe(true); + }); + + it('returns false when the screen is larger than medium ', () => { + jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('lg'); + + expect(isMobile()).toBe(false); + }); + }); + + describe('getTopFrequentItems', () => { + it('returns empty array if no items provided', () => { + const result = getTopFrequentItems(); + + expect(result.length).toBe(0); + }); + + it('returns correct amount of items for mobile', () => { + jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('md'); + const result = getTopFrequentItems(unsortedFrequentItems); + + expect(result.length).toBe(FREQUENT_ITEMS.LIST_COUNT_MOBILE); + }); + + it('returns correct amount of items for desktop', () => { + jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xl'); + const result = getTopFrequentItems(unsortedFrequentItems); + + expect(result.length).toBe(FREQUENT_ITEMS.LIST_COUNT_DESKTOP); + }); + + it('sorts frequent items in order of frequency and lastAccessedOn', () => { + jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xl'); + const result = getTopFrequentItems(unsortedFrequentItems); + const expectedResult = sortedFrequentItems.slice(0, FREQUENT_ITEMS.LIST_COUNT_DESKTOP); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('updateExistingFrequentItem', () => { + let mockedProject; + + beforeEach(() => { + mockedProject = { + ...mockProject, + frequency: 1, + lastAccessedOn: 1497979281815, + }; + }); + + it('updates item if accessed over an hour ago', () => { + const newTimestamp = Date.now() + HOUR_IN_MS + 1; + const newItem = { + ...mockedProject, + lastAccessedOn: newTimestamp, + }; + const result = updateExistingFrequentItem(mockedProject, newItem); + + expect(result.frequency).toBe(mockedProject.frequency + 1); + }); + + it('does not update item if accessed within the hour', () => { + const newItem = { + ...mockedProject, + lastAccessedOn: mockedProject.lastAccessedOn + HOUR_IN_MS, + }; + const result = updateExistingFrequentItem(mockedProject, newItem); + + expect(result.frequency).toBe(mockedProject.frequency); + }); + }); + + describe('sanitizeItem', () => { + it('strips HTML tags for name and namespace', () => { + const input = { + name: '<br><b>test</b>', + namespace: '<br>test', + id: 1, + }; + + expect(sanitizeItem(input)).toEqual({ name: 'test', namespace: 'test', id: 1 }); + }); + + it("skips `name` key if it doesn't exist on the item", () => { + const input = { + namespace: '<br>test', + id: 1, + }; + + expect(sanitizeItem(input)).toEqual({ namespace: 'test', id: 1 }); + }); + + it("skips `namespace` key if it doesn't exist on the item", () => { + const input = { + name: '<br><b>test</b>', + id: 1, + }; + + expect(sanitizeItem(input)).toEqual({ name: 'test', id: 1 }); + }); + }); +}); diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js new file mode 100644 index 00000000000..35eda21e047 --- /dev/null +++ b/spec/frontend/groups/components/app_spec.js @@ -0,0 +1,507 @@ +import '~/flash'; +import $ from 'jquery'; +import Vue from 'vue'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import appComponent from '~/groups/components/app.vue'; +import groupFolderComponent from '~/groups/components/group_folder.vue'; +import groupItemComponent from '~/groups/components/group_item.vue'; +import eventHub from '~/groups/event_hub'; +import GroupsStore from '~/groups/store/groups_store'; +import GroupsService from '~/groups/service/groups_service'; +import * as urlUtilities from '~/lib/utils/url_utility'; + +import { + mockEndpoint, + mockGroups, + mockSearchedGroups, + mockRawPageInfo, + mockParentGroupItem, + mockRawChildren, + mockChildren, + mockPageInfo, +} from '../mock_data'; + +const createComponent = (hideProjects = false) => { + const Component = Vue.extend(appComponent); + const store = new GroupsStore(false); + const service = new GroupsService(mockEndpoint); + + store.state.pageInfo = mockPageInfo; + + return new Component({ + propsData: { + store, + service, + hideProjects, + }, + }); +}; + +describe('AppComponent', () => { + let vm; + let mock; + let getGroupsSpy; + + beforeEach(() => { + mock = new AxiosMockAdapter(axios); + mock.onGet('/dashboard/groups.json').reply(200, mockGroups); + Vue.component('group-folder', groupFolderComponent); + Vue.component('group-item', groupItemComponent); + + vm = createComponent(); + getGroupsSpy = jest.spyOn(vm.service, 'getGroups'); + return vm.$nextTick(); + }); + + describe('computed', () => { + beforeEach(() => { + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('groups', () => { + it('should return list of groups from store', () => { + jest.spyOn(vm.store, 'getGroups').mockImplementation(() => {}); + + const { groups } = vm; + + expect(vm.store.getGroups).toHaveBeenCalled(); + expect(groups).not.toBeDefined(); + }); + }); + + describe('pageInfo', () => { + it('should return pagination info from store', () => { + jest.spyOn(vm.store, 'getPaginationInfo').mockImplementation(() => {}); + + const { pageInfo } = vm; + + expect(vm.store.getPaginationInfo).toHaveBeenCalled(); + expect(pageInfo).not.toBeDefined(); + }); + }); + }); + + describe('methods', () => { + beforeEach(() => { + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('fetchGroups', () => { + it('should call `getGroups` with all the params provided', () => { + return vm + .fetchGroups({ + parentId: 1, + page: 2, + filterGroupsBy: 'git', + sortBy: 'created_desc', + archived: true, + }) + .then(() => { + expect(getGroupsSpy).toHaveBeenCalledWith(1, 2, 'git', 'created_desc', true); + }); + }); + + it('should set headers to store for building pagination info when called with `updatePagination`', () => { + mock.onGet('/dashboard/groups.json').reply(200, { headers: mockRawPageInfo }); + + jest.spyOn(vm, 'updatePagination').mockImplementation(() => {}); + + return vm.fetchGroups({ updatePagination: true }).then(() => { + expect(getGroupsSpy).toHaveBeenCalled(); + expect(vm.updatePagination).toHaveBeenCalled(); + }); + }); + + it('should show flash error when request fails', () => { + mock.onGet('/dashboard/groups.json').reply(400); + + jest.spyOn($, 'scrollTo').mockImplementation(() => {}); + jest.spyOn(window, 'Flash').mockImplementation(() => {}); + + return vm.fetchGroups({}).then(() => { + expect(vm.isLoading).toBe(false); + expect($.scrollTo).toHaveBeenCalledWith(0); + expect(window.Flash).toHaveBeenCalledWith('An error occurred. Please try again.'); + }); + }); + }); + + describe('fetchAllGroups', () => { + beforeEach(() => { + jest.spyOn(vm, 'fetchGroups'); + jest.spyOn(vm, 'updateGroups'); + }); + + it('should fetch default set of groups', () => { + jest.spyOn(vm, 'updatePagination'); + + const fetchPromise = vm.fetchAllGroups(); + + expect(vm.isLoading).toBe(true); + + return fetchPromise.then(() => { + expect(vm.isLoading).toBe(false); + expect(vm.updateGroups).toHaveBeenCalled(); + }); + }); + + it('should fetch matching set of groups when app is loaded with search query', () => { + mock.onGet('/dashboard/groups.json').reply(200, mockSearchedGroups); + + const fetchPromise = vm.fetchAllGroups(); + + expect(vm.fetchGroups).toHaveBeenCalledWith({ + page: null, + filterGroupsBy: null, + sortBy: null, + updatePagination: true, + archived: null, + }); + return fetchPromise.then(() => { + expect(vm.updateGroups).toHaveBeenCalled(); + }); + }); + }); + + describe('fetchPage', () => { + beforeEach(() => { + jest.spyOn(vm, 'fetchGroups'); + jest.spyOn(vm, 'updateGroups'); + }); + + it('should fetch groups for provided page details and update window state', () => { + jest.spyOn(urlUtilities, 'mergeUrlParams'); + jest.spyOn(window.history, 'replaceState').mockImplementation(() => {}); + jest.spyOn($, 'scrollTo').mockImplementation(() => {}); + + const fetchPagePromise = vm.fetchPage(2, null, null, true); + + expect(vm.isLoading).toBe(true); + expect(vm.fetchGroups).toHaveBeenCalledWith({ + page: 2, + filterGroupsBy: null, + sortBy: null, + updatePagination: true, + archived: true, + }); + + return fetchPagePromise.then(() => { + expect(vm.isLoading).toBe(false); + expect($.scrollTo).toHaveBeenCalledWith(0); + expect(urlUtilities.mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, expect.any(String)); + expect(window.history.replaceState).toHaveBeenCalledWith( + { + page: expect.any(String), + }, + expect.any(String), + expect.any(String), + ); + + expect(vm.updateGroups).toHaveBeenCalled(); + }); + }); + }); + + describe('toggleChildren', () => { + let groupItem; + + beforeEach(() => { + groupItem = { ...mockParentGroupItem }; + groupItem.isOpen = false; + groupItem.isChildrenLoading = false; + }); + + it('should fetch children of given group and expand it if group is collapsed and children are not loaded', () => { + mock.onGet('/dashboard/groups.json').reply(200, mockRawChildren); + jest.spyOn(vm, 'fetchGroups'); + jest.spyOn(vm.store, 'setGroupChildren').mockImplementation(() => {}); + + vm.toggleChildren(groupItem); + + expect(groupItem.isChildrenLoading).toBe(true); + expect(vm.fetchGroups).toHaveBeenCalledWith({ + parentId: groupItem.id, + }); + return waitForPromises().then(() => { + expect(vm.store.setGroupChildren).toHaveBeenCalled(); + }); + }); + + it('should skip network request while expanding group if children are already loaded', () => { + jest.spyOn(vm, 'fetchGroups'); + groupItem.children = mockRawChildren; + + vm.toggleChildren(groupItem); + + expect(vm.fetchGroups).not.toHaveBeenCalled(); + expect(groupItem.isOpen).toBe(true); + }); + + it('should collapse group if it is already expanded', () => { + jest.spyOn(vm, 'fetchGroups'); + groupItem.isOpen = true; + + vm.toggleChildren(groupItem); + + expect(vm.fetchGroups).not.toHaveBeenCalled(); + expect(groupItem.isOpen).toBe(false); + }); + + it('should set `isChildrenLoading` back to `false` if load request fails', () => { + mock.onGet('/dashboard/groups.json').reply(400); + + vm.toggleChildren(groupItem); + + expect(groupItem.isChildrenLoading).toBe(true); + return waitForPromises().then(() => { + expect(groupItem.isChildrenLoading).toBe(false); + }); + }); + }); + + describe('showLeaveGroupModal', () => { + it('caches candidate group (as props) which is to be left', () => { + const group = { ...mockParentGroupItem }; + + expect(vm.targetGroup).toBe(null); + expect(vm.targetParentGroup).toBe(null); + vm.showLeaveGroupModal(group, mockParentGroupItem); + + expect(vm.targetGroup).not.toBe(null); + expect(vm.targetParentGroup).not.toBe(null); + }); + + it('updates props which show modal confirmation dialog', () => { + const group = { ...mockParentGroupItem }; + + expect(vm.showModal).toBe(false); + expect(vm.groupLeaveConfirmationMessage).toBe(''); + vm.showLeaveGroupModal(group, mockParentGroupItem); + + expect(vm.showModal).toBe(true); + expect(vm.groupLeaveConfirmationMessage).toBe( + `Are you sure you want to leave the "${group.fullName}" group?`, + ); + }); + }); + + describe('hideLeaveGroupModal', () => { + it('hides modal confirmation which is shown before leaving the group', () => { + const group = { ...mockParentGroupItem }; + vm.showLeaveGroupModal(group, mockParentGroupItem); + + expect(vm.showModal).toBe(true); + vm.hideLeaveGroupModal(); + + expect(vm.showModal).toBe(false); + }); + }); + + describe('leaveGroup', () => { + let groupItem; + let childGroupItem; + + beforeEach(() => { + groupItem = { ...mockParentGroupItem }; + groupItem.children = mockChildren; + [childGroupItem] = groupItem.children; + groupItem.isChildrenLoading = false; + vm.targetGroup = childGroupItem; + vm.targetParentGroup = groupItem; + }); + + it('hides modal confirmation leave group and remove group item from tree', () => { + const notice = `You left the "${childGroupItem.fullName}" group.`; + jest.spyOn(vm.service, 'leaveGroup').mockResolvedValue({ data: { notice } }); + jest.spyOn(vm.store, 'removeGroup'); + jest.spyOn(window, 'Flash').mockImplementation(() => {}); + jest.spyOn($, 'scrollTo').mockImplementation(() => {}); + + vm.leaveGroup(); + + expect(vm.showModal).toBe(false); + expect(vm.targetGroup.isBeingRemoved).toBe(true); + expect(vm.service.leaveGroup).toHaveBeenCalledWith(vm.targetGroup.leavePath); + return waitForPromises().then(() => { + expect($.scrollTo).toHaveBeenCalledWith(0); + expect(vm.store.removeGroup).toHaveBeenCalledWith(vm.targetGroup, vm.targetParentGroup); + expect(window.Flash).toHaveBeenCalledWith(notice, 'notice'); + }); + }); + + it('should show error flash message if request failed to leave group', () => { + const message = 'An error occurred. Please try again.'; + jest.spyOn(vm.service, 'leaveGroup').mockRejectedValue({ status: 500 }); + jest.spyOn(vm.store, 'removeGroup'); + jest.spyOn(window, 'Flash').mockImplementation(() => {}); + + vm.leaveGroup(); + + expect(vm.targetGroup.isBeingRemoved).toBe(true); + expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath); + return waitForPromises().then(() => { + expect(vm.store.removeGroup).not.toHaveBeenCalled(); + expect(window.Flash).toHaveBeenCalledWith(message); + expect(vm.targetGroup.isBeingRemoved).toBe(false); + }); + }); + + it('should show appropriate error flash message if request forbids to leave group', () => { + const message = 'Failed to leave the group. Please make sure you are not the only owner.'; + jest.spyOn(vm.service, 'leaveGroup').mockRejectedValue({ status: 403 }); + jest.spyOn(vm.store, 'removeGroup'); + jest.spyOn(window, 'Flash').mockImplementation(() => {}); + + vm.leaveGroup(childGroupItem, groupItem); + + expect(vm.targetGroup.isBeingRemoved).toBe(true); + expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath); + return waitForPromises().then(() => { + expect(vm.store.removeGroup).not.toHaveBeenCalled(); + expect(window.Flash).toHaveBeenCalledWith(message); + expect(vm.targetGroup.isBeingRemoved).toBe(false); + }); + }); + }); + + describe('updatePagination', () => { + it('should set pagination info to store from provided headers', () => { + jest.spyOn(vm.store, 'setPaginationInfo').mockImplementation(() => {}); + + vm.updatePagination(mockRawPageInfo); + + expect(vm.store.setPaginationInfo).toHaveBeenCalledWith(mockRawPageInfo); + }); + }); + + describe('updateGroups', () => { + it('should call setGroups on store if method was called directly', () => { + jest.spyOn(vm.store, 'setGroups').mockImplementation(() => {}); + + vm.updateGroups(mockGroups); + + expect(vm.store.setGroups).toHaveBeenCalledWith(mockGroups); + }); + + it('should call setSearchedGroups on store if method was called with fromSearch param', () => { + jest.spyOn(vm.store, 'setSearchedGroups').mockImplementation(() => {}); + + vm.updateGroups(mockGroups, true); + + expect(vm.store.setSearchedGroups).toHaveBeenCalledWith(mockGroups); + }); + + it('should set `isSearchEmpty` prop based on groups count', () => { + vm.updateGroups(mockGroups); + + expect(vm.isSearchEmpty).toBe(false); + + vm.updateGroups([]); + + expect(vm.isSearchEmpty).toBe(true); + }); + }); + }); + + describe('created', () => { + it('should bind event listeners on eventHub', () => { + jest.spyOn(eventHub, '$on').mockImplementation(() => {}); + + const newVm = createComponent(); + newVm.$mount(); + + return vm.$nextTick().then(() => { + expect(eventHub.$on).toHaveBeenCalledWith('fetchPage', expect.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('toggleChildren', expect.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('showLeaveGroupModal', expect.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('updatePagination', expect.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('updateGroups', expect.any(Function)); + newVm.$destroy(); + }); + }); + + it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', () => { + const newVm = createComponent(); + newVm.$mount(); + return vm.$nextTick().then(() => { + expect(newVm.searchEmptyMessage).toBe('No groups or projects matched your search'); + newVm.$destroy(); + }); + }); + + it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', () => { + const newVm = createComponent(true); + newVm.$mount(); + return vm.$nextTick().then(() => { + expect(newVm.searchEmptyMessage).toBe('No groups matched your search'); + newVm.$destroy(); + }); + }); + }); + + describe('beforeDestroy', () => { + it('should unbind event listeners on eventHub', () => { + jest.spyOn(eventHub, '$off').mockImplementation(() => {}); + + const newVm = createComponent(); + newVm.$mount(); + newVm.$destroy(); + + return vm.$nextTick().then(() => { + expect(eventHub.$off).toHaveBeenCalledWith('fetchPage', expect.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('toggleChildren', expect.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('showLeaveGroupModal', expect.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('updatePagination', expect.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('updateGroups', expect.any(Function)); + }); + }); + }); + + describe('template', () => { + beforeEach(() => { + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render loading icon', () => { + vm.isLoading = true; + return vm.$nextTick().then(() => { + expect(vm.$el.querySelector('.loading-animation')).toBeDefined(); + expect(vm.$el.querySelector('span').getAttribute('aria-label')).toBe('Loading groups'); + }); + }); + + it('should render groups tree', () => { + vm.store.state.groups = [mockParentGroupItem]; + vm.isLoading = false; + return vm.$nextTick().then(() => { + expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined(); + }); + }); + + it('renders modal confirmation dialog', () => { + vm.groupLeaveConfirmationMessage = 'Are you sure you want to leave the "foo" group?'; + vm.showModal = true; + return vm.$nextTick().then(() => { + const modalDialogEl = vm.$el.querySelector('.modal'); + + expect(modalDialogEl).not.toBe(null); + expect(modalDialogEl.querySelector('.modal-title').innerText.trim()).toBe('Are you sure?'); + expect(modalDialogEl.querySelector('.btn.btn-warning').innerText.trim()).toBe('Leave'); + }); + }); + }); +}); diff --git a/spec/frontend/groups/components/group_folder_spec.js b/spec/frontend/groups/components/group_folder_spec.js new file mode 100644 index 00000000000..a40fa9bece8 --- /dev/null +++ b/spec/frontend/groups/components/group_folder_spec.js @@ -0,0 +1,65 @@ +import Vue from 'vue'; + +import groupFolderComponent from '~/groups/components/group_folder.vue'; +import groupItemComponent from '~/groups/components/group_item.vue'; +import { mockGroups, mockParentGroupItem } from '../mock_data'; + +const createComponent = (groups = mockGroups, parentGroup = mockParentGroupItem) => { + const Component = Vue.extend(groupFolderComponent); + + return new Component({ + propsData: { + groups, + parentGroup, + }, + }); +}; + +describe('GroupFolderComponent', () => { + let vm; + + beforeEach(() => { + Vue.component('group-item', groupItemComponent); + + vm = createComponent(); + vm.$mount(); + + return Vue.nextTick(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('hasMoreChildren', () => { + it('should return false when childrenCount of group is less than MAX_CHILDREN_COUNT', () => { + expect(vm.hasMoreChildren).toBeFalsy(); + }); + }); + + describe('moreChildrenStats', () => { + it('should return message with count of excess children over MAX_CHILDREN_COUNT limit', () => { + expect(vm.moreChildrenStats).toBe('3 more items'); + }); + }); + }); + + describe('template', () => { + it('should render component template correctly', () => { + expect(vm.$el.classList.contains('group-list-tree')).toBeTruthy(); + expect(vm.$el.querySelectorAll('li.group-row').length).toBe(7); + }); + + it('should render more children link when groups list has children over MAX_CHILDREN_COUNT limit', () => { + const parentGroup = { ...mockParentGroupItem }; + parentGroup.childrenCount = 21; + + const newVm = createComponent(mockGroups, parentGroup); + newVm.$mount(); + + expect(newVm.$el.querySelector('li.group-row a.has-more-items')).toBeDefined(); + newVm.$destroy(); + }); + }); +}); diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js new file mode 100644 index 00000000000..7eb1c54ddb2 --- /dev/null +++ b/spec/frontend/groups/components/group_item_spec.js @@ -0,0 +1,215 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import groupItemComponent from '~/groups/components/group_item.vue'; +import groupFolderComponent from '~/groups/components/group_folder.vue'; +import eventHub from '~/groups/event_hub'; +import * as urlUtilities from '~/lib/utils/url_utility'; +import { mockParentGroupItem, mockChildren } from '../mock_data'; + +const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => { + const Component = Vue.extend(groupItemComponent); + + return mountComponent(Component, { + group, + parentGroup, + }); +}; + +describe('GroupItemComponent', () => { + let vm; + + beforeEach(() => { + Vue.component('group-folder', groupFolderComponent); + + vm = createComponent(); + + return Vue.nextTick(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('groupDomId', () => { + it('should return ID string suffixed with group ID', () => { + expect(vm.groupDomId).toBe('group-55'); + }); + }); + + describe('rowClass', () => { + it('should return map of classes based on group details', () => { + const classes = ['is-open', 'has-children', 'has-description', 'being-removed']; + const { rowClass } = vm; + + expect(Object.keys(rowClass).length).toBe(classes.length); + Object.keys(rowClass).forEach(className => { + expect(classes.indexOf(className)).toBeGreaterThan(-1); + }); + }); + }); + + describe('hasChildren', () => { + it('should return boolean value representing if group has any children present', () => { + let newVm; + const group = { ...mockParentGroupItem }; + + group.childrenCount = 5; + newVm = createComponent(group); + + expect(newVm.hasChildren).toBeTruthy(); + newVm.$destroy(); + + group.childrenCount = 0; + newVm = createComponent(group); + + expect(newVm.hasChildren).toBeFalsy(); + newVm.$destroy(); + }); + }); + + describe('hasAvatar', () => { + it('should return boolean value representing if group has any avatar present', () => { + let newVm; + const group = { ...mockParentGroupItem }; + + group.avatarUrl = null; + newVm = createComponent(group); + + expect(newVm.hasAvatar).toBeFalsy(); + newVm.$destroy(); + + group.avatarUrl = '/uploads/group_avatar.png'; + newVm = createComponent(group); + + expect(newVm.hasAvatar).toBeTruthy(); + newVm.$destroy(); + }); + }); + + describe('isGroup', () => { + it('should return boolean value representing if group item is of type `group` or not', () => { + let newVm; + const group = { ...mockParentGroupItem }; + + group.type = 'group'; + newVm = createComponent(group); + + expect(newVm.isGroup).toBeTruthy(); + newVm.$destroy(); + + group.type = 'project'; + newVm = createComponent(group); + + expect(newVm.isGroup).toBeFalsy(); + newVm.$destroy(); + }); + }); + }); + + describe('methods', () => { + describe('onClickRowGroup', () => { + let event; + + beforeEach(() => { + const classList = { + contains() { + return false; + }, + }; + + event = { + target: { + classList, + parentElement: { + classList, + }, + }, + }; + }); + + it('should emit `toggleChildren` event when expand is clicked on a group and it has children present', () => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + vm.onClickRowGroup(event); + + expect(eventHub.$emit).toHaveBeenCalledWith('toggleChildren', vm.group); + }); + + it('should navigate page to group homepage if group does not have any children present', () => { + jest.spyOn(urlUtilities, 'visitUrl').mockImplementation(); + const group = { ...mockParentGroupItem }; + group.childrenCount = 0; + const newVm = createComponent(group); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + newVm.onClickRowGroup(event); + + expect(eventHub.$emit).not.toHaveBeenCalled(); + expect(urlUtilities.visitUrl).toHaveBeenCalledWith(newVm.group.relativePath); + }); + }); + }); + + describe('template', () => { + let group = null; + + describe('for a group pending deletion', () => { + beforeEach(() => { + group = { ...mockParentGroupItem, pendingRemoval: true }; + vm = createComponent(group); + }); + + it('renders the group pending removal badge', () => { + const badgeEl = vm.$el.querySelector('.badge-warning'); + + expect(badgeEl).toBeDefined(); + expect(badgeEl.innerHTML).toContain('pending removal'); + }); + }); + + describe('for a group not scheduled for deletion', () => { + beforeEach(() => { + group = { ...mockParentGroupItem, pendingRemoval: false }; + vm = createComponent(group); + }); + + it('does not render the group pending removal badge', () => { + const groupTextContainer = vm.$el.querySelector('.group-text-container'); + + expect(groupTextContainer).not.toContain('pending removal'); + }); + }); + + it('should render component template correctly', () => { + const visibilityIconEl = vm.$el.querySelector('.item-visibility'); + + expect(vm.$el.getAttribute('id')).toBe('group-55'); + expect(vm.$el.classList.contains('group-row')).toBeTruthy(); + + expect(vm.$el.querySelector('.group-row-contents')).toBeDefined(); + expect(vm.$el.querySelector('.group-row-contents .controls')).toBeDefined(); + expect(vm.$el.querySelector('.group-row-contents .stats')).toBeDefined(); + + expect(vm.$el.querySelector('.folder-toggle-wrap')).toBeDefined(); + expect(vm.$el.querySelector('.folder-toggle-wrap .folder-caret')).toBeDefined(); + expect(vm.$el.querySelector('.folder-toggle-wrap .item-type-icon')).toBeDefined(); + + expect(vm.$el.querySelector('.avatar-container')).toBeDefined(); + expect(vm.$el.querySelector('.avatar-container a.no-expand')).toBeDefined(); + expect(vm.$el.querySelector('.avatar-container .avatar')).toBeDefined(); + + expect(vm.$el.querySelector('.title')).toBeDefined(); + expect(vm.$el.querySelector('.title a.no-expand')).toBeDefined(); + + expect(visibilityIconEl).not.toBe(null); + expect(visibilityIconEl.dataset.originalTitle).toBe(vm.visibilityTooltip); + expect(visibilityIconEl.querySelectorAll('svg').length).toBeGreaterThan(0); + + expect(vm.$el.querySelector('.access-type')).toBeDefined(); + expect(vm.$el.querySelector('.description')).toBeDefined(); + + expect(vm.$el.querySelector('.group-list-tree')).toBeDefined(); + }); + }); +}); diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js new file mode 100644 index 00000000000..6205400eb03 --- /dev/null +++ b/spec/frontend/groups/components/groups_spec.js @@ -0,0 +1,72 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import groupsComponent from '~/groups/components/groups.vue'; +import groupFolderComponent from '~/groups/components/group_folder.vue'; +import groupItemComponent from '~/groups/components/group_item.vue'; +import eventHub from '~/groups/event_hub'; +import { mockGroups, mockPageInfo } from '../mock_data'; + +const createComponent = (searchEmpty = false) => { + const Component = Vue.extend(groupsComponent); + + return mountComponent(Component, { + groups: mockGroups, + pageInfo: mockPageInfo, + searchEmptyMessage: 'No matching results', + searchEmpty, + }); +}; + +describe('GroupsComponent', () => { + let vm; + + beforeEach(() => { + Vue.component('group-folder', groupFolderComponent); + Vue.component('group-item', groupItemComponent); + + vm = createComponent(); + + return vm.$nextTick(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('methods', () => { + describe('change', () => { + it('should emit `fetchPage` event when page is changed via pagination', () => { + jest.spyOn(eventHub, '$emit').mockImplementation(); + + vm.change(2); + + expect(eventHub.$emit).toHaveBeenCalledWith( + 'fetchPage', + 2, + expect.any(Object), + expect.any(Object), + expect.any(Object), + ); + }); + }); + }); + + describe('template', () => { + it('should render component template correctly', () => { + return vm.$nextTick().then(() => { + expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined(); + expect(vm.$el.querySelector('.group-list-tree')).toBeDefined(); + expect(vm.$el.querySelector('.gl-pagination')).toBeDefined(); + expect(vm.$el.querySelectorAll('.has-no-search-results').length).toBe(0); + }); + }); + + it('should render empty search message when `searchEmpty` is `true`', () => { + vm.searchEmpty = true; + return vm.$nextTick().then(() => { + expect(vm.$el.querySelector('.has-no-search-results')).toBeDefined(); + }); + }); + }); +}); diff --git a/spec/frontend/groups/components/item_actions_spec.js b/spec/frontend/groups/components/item_actions_spec.js new file mode 100644 index 00000000000..c0dc1a816e6 --- /dev/null +++ b/spec/frontend/groups/components/item_actions_spec.js @@ -0,0 +1,84 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import itemActionsComponent from '~/groups/components/item_actions.vue'; +import eventHub from '~/groups/event_hub'; +import { mockParentGroupItem, mockChildren } from '../mock_data'; + +const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => { + const Component = Vue.extend(itemActionsComponent); + + return mountComponent(Component, { + group, + parentGroup, + }); +}; + +describe('ItemActionsComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('methods', () => { + describe('onLeaveGroup', () => { + it('emits `showLeaveGroupModal` event with `group` and `parentGroup` props', () => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + vm.onLeaveGroup(); + + expect(eventHub.$emit).toHaveBeenCalledWith( + 'showLeaveGroupModal', + vm.group, + vm.parentGroup, + ); + }); + }); + }); + + describe('template', () => { + it('should render component template correctly', () => { + expect(vm.$el.classList.contains('controls')).toBeTruthy(); + }); + + it('should render Edit Group button with correct attribute values', () => { + const group = { ...mockParentGroupItem }; + group.canEdit = true; + const newVm = createComponent(group); + + const editBtn = newVm.$el.querySelector('a.edit-group'); + + expect(editBtn).toBeDefined(); + expect(editBtn.classList.contains('no-expand')).toBeTruthy(); + expect(editBtn.getAttribute('href')).toBe(group.editPath); + expect(editBtn.getAttribute('aria-label')).toBe('Edit group'); + expect(editBtn.dataset.originalTitle).toBe('Edit group'); + expect(editBtn.querySelectorAll('svg use').length).not.toBe(0); + expect(editBtn.querySelector('svg use').getAttribute('xlink:href')).toContain('#settings'); + + newVm.$destroy(); + }); + + it('should render Leave Group button with correct attribute values', () => { + const group = { ...mockParentGroupItem }; + group.canLeave = true; + const newVm = createComponent(group); + + const leaveBtn = newVm.$el.querySelector('a.leave-group'); + + expect(leaveBtn).toBeDefined(); + expect(leaveBtn.classList.contains('no-expand')).toBeTruthy(); + expect(leaveBtn.getAttribute('href')).toBe(group.leavePath); + expect(leaveBtn.getAttribute('aria-label')).toBe('Leave this group'); + expect(leaveBtn.dataset.originalTitle).toBe('Leave this group'); + expect(leaveBtn.querySelectorAll('svg use').length).not.toBe(0); + expect(leaveBtn.querySelector('svg use').getAttribute('xlink:href')).toContain('#leave'); + + newVm.$destroy(); + }); + }); +}); diff --git a/spec/frontend/groups/components/item_caret_spec.js b/spec/frontend/groups/components/item_caret_spec.js new file mode 100644 index 00000000000..bfe27be9b51 --- /dev/null +++ b/spec/frontend/groups/components/item_caret_spec.js @@ -0,0 +1,38 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import itemCaretComponent from '~/groups/components/item_caret.vue'; + +const createComponent = (isGroupOpen = false) => { + const Component = Vue.extend(itemCaretComponent); + + return mountComponent(Component, { + isGroupOpen, + }); +}; + +describe('ItemCaretComponent', () => { + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + describe('template', () => { + it('should render component template correctly', () => { + vm = createComponent(); + expect(vm.$el.classList.contains('folder-caret')).toBeTruthy(); + expect(vm.$el.querySelectorAll('svg').length).toBe(1); + }); + + it('should render caret down icon if `isGroupOpen` prop is `true`', () => { + vm = createComponent(true); + expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('angle-down'); + }); + + it('should render caret right icon if `isGroupOpen` prop is `false`', () => { + vm = createComponent(); + expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('angle-right'); + }); + }); +}); diff --git a/spec/frontend/groups/components/item_stats_spec.js b/spec/frontend/groups/components/item_stats_spec.js new file mode 100644 index 00000000000..771643609ec --- /dev/null +++ b/spec/frontend/groups/components/item_stats_spec.js @@ -0,0 +1,119 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import itemStatsComponent from '~/groups/components/item_stats.vue'; +import { + mockParentGroupItem, + ITEM_TYPE, + VISIBILITY_TYPE_ICON, + GROUP_VISIBILITY_TYPE, + PROJECT_VISIBILITY_TYPE, +} from '../mock_data'; + +const createComponent = (item = mockParentGroupItem) => { + const Component = Vue.extend(itemStatsComponent); + + return mountComponent(Component, { + item, + }); +}; + +describe('ItemStatsComponent', () => { + describe('computed', () => { + describe('visibilityIcon', () => { + it('should return icon class based on `item.visibility` value', () => { + Object.keys(VISIBILITY_TYPE_ICON).forEach(visibility => { + const item = { ...mockParentGroupItem, visibility }; + const vm = createComponent(item); + + expect(vm.visibilityIcon).toBe(VISIBILITY_TYPE_ICON[visibility]); + vm.$destroy(); + }); + }); + }); + + describe('visibilityTooltip', () => { + it('should return tooltip string for Group based on `item.visibility` value', () => { + Object.keys(GROUP_VISIBILITY_TYPE).forEach(visibility => { + const item = { ...mockParentGroupItem, visibility, type: ITEM_TYPE.GROUP }; + const vm = createComponent(item); + + expect(vm.visibilityTooltip).toBe(GROUP_VISIBILITY_TYPE[visibility]); + vm.$destroy(); + }); + }); + + it('should return tooltip string for Project based on `item.visibility` value', () => { + Object.keys(PROJECT_VISIBILITY_TYPE).forEach(visibility => { + const item = { ...mockParentGroupItem, visibility, type: ITEM_TYPE.PROJECT }; + const vm = createComponent(item); + + expect(vm.visibilityTooltip).toBe(PROJECT_VISIBILITY_TYPE[visibility]); + vm.$destroy(); + }); + }); + }); + + describe('isProject', () => { + it('should return boolean value representing whether `item.type` is Project or not', () => { + let item; + let vm; + + item = { ...mockParentGroupItem, type: ITEM_TYPE.PROJECT }; + vm = createComponent(item); + + expect(vm.isProject).toBeTruthy(); + vm.$destroy(); + + item = { ...mockParentGroupItem, type: ITEM_TYPE.GROUP }; + vm = createComponent(item); + + expect(vm.isProject).toBeFalsy(); + vm.$destroy(); + }); + }); + + describe('isGroup', () => { + it('should return boolean value representing whether `item.type` is Group or not', () => { + let item; + let vm; + + item = { ...mockParentGroupItem, type: ITEM_TYPE.GROUP }; + vm = createComponent(item); + + expect(vm.isGroup).toBeTruthy(); + vm.$destroy(); + + item = { ...mockParentGroupItem, type: ITEM_TYPE.PROJECT }; + vm = createComponent(item); + + expect(vm.isGroup).toBeFalsy(); + vm.$destroy(); + }); + }); + }); + + describe('template', () => { + it('renders component container element correctly', () => { + const vm = createComponent(); + + expect(vm.$el.classList.contains('stats')).toBeTruthy(); + + vm.$destroy(); + }); + + it('renders start count and last updated information for project item correctly', () => { + const item = { ...mockParentGroupItem, type: ITEM_TYPE.PROJECT, starCount: 4 }; + const vm = createComponent(item); + + const projectStarIconEl = vm.$el.querySelector('.project-stars'); + + expect(projectStarIconEl).not.toBeNull(); + expect(projectStarIconEl.querySelectorAll('svg').length).toBeGreaterThan(0); + expect(projectStarIconEl.querySelectorAll('.stat-value').length).toBeGreaterThan(0); + expect(vm.$el.querySelectorAll('.last-updated').length).toBeGreaterThan(0); + + vm.$destroy(); + }); + }); +}); diff --git a/spec/frontend/groups/components/item_stats_value_spec.js b/spec/frontend/groups/components/item_stats_value_spec.js new file mode 100644 index 00000000000..da6f145fa19 --- /dev/null +++ b/spec/frontend/groups/components/item_stats_value_spec.js @@ -0,0 +1,82 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import itemStatsValueComponent from '~/groups/components/item_stats_value.vue'; + +const createComponent = ({ title, cssClass, iconName, tooltipPlacement, value }) => { + const Component = Vue.extend(itemStatsValueComponent); + + return mountComponent(Component, { + title, + cssClass, + iconName, + tooltipPlacement, + value, + }); +}; + +describe('ItemStatsValueComponent', () => { + describe('computed', () => { + let vm; + const itemConfig = { + title: 'Subgroups', + cssClass: 'number-subgroups', + iconName: 'folder', + tooltipPlacement: 'left', + }; + + describe('isValuePresent', () => { + it('returns true if non-empty `value` is present', () => { + vm = createComponent({ ...itemConfig, value: 10 }); + + expect(vm.isValuePresent).toBeTruthy(); + }); + + it('returns false if empty `value` is present', () => { + vm = createComponent(itemConfig); + + expect(vm.isValuePresent).toBeFalsy(); + }); + + afterEach(() => { + vm.$destroy(); + }); + }); + }); + + describe('template', () => { + let vm; + beforeEach(() => { + vm = createComponent({ + title: 'Subgroups', + cssClass: 'number-subgroups', + iconName: 'folder', + tooltipPlacement: 'left', + value: 10, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders component element correctly', () => { + expect(vm.$el.classList.contains('number-subgroups')).toBeTruthy(); + expect(vm.$el.querySelectorAll('svg').length).toBeGreaterThan(0); + expect(vm.$el.querySelectorAll('.stat-value').length).toBeGreaterThan(0); + }); + + it('renders element tooltip correctly', () => { + expect(vm.$el.dataset.originalTitle).toBe('Subgroups'); + expect(vm.$el.dataset.placement).toBe('left'); + }); + + it('renders element icon correctly', () => { + expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('folder'); + }); + + it('renders value count correctly', () => { + expect(vm.$el.querySelector('.stat-value').innerText.trim()).toContain('10'); + }); + }); +}); diff --git a/spec/frontend/groups/components/item_type_icon_spec.js b/spec/frontend/groups/components/item_type_icon_spec.js new file mode 100644 index 00000000000..251b5b5ff4c --- /dev/null +++ b/spec/frontend/groups/components/item_type_icon_spec.js @@ -0,0 +1,53 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import itemTypeIconComponent from '~/groups/components/item_type_icon.vue'; +import { ITEM_TYPE } from '../mock_data'; + +const createComponent = (itemType = ITEM_TYPE.GROUP, isGroupOpen = false) => { + const Component = Vue.extend(itemTypeIconComponent); + + return mountComponent(Component, { + itemType, + isGroupOpen, + }); +}; + +describe('ItemTypeIconComponent', () => { + describe('template', () => { + it('should render component template correctly', () => { + const vm = createComponent(); + + expect(vm.$el.classList.contains('item-type-icon')).toBeTruthy(); + vm.$destroy(); + }); + + it('should render folder open or close icon based `isGroupOpen` prop value', () => { + let vm; + + vm = createComponent(ITEM_TYPE.GROUP, true); + + expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder-open'); + vm.$destroy(); + + vm = createComponent(ITEM_TYPE.GROUP); + + expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder'); + vm.$destroy(); + }); + + it('should render bookmark icon based on `isProject` prop value', () => { + let vm; + + vm = createComponent(ITEM_TYPE.PROJECT); + + expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('bookmark'); + vm.$destroy(); + + vm = createComponent(ITEM_TYPE.GROUP); + + expect(vm.$el.querySelector('use').getAttribute('xlink:href')).not.toContain('bookmark'); + vm.$destroy(); + }); + }); +}); diff --git a/spec/frontend/groups/mock_data.js b/spec/frontend/groups/mock_data.js new file mode 100644 index 00000000000..380dda9f7b1 --- /dev/null +++ b/spec/frontend/groups/mock_data.js @@ -0,0 +1,398 @@ +export const mockEndpoint = '/dashboard/groups.json'; + +export const ITEM_TYPE = { + PROJECT: 'project', + GROUP: 'group', +}; + +export const GROUP_VISIBILITY_TYPE = { + public: 'Public - The group and any public projects can be viewed without any authentication.', + internal: 'Internal - The group and any internal projects can be viewed by any logged in user.', + private: 'Private - The group and its projects can only be viewed by members.', +}; + +export const PROJECT_VISIBILITY_TYPE = { + public: 'Public - The project can be accessed without any authentication.', + internal: 'Internal - The project can be accessed by any logged in user.', + private: + 'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.', +}; + +export const VISIBILITY_TYPE_ICON = { + public: 'earth', + internal: 'shield', + private: 'lock', +}; + +export const mockParentGroupItem = { + id: 55, + name: 'hardware', + description: '', + visibility: 'public', + fullName: 'platform / hardware', + relativePath: '/platform/hardware', + canEdit: true, + type: 'group', + avatarUrl: null, + permission: 'Owner', + editPath: '/groups/platform/hardware/edit', + childrenCount: 3, + leavePath: '/groups/platform/hardware/group_members/leave', + parentId: 54, + memberCount: '1', + projectCount: 1, + subgroupCount: 2, + canLeave: false, + children: [], + isOpen: true, + isChildrenLoading: false, + isBeingRemoved: false, + updatedAt: '2017-04-09T18:40:39.101Z', +}; + +export const mockRawChildren = [ + { + id: 57, + name: 'bsp', + description: '', + visibility: 'public', + full_name: 'platform / hardware / bsp', + relative_path: '/platform/hardware/bsp', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/hardware/bsp/edit', + children_count: 6, + leave_path: '/groups/platform/hardware/bsp/group_members/leave', + parent_id: 55, + number_users_with_delimiter: '1', + project_count: 4, + subgroup_count: 2, + can_leave: false, + children: [], + updated_at: '2017-04-09T18:40:39.101Z', + }, +]; + +export const mockChildren = [ + { + id: 57, + name: 'bsp', + description: '', + visibility: 'public', + fullName: 'platform / hardware / bsp', + relativePath: '/platform/hardware/bsp', + canEdit: true, + type: 'group', + avatarUrl: null, + permission: 'Owner', + editPath: '/groups/platform/hardware/bsp/edit', + childrenCount: 6, + leavePath: '/groups/platform/hardware/bsp/group_members/leave', + parentId: 55, + memberCount: '1', + projectCount: 4, + subgroupCount: 2, + canLeave: false, + children: [], + isOpen: true, + isChildrenLoading: false, + isBeingRemoved: false, + updatedAt: '2017-04-09T18:40:39.101Z', + }, +]; + +export const mockGroups = [ + { + id: 75, + name: 'test-group', + description: '', + visibility: 'public', + full_name: 'test-group', + relative_path: '/test-group', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/test-group/edit', + children_count: 2, + leave_path: '/groups/test-group/group_members/leave', + parent_id: null, + number_users_with_delimiter: '1', + project_count: 2, + subgroup_count: 0, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + }, + { + id: 67, + name: 'open-source', + description: '', + visibility: 'private', + full_name: 'open-source', + relative_path: '/open-source', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/open-source/edit', + children_count: 0, + leave_path: '/groups/open-source/group_members/leave', + parent_id: null, + number_users_with_delimiter: '1', + project_count: 0, + subgroup_count: 0, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + }, + { + id: 54, + name: 'platform', + description: '', + visibility: 'public', + full_name: 'platform', + relative_path: '/platform', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/edit', + children_count: 1, + leave_path: '/groups/platform/group_members/leave', + parent_id: null, + number_users_with_delimiter: '1', + project_count: 0, + subgroup_count: 1, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + }, + { + id: 5, + name: 'H5bp', + description: 'Minus dolor consequuntur qui nam recusandae quam incidunt.', + visibility: 'public', + full_name: 'H5bp', + relative_path: '/h5bp', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/h5bp/edit', + children_count: 1, + leave_path: '/groups/h5bp/group_members/leave', + parent_id: null, + number_users_with_delimiter: '5', + project_count: 1, + subgroup_count: 0, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + }, + { + id: 4, + name: 'Twitter', + description: 'Deserunt hic nostrum placeat veniam.', + visibility: 'public', + full_name: 'Twitter', + relative_path: '/twitter', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/twitter/edit', + children_count: 2, + leave_path: '/groups/twitter/group_members/leave', + parent_id: null, + number_users_with_delimiter: '5', + project_count: 2, + subgroup_count: 0, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + }, + { + id: 3, + name: 'Documentcloud', + description: 'Consequatur saepe totam ea pariatur maxime.', + visibility: 'public', + full_name: 'Documentcloud', + relative_path: '/documentcloud', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/documentcloud/edit', + children_count: 1, + leave_path: '/groups/documentcloud/group_members/leave', + parent_id: null, + number_users_with_delimiter: '5', + project_count: 1, + subgroup_count: 0, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + }, + { + id: 2, + name: 'Gitlab Org', + description: 'Debitis ea quas aperiam velit doloremque ab.', + visibility: 'public', + full_name: 'Gitlab Org', + relative_path: '/gitlab-org', + can_edit: true, + type: 'group', + avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png', + permission: 'Owner', + edit_path: '/groups/gitlab-org/edit', + children_count: 4, + leave_path: '/groups/gitlab-org/group_members/leave', + parent_id: null, + number_users_with_delimiter: '5', + project_count: 4, + subgroup_count: 0, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + }, +]; + +export const mockSearchedGroups = [ + { + id: 55, + name: 'hardware', + description: '', + visibility: 'public', + full_name: 'platform / hardware', + relative_path: '/platform/hardware', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/hardware/edit', + children_count: 3, + leave_path: '/groups/platform/hardware/group_members/leave', + parent_id: 54, + number_users_with_delimiter: '1', + project_count: 1, + subgroup_count: 2, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + children: [ + { + id: 57, + name: 'bsp', + description: '', + visibility: 'public', + full_name: 'platform / hardware / bsp', + relative_path: '/platform/hardware/bsp', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/hardware/bsp/edit', + children_count: 6, + leave_path: '/groups/platform/hardware/bsp/group_members/leave', + parent_id: 55, + number_users_with_delimiter: '1', + project_count: 4, + subgroup_count: 2, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + children: [ + { + id: 60, + name: 'kernel', + description: '', + visibility: 'public', + full_name: 'platform / hardware / bsp / kernel', + relative_path: '/platform/hardware/bsp/kernel', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/hardware/bsp/kernel/edit', + children_count: 1, + leave_path: '/groups/platform/hardware/bsp/kernel/group_members/leave', + parent_id: 57, + number_users_with_delimiter: '1', + project_count: 0, + subgroup_count: 1, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + children: [ + { + id: 61, + name: 'common', + description: '', + visibility: 'public', + full_name: 'platform / hardware / bsp / kernel / common', + relative_path: '/platform/hardware/bsp/kernel/common', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/hardware/bsp/kernel/common/edit', + children_count: 2, + leave_path: '/groups/platform/hardware/bsp/kernel/common/group_members/leave', + parent_id: 60, + number_users_with_delimiter: '1', + project_count: 2, + subgroup_count: 0, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + children: [ + { + id: 17, + name: 'v4.4', + description: + 'Voluptatem qui ea error aperiam veritatis doloremque consequatur temporibus.', + visibility: 'public', + full_name: 'platform / hardware / bsp / kernel / common / v4.4', + relative_path: '/platform/hardware/bsp/kernel/common/v4.4', + can_edit: true, + type: 'project', + avatar_url: null, + permission: null, + edit_path: '/platform/hardware/bsp/kernel/common/v4.4/edit', + star_count: 0, + updated_at: '2017-09-12T06:37:04.925Z', + }, + { + id: 16, + name: 'v4.1', + description: 'Rerum expedita voluptatem doloribus neque ducimus ut hic.', + visibility: 'public', + full_name: 'platform / hardware / bsp / kernel / common / v4.1', + relative_path: '/platform/hardware/bsp/kernel/common/v4.1', + can_edit: true, + type: 'project', + avatar_url: null, + permission: null, + edit_path: '/platform/hardware/bsp/kernel/common/v4.1/edit', + star_count: 0, + updated_at: '2017-04-09T18:41:03.112Z', + }, + ], + }, + ], + }, + ], + }, + ], + }, +]; + +export const mockRawPageInfo = { + 'x-per-page': 10, + 'x-page': 10, + 'x-total': 10, + 'x-total-pages': 10, + 'x-next-page': 10, + 'x-prev-page': 10, +}; + +export const mockPageInfo = { + perPage: 10, + page: 10, + total: 10, + totalPages: 10, + nextPage: 10, + prevPage: 10, +}; diff --git a/spec/frontend/groups/service/groups_service_spec.js b/spec/frontend/groups/service/groups_service_spec.js new file mode 100644 index 00000000000..38a565eba01 --- /dev/null +++ b/spec/frontend/groups/service/groups_service_spec.js @@ -0,0 +1,42 @@ +import axios from '~/lib/utils/axios_utils'; + +import GroupsService from '~/groups/service/groups_service'; +import { mockEndpoint, mockParentGroupItem } from '../mock_data'; + +describe('GroupsService', () => { + let service; + + beforeEach(() => { + service = new GroupsService(mockEndpoint); + }); + + describe('getGroups', () => { + it('should return promise for `GET` request on provided endpoint', () => { + jest.spyOn(axios, 'get').mockResolvedValue(); + const params = { + page: 2, + filter: 'git', + sort: 'created_asc', + archived: true, + }; + + service.getGroups(55, 2, 'git', 'created_asc', true); + + expect(axios.get).toHaveBeenCalledWith(mockEndpoint, { params: { parent_id: 55 } }); + + service.getGroups(null, 2, 'git', 'created_asc', true); + + expect(axios.get).toHaveBeenCalledWith(mockEndpoint, { params }); + }); + }); + + describe('leaveGroup', () => { + it('should return promise for `DELETE` request on provided endpoint', () => { + jest.spyOn(axios, 'delete').mockResolvedValue(); + + service.leaveGroup(mockParentGroupItem.leavePath); + + expect(axios.delete).toHaveBeenCalledWith(mockParentGroupItem.leavePath); + }); + }); +}); diff --git a/spec/frontend/groups/store/groups_store_spec.js b/spec/frontend/groups/store/groups_store_spec.js new file mode 100644 index 00000000000..7d12f73d270 --- /dev/null +++ b/spec/frontend/groups/store/groups_store_spec.js @@ -0,0 +1,123 @@ +import GroupsStore from '~/groups/store/groups_store'; +import { + mockGroups, + mockSearchedGroups, + mockParentGroupItem, + mockRawChildren, + mockRawPageInfo, +} from '../mock_data'; + +describe('ProjectsStore', () => { + describe('constructor', () => { + it('should initialize default state', () => { + let store; + + store = new GroupsStore(); + + expect(Object.keys(store.state).length).toBe(2); + expect(Array.isArray(store.state.groups)).toBeTruthy(); + expect(Object.keys(store.state.pageInfo).length).toBe(0); + expect(store.hideProjects).not.toBeDefined(); + + store = new GroupsStore(true); + + expect(store.hideProjects).toBeTruthy(); + }); + }); + + describe('setGroups', () => { + it('should set groups to state', () => { + const store = new GroupsStore(); + jest.spyOn(store, 'formatGroupItem'); + + store.setGroups(mockGroups); + + expect(store.state.groups.length).toBe(mockGroups.length); + expect(store.formatGroupItem).toHaveBeenCalledWith(expect.any(Object)); + expect(Object.keys(store.state.groups[0]).indexOf('fullName')).toBeGreaterThan(-1); + }); + }); + + describe('setSearchedGroups', () => { + it('should set searched groups to state', () => { + const store = new GroupsStore(); + jest.spyOn(store, 'formatGroupItem'); + + store.setSearchedGroups(mockSearchedGroups); + + expect(store.state.groups.length).toBe(mockSearchedGroups.length); + expect(store.formatGroupItem).toHaveBeenCalledWith(expect.any(Object)); + expect(Object.keys(store.state.groups[0]).indexOf('fullName')).toBeGreaterThan(-1); + expect(Object.keys(store.state.groups[0].children[0]).indexOf('fullName')).toBeGreaterThan( + -1, + ); + }); + }); + + describe('setGroupChildren', () => { + it('should set children to group item in state', () => { + const store = new GroupsStore(); + jest.spyOn(store, 'formatGroupItem'); + + store.setGroupChildren(mockParentGroupItem, mockRawChildren); + + expect(store.formatGroupItem).toHaveBeenCalledWith(expect.any(Object)); + expect(mockParentGroupItem.children.length).toBe(1); + expect(Object.keys(mockParentGroupItem.children[0]).indexOf('fullName')).toBeGreaterThan(-1); + expect(mockParentGroupItem.isOpen).toBeTruthy(); + expect(mockParentGroupItem.isChildrenLoading).toBeFalsy(); + }); + }); + + describe('setPaginationInfo', () => { + it('should parse and set pagination info in state', () => { + const store = new GroupsStore(); + + store.setPaginationInfo(mockRawPageInfo); + + expect(store.state.pageInfo.perPage).toBe(10); + expect(store.state.pageInfo.page).toBe(10); + expect(store.state.pageInfo.total).toBe(10); + expect(store.state.pageInfo.totalPages).toBe(10); + expect(store.state.pageInfo.nextPage).toBe(10); + expect(store.state.pageInfo.previousPage).toBe(10); + }); + }); + + describe('formatGroupItem', () => { + it('should parse group item object and return updated object', () => { + let store; + let updatedGroupItem; + + store = new GroupsStore(); + updatedGroupItem = store.formatGroupItem(mockRawChildren[0]); + + expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1); + expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].children_count); + expect(updatedGroupItem.isChildrenLoading).toBe(false); + expect(updatedGroupItem.isBeingRemoved).toBe(false); + + store = new GroupsStore(true); + updatedGroupItem = store.formatGroupItem(mockRawChildren[0]); + + expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1); + expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].subgroup_count); + }); + }); + + describe('removeGroup', () => { + it('should remove children from group item in state', () => { + const store = new GroupsStore(); + const rawParentGroup = { ...mockGroups[0] }; + const rawChildGroup = { ...mockGroups[1] }; + + store.setGroups([rawParentGroup]); + store.setGroupChildren(store.state.groups[0], [rawChildGroup]); + const childItem = store.state.groups[0].children[0]; + + store.removeGroup(childItem, store.state.groups[0]); + + expect(store.state.groups[0].children.length).toBe(0); + }); + }); +}); diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js index 0a74799283a..6d2d7976196 100644 --- a/spec/frontend/header_spec.js +++ b/spec/frontend/header_spec.js @@ -60,8 +60,8 @@ describe('Header', () => { beforeEach(() => { setFixtures(` <li class="js-nav-user-dropdown"> - <a class="js-buy-ci-minutes-link" data-track-event="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy CI minutes - </a> + <a class="js-buy-ci-minutes-link" data-track-event="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy CI minutes</a> + <a class="js-upgrade-plan-link" data-track-event="click_upgrade_link" data-track-label="free" data-track-property="user_dropdown">Upgrade</a> </li>`); trackingSpy = mockTracking('_category_', $('.js-nav-user-dropdown').element, jest.spyOn); @@ -77,8 +77,16 @@ describe('Header', () => { it('sends a tracking event when the dropdown is opened and contains Buy CI minutes link', () => { $('.js-nav-user-dropdown').trigger('shown.bs.dropdown'); - expect(trackingSpy).toHaveBeenCalledTimes(1); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'show_buy_ci_minutes', { + expect(trackingSpy).toHaveBeenCalledWith('some:page', 'show_buy_ci_minutes', { + label: 'free', + property: 'user_dropdown', + }); + }); + + it('sends a tracking event when the dropdown is opened and contains Upgrade link', () => { + $('.js-nav-user-dropdown').trigger('shown.bs.dropdown'); + + expect(trackingSpy).toHaveBeenCalledWith('some:page', 'show_upgrade_link', { label: 'free', property: 'user_dropdown', }); diff --git a/spec/frontend/helpers/class_spec_helper.js b/spec/frontend/helpers/class_spec_helper.js index 7a60d33b471..b26f087f0c5 100644 --- a/spec/frontend/helpers/class_spec_helper.js +++ b/spec/frontend/helpers/class_spec_helper.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line jest/no-export export default class ClassSpecHelper { static itShouldBeAStaticMethod(base, method) { return it('should be a static method', () => { diff --git a/spec/frontend/helpers/event_hub_factory_spec.js b/spec/frontend/helpers/event_hub_factory_spec.js new file mode 100644 index 00000000000..dcfec6b836a --- /dev/null +++ b/spec/frontend/helpers/event_hub_factory_spec.js @@ -0,0 +1,94 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +describe('event bus factory', () => { + let eventBus; + + beforeEach(() => { + eventBus = createEventHub(); + }); + + afterEach(() => { + eventBus = null; + }); + + describe('underlying module', () => { + let mitt; + + 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(); + }); + + it('creates an emitter', () => { + expect(mitt).toHaveBeenCalled(); + }); + }); + + 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}`]); + }); + }); + + describe('once', () => { + const event = 'foobar'; + let handler; + + beforeEach(() => { + jest.spyOn(eventBus, 'on'); + jest.spyOn(eventBus, 'off'); + handler = jest.fn(); + eventBus.once(event, handler); + }); + + it('calls on internally', () => { + expect(eventBus.on).toHaveBeenCalled(); + }); + + it('calls handler when event is emitted', () => { + eventBus.emit(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); + expect(handler).toHaveBeenCalledTimes(1); + }); + + describe('when the handler thows an error', () => { + beforeEach(() => { + handler = jest.fn().mockImplementation(() => { + throw new Error(); + }); + eventBus.once(event, handler); + }); + + it('calls off when event is emitted', () => { + expect(() => { + eventBus.emit(event); + }).toThrow(); + expect(eventBus.off).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/helpers/filtered_search_spec_helper.js b/spec/frontend/helpers/filtered_search_spec_helper.js new file mode 100644 index 00000000000..ceb7982bbc3 --- /dev/null +++ b/spec/frontend/helpers/filtered_search_spec_helper.js @@ -0,0 +1,69 @@ +export default class FilteredSearchSpecHelper { + static createFilterVisualTokenHTML(name, operator, value, isSelected) { + return FilteredSearchSpecHelper.createFilterVisualToken(name, operator, value, isSelected) + .outerHTML; + } + + static createFilterVisualToken(name, operator, value, isSelected = false) { + const li = document.createElement('li'); + li.classList.add('js-visual-token', 'filtered-search-token', `search-token-${name}`); + + li.innerHTML = ` + <div class="selectable ${isSelected ? 'selected' : ''}" role="button"> + <div class="name">${name}</div> + <div class="operator">${operator}</div> + <div class="value-container"> + <div class="value">${value}</div> + <div class="remove-token" role="button"> + <i class="fa fa-close"></i> + </div> + </div> + </div> + `; + + return li; + } + + static createNameFilterVisualTokenHTML(name) { + return ` + <li class="js-visual-token filtered-search-token"> + <div class="name">${name}</div> + </li> + `; + } + + static createNameOperatorFilterVisualTokenHTML(name, operator) { + return ` + <li class="js-visual-token filtered-search-token"> + <div class="name">${name}</div> + <div class="operator">${operator}</div> + </li> + `; + } + + static createSearchVisualToken(name) { + const li = document.createElement('li'); + li.classList.add('js-visual-token', 'filtered-search-term'); + li.innerHTML = `<div class="name">${name}</div>`; + return li; + } + + static createSearchVisualTokenHTML(name) { + return FilteredSearchSpecHelper.createSearchVisualToken(name).outerHTML; + } + + static createInputHTML(placeholder = '', value = '') { + return ` + <li class="input-token"> + <input type='text' class='filtered-search' placeholder='${placeholder}' value='${value}'/> + </li> + `; + } + + static createTokensContainerHTML(html, inputPlaceholder) { + return ` + ${html} + ${FilteredSearchSpecHelper.createInputHTML(inputPlaceholder)} + `; + } +} diff --git a/spec/frontend/helpers/fixtures.js b/spec/frontend/helpers/fixtures.js index 778196843db..a89ceab3f8e 100644 --- a/spec/frontend/helpers/fixtures.js +++ b/spec/frontend/helpers/fixtures.js @@ -23,11 +23,12 @@ Did you run bin/rake frontend:fixtures?`, export const getJSONFixture = relativePath => JSON.parse(getFixture(relativePath)); export const resetHTMLFixture = () => { - document.body.textContent = ''; + document.head.innerHTML = ''; + document.body.innerHTML = ''; }; export const setHTMLFixture = (htmlContent, resetHook = afterEach) => { - document.body.outerHTML = htmlContent; + document.body.innerHTML = htmlContent; resetHook(resetHTMLFixture); }; diff --git a/spec/frontend/helpers/set_window_location_helper.js b/spec/frontend/helpers/set_window_location_helper.js new file mode 100644 index 00000000000..a94e73762c9 --- /dev/null +++ b/spec/frontend/helpers/set_window_location_helper.js @@ -0,0 +1,40 @@ +/** + * setWindowLocation allows for setting `window.location` + * (doing so directly is causing an error in jsdom) + * + * Example usage: + * assert(window.location.hash === undefined); + * setWindowLocation('http://example.com#foo') + * assert(window.location.hash === '#foo'); + * + * More information: + * https://github.com/facebook/jest/issues/890 + * + * @param url + */ +export default function setWindowLocation(url) { + const parsedUrl = new URL(url); + + const newLocationValue = [ + 'hash', + 'host', + 'hostname', + 'href', + 'origin', + 'pathname', + 'port', + 'protocol', + 'search', + ].reduce( + (location, prop) => ({ + ...location, + [prop]: parsedUrl[prop], + }), + {}, + ); + + Object.defineProperty(window, 'location', { + value: newLocationValue, + writable: true, + }); +} diff --git a/spec/frontend/helpers/set_window_location_helper_spec.js b/spec/frontend/helpers/set_window_location_helper_spec.js new file mode 100644 index 00000000000..2a2c024c824 --- /dev/null +++ b/spec/frontend/helpers/set_window_location_helper_spec.js @@ -0,0 +1,40 @@ +import setWindowLocation from './set_window_location_helper'; + +describe('setWindowLocation', () => { + const originalLocation = window.location; + + afterEach(() => { + window.location = originalLocation; + }); + + it.each` + url | property | value + ${'https://gitlab.com#foo'} | ${'hash'} | ${'#foo'} + ${'http://gitlab.com'} | ${'host'} | ${'gitlab.com'} + ${'http://gitlab.org'} | ${'hostname'} | ${'gitlab.org'} + ${'http://gitlab.org/foo#bar'} | ${'href'} | ${'http://gitlab.org/foo#bar'} + ${'http://gitlab.com'} | ${'origin'} | ${'http://gitlab.com'} + ${'http://gitlab.com/foo/bar/baz'} | ${'pathname'} | ${'/foo/bar/baz'} + ${'https://gitlab.com'} | ${'protocol'} | ${'https:'} + ${'http://gitlab.com#foo'} | ${'protocol'} | ${'http:'} + ${'http://gitlab.com:8080'} | ${'port'} | ${'8080'} + ${'http://gitlab.com?foo=bar&bar=foo'} | ${'search'} | ${'?foo=bar&bar=foo'} + `( + 'sets "window.location.$property" to be "$value" when called with: "$url"', + ({ url, property, value }) => { + expect(window.location).toBe(originalLocation); + + setWindowLocation(url); + + expect(window.location[property]).toBe(value); + }, + ); + + it.each([null, 1, undefined, false, '', 'gitlab.com'])( + 'throws an error when called with an invalid url: "%s"', + invalidUrl => { + expect(() => setWindowLocation(invalidUrl)).toThrow(new TypeError('Invalid URL')); + expect(window.location).toBe(originalLocation); + }, + ); +}); diff --git a/spec/frontend/helpers/vue_mount_component_helper.js b/spec/frontend/helpers/vue_mount_component_helper.js index 6848c95d95d..615ff69a01c 100644 --- a/spec/frontend/helpers/vue_mount_component_helper.js +++ b/spec/frontend/helpers/vue_mount_component_helper.js @@ -1,22 +1,38 @@ import Vue from 'vue'; +/** + * Deprecated. Please do not use. + * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445 + */ const mountComponent = (Component, props = {}, el = null) => new Component({ propsData: props, }).$mount(el); +/** + * Deprecated. Please do not use. + * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445 + */ export const createComponentWithStore = (Component, store, propsData = {}) => new Component({ store, propsData, }); +/** + * Deprecated. Please do not use. + * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445 + */ export const mountComponentWithStore = (Component, { el, props, store }) => new Component({ store, propsData: props || {}, }).$mount(el); +/** + * Deprecated. Please do not use. + * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445 + */ export const mountComponentWithSlots = (Component, { props, slots }) => { const component = new Component({ propsData: props || {}, @@ -30,9 +46,18 @@ export const mountComponentWithSlots = (Component, { props, slots }) => { /** * Mount a component with the given render method. * + * ----------------------------- + * Deprecated. Please do not use. + * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445 + * ----------------------------- + * * This helps with inserting slots that need to be compiled. */ export const mountComponentWithRender = (render, el = null) => mountComponent(Vue.extend({ render }), {}, el); +/** + * Deprecated. Please do not use. + * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445 + */ export default mountComponent; diff --git a/spec/frontend/helpers/web_worker_mock.js b/spec/frontend/helpers/web_worker_mock.js new file mode 100644 index 00000000000..2b4a391e1d2 --- /dev/null +++ b/spec/frontend/helpers/web_worker_mock.js @@ -0,0 +1,10 @@ +/* eslint-disable class-methods-use-this */ +export default class WebWorkerMock { + addEventListener() {} + + removeEventListener() {} + + terminate() {} + + postMessage() {} +} diff --git a/spec/frontend/ide/components/activity_bar_spec.js b/spec/frontend/ide/components/activity_bar_spec.js new file mode 100644 index 00000000000..8b3853d4535 --- /dev/null +++ b/spec/frontend/ide/components/activity_bar_spec.js @@ -0,0 +1,72 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import { leftSidebarViews } from '~/ide/constants'; +import ActivityBar from '~/ide/components/activity_bar.vue'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { resetStore } from '../helpers'; + +describe('IDE activity bar', () => { + const Component = Vue.extend(ActivityBar); + let vm; + + beforeEach(() => { + Vue.set(store.state.projects, 'abcproject', { + web_url: 'testing', + }); + Vue.set(store.state, 'currentProjectId', 'abcproject'); + + vm = createComponentWithStore(Component, store); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + describe('updateActivityBarView', () => { + beforeEach(() => { + jest.spyOn(vm, 'updateActivityBarView').mockImplementation(() => {}); + + vm.$mount(); + }); + + it('calls updateActivityBarView with edit value on click', () => { + vm.$el.querySelector('.js-ide-edit-mode').click(); + + expect(vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.edit.name); + }); + + it('calls updateActivityBarView with commit value on click', () => { + vm.$el.querySelector('.js-ide-commit-mode').click(); + + expect(vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.commit.name); + }); + + it('calls updateActivityBarView with review value on click', () => { + vm.$el.querySelector('.js-ide-review-mode').click(); + + expect(vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.review.name); + }); + }); + + describe('active item', () => { + beforeEach(() => { + vm.$mount(); + }); + + it('sets edit item active', () => { + expect(vm.$el.querySelector('.js-ide-edit-mode').classList).toContain('active'); + }); + + it('sets commit item active', done => { + vm.$store.state.currentActivityView = leftSidebarViews.commit.name; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.js-ide-commit-mode').classList).toContain('active'); + + done(); + }); + }); + }); +}); diff --git a/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js b/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js index a25aba61516..ff780939026 100644 --- a/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js @@ -7,27 +7,32 @@ import { file } from '../../helpers'; const localVue = createLocalVue(); localVue.use(Vuex); +const TEST_FILE_PATH = 'test/file/path'; + describe('IDE commit editor header', () => { let wrapper; - let f; let store; - const findDiscardModal = () => wrapper.find({ ref: 'discardModal' }); - const findDiscardButton = () => wrapper.find({ ref: 'discardButton' }); - - beforeEach(() => { - f = file('file'); - store = createStore(); - + const createComponent = (fileProps = {}) => { wrapper = mount(EditorHeader, { store, localVue, propsData: { - activeFile: f, + activeFile: { + ...file(TEST_FILE_PATH), + staged: true, + ...fileProps, + }, }, }); + }; - jest.spyOn(wrapper.vm, 'discardChanges').mockImplementation(); + const findDiscardModal = () => wrapper.find({ ref: 'discardModal' }); + const findDiscardButton = () => wrapper.find({ ref: 'discardButton' }); + + beforeEach(() => { + store = createStore(); + jest.spyOn(store, 'dispatch').mockImplementation(); }); afterEach(() => { @@ -35,29 +40,38 @@ describe('IDE commit editor header', () => { wrapper = null; }); - it('renders button to discard', () => { - expect(wrapper.vm.$el.querySelectorAll('.btn')).toHaveLength(1); + it.each` + fileProps | shouldExist + ${{ staged: false, changed: false }} | ${false} + ${{ staged: true, changed: false }} | ${true} + ${{ staged: false, changed: true }} | ${true} + ${{ staged: true, changed: true }} | ${true} + `('with $fileProps, show discard button is $shouldExist', ({ fileProps, shouldExist }) => { + createComponent(fileProps); + + expect(findDiscardButton().exists()).toBe(shouldExist); }); describe('discard button', () => { - let modal; - beforeEach(() => { - modal = findDiscardModal(); + createComponent(); + const modal = findDiscardModal(); jest.spyOn(modal.vm, 'show'); findDiscardButton().trigger('click'); }); it('opens a dialog confirming discard', () => { - expect(modal.vm.show).toHaveBeenCalled(); + expect(findDiscardModal().vm.show).toHaveBeenCalled(); }); it('calls discardFileChanges if dialog result is confirmed', () => { - modal.vm.$emit('ok'); + expect(store.dispatch).not.toHaveBeenCalled(); + + findDiscardModal().vm.$emit('ok'); - expect(wrapper.vm.discardChanges).toHaveBeenCalledWith(f.path); + expect(store.dispatch).toHaveBeenCalledWith('discardFileChanges', TEST_FILE_PATH); }); }); }); diff --git a/spec/frontend/ide/components/commit_sidebar/form_spec.js b/spec/frontend/ide/components/commit_sidebar/form_spec.js index dfde69ab2df..129180bb46e 100644 --- a/spec/frontend/ide/components/commit_sidebar/form_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/form_spec.js @@ -1,6 +1,5 @@ import Vue from 'vue'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import waitForPromises from 'helpers/wait_for_promises'; import { projectData } from 'jest/ide/mock_data'; import store from '~/ide/stores'; import CommitForm from '~/ide/components/commit_sidebar/form.vue'; @@ -31,10 +30,10 @@ describe('IDE commit form', () => { }); describe('compact', () => { - beforeEach(done => { + beforeEach(() => { vm.isCompact = true; - vm.$nextTick(done); + return vm.$nextTick(); }); it('renders commit button in compact mode', () => { @@ -46,95 +45,84 @@ describe('IDE commit form', () => { expect(vm.$el.querySelector('form')).toBeNull(); }); - it('renders overview text', done => { + it('renders overview text', () => { vm.$store.state.stagedFiles.push('test'); - vm.$nextTick(() => { + return vm.$nextTick(() => { expect(vm.$el.querySelector('p').textContent).toContain('1 changed file'); - done(); }); }); - it('shows form when clicking commit button', done => { + it('shows form when clicking commit button', () => { vm.$el.querySelector('.btn-primary').click(); - vm.$nextTick(() => { + return vm.$nextTick(() => { expect(vm.$el.querySelector('form')).not.toBeNull(); - - done(); }); }); - it('toggles activity bar view when clicking commit button', done => { + it('toggles activity bar view when clicking commit button', () => { vm.$el.querySelector('.btn-primary').click(); - vm.$nextTick(() => { + return vm.$nextTick(() => { expect(store.state.currentActivityView).toBe(leftSidebarViews.commit.name); - - done(); }); }); - it('collapses if lastCommitMsg is set to empty and current view is not commit view', done => { + it('collapses if lastCommitMsg is set to empty and current view is not commit view', () => { store.state.lastCommitMsg = 'abc'; store.state.currentActivityView = leftSidebarViews.edit.name; - vm.$nextTick(() => { - // if commit message is set, form is uncollapsed - expect(vm.isCompact).toBe(false); + return vm + .$nextTick() + .then(() => { + // if commit message is set, form is uncollapsed + expect(vm.isCompact).toBe(false); - store.state.lastCommitMsg = ''; + store.state.lastCommitMsg = ''; - vm.$nextTick(() => { + return vm.$nextTick(); + }) + .then(() => { // collapsed when set to empty expect(vm.isCompact).toBe(true); - - done(); }); - }); }); }); describe('full', () => { - beforeEach(done => { + beforeEach(() => { vm.isCompact = false; - vm.$nextTick(done); + return vm.$nextTick(); }); - it('updates commitMessage in store on input', done => { + it('updates commitMessage in store on input', () => { const textarea = vm.$el.querySelector('textarea'); textarea.value = 'testing commit message'; textarea.dispatchEvent(new Event('input')); - waitForPromises() - .then(() => { - expect(vm.$store.state.commit.commitMessage).toBe('testing commit message'); - }) - .then(done) - .catch(done.fail); + return vm.$nextTick().then(() => { + expect(vm.$store.state.commit.commitMessage).toBe('testing commit message'); + }); }); - it('updating currentActivityView not to commit view sets compact mode', done => { + it('updating currentActivityView not to commit view sets compact mode', () => { store.state.currentActivityView = 'a'; - vm.$nextTick(() => { + return vm.$nextTick(() => { expect(vm.isCompact).toBe(true); - - done(); }); }); - it('always opens itself in full view current activity view is not commit view when clicking commit button', done => { + it('always opens itself in full view current activity view is not commit view when clicking commit button', () => { vm.$el.querySelector('.btn-primary').click(); - vm.$nextTick(() => { + return vm.$nextTick(() => { expect(store.state.currentActivityView).toBe(leftSidebarViews.commit.name); expect(vm.isCompact).toBe(false); - - done(); }); }); @@ -143,41 +131,54 @@ describe('IDE commit form', () => { expect(vm.$el.querySelector('.btn-default').textContent).toContain('Collapse'); }); - it('resets commitMessage when clicking discard button', done => { + it('resets commitMessage when clicking discard button', () => { vm.$store.state.commit.commitMessage = 'testing commit message'; - waitForPromises() + return vm + .$nextTick() .then(() => { vm.$el.querySelector('.btn-default').click(); }) - .then(Vue.nextTick) + .then(() => vm.$nextTick()) .then(() => { expect(vm.$store.state.commit.commitMessage).not.toBe('testing commit message'); - }) - .then(done) - .catch(done.fail); + }); }); }); describe('when submitting', () => { beforeEach(() => { - jest.spyOn(vm, 'commitChanges').mockImplementation(() => {}); + jest.spyOn(vm, 'commitChanges'); + vm.$store.state.stagedFiles.push('test'); + vm.$store.state.commit.commitMessage = 'testing commit message'; }); - it('calls commitChanges', done => { - vm.$store.state.commit.commitMessage = 'testing commit message'; + it('calls commitChanges', () => { + vm.commitChanges.mockResolvedValue({ success: true }); + + return vm.$nextTick().then(() => { + vm.$el.querySelector('.btn-success').click(); + + expect(vm.commitChanges).toHaveBeenCalled(); + }); + }); + + it('opens new branch modal if commitChanges throws an error', () => { + vm.commitChanges.mockRejectedValue({ success: false }); - waitForPromises() + jest.spyOn(vm.$refs.createBranchModal, 'show').mockImplementation(); + + return vm + .$nextTick() .then(() => { vm.$el.querySelector('.btn-success').click(); + + return vm.$nextTick(); }) - .then(Vue.nextTick) .then(() => { - expect(vm.commitChanges).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + expect(vm.$refs.createBranchModal.show).toHaveBeenCalled(); + }); }); }); }); diff --git a/spec/frontend/ide/components/commit_sidebar/list_spec.js b/spec/frontend/ide/components/commit_sidebar/list_spec.js index ee209487665..2b5664ffc4e 100644 --- a/spec/frontend/ide/components/commit_sidebar/list_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/list_spec.js @@ -21,8 +21,6 @@ describe('Multi-file editor commit sidebar list', () => { keyPrefix: 'staged', }); - vm.$store.state.rightPanelCollapsed = false; - vm.$mount(); }); diff --git a/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js b/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js new file mode 100644 index 00000000000..ac80ba58056 --- /dev/null +++ b/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js @@ -0,0 +1,134 @@ +import Vue from 'vue'; +import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { resetStore } from 'jest/ide/helpers'; +import store from '~/ide/stores'; +import radioGroup from '~/ide/components/commit_sidebar/radio_group.vue'; + +describe('IDE commit sidebar radio group', () => { + let vm; + + beforeEach(done => { + const Component = Vue.extend(radioGroup); + + store.state.commit.commitAction = '2'; + + vm = createComponentWithStore(Component, store, { + value: '1', + label: 'test', + checked: true, + }); + + vm.$mount(); + + Vue.nextTick(done); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('uses label if present', () => { + expect(vm.$el.textContent).toContain('test'); + }); + + it('uses slot if label is not present', done => { + vm.$destroy(); + + vm = new Vue({ + components: { + radioGroup, + }, + store, + render: createElement => + createElement('radio-group', { props: { value: '1' } }, 'Testing slot'), + }); + + vm.$mount(); + + Vue.nextTick(() => { + expect(vm.$el.textContent).toContain('Testing slot'); + + done(); + }); + }); + + it('updates store when changing radio button', done => { + vm.$el.querySelector('input').dispatchEvent(new Event('change')); + + Vue.nextTick(() => { + expect(store.state.commit.commitAction).toBe('1'); + + done(); + }); + }); + + describe('with input', () => { + beforeEach(done => { + vm.$destroy(); + + const Component = Vue.extend(radioGroup); + + store.state.commit.commitAction = '1'; + store.state.commit.newBranchName = 'test-123'; + + vm = createComponentWithStore(Component, store, { + value: '1', + label: 'test', + checked: true, + showInput: true, + }); + + vm.$mount(); + + Vue.nextTick(done); + }); + + it('renders input box when commitAction matches value', () => { + expect(vm.$el.querySelector('.form-control')).not.toBeNull(); + }); + + it('hides input when commitAction doesnt match value', done => { + store.state.commit.commitAction = '2'; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.form-control')).toBeNull(); + done(); + }); + }); + + it('updates branch name in store on input', done => { + const input = vm.$el.querySelector('.form-control'); + input.value = 'testing-123'; + input.dispatchEvent(new Event('input')); + + Vue.nextTick(() => { + expect(store.state.commit.newBranchName).toBe('testing-123'); + + done(); + }); + }); + + it('renders newBranchName if present', () => { + const input = vm.$el.querySelector('.form-control'); + + expect(input.value).toBe('test-123'); + }); + }); + + describe('tooltipTitle', () => { + it('returns title when disabled', () => { + vm.title = 'test title'; + vm.disabled = true; + + expect(vm.tooltipTitle).toBe('test title'); + }); + + it('returns blank when not disabled', () => { + vm.title = 'test title'; + + expect(vm.tooltipTitle).not.toBe('test title'); + }); + }); +}); diff --git a/spec/frontend/ide/components/file_row_extra_spec.js b/spec/frontend/ide/components/file_row_extra_spec.js new file mode 100644 index 00000000000..e78bacadebb --- /dev/null +++ b/spec/frontend/ide/components/file_row_extra_spec.js @@ -0,0 +1,170 @@ +import Vue from 'vue'; +import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { createStore } from '~/ide/stores'; +import FileRowExtra from '~/ide/components/file_row_extra.vue'; +import { file, resetStore } from '../helpers'; + +describe('IDE extra file row component', () => { + let Component; + let vm; + let unstagedFilesCount = 0; + let stagedFilesCount = 0; + let changesCount = 0; + + beforeAll(() => { + Component = Vue.extend(FileRowExtra); + }); + + beforeEach(() => { + vm = createComponentWithStore(Component, createStore(), { + file: { + ...file('test'), + }, + dropdownOpen: false, + }); + + jest.spyOn(vm, 'getUnstagedFilesCountForPath', 'get').mockReturnValue(() => unstagedFilesCount); + jest.spyOn(vm, 'getStagedFilesCountForPath', 'get').mockReturnValue(() => stagedFilesCount); + jest.spyOn(vm, 'getChangesInFolder', 'get').mockReturnValue(() => changesCount); + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + resetStore(vm.$store); + + stagedFilesCount = 0; + unstagedFilesCount = 0; + changesCount = 0; + }); + + describe('folderChangesTooltip', () => { + it('returns undefined when changes count is 0', () => { + changesCount = 0; + + expect(vm.folderChangesTooltip).toBe(undefined); + }); + + [{ input: 1, output: '1 changed file' }, { input: 2, output: '2 changed files' }].forEach( + ({ input, output }) => { + it('returns changed files count if changes count is not 0', () => { + changesCount = input; + + expect(vm.folderChangesTooltip).toBe(output); + }); + }, + ); + }); + + describe('show tree changes count', () => { + it('does not show for blobs', () => { + vm.file.type = 'blob'; + + expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null); + }); + + it('does not show when changes count is 0', () => { + vm.file.type = 'tree'; + + expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null); + }); + + it('does not show when tree is open', done => { + vm.file.type = 'tree'; + vm.file.opened = true; + changesCount = 1; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null); + + done(); + }); + }); + + it('shows for trees with changes', done => { + vm.file.type = 'tree'; + vm.file.opened = false; + changesCount = 1; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.ide-tree-changes')).not.toBe(null); + + done(); + }); + }); + }); + + describe('changes file icon', () => { + it('hides when file is not changed', () => { + expect(vm.$el.querySelector('.file-changed-icon')).toBe(null); + }); + + it('shows when file is changed', done => { + vm.file.changed = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null); + + done(); + }); + }); + + it('shows when file is staged', done => { + vm.file.staged = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null); + + done(); + }); + }); + + it('shows when file is a tempFile', done => { + vm.file.tempFile = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null); + + done(); + }); + }); + + it('shows when file is renamed', done => { + vm.file.prevPath = 'original-file'; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null); + + done(); + }); + }); + + it('hides when file is renamed', done => { + vm.file.prevPath = 'original-file'; + vm.file.type = 'tree'; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.file-changed-icon')).toBe(null); + + done(); + }); + }); + }); + + describe('merge request icon', () => { + it('hides when not a merge request change', () => { + expect(vm.$el.querySelector('.ic-git-merge')).toBe(null); + }); + + it('shows when a merge request change', done => { + vm.file.mrChange = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.ic-git-merge')).not.toBe(null); + + done(); + }); + }); + }); +}); diff --git a/spec/frontend/ide/components/file_templates/bar_spec.js b/spec/frontend/ide/components/file_templates/bar_spec.js new file mode 100644 index 00000000000..21dbe18a223 --- /dev/null +++ b/spec/frontend/ide/components/file_templates/bar_spec.js @@ -0,0 +1,117 @@ +import Vue from 'vue'; +import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { createStore } from '~/ide/stores'; +import Bar from '~/ide/components/file_templates/bar.vue'; +import { resetStore, file } from '../../helpers'; + +describe('IDE file templates bar component', () => { + let Component; + let vm; + + beforeAll(() => { + Component = Vue.extend(Bar); + }); + + beforeEach(() => { + const store = createStore(); + + store.state.openFiles.push({ + ...file('file'), + opened: true, + active: true, + }); + + vm = mountComponentWithStore(Component, { store }); + }); + + afterEach(() => { + vm.$destroy(); + resetStore(vm.$store); + }); + + describe('template type dropdown', () => { + it('renders dropdown component', () => { + expect(vm.$el.querySelector('.dropdown').textContent).toContain('Choose a type'); + }); + + it('calls setSelectedTemplateType when clicking item', () => { + jest.spyOn(vm, 'setSelectedTemplateType').mockImplementation(); + + vm.$el.querySelector('.dropdown-content button').click(); + + expect(vm.setSelectedTemplateType).toHaveBeenCalledWith({ + name: '.gitlab-ci.yml', + key: 'gitlab_ci_ymls', + }); + }); + }); + + describe('template dropdown', () => { + beforeEach(done => { + vm.$store.state.fileTemplates.templates = [ + { + name: 'test', + }, + ]; + vm.$store.state.fileTemplates.selectedTemplateType = { + name: '.gitlab-ci.yml', + key: 'gitlab_ci_ymls', + }; + + vm.$nextTick(done); + }); + + it('renders dropdown component', () => { + expect(vm.$el.querySelectorAll('.dropdown')[1].textContent).toContain('Choose a template'); + }); + + it('calls fetchTemplate on click', () => { + jest.spyOn(vm, 'fetchTemplate').mockImplementation(); + + vm.$el + .querySelectorAll('.dropdown-content')[1] + .querySelector('button') + .click(); + + expect(vm.fetchTemplate).toHaveBeenCalledWith({ + name: 'test', + }); + }); + }); + + it('shows undo button if updateSuccess is true', done => { + vm.$store.state.fileTemplates.updateSuccess = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.btn-default').style.display).not.toBe('none'); + + done(); + }); + }); + + it('calls undoFileTemplate when clicking undo button', () => { + jest.spyOn(vm, 'undoFileTemplate').mockImplementation(); + + vm.$el.querySelector('.btn-default').click(); + + expect(vm.undoFileTemplate).toHaveBeenCalled(); + }); + + it('calls setSelectedTemplateType if activeFile name matches a template', done => { + const fileName = '.gitlab-ci.yml'; + + jest.spyOn(vm, 'setSelectedTemplateType').mockImplementation(() => {}); + vm.$store.state.openFiles[0].name = fileName; + + vm.setInitialType(); + + vm.$nextTick(() => { + expect(vm.setSelectedTemplateType).toHaveBeenCalledWith({ + name: fileName, + key: 'gitlab_ci_ymls', + }); + + done(); + }); + }); +}); diff --git a/spec/frontend/ide/components/ide_review_spec.js b/spec/frontend/ide/components/ide_review_spec.js new file mode 100644 index 00000000000..b56957e1f6d --- /dev/null +++ b/spec/frontend/ide/components/ide_review_spec.js @@ -0,0 +1,73 @@ +import Vue from 'vue'; +import IdeReview from '~/ide/components/ide_review.vue'; +import { createStore } from '~/ide/stores'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { trimText } from '../../helpers/text_helper'; +import { resetStore, file } from '../helpers'; +import { projectData } from '../mock_data'; + +describe('IDE review mode', () => { + const Component = Vue.extend(IdeReview); + let vm; + let store; + + beforeEach(() => { + store = createStore(); + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { ...projectData }; + Vue.set(store.state.trees, 'abcproject/master', { + tree: [file('fileName')], + loading: false, + }); + + vm = createComponentWithStore(Component, store).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders list of files', () => { + expect(vm.$el.textContent).toContain('fileName'); + }); + + describe('merge request', () => { + beforeEach(() => { + store.state.currentMergeRequestId = '1'; + store.state.projects.abcproject.mergeRequests['1'] = { + iid: 123, + web_url: 'testing123', + }; + + return vm.$nextTick(); + }); + + it('renders edit dropdown', () => { + expect(vm.$el.querySelector('.btn')).not.toBe(null); + }); + + it('renders merge request link & IID', () => { + store.state.viewer = 'mrdiff'; + + return vm.$nextTick(() => { + const link = vm.$el.querySelector('.ide-review-sub-header'); + + expect(link.querySelector('a').getAttribute('href')).toBe('testing123'); + expect(trimText(link.textContent)).toBe('Merge request (!123)'); + }); + }); + + it('changes text to latest changes when viewer is not mrdiff', () => { + store.state.viewer = 'diff'; + + return vm.$nextTick(() => { + expect(trimText(vm.$el.querySelector('.ide-review-sub-header').textContent)).toBe( + 'Latest changes', + ); + }); + }); + }); +}); diff --git a/spec/frontend/ide/components/ide_side_bar_spec.js b/spec/frontend/ide/components/ide_side_bar_spec.js new file mode 100644 index 00000000000..65cad2e7eb0 --- /dev/null +++ b/spec/frontend/ide/components/ide_side_bar_spec.js @@ -0,0 +1,57 @@ +import Vue from 'vue'; +import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import store from '~/ide/stores'; +import ideSidebar from '~/ide/components/ide_side_bar.vue'; +import { leftSidebarViews } from '~/ide/constants'; +import { resetStore } from '../helpers'; +import { projectData } from '../mock_data'; + +describe('IdeSidebar', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(ideSidebar); + + store.state.currentProjectId = 'abcproject'; + store.state.projects.abcproject = projectData; + + vm = createComponentWithStore(Component, store).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders a sidebar', () => { + expect(vm.$el.querySelector('.multi-file-commit-panel-inner')).not.toBeNull(); + }); + + it('renders loading icon component', done => { + vm.$store.state.loading = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull(); + expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3); + + done(); + }); + }); + + describe('activityBarComponent', () => { + it('renders tree component', () => { + expect(vm.$el.querySelector('.ide-file-list')).not.toBeNull(); + }); + + it('renders commit component', done => { + vm.$store.state.currentActivityView = leftSidebarViews.commit.name; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.multi-file-commit-panel-section')).not.toBeNull(); + + done(); + }); + }); + }); +}); diff --git a/spec/frontend/ide/components/ide_spec.js b/spec/frontend/ide/components/ide_spec.js new file mode 100644 index 00000000000..78a280e6304 --- /dev/null +++ b/spec/frontend/ide/components/ide_spec.js @@ -0,0 +1,125 @@ +import Vue from 'vue'; +import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import store from '~/ide/stores'; +import ide from '~/ide/components/ide.vue'; +import { file, resetStore } from '../helpers'; +import { projectData } from '../mock_data'; + +function bootstrap(projData) { + const Component = Vue.extend(ide); + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { ...projData }; + Vue.set(store.state.trees, 'abcproject/master', { + tree: [], + loading: false, + }); + + return createComponentWithStore(Component, store, { + emptyStateSvgPath: 'svg', + noChangesStateSvgPath: 'svg', + committedStateSvgPath: 'svg', + }); +} + +describe('ide component, empty repo', () => { + let vm; + + beforeEach(() => { + const emptyProjData = { ...projectData, empty_repo: true, branches: {} }; + vm = bootstrap(emptyProjData); + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders "New file" button in empty repo', done => { + vm.$nextTick(() => { + expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).not.toBeNull(); + done(); + }); + }); +}); + +describe('ide component, non-empty repo', () => { + let vm; + + beforeEach(() => { + vm = bootstrap(projectData); + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('shows error message when set', done => { + expect(vm.$el.querySelector('.gl-alert')).toBe(null); + + vm.$store.state.errorMessage = { + text: 'error', + }; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.gl-alert')).not.toBe(null); + + done(); + }); + }); + + describe('onBeforeUnload', () => { + it('returns undefined when no staged files or changed files', () => { + expect(vm.onBeforeUnload()).toBe(undefined); + }); + + it('returns warning text when their are changed files', () => { + vm.$store.state.changedFiles.push(file()); + + expect(vm.onBeforeUnload()).toBe('Are you sure you want to lose unsaved changes?'); + }); + + it('returns warning text when their are staged files', () => { + vm.$store.state.stagedFiles.push(file()); + + expect(vm.onBeforeUnload()).toBe('Are you sure you want to lose unsaved changes?'); + }); + + it('updates event object', () => { + const event = {}; + vm.$store.state.stagedFiles.push(file()); + + vm.onBeforeUnload(event); + + expect(event.returnValue).toBe('Are you sure you want to lose unsaved changes?'); + }); + }); + + describe('non-existent branch', () => { + it('does not render "New file" button for non-existent branch when repo is not empty', done => { + vm.$nextTick(() => { + expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).toBeNull(); + done(); + }); + }); + }); + + describe('branch with files', () => { + beforeEach(() => { + store.state.trees['abcproject/master'].tree = [file()]; + }); + + it('does not render "New file" button', done => { + vm.$nextTick(() => { + expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).toBeNull(); + done(); + }); + }); + }); +}); diff --git a/spec/frontend/ide/components/ide_status_bar_spec.js b/spec/frontend/ide/components/ide_status_bar_spec.js new file mode 100644 index 00000000000..bc8144f544c --- /dev/null +++ b/spec/frontend/ide/components/ide_status_bar_spec.js @@ -0,0 +1,127 @@ +import Vue from 'vue'; +import _ from 'lodash'; +import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { TEST_HOST } from '../../helpers/test_constants'; +import { createStore } from '~/ide/stores'; +import IdeStatusBar from '~/ide/components/ide_status_bar.vue'; +import { rightSidebarViews } from '~/ide/constants'; +import { projectData } from '../mock_data'; + +const TEST_PROJECT_ID = 'abcproject'; +const TEST_MERGE_REQUEST_ID = '9001'; +const TEST_MERGE_REQUEST_URL = `${TEST_HOST}merge-requests/${TEST_MERGE_REQUEST_ID}`; + +describe('ideStatusBar', () => { + let store; + let vm; + + const createComponent = () => { + vm = createComponentWithStore(Vue.extend(IdeStatusBar), store).$mount(); + }; + const findMRStatus = () => vm.$el.querySelector('.js-ide-status-mr'); + + beforeEach(() => { + store = createStore(); + store.state.currentProjectId = TEST_PROJECT_ID; + store.state.projects[TEST_PROJECT_ID] = _.clone(projectData); + store.state.currentBranchId = 'master'; + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('triggers a setInterval', () => { + expect(vm.intervalId).not.toBe(null); + }); + + it('renders the statusbar', () => { + expect(vm.$el.className).toBe('ide-status-bar'); + }); + + describe('commitAgeUpdate', () => { + beforeEach(() => { + jest.spyOn(vm, 'commitAgeUpdate').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('gets called every second', () => { + expect(vm.commitAgeUpdate).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1000); + + expect(vm.commitAgeUpdate.mock.calls.length).toEqual(1); + + jest.advanceTimersByTime(1000); + + expect(vm.commitAgeUpdate.mock.calls.length).toEqual(2); + }); + }); + + describe('getCommitPath', () => { + it('returns the path to the commit details', () => { + expect(vm.getCommitPath('abc123de')).toBe('/commit/abc123de'); + }); + }); + + describe('pipeline status', () => { + it('opens right sidebar on clicking icon', done => { + jest.spyOn(vm, 'openRightPane').mockImplementation(() => {}); + Vue.set(vm.$store.state.pipelines, 'latestPipeline', { + details: { + status: { + text: 'success', + details_path: 'test', + icon: 'status_success', + }, + }, + commit: { + author_gravatar_url: 'www', + }, + }); + + vm.$nextTick() + .then(() => { + vm.$el.querySelector('.ide-status-pipeline button').click(); + + expect(vm.openRightPane).toHaveBeenCalledWith(rightSidebarViews.pipelines); + }) + .then(done) + .catch(done.fail); + }); + }); + + it('does not show merge request status', () => { + expect(findMRStatus()).toBe(null); + }); + }); + + describe('with merge request in store', () => { + beforeEach(() => { + store.state.projects[TEST_PROJECT_ID].mergeRequests = { + [TEST_MERGE_REQUEST_ID]: { + web_url: TEST_MERGE_REQUEST_URL, + references: { + short: `!${TEST_MERGE_REQUEST_ID}`, + }, + }, + }; + store.state.currentMergeRequestId = TEST_MERGE_REQUEST_ID; + + createComponent(); + }); + + it('shows merge request status', () => { + expect(findMRStatus().textContent.trim()).toEqual(`Merge request !${TEST_MERGE_REQUEST_ID}`); + expect(findMRStatus().querySelector('a').href).toEqual(TEST_MERGE_REQUEST_URL); + }); + }); +}); diff --git a/spec/frontend/ide/components/ide_tree_list_spec.js b/spec/frontend/ide/components/ide_tree_list_spec.js new file mode 100644 index 00000000000..30f11db3153 --- /dev/null +++ b/spec/frontend/ide/components/ide_tree_list_spec.js @@ -0,0 +1,77 @@ +import Vue from 'vue'; +import IdeTreeList from '~/ide/components/ide_tree_list.vue'; +import store from '~/ide/stores'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { resetStore, file } from '../helpers'; +import { projectData } from '../mock_data'; + +describe('IDE tree list', () => { + const Component = Vue.extend(IdeTreeList); + const normalBranchTree = [file('fileName')]; + const emptyBranchTree = []; + let vm; + + const bootstrapWithTree = (tree = normalBranchTree) => { + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { ...projectData }; + Vue.set(store.state.trees, 'abcproject/master', { + tree, + loading: false, + }); + + vm = createComponentWithStore(Component, store, { + viewerType: 'edit', + }); + }; + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + describe('normal branch', () => { + beforeEach(() => { + bootstrapWithTree(); + + jest.spyOn(vm, 'updateViewer'); + + vm.$mount(); + }); + + it('updates viewer on mount', () => { + expect(vm.updateViewer).toHaveBeenCalledWith('edit'); + }); + + it('renders loading indicator', done => { + store.state.trees['abcproject/master'].loading = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull(); + expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3); + + done(); + }); + }); + + it('renders list of files', () => { + expect(vm.$el.textContent).toContain('fileName'); + }); + }); + + describe('empty-branch state', () => { + beforeEach(() => { + bootstrapWithTree(emptyBranchTree); + + jest.spyOn(vm, 'updateViewer'); + + vm.$mount(); + }); + + it('does not load files if the branch is empty', () => { + expect(vm.$el.textContent).not.toContain('fileName'); + expect(vm.$el.textContent).toContain('No files'); + }); + }); +}); diff --git a/spec/frontend/ide/components/ide_tree_spec.js b/spec/frontend/ide/components/ide_tree_spec.js new file mode 100644 index 00000000000..01f007f09c3 --- /dev/null +++ b/spec/frontend/ide/components/ide_tree_spec.js @@ -0,0 +1,34 @@ +import Vue from 'vue'; +import IdeTree from '~/ide/components/ide_tree.vue'; +import store from '~/ide/stores'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { resetStore, file } from '../helpers'; +import { projectData } from '../mock_data'; + +describe('IdeRepoTree', () => { + let vm; + + beforeEach(() => { + const IdeRepoTree = Vue.extend(IdeTree); + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { ...projectData }; + Vue.set(store.state.trees, 'abcproject/master', { + tree: [file('fileName')], + loading: false, + }); + + vm = createComponentWithStore(IdeRepoTree, store).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders list of files', () => { + expect(vm.$el.textContent).toContain('fileName'); + }); +}); diff --git a/spec/frontend/ide/components/jobs/detail/description_spec.js b/spec/frontend/ide/components/jobs/detail/description_spec.js new file mode 100644 index 00000000000..babae00d2f7 --- /dev/null +++ b/spec/frontend/ide/components/jobs/detail/description_spec.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; +import Description from '~/ide/components/jobs/detail/description.vue'; +import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { jobs } from '../../../mock_data'; + +describe('IDE job description', () => { + const Component = Vue.extend(Description); + let vm; + + beforeEach(() => { + vm = mountComponent(Component, { + job: jobs[0], + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders job details', () => { + expect(vm.$el.textContent).toContain('#1'); + expect(vm.$el.textContent).toContain('test'); + }); + + it('renders CI icon', () => { + expect(vm.$el.querySelector('.ci-status-icon .ic-status_success_borderless')).not.toBe(null); + }); +}); diff --git a/spec/frontend/ide/components/jobs/item_spec.js b/spec/frontend/ide/components/jobs/item_spec.js new file mode 100644 index 00000000000..2f97d39e98e --- /dev/null +++ b/spec/frontend/ide/components/jobs/item_spec.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import JobItem from '~/ide/components/jobs/item.vue'; +import mountComponent from '../../../helpers/vue_mount_component_helper'; +import { jobs } from '../../mock_data'; + +describe('IDE jobs item', () => { + const Component = Vue.extend(JobItem); + const job = jobs[0]; + let vm; + + beforeEach(() => { + vm = mountComponent(Component, { + job, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders job details', () => { + expect(vm.$el.textContent).toContain(job.name); + expect(vm.$el.textContent).toContain(`#${job.id}`); + }); + + it('renders CI icon', () => { + expect(vm.$el.querySelector('.ic-status_success_borderless')).not.toBe(null); + }); + + it('does not render view logs button if not started', done => { + vm.job.started = false; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.btn')).toBe(null); + + done(); + }); + }); +}); diff --git a/spec/frontend/ide/components/merge_requests/item_spec.js b/spec/frontend/ide/components/merge_requests/item_spec.js new file mode 100644 index 00000000000..6a2451ad263 --- /dev/null +++ b/spec/frontend/ide/components/merge_requests/item_spec.js @@ -0,0 +1,63 @@ +import Vue from 'vue'; +import router from '~/ide/ide_router'; +import Item from '~/ide/components/merge_requests/item.vue'; +import mountCompontent from '../../../helpers/vue_mount_component_helper'; + +describe('IDE merge request item', () => { + const Component = Vue.extend(Item); + let vm; + + beforeEach(() => { + vm = mountCompontent(Component, { + item: { + iid: 1, + projectPathWithNamespace: 'gitlab-org/gitlab-ce', + title: 'Merge request title', + }, + currentId: '1', + currentProjectId: 'gitlab-org/gitlab-ce', + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders merge requests data', () => { + expect(vm.$el.textContent).toContain('Merge request title'); + expect(vm.$el.textContent).toContain('gitlab-org/gitlab-ce!1'); + }); + + it('renders link with href', () => { + const expectedHref = router.resolve( + `/project/${vm.item.projectPathWithNamespace}/merge_requests/${vm.item.iid}`, + ).href; + + expect(vm.$el.tagName.toLowerCase()).toBe('a'); + expect(vm.$el).toHaveAttr('href', expectedHref); + }); + + it('renders icon if ID matches currentId', () => { + expect(vm.$el.querySelector('.ic-mobile-issue-close')).not.toBe(null); + }); + + it('does not render icon if ID does not match currentId', done => { + vm.currentId = '2'; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.ic-mobile-issue-close')).toBe(null); + + done(); + }); + }); + + it('does not render icon if project ID does not match', done => { + vm.currentProjectId = 'test/test'; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.ic-mobile-issue-close')).toBe(null); + + done(); + }); + }); +}); diff --git a/spec/frontend/ide/components/nav_dropdown_button_spec.js b/spec/frontend/ide/components/nav_dropdown_button_spec.js new file mode 100644 index 00000000000..2aa3992a6d8 --- /dev/null +++ b/spec/frontend/ide/components/nav_dropdown_button_spec.js @@ -0,0 +1,93 @@ +import Vue from 'vue'; +import { trimText } from 'helpers/text_helper'; +import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import NavDropdownButton from '~/ide/components/nav_dropdown_button.vue'; +import { createStore } from '~/ide/stores'; + +describe('NavDropdown', () => { + const TEST_BRANCH_ID = 'lorem-ipsum-dolar'; + const TEST_MR_ID = '12345'; + let store; + let vm; + + beforeEach(() => { + store = createStore(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + const createComponent = (props = {}) => { + vm = mountComponentWithStore(Vue.extend(NavDropdownButton), { props, store }); + vm.$mount(); + }; + + const findIcon = name => vm.$el.querySelector(`.ic-${name}`); + const findMRIcon = () => findIcon('merge-request'); + const findBranchIcon = () => findIcon('branch'); + + describe('normal', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders empty placeholders, if state is falsey', () => { + expect(trimText(vm.$el.textContent)).toEqual('- -'); + }); + + it('renders branch name, if state has currentBranchId', done => { + vm.$store.state.currentBranchId = TEST_BRANCH_ID; + + vm.$nextTick() + .then(() => { + expect(trimText(vm.$el.textContent)).toEqual(`${TEST_BRANCH_ID} -`); + }) + .then(done) + .catch(done.fail); + }); + + it('renders mr id, if state has currentMergeRequestId', done => { + vm.$store.state.currentMergeRequestId = TEST_MR_ID; + + vm.$nextTick() + .then(() => { + expect(trimText(vm.$el.textContent)).toEqual(`- !${TEST_MR_ID}`); + }) + .then(done) + .catch(done.fail); + }); + + it('renders branch and mr, if state has both', done => { + vm.$store.state.currentBranchId = TEST_BRANCH_ID; + vm.$store.state.currentMergeRequestId = TEST_MR_ID; + + vm.$nextTick() + .then(() => { + expect(trimText(vm.$el.textContent)).toEqual(`${TEST_BRANCH_ID} !${TEST_MR_ID}`); + }) + .then(done) + .catch(done.fail); + }); + + it('shows icons', () => { + expect(findBranchIcon()).toBeTruthy(); + expect(findMRIcon()).toBeTruthy(); + }); + }); + + describe('with showMergeRequests false', () => { + beforeEach(() => { + createComponent({ showMergeRequests: false }); + }); + + it('shows single empty placeholder, if state is falsey', () => { + expect(trimText(vm.$el.textContent)).toEqual('-'); + }); + + it('shows only branch icon', () => { + expect(findBranchIcon()).toBeTruthy(); + expect(findMRIcon()).toBe(null); + }); + }); +}); diff --git a/spec/frontend/ide/components/nav_dropdown_spec.js b/spec/frontend/ide/components/nav_dropdown_spec.js new file mode 100644 index 00000000000..ce123d925c8 --- /dev/null +++ b/spec/frontend/ide/components/nav_dropdown_spec.js @@ -0,0 +1,102 @@ +import $ from 'jquery'; +import { mount } from '@vue/test-utils'; +import { createStore } from '~/ide/stores'; +import NavDropdown from '~/ide/components/nav_dropdown.vue'; +import { PERMISSION_READ_MR } from '~/ide/constants'; + +const TEST_PROJECT_ID = 'lorem-ipsum'; + +describe('IDE NavDropdown', () => { + let store; + let wrapper; + + beforeEach(() => { + store = createStore(); + Object.assign(store.state, { + currentProjectId: TEST_PROJECT_ID, + currentBranchId: 'master', + projects: { + [TEST_PROJECT_ID]: { + userPermissions: { + [PERMISSION_READ_MR]: true, + }, + branches: { + master: { id: 'master' }, + }, + }, + }, + }); + jest.spyOn(store, 'dispatch').mockImplementation(() => {}); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const createComponent = () => { + wrapper = mount(NavDropdown, { + store, + }); + }; + + const findIcon = name => wrapper.find(`.ic-${name}`); + const findMRIcon = () => findIcon('merge-request'); + const findNavForm = () => wrapper.find('.ide-nav-form'); + const showDropdown = () => { + $(wrapper.vm.$el).trigger('show.bs.dropdown'); + }; + const hideDropdown = () => { + $(wrapper.vm.$el).trigger('hide.bs.dropdown'); + }; + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders nothing initially', () => { + expect(findNavForm().exists()).toBe(false); + }); + + it('renders nav form when show.bs.dropdown', done => { + showDropdown(); + + wrapper.vm + .$nextTick() + .then(() => { + expect(findNavForm().exists()).toBe(true); + }) + .then(done) + .catch(done.fail); + }); + + it('destroys nav form when closed', done => { + showDropdown(); + hideDropdown(); + + wrapper.vm + .$nextTick() + .then(() => { + expect(findNavForm().exists()).toBe(false); + }) + .then(done) + .catch(done.fail); + }); + + it('renders merge request icon', () => { + expect(findMRIcon().exists()).toBe(true); + }); + }); + + describe('when user cannot read merge requests', () => { + beforeEach(() => { + store.state.projects[TEST_PROJECT_ID].userPermissions = {}; + + createComponent(); + }); + + it('does not render merge requests', () => { + expect(findMRIcon().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ide/components/new_dropdown/button_spec.js b/spec/frontend/ide/components/new_dropdown/button_spec.js new file mode 100644 index 00000000000..3c611b7de8f --- /dev/null +++ b/spec/frontend/ide/components/new_dropdown/button_spec.js @@ -0,0 +1,65 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import Button from '~/ide/components/new_dropdown/button.vue'; + +describe('IDE new entry dropdown button component', () => { + let Component; + let vm; + + beforeAll(() => { + Component = Vue.extend(Button); + }); + + beforeEach(() => { + vm = mountComponent(Component, { + label: 'Testing', + icon: 'doc-new', + }); + + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders button with label', () => { + expect(vm.$el.textContent).toContain('Testing'); + }); + + it('renders icon', () => { + expect(vm.$el.querySelector('.ic-doc-new')).not.toBe(null); + }); + + it('emits click event', () => { + vm.$el.click(); + + expect(vm.$emit).toHaveBeenCalledWith('click'); + }); + + it('hides label if showLabel is false', done => { + vm.showLabel = false; + + vm.$nextTick(() => { + expect(vm.$el.textContent).not.toContain('Testing'); + + done(); + }); + }); + + describe('tooltipTitle', () => { + it('returns empty string when showLabel is true', () => { + expect(vm.tooltipTitle).toBe(''); + }); + + it('returns label', done => { + vm.showLabel = false; + + vm.$nextTick(() => { + expect(vm.tooltipTitle).toBe('Testing'); + + done(); + }); + }); + }); +}); diff --git a/spec/frontend/ide/components/new_dropdown/index_spec.js b/spec/frontend/ide/components/new_dropdown/index_spec.js new file mode 100644 index 00000000000..00781c16609 --- /dev/null +++ b/spec/frontend/ide/components/new_dropdown/index_spec.js @@ -0,0 +1,84 @@ +import Vue from 'vue'; +import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import store from '~/ide/stores'; +import newDropdown from '~/ide/components/new_dropdown/index.vue'; +import { resetStore } from '../../helpers'; + +describe('new dropdown component', () => { + let vm; + + beforeEach(() => { + const component = Vue.extend(newDropdown); + + vm = createComponentWithStore(component, store, { + branch: 'master', + path: '', + mouseOver: false, + type: 'tree', + }); + + vm.$store.state.currentProjectId = 'abcproject'; + vm.$store.state.path = ''; + vm.$store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + + vm.$mount(); + + jest.spyOn(vm.$refs.newModal, 'open').mockImplementation(() => {}); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders new file, upload and new directory links', () => { + const buttons = vm.$el.querySelectorAll('.dropdown-menu button'); + + expect(buttons[0].textContent.trim()).toBe('New file'); + expect(buttons[1].textContent.trim()).toBe('Upload file'); + expect(buttons[2].textContent.trim()).toBe('New directory'); + }); + + describe('createNewItem', () => { + it('opens modal for a blob when new file is clicked', () => { + vm.$el.querySelectorAll('.dropdown-menu button')[0].click(); + + expect(vm.$refs.newModal.open).toHaveBeenCalledWith('blob', ''); + }); + + it('opens modal for a tree when new directory is clicked', () => { + vm.$el.querySelectorAll('.dropdown-menu button')[2].click(); + + expect(vm.$refs.newModal.open).toHaveBeenCalledWith('tree', ''); + }); + }); + + describe('isOpen', () => { + it('scrolls dropdown into view', done => { + jest.spyOn(vm.$refs.dropdownMenu, 'scrollIntoView').mockImplementation(() => {}); + + vm.isOpen = true; + + setImmediate(() => { + expect(vm.$refs.dropdownMenu.scrollIntoView).toHaveBeenCalledWith({ + block: 'nearest', + }); + + done(); + }); + }); + }); + + describe('delete entry', () => { + it('calls delete action', () => { + jest.spyOn(vm, 'deleteEntry').mockImplementation(() => {}); + + vm.$el.querySelectorAll('.dropdown-menu button')[4].click(); + + expect(vm.deleteEntry).toHaveBeenCalledWith(''); + }); + }); +}); diff --git a/spec/frontend/ide/components/new_dropdown/modal_spec.js b/spec/frontend/ide/components/new_dropdown/modal_spec.js new file mode 100644 index 00000000000..62a59a76bf4 --- /dev/null +++ b/spec/frontend/ide/components/new_dropdown/modal_spec.js @@ -0,0 +1,175 @@ +import Vue from 'vue'; +import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { createStore } from '~/ide/stores'; +import modal from '~/ide/components/new_dropdown/modal.vue'; +import createFlash from '~/flash'; + +jest.mock('~/flash'); + +describe('new file modal component', () => { + const Component = Vue.extend(modal); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + describe.each` + entryType | modalTitle | btnTitle | showsFileTemplates + ${'tree'} | ${'Create new directory'} | ${'Create directory'} | ${false} + ${'blob'} | ${'Create new file'} | ${'Create file'} | ${true} + `('$entryType', ({ entryType, modalTitle, btnTitle, showsFileTemplates }) => { + beforeEach(done => { + const store = createStore(); + + vm = createComponentWithStore(Component, store).$mount(); + vm.open(entryType); + vm.name = 'testing'; + + vm.$nextTick(done); + }); + + afterEach(() => { + vm.close(); + }); + + it(`sets modal title as ${entryType}`, () => { + expect(document.querySelector('.modal-title').textContent.trim()).toBe(modalTitle); + }); + + it(`sets button label as ${entryType}`, () => { + expect(document.querySelector('.btn-success').textContent.trim()).toBe(btnTitle); + }); + + it(`sets form label as ${entryType}`, () => { + expect(document.querySelector('.label-bold').textContent.trim()).toBe('Name'); + }); + + it(`shows file templates: ${showsFileTemplates}`, () => { + const templateFilesEl = document.querySelector('.file-templates'); + expect(Boolean(templateFilesEl)).toBe(showsFileTemplates); + }); + }); + + describe('rename entry', () => { + beforeEach(() => { + const store = createStore(); + store.state.entries = { + 'test-path': { + name: 'test', + type: 'blob', + path: 'test-path', + }, + }; + + vm = createComponentWithStore(Component, store).$mount(); + }); + + it.each` + entryType | modalTitle | btnTitle + ${'tree'} | ${'Rename folder'} | ${'Rename folder'} + ${'blob'} | ${'Rename file'} | ${'Rename file'} + `( + 'renders title and button for renaming $entryType', + ({ entryType, modalTitle, btnTitle }, done) => { + vm.$store.state.entries['test-path'].type = entryType; + vm.open('rename', 'test-path'); + + vm.$nextTick(() => { + expect(document.querySelector('.modal-title').textContent.trim()).toBe(modalTitle); + expect(document.querySelector('.btn-success').textContent.trim()).toBe(btnTitle); + + done(); + }); + }, + ); + + describe('entryName', () => { + it('returns entries name', () => { + vm.open('rename', 'test-path'); + + expect(vm.entryName).toBe('test-path'); + }); + + it('does not reset entryName to its old value if empty', () => { + vm.entryName = 'hello'; + vm.entryName = ''; + + expect(vm.entryName).toBe(''); + }); + }); + + describe('open', () => { + it('sets entryName to path provided if modalType is rename', () => { + vm.open('rename', 'test-path'); + + expect(vm.entryName).toBe('test-path'); + }); + + it("appends '/' to the path if modalType isn't rename", () => { + vm.open('blob', 'test-path'); + + expect(vm.entryName).toBe('test-path/'); + }); + + it('leaves entryName blank if no path is provided', () => { + vm.open('blob'); + + expect(vm.entryName).toBe(''); + }); + }); + }); + + describe('submitForm', () => { + let store; + + beforeEach(() => { + store = createStore(); + store.state.entries = { + 'test-path/test': { + name: 'test', + deleted: false, + }, + }; + + vm = createComponentWithStore(Component, store).$mount(); + }); + + it('throws an error when target entry exists', () => { + vm.open('rename', 'test-path/test'); + + expect(createFlash).not.toHaveBeenCalled(); + + vm.submitForm(); + + expect(createFlash).toHaveBeenCalledWith( + 'The name "test-path/test" is already taken in this directory.', + 'alert', + expect.anything(), + null, + false, + true, + ); + }); + + it('does not throw error when target entry does not exist', () => { + jest.spyOn(vm, 'renameEntry').mockImplementation(); + + vm.open('rename', 'test-path/test'); + vm.entryName = 'test-path/test2'; + vm.submitForm(); + + expect(createFlash).not.toHaveBeenCalled(); + }); + + it('removes leading/trailing found in the new name', () => { + vm.open('rename', 'test-path/test'); + + vm.entryName = 'test-path /test'; + + vm.submitForm(); + + expect(vm.entryName).toBe('test-path/test'); + }); + }); +}); diff --git a/spec/frontend/ide/components/new_dropdown/upload_spec.js b/spec/frontend/ide/components/new_dropdown/upload_spec.js new file mode 100644 index 00000000000..a418fdeb572 --- /dev/null +++ b/spec/frontend/ide/components/new_dropdown/upload_spec.js @@ -0,0 +1,112 @@ +import Vue from 'vue'; +import createComponent from 'helpers/vue_mount_component_helper'; +import upload from '~/ide/components/new_dropdown/upload.vue'; + +describe('new dropdown upload', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(upload); + + vm = createComponent(Component, { + path: '', + }); + + vm.entryName = 'testing'; + + jest.spyOn(vm, '$emit'); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('openFile', () => { + it('calls for each file', () => { + const files = ['test', 'test2', 'test3']; + + jest.spyOn(vm, 'readFile').mockImplementation(() => {}); + jest.spyOn(vm.$refs.fileUpload, 'files', 'get').mockReturnValue(files); + + vm.openFile(); + + expect(vm.readFile.mock.calls.length).toBe(3); + + files.forEach((file, i) => { + expect(vm.readFile.mock.calls[i]).toEqual([file]); + }); + }); + }); + + describe('readFile', () => { + beforeEach(() => { + jest.spyOn(FileReader.prototype, 'readAsDataURL').mockImplementation(() => {}); + }); + + it('calls readAsDataURL for all files', () => { + const file = { + type: 'images/png', + }; + + vm.readFile(file); + + expect(FileReader.prototype.readAsDataURL).toHaveBeenCalledWith(file); + }); + }); + + describe('createFile', () => { + const textTarget = { + result: 'base64,cGxhaW4gdGV4dA==', + }; + const binaryTarget = { + result: 'base64,w4I=', + }; + const textFile = new File(['plain text'], 'textFile'); + + const binaryFile = { + name: 'binaryFile', + type: 'image/png', + }; + + beforeEach(() => { + jest.spyOn(FileReader.prototype, 'readAsText'); + }); + + it('calls readAsText and creates file in plain text (without encoding) if the file content is plain text', done => { + const waitForCreate = new Promise(resolve => vm.$on('create', resolve)); + + vm.createFile(textTarget, textFile); + + expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(textFile); + + waitForCreate + .then(() => { + expect(vm.$emit).toHaveBeenCalledWith('create', { + name: textFile.name, + type: 'blob', + content: 'plain text', + base64: false, + binary: false, + rawPath: '', + }); + }) + .then(done) + .catch(done.fail); + }); + + it('splits content on base64 if binary', () => { + vm.createFile(binaryTarget, binaryFile); + + expect(FileReader.prototype.readAsText).not.toHaveBeenCalledWith(textFile); + + expect(vm.$emit).toHaveBeenCalledWith('create', { + name: binaryFile.name, + type: 'blob', + content: binaryTarget.result.split('base64,')[1], + base64: true, + binary: true, + rawPath: binaryTarget.result, + }); + }); + }); +}); diff --git a/spec/frontend/ide/components/pipelines/list_spec.js b/spec/frontend/ide/components/pipelines/list_spec.js index 11e672b6685..d909a5e478e 100644 --- a/spec/frontend/ide/components/pipelines/list_spec.js +++ b/spec/frontend/ide/components/pipelines/list_spec.js @@ -7,10 +7,15 @@ import JobsList from '~/ide/components/jobs/list.vue'; import Tab from '~/vue_shared/components/tabs/tab.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { pipelines } from '../../../../javascripts/ide/mock_data'; +import IDEServices from '~/ide/services'; const localVue = createLocalVue(); localVue.use(Vuex); +jest.mock('~/ide/services', () => ({ + pingUsage: jest.fn(), +})); + describe('IDE pipelines list', () => { let wrapper; @@ -25,14 +30,18 @@ describe('IDE pipelines list', () => { }; const fetchLatestPipelineMock = jest.fn(); + const pingUsageMock = jest.fn(); const failedStagesGetterMock = jest.fn().mockReturnValue([]); + const fakeProjectPath = 'alpha/beta'; const createComponent = (state = {}) => { const { pipelines: pipelinesState, ...restOfState } = state; const { defaultPipelines, ...defaultRestOfState } = defaultState; const fakeStore = new Vuex.Store({ - getters: { currentProject: () => ({ web_url: 'some/url ' }) }, + getters: { + currentProject: () => ({ web_url: 'some/url ', path_with_namespace: fakeProjectPath }), + }, state: { ...defaultRestOfState, ...restOfState, @@ -46,6 +55,7 @@ describe('IDE pipelines list', () => { }, actions: { fetchLatestPipeline: fetchLatestPipelineMock, + pingUsage: pingUsageMock, }, getters: { jobsCount: () => 1, @@ -77,6 +87,11 @@ describe('IDE pipelines list', () => { expect(fetchLatestPipelineMock).toHaveBeenCalled(); }); + it('pings pipeline usage', () => { + createComponent(); + expect(IDEServices.pingUsage).toHaveBeenCalledWith(fakeProjectPath); + }); + describe('when loading', () => { let defaultPipelinesLoadingState; beforeAll(() => { diff --git a/spec/frontend/ide/components/preview/clientside_spec.js b/spec/frontend/ide/components/preview/clientside_spec.js index 0cde6fb6060..7b2025f5e9f 100644 --- a/spec/frontend/ide/components/preview/clientside_spec.js +++ b/spec/frontend/ide/components/preview/clientside_spec.js @@ -70,14 +70,6 @@ describe('IDE clientside preview', () => { }); }; - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - afterEach(() => { wrapper.destroy(); }); diff --git a/spec/frontend/ide/components/repo_commit_section_spec.js b/spec/frontend/ide/components/repo_commit_section_spec.js index 5ea03eb1593..237be018807 100644 --- a/spec/frontend/ide/components/repo_commit_section_spec.js +++ b/spec/frontend/ide/components/repo_commit_section_spec.js @@ -36,7 +36,6 @@ describe('RepoCommitSection', () => { }), ); - store.state.rightPanelCollapsed = false; store.state.currentBranch = 'master'; store.state.changedFiles = []; store.state.stagedFiles = [{ ...files[0] }, { ...files[1] }]; diff --git a/spec/frontend/ide/components/repo_tab_spec.js b/spec/frontend/ide/components/repo_tab_spec.js new file mode 100644 index 00000000000..82ea73ffbb1 --- /dev/null +++ b/spec/frontend/ide/components/repo_tab_spec.js @@ -0,0 +1,185 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import repoTab from '~/ide/components/repo_tab.vue'; +import router from '~/ide/ide_router'; +import { file, resetStore } from '../helpers'; + +describe('RepoTab', () => { + let vm; + + function createComponent(propsData) { + const RepoTab = Vue.extend(repoTab); + + return new RepoTab({ + store, + propsData, + }).$mount(); + } + + beforeEach(() => { + jest.spyOn(router, 'push').mockImplementation(() => {}); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders a close link and a name link', () => { + vm = createComponent({ + tab: file(), + }); + vm.$store.state.openFiles.push(vm.tab); + const close = vm.$el.querySelector('.multi-file-tab-close'); + const name = vm.$el.querySelector(`[title="${vm.tab.url}"]`); + + expect(close.innerHTML).toContain('#close'); + expect(name.textContent.trim()).toEqual(vm.tab.name); + }); + + it('does not call openPendingTab when tab is active', done => { + vm = createComponent({ + tab: { + ...file(), + pending: true, + active: true, + }, + }); + + jest.spyOn(vm, 'openPendingTab').mockImplementation(() => {}); + + vm.$el.click(); + + vm.$nextTick(() => { + expect(vm.openPendingTab).not.toHaveBeenCalled(); + + done(); + }); + }); + + it('fires clickFile when the link is clicked', () => { + vm = createComponent({ + tab: file(), + }); + + jest.spyOn(vm, 'clickFile').mockImplementation(() => {}); + + vm.$el.click(); + + expect(vm.clickFile).toHaveBeenCalledWith(vm.tab); + }); + + it('calls closeFile when clicking close button', () => { + vm = createComponent({ + tab: file(), + }); + + jest.spyOn(vm, 'closeFile').mockImplementation(() => {}); + + vm.$el.querySelector('.multi-file-tab-close').click(); + + expect(vm.closeFile).toHaveBeenCalledWith(vm.tab); + }); + + it('changes icon on hover', done => { + const tab = file(); + tab.changed = true; + vm = createComponent({ + tab, + }); + + vm.$el.dispatchEvent(new Event('mouseover')); + + Vue.nextTick() + .then(() => { + expect(vm.$el.querySelector('.file-modified')).toBeNull(); + + vm.$el.dispatchEvent(new Event('mouseout')); + }) + .then(Vue.nextTick) + .then(() => { + expect(vm.$el.querySelector('.file-modified')).not.toBeNull(); + + done(); + }) + .catch(done.fail); + }); + + describe('locked file', () => { + let f; + + beforeEach(() => { + f = file('locked file'); + f.file_lock = { + user: { + name: 'testuser', + updated_at: new Date(), + }, + }; + + vm = createComponent({ + tab: f, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders lock icon', () => { + expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull(); + }); + + it('renders a tooltip', () => { + expect(vm.$el.querySelector('span:nth-child(2)').dataset.originalTitle).toContain( + 'Locked by testuser', + ); + }); + }); + + describe('methods', () => { + describe('closeTab', () => { + it('closes tab if file has changed', done => { + const tab = file(); + tab.changed = true; + tab.opened = true; + vm = createComponent({ + tab, + }); + vm.$store.state.openFiles.push(tab); + vm.$store.state.changedFiles.push(tab); + vm.$store.state.entries[tab.path] = tab; + vm.$store.dispatch('setFileActive', tab.path); + + vm.$el.querySelector('.multi-file-tab-close').click(); + + vm.$nextTick(() => { + expect(tab.opened).toBeFalsy(); + expect(vm.$store.state.changedFiles.length).toBe(1); + + done(); + }); + }); + + it('closes tab when clicking close btn', done => { + const tab = file('lose'); + tab.opened = true; + vm = createComponent({ + tab, + }); + vm.$store.state.openFiles.push(tab); + vm.$store.state.entries[tab.path] = tab; + vm.$store.dispatch('setFileActive', tab.path); + + vm.$el.querySelector('.multi-file-tab-close').click(); + + vm.$nextTick(() => { + expect(tab.opened).toBeFalsy(); + + done(); + }); + }); + }); + }); +}); diff --git a/spec/frontend/ide/components/repo_tabs_spec.js b/spec/frontend/ide/components/repo_tabs_spec.js new file mode 100644 index 00000000000..583f71e6121 --- /dev/null +++ b/spec/frontend/ide/components/repo_tabs_spec.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import repoTabs from '~/ide/components/repo_tabs.vue'; +import createComponent from '../../helpers/vue_mount_component_helper'; +import { file } from '../helpers'; + +describe('RepoTabs', () => { + const openedFiles = [file('open1'), file('open2')]; + const RepoTabs = Vue.extend(repoTabs); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + it('renders a list of tabs', done => { + vm = createComponent(RepoTabs, { + files: openedFiles, + viewer: 'editor', + hasChanges: false, + activeFile: file('activeFile'), + hasMergeRequest: false, + }); + openedFiles[0].active = true; + + vm.$nextTick(() => { + const tabs = [...vm.$el.querySelectorAll('.multi-file-tab')]; + + expect(tabs.length).toEqual(2); + expect(tabs[0].parentNode.classList.contains('active')).toEqual(true); + expect(tabs[1].parentNode.classList.contains('active')).toEqual(false); + + done(); + }); + }); +}); diff --git a/spec/frontend/ide/components/shared/tokened_input_spec.js b/spec/frontend/ide/components/shared/tokened_input_spec.js new file mode 100644 index 00000000000..e687216bd06 --- /dev/null +++ b/spec/frontend/ide/components/shared/tokened_input_spec.js @@ -0,0 +1,133 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import TokenedInput from '~/ide/components/shared/tokened_input.vue'; + +const TEST_PLACEHOLDER = 'Searching in test'; +const TEST_TOKENS = [ + { label: 'lorem', id: 1 }, + { label: 'ipsum', id: 2 }, + { label: 'dolar', id: 3 }, +]; +const TEST_VALUE = 'lorem'; + +function getTokenElements(vm) { + return Array.from(vm.$el.querySelectorAll('.filtered-search-token button')); +} + +function createBackspaceEvent() { + const e = new Event('keyup'); + e.keyCode = 8; + e.which = e.keyCode; + e.altKey = false; + e.ctrlKey = true; + e.shiftKey = false; + e.metaKey = false; + return e; +} + +describe('IDE shared/TokenedInput', () => { + const Component = Vue.extend(TokenedInput); + let vm; + + beforeEach(() => { + vm = mountComponent(Component, { + tokens: TEST_TOKENS, + placeholder: TEST_PLACEHOLDER, + value: TEST_VALUE, + }); + + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders tokens', () => { + const renderedTokens = getTokenElements(vm).map(x => x.textContent.trim()); + + expect(renderedTokens).toEqual(TEST_TOKENS.map(x => x.label)); + }); + + it('renders input', () => { + expect(vm.$refs.input).toBeTruthy(); + expect(vm.$refs.input).toHaveValue(TEST_VALUE); + }); + + it('renders placeholder, when tokens are empty', done => { + vm.tokens = []; + + vm.$nextTick() + .then(() => { + expect(vm.$refs.input).toHaveAttr('placeholder', TEST_PLACEHOLDER); + }) + .then(done) + .catch(done.fail); + }); + + it('triggers "removeToken" on token click', () => { + getTokenElements(vm)[0].click(); + + expect(vm.$emit).toHaveBeenCalledWith('removeToken', TEST_TOKENS[0]); + }); + + it('when input triggers backspace event, it calls "onBackspace"', () => { + jest.spyOn(vm, 'onBackspace').mockImplementation(() => {}); + + vm.$refs.input.dispatchEvent(createBackspaceEvent()); + vm.$refs.input.dispatchEvent(createBackspaceEvent()); + + expect(vm.onBackspace).toHaveBeenCalledTimes(2); + }); + + it('triggers "removeToken" on backspaces when value is empty', () => { + vm.value = ''; + + vm.onBackspace(); + + expect(vm.$emit).not.toHaveBeenCalled(); + expect(vm.backspaceCount).toEqual(1); + + vm.onBackspace(); + + expect(vm.$emit).toHaveBeenCalledWith('removeToken', TEST_TOKENS[TEST_TOKENS.length - 1]); + expect(vm.backspaceCount).toEqual(0); + }); + + it('does not trigger "removeToken" on backspaces when value is not empty', () => { + vm.onBackspace(); + vm.onBackspace(); + + expect(vm.backspaceCount).toEqual(0); + expect(vm.$emit).not.toHaveBeenCalled(); + }); + + it('does not trigger "removeToken" on backspaces when tokens are empty', () => { + vm.tokens = []; + + vm.onBackspace(); + vm.onBackspace(); + + expect(vm.backspaceCount).toEqual(0); + expect(vm.$emit).not.toHaveBeenCalled(); + }); + + it('triggers "focus" on input focus', () => { + vm.$refs.input.dispatchEvent(new Event('focus')); + + expect(vm.$emit).toHaveBeenCalledWith('focus'); + }); + + it('triggers "blur" on input blur', () => { + vm.$refs.input.dispatchEvent(new Event('blur')); + + expect(vm.$emit).toHaveBeenCalledWith('blur'); + }); + + it('triggers "input" with value on input change', () => { + vm.$refs.input.value = 'something-else'; + vm.$refs.input.dispatchEvent(new Event('input')); + + expect(vm.$emit).toHaveBeenCalledWith('input', 'something-else'); + }); +}); diff --git a/spec/frontend/ide/lib/common/model_manager_spec.js b/spec/frontend/ide/lib/common/model_manager_spec.js new file mode 100644 index 00000000000..08e4ab0f113 --- /dev/null +++ b/spec/frontend/ide/lib/common/model_manager_spec.js @@ -0,0 +1,126 @@ +import eventHub from '~/ide/eventhub'; +import ModelManager from '~/ide/lib/common/model_manager'; +import { file } from '../../helpers'; + +describe('Multi-file editor library model manager', () => { + let instance; + + beforeEach(() => { + instance = new ModelManager(); + }); + + afterEach(() => { + instance.dispose(); + }); + + describe('addModel', () => { + it('caches model', () => { + instance.addModel(file()); + + expect(instance.models.size).toBe(1); + }); + + it('caches model by file path', () => { + const f = file('path-name'); + instance.addModel(f); + + expect(instance.models.keys().next().value).toBe(f.key); + }); + + it('adds model into disposable', () => { + jest.spyOn(instance.disposable, 'add'); + + instance.addModel(file()); + + expect(instance.disposable.add).toHaveBeenCalled(); + }); + + it('returns cached model', () => { + jest.spyOn(instance.models, 'get'); + + instance.addModel(file()); + instance.addModel(file()); + + expect(instance.models.get).toHaveBeenCalled(); + }); + + it('adds eventHub listener', () => { + const f = file(); + jest.spyOn(eventHub, '$on'); + + instance.addModel(f); + + expect(eventHub.$on).toHaveBeenCalledWith( + `editor.update.model.dispose.${f.key}`, + expect.anything(), + ); + }); + }); + + describe('hasCachedModel', () => { + it('returns false when no models exist', () => { + expect(instance.hasCachedModel('path')).toBeFalsy(); + }); + + it('returns true when model exists', () => { + const f = file('path-name'); + + instance.addModel(f); + + expect(instance.hasCachedModel(f.key)).toBeTruthy(); + }); + }); + + describe('getModel', () => { + it('returns cached model', () => { + instance.addModel(file('path-name')); + + expect(instance.getModel('path-name')).not.toBeNull(); + }); + }); + + describe('removeCachedModel', () => { + let f; + + beforeEach(() => { + f = file(); + + instance.addModel(f); + }); + + it('clears cached model', () => { + instance.removeCachedModel(f); + + expect(instance.models.size).toBe(0); + }); + + it('removes eventHub listener', () => { + jest.spyOn(eventHub, '$off'); + + instance.removeCachedModel(f); + + expect(eventHub.$off).toHaveBeenCalledWith( + `editor.update.model.dispose.${f.key}`, + expect.anything(), + ); + }); + }); + + describe('dispose', () => { + it('clears cached models', () => { + instance.addModel(file()); + + instance.dispose(); + + expect(instance.models.size).toBe(0); + }); + + it('calls disposable dispose', () => { + jest.spyOn(instance.disposable, 'dispose'); + + instance.dispose(); + + expect(instance.disposable.dispose).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/ide/lib/common/model_spec.js b/spec/frontend/ide/lib/common/model_spec.js new file mode 100644 index 00000000000..2ef2f0da6da --- /dev/null +++ b/spec/frontend/ide/lib/common/model_spec.js @@ -0,0 +1,137 @@ +import eventHub from '~/ide/eventhub'; +import Model from '~/ide/lib/common/model'; +import { file } from '../../helpers'; + +describe('Multi-file editor library model', () => { + let model; + + beforeEach(() => { + jest.spyOn(eventHub, '$on'); + + const f = file('path'); + f.mrChange = { diff: 'ABC' }; + f.baseRaw = 'test'; + model = new Model(f); + }); + + afterEach(() => { + model.dispose(); + }); + + it('creates original model & base model & new model', () => { + expect(model.originalModel).not.toBeNull(); + expect(model.model).not.toBeNull(); + expect(model.baseModel).not.toBeNull(); + + expect(model.originalModel.uri.path).toBe('original/path--path'); + expect(model.model.uri.path).toBe('path--path'); + expect(model.baseModel.uri.path).toBe('target/path--path'); + }); + + it('creates model with head file to compare against', () => { + const f = file('path'); + model.dispose(); + + model = new Model(f, { + ...f, + content: '123 testing', + }); + + expect(model.head).not.toBeNull(); + expect(model.getOriginalModel().getValue()).toBe('123 testing'); + }); + + it('adds eventHub listener', () => { + expect(eventHub.$on).toHaveBeenCalledWith( + `editor.update.model.dispose.${model.file.key}`, + expect.anything(), + ); + }); + + describe('path', () => { + it('returns file path', () => { + expect(model.path).toBe(model.file.key); + }); + }); + + describe('getModel', () => { + it('returns model', () => { + expect(model.getModel()).toBe(model.model); + }); + }); + + describe('getOriginalModel', () => { + it('returns original model', () => { + expect(model.getOriginalModel()).toBe(model.originalModel); + }); + }); + + describe('getBaseModel', () => { + it('returns base model', () => { + expect(model.getBaseModel()).toBe(model.baseModel); + }); + }); + + describe('setValue', () => { + it('updates models value', () => { + model.setValue('testing 123'); + + expect(model.getModel().getValue()).toBe('testing 123'); + }); + }); + + describe('onChange', () => { + it('calls callback on change', done => { + const spy = jest.fn(); + model.onChange(spy); + + model.getModel().setValue('123'); + + setImmediate(() => { + expect(spy).toHaveBeenCalledWith(model, expect.anything()); + done(); + }); + }); + }); + + describe('dispose', () => { + it('calls disposable dispose', () => { + jest.spyOn(model.disposable, 'dispose'); + + model.dispose(); + + expect(model.disposable.dispose).toHaveBeenCalled(); + }); + + it('clears events', () => { + model.onChange(() => {}); + + expect(model.events.size).toBe(1); + + model.dispose(); + + expect(model.events.size).toBe(0); + }); + + it('removes eventHub listener', () => { + jest.spyOn(eventHub, '$off'); + + model.dispose(); + + expect(eventHub.$off).toHaveBeenCalledWith( + `editor.update.model.dispose.${model.file.key}`, + expect.anything(), + ); + }); + + it('calls onDispose callback', () => { + const disposeSpy = jest.fn(); + + model.onDispose(disposeSpy); + + model.dispose(); + + expect(disposeSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/ide/lib/decorations/controller_spec.js b/spec/frontend/ide/lib/decorations/controller_spec.js new file mode 100644 index 00000000000..4556fc9d646 --- /dev/null +++ b/spec/frontend/ide/lib/decorations/controller_spec.js @@ -0,0 +1,143 @@ +import Editor from '~/ide/lib/editor'; +import DecorationsController from '~/ide/lib/decorations/controller'; +import Model from '~/ide/lib/common/model'; +import { file } from '../../helpers'; + +describe('Multi-file editor library decorations controller', () => { + let editorInstance; + let controller; + let model; + + beforeEach(() => { + editorInstance = Editor.create(); + editorInstance.createInstance(document.createElement('div')); + + controller = new DecorationsController(editorInstance); + model = new Model(file('path')); + }); + + afterEach(() => { + model.dispose(); + editorInstance.dispose(); + controller.dispose(); + }); + + describe('getAllDecorationsForModel', () => { + it('returns empty array when no decorations exist for model', () => { + const decorations = controller.getAllDecorationsForModel(model); + + expect(decorations).toEqual([]); + }); + + it('returns decorations by model URL', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + const decorations = controller.getAllDecorationsForModel(model); + + expect(decorations[0]).toEqual({ decoration: 'decorationValue' }); + }); + }); + + describe('addDecorations', () => { + it('caches decorations in a new map', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + expect(controller.decorations.size).toBe(1); + }); + + it('does not create new cache model', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue2' }]); + + expect(controller.decorations.size).toBe(1); + }); + + it('caches decorations by model URL', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + expect(controller.decorations.size).toBe(1); + expect(controller.decorations.keys().next().value).toBe('gitlab:path--path'); + }); + + it('calls decorate method', () => { + jest.spyOn(controller, 'decorate').mockImplementation(() => {}); + + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + expect(controller.decorate).toHaveBeenCalled(); + }); + }); + + describe('decorate', () => { + it('sets decorations on editor instance', () => { + jest.spyOn(controller.editor.instance, 'deltaDecorations').mockImplementation(() => {}); + + controller.decorate(model); + + expect(controller.editor.instance.deltaDecorations).toHaveBeenCalledWith([], []); + }); + + it('caches decorations', () => { + jest.spyOn(controller.editor.instance, 'deltaDecorations').mockReturnValue([]); + + controller.decorate(model); + + expect(controller.editorDecorations.size).toBe(1); + }); + + it('caches decorations by model URL', () => { + jest.spyOn(controller.editor.instance, 'deltaDecorations').mockReturnValue([]); + + controller.decorate(model); + + expect(controller.editorDecorations.keys().next().value).toBe('gitlab:path--path'); + }); + }); + + describe('dispose', () => { + it('clears cached decorations', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + controller.dispose(); + + expect(controller.decorations.size).toBe(0); + }); + + it('clears cached editorDecorations', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + controller.dispose(); + + expect(controller.editorDecorations.size).toBe(0); + }); + }); + + describe('hasDecorations', () => { + it('returns true when decorations are cached', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + expect(controller.hasDecorations(model)).toBe(true); + }); + + it('returns false when no model decorations exist', () => { + expect(controller.hasDecorations(model)).toBe(false); + }); + }); + + describe('removeDecorations', () => { + beforeEach(() => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + controller.decorate(model); + }); + + it('removes cached decorations', () => { + expect(controller.decorations.size).not.toBe(0); + expect(controller.editorDecorations.size).not.toBe(0); + + controller.removeDecorations(model); + + expect(controller.decorations.size).toBe(0); + expect(controller.editorDecorations.size).toBe(0); + }); + }); +}); diff --git a/spec/frontend/ide/lib/diff/controller_spec.js b/spec/frontend/ide/lib/diff/controller_spec.js new file mode 100644 index 00000000000..0b33a4c6ad6 --- /dev/null +++ b/spec/frontend/ide/lib/diff/controller_spec.js @@ -0,0 +1,215 @@ +import { Range } from 'monaco-editor'; +import Editor from '~/ide/lib/editor'; +import ModelManager from '~/ide/lib/common/model_manager'; +import DecorationsController from '~/ide/lib/decorations/controller'; +import DirtyDiffController, { getDiffChangeType, getDecorator } from '~/ide/lib/diff/controller'; +import { computeDiff } from '~/ide/lib/diff/diff'; +import { file } from '../../helpers'; + +describe('Multi-file editor library dirty diff controller', () => { + let editorInstance; + let controller; + let modelManager; + let decorationsController; + let model; + + beforeEach(() => { + editorInstance = Editor.create(); + editorInstance.createInstance(document.createElement('div')); + + modelManager = new ModelManager(); + decorationsController = new DecorationsController(editorInstance); + + model = modelManager.addModel(file('path')); + + controller = new DirtyDiffController(modelManager, decorationsController); + }); + + afterEach(() => { + controller.dispose(); + model.dispose(); + decorationsController.dispose(); + editorInstance.dispose(); + }); + + describe('getDiffChangeType', () => { + ['added', 'removed', 'modified'].forEach(type => { + it(`returns ${type}`, () => { + const change = { + [type]: true, + }; + + expect(getDiffChangeType(change)).toBe(type); + }); + }); + }); + + describe('getDecorator', () => { + ['added', 'removed', 'modified'].forEach(type => { + it(`returns with linesDecorationsClassName for ${type}`, () => { + const change = { + [type]: true, + }; + + expect(getDecorator(change).options.linesDecorationsClassName).toBe( + `dirty-diff dirty-diff-${type}`, + ); + }); + + it('returns with line numbers', () => { + const change = { + lineNumber: 1, + endLineNumber: 2, + [type]: true, + }; + + const { range } = getDecorator(change); + + expect(range.startLineNumber).toBe(1); + expect(range.endLineNumber).toBe(2); + expect(range.startColumn).toBe(1); + expect(range.endColumn).toBe(1); + }); + }); + }); + + describe('attachModel', () => { + it('adds change event callback', () => { + jest.spyOn(model, 'onChange').mockImplementation(() => {}); + + controller.attachModel(model); + + expect(model.onChange).toHaveBeenCalled(); + }); + + it('adds dispose event callback', () => { + jest.spyOn(model, 'onDispose').mockImplementation(() => {}); + + controller.attachModel(model); + + expect(model.onDispose).toHaveBeenCalled(); + }); + + it('calls throttledComputeDiff on change', () => { + jest.spyOn(controller, 'throttledComputeDiff').mockImplementation(() => {}); + + controller.attachModel(model); + + model.getModel().setValue('123'); + + expect(controller.throttledComputeDiff).toHaveBeenCalled(); + }); + + it('caches model', () => { + controller.attachModel(model); + + expect(controller.models.has(model.url)).toBe(true); + }); + }); + + describe('computeDiff', () => { + it('posts to worker', () => { + jest.spyOn(controller.dirtyDiffWorker, 'postMessage').mockImplementation(() => {}); + + controller.computeDiff(model); + + expect(controller.dirtyDiffWorker.postMessage).toHaveBeenCalledWith({ + path: model.path, + originalContent: '', + newContent: '', + }); + }); + }); + + describe('reDecorate', () => { + it('calls computeDiff when no decorations are cached', () => { + jest.spyOn(controller, 'computeDiff').mockImplementation(() => {}); + + controller.reDecorate(model); + + expect(controller.computeDiff).toHaveBeenCalledWith(model); + }); + + it('calls decorate when decorations are cached', () => { + jest.spyOn(controller.decorationsController, 'decorate').mockImplementation(() => {}); + + controller.decorationsController.decorations.set(model.url, 'test'); + + controller.reDecorate(model); + + expect(controller.decorationsController.decorate).toHaveBeenCalledWith(model); + }); + }); + + describe('decorate', () => { + it('adds decorations into decorations controller', () => { + jest.spyOn(controller.decorationsController, 'addDecorations').mockImplementation(() => {}); + + controller.decorate({ data: { changes: [], path: model.path } }); + + expect(controller.decorationsController.addDecorations).toHaveBeenCalledWith( + model, + 'dirtyDiff', + expect.anything(), + ); + }); + + it('adds decorations into editor', () => { + const spy = jest.spyOn(controller.decorationsController.editor.instance, 'deltaDecorations'); + + controller.decorate({ + data: { changes: computeDiff('123', '1234'), path: model.path }, + }); + + expect(spy).toHaveBeenCalledWith( + [], + [ + { + range: new Range(1, 1, 1, 1), + options: { + isWholeLine: true, + linesDecorationsClassName: 'dirty-diff dirty-diff-modified', + }, + }, + ], + ); + }); + }); + + describe('dispose', () => { + it('calls disposable dispose', () => { + jest.spyOn(controller.disposable, 'dispose'); + + controller.dispose(); + + expect(controller.disposable.dispose).toHaveBeenCalled(); + }); + + it('terminates worker', () => { + jest.spyOn(controller.dirtyDiffWorker, 'terminate'); + + controller.dispose(); + + expect(controller.dirtyDiffWorker.terminate).toHaveBeenCalled(); + }); + + it('removes worker event listener', () => { + jest.spyOn(controller.dirtyDiffWorker, 'removeEventListener'); + + controller.dispose(); + + expect(controller.dirtyDiffWorker.removeEventListener).toHaveBeenCalledWith( + 'message', + expect.anything(), + ); + }); + + it('clears cached models', () => { + controller.attachModel(model); + + model.dispose(); + + expect(controller.models.size).toBe(0); + }); + }); +}); diff --git a/spec/frontend/ide/lib/editor_spec.js b/spec/frontend/ide/lib/editor_spec.js new file mode 100644 index 00000000000..36d4c3c26ee --- /dev/null +++ b/spec/frontend/ide/lib/editor_spec.js @@ -0,0 +1,302 @@ +import { editor as monacoEditor, languages as monacoLanguages } from 'monaco-editor'; +import Editor from '~/ide/lib/editor'; +import { defaultEditorOptions } from '~/ide/lib/editor_options'; +import { file } from '../helpers'; + +describe('Multi-file editor library', () => { + let instance; + let el; + let holder; + + const setNodeOffsetWidth = val => { + Object.defineProperty(instance.instance.getDomNode(), 'offsetWidth', { + get() { + return val; + }, + }); + }; + + beforeEach(() => { + el = document.createElement('div'); + holder = document.createElement('div'); + el.appendChild(holder); + + document.body.appendChild(el); + + instance = Editor.create(); + }); + + afterEach(() => { + instance.modelManager.dispose(); + instance.dispose(); + Editor.editorInstance = null; + + el.remove(); + }); + + it('creates instance of editor', () => { + expect(Editor.editorInstance).not.toBeNull(); + }); + + it('creates instance returns cached instance', () => { + expect(Editor.create()).toEqual(instance); + }); + + describe('createInstance', () => { + it('creates editor instance', () => { + jest.spyOn(monacoEditor, 'create'); + + instance.createInstance(holder); + + expect(monacoEditor.create).toHaveBeenCalled(); + }); + + it('creates dirty diff controller', () => { + instance.createInstance(holder); + + expect(instance.dirtyDiffController).not.toBeNull(); + }); + + it('creates model manager', () => { + instance.createInstance(holder); + + expect(instance.modelManager).not.toBeNull(); + }); + }); + + describe('createDiffInstance', () => { + it('creates editor instance', () => { + jest.spyOn(monacoEditor, 'createDiffEditor'); + + instance.createDiffInstance(holder); + + expect(monacoEditor.createDiffEditor).toHaveBeenCalledWith(holder, { + ...defaultEditorOptions, + quickSuggestions: false, + occurrencesHighlight: false, + renderSideBySide: false, + readOnly: true, + renderLineHighlight: 'all', + hideCursorInOverviewRuler: false, + }); + }); + }); + + describe('createModel', () => { + it('calls model manager addModel', () => { + jest.spyOn(instance.modelManager, 'addModel').mockImplementation(() => {}); + + instance.createModel('FILE'); + + expect(instance.modelManager.addModel).toHaveBeenCalledWith('FILE', null); + }); + }); + + describe('attachModel', () => { + let model; + + beforeEach(() => { + instance.createInstance(document.createElement('div')); + + model = instance.createModel(file()); + }); + + it('sets the current model on the instance', () => { + instance.attachModel(model); + + expect(instance.currentModel).toBe(model); + }); + + it('attaches the model to the current instance', () => { + jest.spyOn(instance.instance, 'setModel').mockImplementation(() => {}); + + instance.attachModel(model); + + expect(instance.instance.setModel).toHaveBeenCalledWith(model.getModel()); + }); + + it('sets original & modified when diff editor', () => { + jest.spyOn(instance.instance, 'getEditorType').mockReturnValue('vs.editor.IDiffEditor'); + jest.spyOn(instance.instance, 'setModel').mockImplementation(() => {}); + + instance.attachModel(model); + + expect(instance.instance.setModel).toHaveBeenCalledWith({ + original: model.getOriginalModel(), + modified: model.getModel(), + }); + }); + + it('attaches the model to the dirty diff controller', () => { + jest.spyOn(instance.dirtyDiffController, 'attachModel').mockImplementation(() => {}); + + instance.attachModel(model); + + expect(instance.dirtyDiffController.attachModel).toHaveBeenCalledWith(model); + }); + + it('re-decorates with the dirty diff controller', () => { + jest.spyOn(instance.dirtyDiffController, 'reDecorate').mockImplementation(() => {}); + + instance.attachModel(model); + + expect(instance.dirtyDiffController.reDecorate).toHaveBeenCalledWith(model); + }); + }); + + describe('attachMergeRequestModel', () => { + let model; + + beforeEach(() => { + instance.createDiffInstance(document.createElement('div')); + + const f = file(); + f.mrChanges = { diff: 'ABC' }; + f.baseRaw = 'testing'; + + model = instance.createModel(f); + }); + + it('sets original & modified', () => { + jest.spyOn(instance.instance, 'setModel').mockImplementation(() => {}); + + instance.attachMergeRequestModel(model); + + expect(instance.instance.setModel).toHaveBeenCalledWith({ + original: model.getBaseModel(), + modified: model.getModel(), + }); + }); + }); + + describe('clearEditor', () => { + it('resets the editor model', () => { + instance.createInstance(document.createElement('div')); + + jest.spyOn(instance.instance, 'setModel').mockImplementation(() => {}); + + instance.clearEditor(); + + expect(instance.instance.setModel).toHaveBeenCalledWith(null); + }); + }); + + describe('languages', () => { + it('registers custom languages defined with Monaco', () => { + expect(monacoLanguages.getLanguages()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'vue', + }), + ]), + ); + }); + }); + + describe('dispose', () => { + it('calls disposble dispose method', () => { + jest.spyOn(instance.disposable, 'dispose'); + + instance.dispose(); + + expect(instance.disposable.dispose).toHaveBeenCalled(); + }); + + it('resets instance', () => { + instance.createInstance(document.createElement('div')); + + expect(instance.instance).not.toBeNull(); + + instance.dispose(); + + expect(instance.instance).toBeNull(); + }); + + it('does not dispose modelManager', () => { + jest.spyOn(instance.modelManager, 'dispose').mockImplementation(() => {}); + + instance.dispose(); + + expect(instance.modelManager.dispose).not.toHaveBeenCalled(); + }); + + it('does not dispose decorationsController', () => { + jest.spyOn(instance.decorationsController, 'dispose').mockImplementation(() => {}); + + instance.dispose(); + + expect(instance.decorationsController.dispose).not.toHaveBeenCalled(); + }); + }); + + describe('updateDiffView', () => { + describe('edit mode', () => { + it('does not update options', () => { + instance.createInstance(holder); + + jest.spyOn(instance.instance, 'updateOptions').mockImplementation(() => {}); + + instance.updateDiffView(); + + expect(instance.instance.updateOptions).not.toHaveBeenCalled(); + }); + }); + + describe('diff mode', () => { + beforeEach(() => { + instance.createDiffInstance(holder); + + jest.spyOn(instance.instance, 'updateOptions'); + }); + + it('sets renderSideBySide to false if el is less than 700 pixels', () => { + setNodeOffsetWidth(600); + + expect(instance.instance.updateOptions).not.toHaveBeenCalledWith({ + renderSideBySide: false, + }); + }); + + it('sets renderSideBySide to false if el is more than 700 pixels', () => { + setNodeOffsetWidth(800); + + expect(instance.instance.updateOptions).not.toHaveBeenCalledWith({ + renderSideBySide: true, + }); + }); + }); + }); + + describe('isDiffEditorType', () => { + it('returns true when diff editor', () => { + instance.createDiffInstance(holder); + + expect(instance.isDiffEditorType).toBe(true); + }); + + it('returns false when not diff editor', () => { + instance.createInstance(holder); + + expect(instance.isDiffEditorType).toBe(false); + }); + }); + + it('sets quickSuggestions to false when language is markdown', () => { + instance.createInstance(holder); + + jest.spyOn(instance.instance, 'updateOptions'); + + const model = instance.createModel({ + ...file(), + key: 'index.md', + path: 'index.md', + }); + + instance.attachModel(model); + + expect(instance.instance.updateOptions).toHaveBeenCalledWith({ + readOnly: false, + quickSuggestions: false, + }); + }); +}); diff --git a/spec/frontend/ide/lib/languages/vue_spec.js b/spec/frontend/ide/lib/languages/vue_spec.js new file mode 100644 index 00000000000..3d8784c1436 --- /dev/null +++ b/spec/frontend/ide/lib/languages/vue_spec.js @@ -0,0 +1,92 @@ +import { editor } from 'monaco-editor'; +import { registerLanguages } from '~/ide/utils'; +import vue from '~/ide/lib/languages/vue'; + +// This file only tests syntax specific to vue. This does not test existing syntaxes +// of html, javascript, css and handlebars, which vue files extend. +describe('tokenization for .vue files', () => { + beforeEach(() => { + registerLanguages(vue); + }); + + test.each([ + [ + '<div v-if="something">content</div>', + [ + [ + { language: 'vue', offset: 0, type: 'delimiter.html' }, + { language: 'vue', offset: 1, type: 'tag.html' }, + { language: 'vue', offset: 4, type: '' }, + { language: 'vue', offset: 5, type: 'variable' }, + { language: 'vue', offset: 21, type: 'delimiter.html' }, + { language: 'vue', offset: 22, type: '' }, + { language: 'vue', offset: 29, type: 'delimiter.html' }, + { language: 'vue', offset: 31, type: 'tag.html' }, + { language: 'vue', offset: 34, type: 'delimiter.html' }, + ], + ], + ], + [ + '<input :placeholder="placeholder">', + [ + [ + { language: 'vue', offset: 0, type: 'delimiter.html' }, + { language: 'vue', offset: 1, type: 'tag.html' }, + { language: 'vue', offset: 6, type: '' }, + { language: 'vue', offset: 7, type: 'variable' }, + { language: 'vue', offset: 33, type: 'delimiter.html' }, + ], + ], + ], + [ + '<gl-modal @ok="submitForm()"></gl-modal>', + [ + [ + { language: 'vue', offset: 0, type: 'delimiter.html' }, + { language: 'vue', offset: 1, type: 'tag.html' }, + { language: 'vue', offset: 3, type: 'attribute.name' }, + { language: 'vue', offset: 9, type: '' }, + { language: 'vue', offset: 10, type: 'variable' }, + { language: 'vue', offset: 28, type: 'delimiter.html' }, + { language: 'vue', offset: 31, type: 'tag.html' }, + { language: 'vue', offset: 33, type: 'attribute.name' }, + { language: 'vue', offset: 39, type: 'delimiter.html' }, + ], + ], + ], + [ + '<a v-on:click.stop="doSomething">...</a>', + [ + [ + { language: 'vue', offset: 0, type: 'delimiter.html' }, + { language: 'vue', offset: 1, type: 'tag.html' }, + { language: 'vue', offset: 2, type: '' }, + { language: 'vue', offset: 3, type: 'variable' }, + { language: 'vue', offset: 32, type: 'delimiter.html' }, + { language: 'vue', offset: 33, type: '' }, + { language: 'vue', offset: 36, type: 'delimiter.html' }, + { language: 'vue', offset: 38, type: 'tag.html' }, + { language: 'vue', offset: 39, type: 'delimiter.html' }, + ], + ], + ], + [ + '<a @[event]="doSomething">...</a>', + [ + [ + { language: 'vue', offset: 0, type: 'delimiter.html' }, + { language: 'vue', offset: 1, type: 'tag.html' }, + { language: 'vue', offset: 2, type: '' }, + { language: 'vue', offset: 3, type: 'variable' }, + { language: 'vue', offset: 25, type: 'delimiter.html' }, + { language: 'vue', offset: 26, type: '' }, + { language: 'vue', offset: 29, type: 'delimiter.html' }, + { language: 'vue', offset: 31, type: 'tag.html' }, + { language: 'vue', offset: 32, type: 'delimiter.html' }, + ], + ], + ], + ])('%s', (string, tokens) => { + expect(editor.tokenize(string, 'vue')).toEqual(tokens); + }); +}); diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js index 658ad37d7f2..3cb6e064aa2 100644 --- a/spec/frontend/ide/services/index_spec.js +++ b/spec/frontend/ide/services/index_spec.js @@ -221,4 +221,67 @@ describe('IDE services', () => { }); }); }); + + describe('getFiles', () => { + let mock; + let relativeUrlRoot; + const TEST_RELATIVE_URL_ROOT = 'blah-blah'; + + beforeEach(() => { + jest.spyOn(axios, 'get'); + relativeUrlRoot = gon.relative_url_root; + gon.relative_url_root = TEST_RELATIVE_URL_ROOT; + + mock = new MockAdapter(axios); + + mock + .onGet(`${TEST_RELATIVE_URL_ROOT}/${TEST_PROJECT_ID}/-/files/${TEST_COMMIT_SHA}`) + .reply(200, [TEST_FILE_PATH]); + }); + + afterEach(() => { + mock.restore(); + gon.relative_url_root = relativeUrlRoot; + }); + + it('initates the api call based on the passed path and commit hash', () => { + return services.getFiles(TEST_PROJECT_ID, TEST_COMMIT_SHA).then(({ data }) => { + expect(axios.get).toHaveBeenCalledWith( + `${gon.relative_url_root}/${TEST_PROJECT_ID}/-/files/${TEST_COMMIT_SHA}`, + expect.any(Object), + ); + expect(data).toEqual([TEST_FILE_PATH]); + }); + }); + }); + + describe('pingUsage', () => { + let mock; + let relativeUrlRoot; + const TEST_RELATIVE_URL_ROOT = 'blah-blah'; + + beforeEach(() => { + jest.spyOn(axios, 'post'); + relativeUrlRoot = gon.relative_url_root; + gon.relative_url_root = TEST_RELATIVE_URL_ROOT; + + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + gon.relative_url_root = relativeUrlRoot; + }); + + it('posts to usage endpoint', () => { + const TEST_PROJECT_PATH = 'foo/bar'; + const axiosURL = `${TEST_RELATIVE_URL_ROOT}/${TEST_PROJECT_PATH}/usage_ping/web_ide_pipelines_count`; + + mock.onPost(axiosURL).reply(200); + + return services.pingUsage(TEST_PROJECT_PATH).then(() => { + expect(axios.post).toHaveBeenCalledWith(axiosURL); + }); + }); + }); }); diff --git a/spec/frontend/ide/stores/mutations_spec.js b/spec/frontend/ide/stores/mutations_spec.js index 5d0fe35a10e..2eca9acb8d8 100644 --- a/spec/frontend/ide/stores/mutations_spec.js +++ b/spec/frontend/ide/stores/mutations_spec.js @@ -55,30 +55,6 @@ describe('Multi-file store mutations', () => { }); }); - describe('SET_LEFT_PANEL_COLLAPSED', () => { - it('sets left panel collapsed', () => { - mutations.SET_LEFT_PANEL_COLLAPSED(localState, true); - - expect(localState.leftPanelCollapsed).toBeTruthy(); - - mutations.SET_LEFT_PANEL_COLLAPSED(localState, false); - - expect(localState.leftPanelCollapsed).toBeFalsy(); - }); - }); - - describe('SET_RIGHT_PANEL_COLLAPSED', () => { - it('sets right panel collapsed', () => { - mutations.SET_RIGHT_PANEL_COLLAPSED(localState, true); - - expect(localState.rightPanelCollapsed).toBeTruthy(); - - mutations.SET_RIGHT_PANEL_COLLAPSED(localState, false); - - expect(localState.rightPanelCollapsed).toBeFalsy(); - }); - }); - describe('CLEAR_STAGED_CHANGES', () => { it('clears stagedFiles array', () => { localState.stagedFiles.push('a'); @@ -339,23 +315,6 @@ describe('Multi-file store mutations', () => { }); }); - describe('OPEN_NEW_ENTRY_MODAL', () => { - it('sets entryModal', () => { - localState.entries.testPath = file(); - - mutations.OPEN_NEW_ENTRY_MODAL(localState, { - type: 'test', - path: 'testPath', - }); - - expect(localState.entryModal).toEqual({ - type: 'test', - path: 'testPath', - entry: localState.entries.testPath, - }); - }); - }); - describe('RENAME_ENTRY', () => { beforeEach(() => { localState.trees = { diff --git a/spec/frontend/ide/stores/utils_spec.js b/spec/frontend/ide/stores/utils_spec.js index 90f2644de62..b87f6c1f05a 100644 --- a/spec/frontend/ide/stores/utils_spec.js +++ b/spec/frontend/ide/stores/utils_spec.js @@ -685,4 +685,75 @@ describe('Multi-file store utils', () => { }); }); }); + + describe('extractMarkdownImagesFromEntries', () => { + let mdFile; + let entries; + + beforeEach(() => { + const img = { content: '/base64/encoded/image+' }; + mdFile = { path: 'path/to/some/directory/myfile.md' }; + entries = { + // invalid (or lack of) extensions are also supported as long as there's + // a real image inside and can go into an <img> tag's `src` and the browser + // can render it + img, + 'img.js': img, + 'img.png': img, + 'img.with.many.dots.png': img, + 'path/to/img.gif': img, + 'path/to/some/img.jpg': img, + 'path/to/some/img 1/img.png': img, + 'path/to/some/directory/img.png': img, + 'path/to/some/directory/img 1.png': img, + }; + }); + + it.each` + markdownBefore | ext | imgAlt | imgTitle + ${'* ![img](/img)'} | ${'jpeg'} | ${'img'} | ${undefined} + ${'* ![img](/img.js)'} | ${'js'} | ${'img'} | ${undefined} + ${'* ![img](img.png)'} | ${'png'} | ${'img'} | ${undefined} + ${'* ![img](./img.png)'} | ${'png'} | ${'img'} | ${undefined} + ${'* ![with spaces](../img 1/img.png)'} | ${'png'} | ${'with spaces'} | ${undefined} + ${'* ![img](../../img.gif " title ")'} | ${'gif'} | ${'img'} | ${' title '} + ${'* ![img](../img.jpg)'} | ${'jpg'} | ${'img'} | ${undefined} + ${'* ![img](/img.png "title")'} | ${'png'} | ${'img'} | ${'title'} + ${'* ![img](/img.with.many.dots.png)'} | ${'png'} | ${'img'} | ${undefined} + ${'* ![img](img 1.png)'} | ${'png'} | ${'img'} | ${undefined} + ${'* ![img](img.png "title here")'} | ${'png'} | ${'img'} | ${'title here'} + `( + 'correctly transforms markdown with uncommitted images: $markdownBefore', + ({ markdownBefore, ext, imgAlt, imgTitle }) => { + mdFile.content = markdownBefore; + + expect(utils.extractMarkdownImagesFromEntries(mdFile, entries)).toEqual({ + content: '* {{gl_md_img_1}}', + images: { + '{{gl_md_img_1}}': { + src: `data:image/${ext};base64,/base64/encoded/image+`, + alt: imgAlt, + title: imgTitle, + }, + }, + }); + }, + ); + + it.each` + markdown + ${'* ![img](i.png)'} + ${'* ![img](img.png invalid title)'} + ${'* ![img](img.png "incorrect" "markdown")'} + ${'* ![img](https://gitlab.com/logo.png)'} + ${'* ![img](https://gitlab.com/some/deep/nested/path/logo.png)'} + `("doesn't touch invalid or non-existant images in markdown: $markdown", ({ markdown }) => { + mdFile.content = markdown; + + expect(utils.extractMarkdownImagesFromEntries(mdFile, entries)).toEqual({ + content: markdown, + images: {}, + }); + }); + }); }); diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js index 44eae7eacbe..ea975500e8d 100644 --- a/spec/frontend/ide/utils_spec.js +++ b/spec/frontend/ide/utils_spec.js @@ -1,6 +1,7 @@ import { commitItemIconMap } from '~/ide/constants'; -import { getCommitIconMap, isTextFile } from '~/ide/utils'; +import { getCommitIconMap, isTextFile, registerLanguages, trimPathComponents } from '~/ide/utils'; import { decorateData } from '~/ide/stores/utils'; +import { languages } from 'monaco-editor'; describe('WebIDE utils', () => { describe('isTextFile', () => { @@ -102,4 +103,93 @@ describe('WebIDE utils', () => { expect(getCommitIconMap(entry)).toEqual(commitItemIconMap.modified); }); }); + + describe('trimPathComponents', () => { + it.each` + input | output + ${'example path '} | ${'example path'} + ${'p/somefile '} | ${'p/somefile'} + ${'p /somefile '} | ${'p/somefile'} + ${'p/ somefile '} | ${'p/somefile'} + ${' p/somefile '} | ${'p/somefile'} + ${'p/somefile .md'} | ${'p/somefile .md'} + ${'path / to / some/file.doc '} | ${'path/to/some/file.doc'} + `('trims all path components in path: "$input"', ({ input, output }) => { + expect(trimPathComponents(input)).toEqual(output); + }); + }); + + describe('registerLanguages', () => { + let langs; + + beforeEach(() => { + langs = [ + { + id: 'html', + extensions: ['.html'], + conf: { comments: { blockComment: ['<!--', '-->'] } }, + language: { tokenizer: {} }, + }, + { + id: 'css', + extensions: ['.css'], + conf: { comments: { blockComment: ['/*', '*/'] } }, + language: { tokenizer: {} }, + }, + { + id: 'js', + extensions: ['.js'], + conf: { comments: { blockComment: ['/*', '*/'] } }, + language: { tokenizer: {} }, + }, + ]; + + jest.spyOn(languages, 'register').mockImplementation(() => {}); + jest.spyOn(languages, 'setMonarchTokensProvider').mockImplementation(() => {}); + jest.spyOn(languages, 'setLanguageConfiguration').mockImplementation(() => {}); + }); + + it('registers all the passed languages with Monaco', () => { + registerLanguages(...langs); + + expect(languages.register.mock.calls).toEqual([ + [ + { + conf: { comments: { blockComment: ['/*', '*/'] } }, + extensions: ['.css'], + id: 'css', + language: { tokenizer: {} }, + }, + ], + [ + { + conf: { comments: { blockComment: ['/*', '*/'] } }, + extensions: ['.js'], + id: 'js', + language: { tokenizer: {} }, + }, + ], + [ + { + conf: { comments: { blockComment: ['<!--', '-->'] } }, + extensions: ['.html'], + id: 'html', + language: { tokenizer: {} }, + }, + ], + ]); + + expect(languages.setMonarchTokensProvider.mock.calls).toEqual([ + ['css', { tokenizer: {} }], + ['js', { tokenizer: {} }], + ['html', { tokenizer: {} }], + ]); + + expect(languages.setLanguageConfiguration.mock.calls).toEqual([ + ['css', { comments: { blockComment: ['/*', '*/'] } }], + ['js', { comments: { blockComment: ['/*', '*/'] } }], + ['html', { comments: { blockComment: ['<!--', '-->'] } }], + ]); + }); + }); }); diff --git a/spec/frontend/image_diff/helpers/badge_helper_spec.js b/spec/frontend/image_diff/helpers/badge_helper_spec.js new file mode 100644 index 00000000000..c970ccc535d --- /dev/null +++ b/spec/frontend/image_diff/helpers/badge_helper_spec.js @@ -0,0 +1,130 @@ +import * as badgeHelper from '~/image_diff/helpers/badge_helper'; +import * as mockData from '../mock_data'; + +describe('badge helper', () => { + const { coordinate, noteId, badgeText, badgeNumber } = mockData; + let containerEl; + let buttonEl; + + beforeEach(() => { + containerEl = document.createElement('div'); + }); + + describe('createImageBadge', () => { + beforeEach(() => { + buttonEl = badgeHelper.createImageBadge(noteId, coordinate); + }); + + it('should create button', () => { + expect(buttonEl.tagName).toEqual('BUTTON'); + expect(buttonEl.getAttribute('type')).toEqual('button'); + }); + + it('should set disabled attribute', () => { + expect(buttonEl.hasAttribute('disabled')).toEqual(true); + }); + + it('should set noteId', () => { + expect(buttonEl.dataset.noteId).toEqual(noteId); + }); + + it('should set coordinate', () => { + expect(buttonEl.style.left).toEqual(`${coordinate.x}px`); + expect(buttonEl.style.top).toEqual(`${coordinate.y}px`); + }); + + describe('classNames', () => { + it('should set .js-image-badge by default', () => { + expect(buttonEl.className).toEqual('js-image-badge'); + }); + + it('should add additional class names if parameter is passed', () => { + const classNames = ['first-class', 'second-class']; + buttonEl = badgeHelper.createImageBadge(noteId, coordinate, classNames); + + expect(buttonEl.className).toEqual(classNames.concat('js-image-badge').join(' ')); + }); + }); + }); + + describe('addImageBadge', () => { + beforeEach(() => { + badgeHelper.addImageBadge(containerEl, { + coordinate, + badgeText, + noteId, + }); + buttonEl = containerEl.querySelector('button'); + }); + + it('should appends button to container', () => { + expect(buttonEl).toBeDefined(); + }); + + it('should add badge classes', () => { + expect(buttonEl.className).toContain('badge badge-pill'); + }); + + it('should set the badge text', () => { + expect(buttonEl.textContent).toEqual(badgeText); + }); + + it('should set the button coordinates', () => { + expect(buttonEl.style.left).toEqual(`${coordinate.x}px`); + expect(buttonEl.style.top).toEqual(`${coordinate.y}px`); + }); + + it('should set the button noteId', () => { + expect(buttonEl.dataset.noteId).toEqual(noteId); + }); + }); + + describe('addImageCommentBadge', () => { + beforeEach(() => { + badgeHelper.addImageCommentBadge(containerEl, { + coordinate, + noteId, + }); + buttonEl = containerEl.querySelector('button'); + }); + + it('should append icon button to container', () => { + expect(buttonEl).toBeDefined(); + }); + + it('should create icon comment button', () => { + const iconEl = buttonEl.querySelector('svg'); + + expect(iconEl).toBeDefined(); + }); + }); + + describe('addAvatarBadge', () => { + let avatarBadgeEl; + + beforeEach(() => { + containerEl.innerHTML = ` + <div id="${noteId}"> + <div class="badge hidden"> + </div> + </div> + `; + + badgeHelper.addAvatarBadge(containerEl, { + detail: { + noteId, + badgeNumber, + }, + }); + avatarBadgeEl = containerEl.querySelector(`#${noteId} .badge`); + }); + + it('should update badge number', () => { + expect(avatarBadgeEl.textContent).toEqual(badgeNumber.toString()); + }); + + it('should remove hidden class', () => { + expect(avatarBadgeEl.classList.contains('hidden')).toEqual(false); + }); + }); +}); diff --git a/spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js b/spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js new file mode 100644 index 00000000000..395bb7de362 --- /dev/null +++ b/spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js @@ -0,0 +1,144 @@ +import * as commentIndicatorHelper from '~/image_diff/helpers/comment_indicator_helper'; +import * as mockData from '../mock_data'; + +describe('commentIndicatorHelper', () => { + const { coordinate } = mockData; + let containerEl; + + beforeEach(() => { + containerEl = document.createElement('div'); + }); + + describe('addCommentIndicator', () => { + let buttonEl; + + beforeEach(() => { + commentIndicatorHelper.addCommentIndicator(containerEl, coordinate); + buttonEl = containerEl.querySelector('button'); + }); + + it('should append button to container', () => { + expect(buttonEl).toBeDefined(); + }); + + describe('button', () => { + it('should set coordinate', () => { + expect(buttonEl.style.left).toEqual(`${coordinate.x}px`); + expect(buttonEl.style.top).toEqual(`${coordinate.y}px`); + }); + + it('should contain image-comment-dark svg', () => { + const svgEl = buttonEl.querySelector('svg'); + + expect(svgEl).toBeDefined(); + + const svgLink = svgEl.querySelector('use').getAttribute('xlink:href'); + + expect(svgLink.indexOf('image-comment-dark')).not.toBe(-1); + }); + }); + }); + + describe('removeCommentIndicator', () => { + it('should return removed false if there is no comment-indicator', () => { + const result = commentIndicatorHelper.removeCommentIndicator(containerEl); + + expect(result.removed).toEqual(false); + }); + + describe('has comment indicator', () => { + let result; + + beforeEach(() => { + containerEl.innerHTML = ` + <div class="comment-indicator" style="left:${coordinate.x}px; top: ${coordinate.y}px;"> + <img src="${gl.TEST_HOST}/image.png"> + </div> + `; + result = commentIndicatorHelper.removeCommentIndicator(containerEl); + }); + + it('should remove comment indicator', () => { + expect(containerEl.querySelector('.comment-indicator')).toBeNull(); + }); + + it('should return removed true', () => { + expect(result.removed).toEqual(true); + }); + + it('should return indicator meta', () => { + expect(result.x).toEqual(coordinate.x); + expect(result.y).toEqual(coordinate.y); + expect(result.image).toBeDefined(); + expect(result.image.width).toBeDefined(); + expect(result.image.height).toBeDefined(); + }); + }); + }); + + describe('showCommentIndicator', () => { + describe('commentIndicator exists', () => { + beforeEach(() => { + containerEl.innerHTML = ` + <button class="comment-indicator"></button> + `; + commentIndicatorHelper.showCommentIndicator(containerEl, coordinate); + }); + + it('should set commentIndicator coordinates', () => { + const commentIndicatorEl = containerEl.querySelector('.comment-indicator'); + + expect(commentIndicatorEl.style.left).toEqual(`${coordinate.x}px`); + expect(commentIndicatorEl.style.top).toEqual(`${coordinate.y}px`); + }); + }); + + describe('commentIndicator does not exist', () => { + beforeEach(() => { + commentIndicatorHelper.showCommentIndicator(containerEl, coordinate); + }); + + it('should addCommentIndicator', () => { + const buttonEl = containerEl.querySelector('.comment-indicator'); + + expect(buttonEl).toBeDefined(); + expect(buttonEl.style.left).toEqual(`${coordinate.x}px`); + expect(buttonEl.style.top).toEqual(`${coordinate.y}px`); + }); + }); + }); + + describe('commentIndicatorOnClick', () => { + let event; + let textAreaEl; + + beforeEach(() => { + containerEl.innerHTML = ` + <div class="diff-viewer"> + <button></button> + <div class="note-container"> + <textarea class="note-textarea"></textarea> + </div> + </div> + `; + textAreaEl = containerEl.querySelector('textarea'); + + event = { + stopPropagation: () => {}, + currentTarget: containerEl.querySelector('button'), + }; + + jest.spyOn(event, 'stopPropagation').mockImplementation(() => {}); + jest.spyOn(textAreaEl, 'focus').mockImplementation(() => {}); + commentIndicatorHelper.commentIndicatorOnClick(event); + }); + + it('should stopPropagation', () => { + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it('should focus textAreaEl', () => { + expect(textAreaEl.focus).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/image_diff/helpers/dom_helper_spec.js b/spec/frontend/image_diff/helpers/dom_helper_spec.js new file mode 100644 index 00000000000..9357d626bbe --- /dev/null +++ b/spec/frontend/image_diff/helpers/dom_helper_spec.js @@ -0,0 +1,120 @@ +import * as domHelper from '~/image_diff/helpers/dom_helper'; +import * as mockData from '../mock_data'; + +describe('domHelper', () => { + const { imageMeta, badgeNumber } = mockData; + + describe('setPositionDataAttribute', () => { + let containerEl; + let attributeAfterCall; + const position = { + myProperty: 'myProperty', + }; + + beforeEach(() => { + containerEl = document.createElement('div'); + containerEl.dataset.position = JSON.stringify(position); + domHelper.setPositionDataAttribute(containerEl, imageMeta); + attributeAfterCall = JSON.parse(containerEl.dataset.position); + }); + + it('should set x, y, width, height', () => { + expect(attributeAfterCall.x).toEqual(imageMeta.x); + expect(attributeAfterCall.y).toEqual(imageMeta.y); + expect(attributeAfterCall.width).toEqual(imageMeta.width); + expect(attributeAfterCall.height).toEqual(imageMeta.height); + }); + + it('should not override other properties', () => { + expect(attributeAfterCall.myProperty).toEqual('myProperty'); + }); + }); + + describe('updateDiscussionAvatarBadgeNumber', () => { + let discussionEl; + + beforeEach(() => { + discussionEl = document.createElement('div'); + discussionEl.innerHTML = ` + <a href="#" class="image-diff-avatar-link"> + <div class="badge"></div> + </a> + `; + domHelper.updateDiscussionAvatarBadgeNumber(discussionEl, badgeNumber); + }); + + it('should update avatar badge number', () => { + expect(discussionEl.querySelector('.badge').textContent).toEqual(badgeNumber.toString()); + }); + }); + + describe('updateDiscussionBadgeNumber', () => { + let discussionEl; + + beforeEach(() => { + discussionEl = document.createElement('div'); + discussionEl.innerHTML = ` + <div class="badge"></div> + `; + domHelper.updateDiscussionBadgeNumber(discussionEl, badgeNumber); + }); + + it('should update discussion badge number', () => { + expect(discussionEl.querySelector('.badge').textContent).toEqual(badgeNumber.toString()); + }); + }); + + describe('toggleCollapsed', () => { + let element; + let discussionNotesEl; + + beforeEach(() => { + element = document.createElement('div'); + element.innerHTML = ` + <div class="discussion-notes"> + <button></button> + <form class="discussion-form"></form> + </div> + `; + discussionNotesEl = element.querySelector('.discussion-notes'); + }); + + describe('not collapsed', () => { + beforeEach(() => { + domHelper.toggleCollapsed({ + currentTarget: element.querySelector('button'), + }); + }); + + it('should add collapsed class', () => { + expect(discussionNotesEl.classList.contains('collapsed')).toEqual(true); + }); + + it('should force formEl to display none', () => { + const formEl = element.querySelector('.discussion-form'); + + expect(formEl.style.display).toEqual('none'); + }); + }); + + describe('collapsed', () => { + beforeEach(() => { + discussionNotesEl.classList.add('collapsed'); + + domHelper.toggleCollapsed({ + currentTarget: element.querySelector('button'), + }); + }); + + it('should remove collapsed class', () => { + expect(discussionNotesEl.classList.contains('collapsed')).toEqual(false); + }); + + it('should force formEl to display block', () => { + const formEl = element.querySelector('.discussion-form'); + + expect(formEl.style.display).toEqual('block'); + }); + }); + }); +}); diff --git a/spec/frontend/image_diff/helpers/utils_helper_spec.js b/spec/frontend/image_diff/helpers/utils_helper_spec.js new file mode 100644 index 00000000000..3b6378be883 --- /dev/null +++ b/spec/frontend/image_diff/helpers/utils_helper_spec.js @@ -0,0 +1,152 @@ +import * as utilsHelper from '~/image_diff/helpers/utils_helper'; +import ImageBadge from '~/image_diff/image_badge'; +import * as mockData from '../mock_data'; + +describe('utilsHelper', () => { + const { noteId, discussionId, image, imageProperties, imageMeta } = mockData; + + describe('resizeCoordinatesToImageElement', () => { + let result; + + beforeEach(() => { + result = utilsHelper.resizeCoordinatesToImageElement(image, imageMeta); + }); + + it('should return x based on widthRatio', () => { + expect(result.x).toEqual(imageMeta.x * 0.5); + }); + + it('should return y based on heightRatio', () => { + expect(result.y).toEqual(imageMeta.y * 0.5); + }); + + it('should return image width', () => { + expect(result.width).toEqual(image.width); + }); + + it('should return image height', () => { + expect(result.height).toEqual(image.height); + }); + }); + + describe('generateBadgeFromDiscussionDOM', () => { + let discussionEl; + let result; + + beforeEach(() => { + const imageFrameEl = document.createElement('div'); + imageFrameEl.innerHTML = ` + <img src="${gl.TEST_HOST}/image.png"> + `; + discussionEl = document.createElement('div'); + discussionEl.dataset.discussionId = discussionId; + discussionEl.innerHTML = ` + <div class="note" id="${noteId}"></div> + `; + discussionEl.dataset.position = JSON.stringify(imageMeta); + result = utilsHelper.generateBadgeFromDiscussionDOM(imageFrameEl, discussionEl); + }); + + it('should return actual image properties', () => { + const { actual } = result; + + expect(actual.x).toEqual(imageMeta.x); + expect(actual.y).toEqual(imageMeta.y); + expect(actual.width).toEqual(imageMeta.width); + expect(actual.height).toEqual(imageMeta.height); + }); + + it('should return browser image properties', () => { + const { browser } = result; + + expect(browser.x).toBeDefined(); + expect(browser.y).toBeDefined(); + expect(browser.width).toBeDefined(); + expect(browser.height).toBeDefined(); + }); + + it('should return instance of ImageBadge', () => { + expect(result instanceof ImageBadge).toEqual(true); + }); + + it('should return noteId', () => { + expect(result.noteId).toEqual(noteId); + }); + + it('should return discussionId', () => { + expect(result.discussionId).toEqual(discussionId); + }); + }); + + describe('getTargetSelection', () => { + let containerEl; + + beforeEach(() => { + containerEl = { + querySelector: () => imageProperties, + }; + }); + + function generateEvent(offsetX, offsetY) { + return { + currentTarget: containerEl, + offsetX, + offsetY, + }; + } + + it('should return browser properties', () => { + const event = generateEvent(25, 25); + const result = utilsHelper.getTargetSelection(event); + + const { browser } = result; + + expect(browser.x).toEqual(event.offsetX); + expect(browser.y).toEqual(event.offsetY); + expect(browser.width).toEqual(imageProperties.width); + expect(browser.height).toEqual(imageProperties.height); + }); + + it('should return resized actual image properties', () => { + const event = generateEvent(50, 50); + const result = utilsHelper.getTargetSelection(event); + + const { actual } = result; + + expect(actual.x).toEqual(100); + expect(actual.y).toEqual(100); + expect(actual.width).toEqual(imageProperties.naturalWidth); + expect(actual.height).toEqual(imageProperties.naturalHeight); + }); + + describe('normalize coordinates', () => { + it('should return x = 0 if x < 0', () => { + const event = generateEvent(-5, 50); + const result = utilsHelper.getTargetSelection(event); + + expect(result.browser.x).toEqual(0); + }); + + it('should return x = width if x > width', () => { + const event = generateEvent(1000, 50); + const result = utilsHelper.getTargetSelection(event); + + expect(result.browser.x).toEqual(imageProperties.width); + }); + + it('should return y = 0 if y < 0', () => { + const event = generateEvent(50, -10); + const result = utilsHelper.getTargetSelection(event); + + expect(result.browser.y).toEqual(0); + }); + + it('should return y = height if y > height', () => { + const event = generateEvent(50, 1000); + const result = utilsHelper.getTargetSelection(event); + + expect(result.browser.y).toEqual(imageProperties.height); + }); + }); + }); +}); diff --git a/spec/frontend/image_diff/image_badge_spec.js b/spec/frontend/image_diff/image_badge_spec.js new file mode 100644 index 00000000000..a11b50ead47 --- /dev/null +++ b/spec/frontend/image_diff/image_badge_spec.js @@ -0,0 +1,84 @@ +import ImageBadge from '~/image_diff/image_badge'; +import imageDiffHelper from '~/image_diff/helpers/index'; +import * as mockData from './mock_data'; + +describe('ImageBadge', () => { + const { noteId, discussionId, imageMeta } = mockData; + const options = { + noteId, + discussionId, + }; + + it('should save actual property', () => { + const imageBadge = new ImageBadge({ ...options, actual: imageMeta }); + + const { actual } = imageBadge; + + expect(actual.x).toEqual(imageMeta.x); + expect(actual.y).toEqual(imageMeta.y); + expect(actual.width).toEqual(imageMeta.width); + expect(actual.height).toEqual(imageMeta.height); + }); + + it('should save browser property', () => { + const imageBadge = new ImageBadge({ ...options, browser: imageMeta }); + + const { browser } = imageBadge; + + expect(browser.x).toEqual(imageMeta.x); + expect(browser.y).toEqual(imageMeta.y); + expect(browser.width).toEqual(imageMeta.width); + expect(browser.height).toEqual(imageMeta.height); + }); + + it('should save noteId', () => { + const imageBadge = new ImageBadge(options); + + expect(imageBadge.noteId).toEqual(noteId); + }); + + it('should save discussionId', () => { + const imageBadge = new ImageBadge(options); + + expect(imageBadge.discussionId).toEqual(discussionId); + }); + + describe('default values', () => { + let imageBadge; + + beforeEach(() => { + imageBadge = new ImageBadge(options); + }); + + it('should return defaultimageMeta if actual property is not provided', () => { + const { actual } = imageBadge; + + expect(actual.x).toEqual(0); + expect(actual.y).toEqual(0); + expect(actual.width).toEqual(0); + expect(actual.height).toEqual(0); + }); + + it('should return defaultimageMeta if browser property is not provided', () => { + const { browser } = imageBadge; + + expect(browser.x).toEqual(0); + expect(browser.y).toEqual(0); + expect(browser.width).toEqual(0); + expect(browser.height).toEqual(0); + }); + }); + + describe('imageEl property is provided and not browser property', () => { + beforeEach(() => { + jest.spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement').mockReturnValue(true); + }); + + it('should generate browser property', () => { + const imageBadge = new ImageBadge({ ...options, imageEl: document.createElement('img') }); + + expect(imageDiffHelper.resizeCoordinatesToImageElement).toHaveBeenCalled(); + expect(imageBadge.browser).toEqual(true); + }); + }); +}); diff --git a/spec/frontend/image_diff/image_diff_spec.js b/spec/frontend/image_diff/image_diff_spec.js new file mode 100644 index 00000000000..c15718b5106 --- /dev/null +++ b/spec/frontend/image_diff/image_diff_spec.js @@ -0,0 +1,361 @@ +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'; + +describe('ImageDiff', () => { + let element; + let imageDiff; + + beforeEach(() => { + setFixtures(` + <div id="element"> + <div class="diff-file"> + <div class="js-image-frame"> + <img src="${gl.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> + <div id="badge-3" class="badge">3</div> + </div> + <div class="note-container"> + <div class="discussion-notes"> + <div class="js-diff-notes-toggle"></div> + <div class="notes"></div> + </div> + <div class="discussion-notes"> + <div class="js-diff-notes-toggle"></div> + <div class="notes"></div> + </div> + </div> + </div> + </div> + `); + element = document.getElementById('element'); + }); + + describe('constructor', () => { + beforeEach(() => { + imageDiff = new ImageDiff(element, { + canCreateNote: true, + renderCommentBadge: true, + }); + }); + + it('should set el', () => { + expect(imageDiff.el).toEqual(element); + }); + + it('should set canCreateNote', () => { + expect(imageDiff.canCreateNote).toEqual(true); + }); + + it('should set renderCommentBadge', () => { + expect(imageDiff.renderCommentBadge).toEqual(true); + }); + + it('should set $noteContainer', () => { + expect(imageDiff.$noteContainer[0]).toEqual(element.querySelector('.note-container')); + }); + + describe('default', () => { + beforeEach(() => { + imageDiff = new ImageDiff(element); + }); + + it('should set canCreateNote as false', () => { + expect(imageDiff.canCreateNote).toEqual(false); + }); + + it('should set renderCommentBadge as false', () => { + expect(imageDiff.renderCommentBadge).toEqual(false); + }); + }); + }); + + describe('init', () => { + beforeEach(() => { + jest.spyOn(ImageDiff.prototype, 'bindEvents').mockImplementation(() => {}); + imageDiff = new ImageDiff(element); + imageDiff.init(); + }); + + it('should set imageFrameEl', () => { + expect(imageDiff.imageFrameEl).toEqual(element.querySelector('.diff-file .js-image-frame')); + }); + + it('should set imageEl', () => { + expect(imageDiff.imageEl).toEqual(element.querySelector('.diff-file .js-image-frame img')); + }); + + it('should call bindEvents', () => { + expect(imageDiff.bindEvents).toHaveBeenCalled(); + }); + }); + + describe('bindEvents', () => { + let imageEl; + + beforeEach(() => { + jest.spyOn(imageDiffHelper, 'toggleCollapsed').mockImplementation(() => {}); + jest.spyOn(imageDiffHelper, 'commentIndicatorOnClick').mockImplementation(() => {}); + jest.spyOn(imageDiffHelper, 'removeCommentIndicator').mockImplementation(() => {}); + jest.spyOn(ImageDiff.prototype, 'imageClicked').mockImplementation(() => {}); + jest.spyOn(ImageDiff.prototype, 'addBadge').mockImplementation(() => {}); + jest.spyOn(ImageDiff.prototype, 'removeBadge').mockImplementation(() => {}); + jest.spyOn(ImageDiff.prototype, 'renderBadges').mockImplementation(() => {}); + imageEl = element.querySelector('.diff-file .js-image-frame img'); + }); + + describe('default', () => { + beforeEach(() => { + jest.spyOn(imageUtility, 'isImageLoaded').mockReturnValue(false); + imageDiff = new ImageDiff(element); + imageDiff.imageEl = imageEl; + imageDiff.bindEvents(); + }); + + it('should register click event delegation to js-diff-notes-toggle', () => { + element.querySelector('.js-diff-notes-toggle').click(); + + expect(imageDiffHelper.toggleCollapsed).toHaveBeenCalled(); + }); + + it('should register click event delegation to comment-indicator', () => { + element.querySelector('.comment-indicator').click(); + + expect(imageDiffHelper.commentIndicatorOnClick).toHaveBeenCalled(); + }); + }); + + describe('image not loaded', () => { + beforeEach(() => { + jest.spyOn(imageUtility, 'isImageLoaded').mockReturnValue(false); + imageDiff = new ImageDiff(element); + imageDiff.imageEl = imageEl; + imageDiff.bindEvents(); + }); + + it('should registers load eventListener', () => { + const loadEvent = new Event('load'); + imageEl.dispatchEvent(loadEvent); + + expect(imageDiff.renderBadges).toHaveBeenCalled(); + }); + }); + + describe('canCreateNote', () => { + beforeEach(() => { + jest.spyOn(imageUtility, 'isImageLoaded').mockReturnValue(false); + imageDiff = new ImageDiff(element, { + canCreateNote: true, + }); + imageDiff.imageEl = imageEl; + imageDiff.bindEvents(); + }); + + it('should register click.imageDiff event', () => { + const event = new CustomEvent('click.imageDiff'); + element.dispatchEvent(event); + + expect(imageDiff.imageClicked).toHaveBeenCalled(); + }); + + it('should register blur.imageDiff event', () => { + const event = new CustomEvent('blur.imageDiff'); + element.dispatchEvent(event); + + expect(imageDiffHelper.removeCommentIndicator).toHaveBeenCalled(); + }); + + it('should register addBadge.imageDiff event', () => { + const event = new CustomEvent('addBadge.imageDiff'); + element.dispatchEvent(event); + + expect(imageDiff.addBadge).toHaveBeenCalled(); + }); + + it('should register removeBadge.imageDiff event', () => { + const event = new CustomEvent('removeBadge.imageDiff'); + element.dispatchEvent(event); + + expect(imageDiff.removeBadge).toHaveBeenCalled(); + }); + }); + + describe('canCreateNote is false', () => { + beforeEach(() => { + jest.spyOn(imageUtility, 'isImageLoaded').mockReturnValue(false); + imageDiff = new ImageDiff(element); + imageDiff.imageEl = imageEl; + imageDiff.bindEvents(); + }); + + it('should not register click.imageDiff event', () => { + const event = new CustomEvent('click.imageDiff'); + element.dispatchEvent(event); + + expect(imageDiff.imageClicked).not.toHaveBeenCalled(); + }); + }); + }); + + describe('imageClicked', () => { + beforeEach(() => { + jest.spyOn(imageDiffHelper, 'getTargetSelection').mockReturnValue({ + actual: {}, + browser: {}, + }); + jest.spyOn(imageDiffHelper, 'setPositionDataAttribute').mockImplementation(() => {}); + jest.spyOn(imageDiffHelper, 'showCommentIndicator').mockImplementation(() => {}); + imageDiff = new ImageDiff(element); + imageDiff.imageClicked({ + detail: { + currentTarget: {}, + }, + }); + }); + + it('should call getTargetSelection', () => { + expect(imageDiffHelper.getTargetSelection).toHaveBeenCalled(); + }); + + it('should call setPositionDataAttribute', () => { + expect(imageDiffHelper.setPositionDataAttribute).toHaveBeenCalled(); + }); + + it('should call showCommentIndicator', () => { + expect(imageDiffHelper.showCommentIndicator).toHaveBeenCalled(); + }); + }); + + describe('renderBadges', () => { + beforeEach(() => { + jest.spyOn(ImageDiff.prototype, 'renderBadge').mockImplementation(() => {}); + imageDiff = new ImageDiff(element); + imageDiff.renderBadges(); + }); + + it('should call renderBadge for each discussionEl', () => { + const discussionEls = element.querySelectorAll('.note-container .discussion-notes .notes'); + + expect(imageDiff.renderBadge.mock.calls.length).toEqual(discussionEls.length); + }); + }); + + describe('renderBadge', () => { + let discussionEls; + + beforeEach(() => { + jest.spyOn(imageDiffHelper, 'addImageBadge').mockImplementation(() => {}); + jest.spyOn(imageDiffHelper, 'addImageCommentBadge').mockImplementation(() => {}); + jest.spyOn(imageDiffHelper, 'generateBadgeFromDiscussionDOM').mockReturnValue({ + browser: {}, + noteId: 'noteId', + }); + discussionEls = element.querySelectorAll('.note-container .discussion-notes .notes'); + imageDiff = new ImageDiff(element); + imageDiff.renderBadge(discussionEls[0], 0); + }); + + it('should populate imageBadges', () => { + expect(imageDiff.imageBadges.length).toEqual(1); + }); + + describe('renderCommentBadge', () => { + beforeEach(() => { + imageDiff.renderCommentBadge = true; + imageDiff.renderBadge(discussionEls[0], 0); + }); + + it('should call addImageCommentBadge', () => { + expect(imageDiffHelper.addImageCommentBadge).toHaveBeenCalled(); + }); + }); + + describe('renderCommentBadge is false', () => { + it('should call addImageBadge', () => { + expect(imageDiffHelper.addImageBadge).toHaveBeenCalled(); + }); + }); + }); + + describe('addBadge', () => { + beforeEach(() => { + jest.spyOn(imageDiffHelper, 'addImageBadge').mockImplementation(() => {}); + jest.spyOn(imageDiffHelper, 'addAvatarBadge').mockImplementation(() => {}); + jest.spyOn(imageDiffHelper, 'updateDiscussionBadgeNumber').mockImplementation(() => {}); + imageDiff = new ImageDiff(element); + imageDiff.imageFrameEl = element.querySelector('.diff-file .js-image-frame'); + imageDiff.addBadge({ + detail: { + x: 0, + y: 1, + width: 25, + height: 50, + noteId: 'noteId', + discussionId: 'discussionId', + }, + }); + }); + + it('should add imageBadge to imageBadges', () => { + expect(imageDiff.imageBadges.length).toEqual(1); + }); + + it('should call addImageBadge', () => { + expect(imageDiffHelper.addImageBadge).toHaveBeenCalled(); + }); + + it('should call addAvatarBadge', () => { + expect(imageDiffHelper.addAvatarBadge).toHaveBeenCalled(); + }); + + it('should call updateDiscussionBadgeNumber', () => { + expect(imageDiffHelper.updateDiscussionBadgeNumber).toHaveBeenCalled(); + }); + }); + + describe('removeBadge', () => { + beforeEach(() => { + const { imageMeta } = mockData; + + jest.spyOn(imageDiffHelper, 'updateDiscussionBadgeNumber').mockImplementation(() => {}); + jest.spyOn(imageDiffHelper, 'updateDiscussionAvatarBadgeNumber').mockImplementation(() => {}); + imageDiff = new ImageDiff(element); + imageDiff.imageBadges = [imageMeta, imageMeta, imageMeta]; + imageDiff.imageFrameEl = element.querySelector('.diff-file .js-image-frame'); + imageDiff.removeBadge({ + detail: { + badgeNumber: 2, + }, + }); + }); + + describe('cascade badge count', () => { + it('should update next imageBadgeEl value', () => { + const imageBadgeEls = imageDiff.imageFrameEl.querySelectorAll('.badge'); + + expect(imageBadgeEls[0].textContent).toEqual('1'); + expect(imageBadgeEls[1].textContent).toEqual('2'); + expect(imageBadgeEls.length).toEqual(2); + }); + + it('should call updateDiscussionBadgeNumber', () => { + expect(imageDiffHelper.updateDiscussionBadgeNumber).toHaveBeenCalled(); + }); + + it('should call updateDiscussionAvatarBadgeNumber', () => { + expect(imageDiffHelper.updateDiscussionAvatarBadgeNumber).toHaveBeenCalled(); + }); + }); + + it('should remove badge from imageBadges', () => { + expect(imageDiff.imageBadges.length).toEqual(2); + }); + + it('should remove imageBadgeEl', () => { + expect(imageDiff.imageFrameEl.querySelector('#badge-2')).toBeNull(); + }); + }); +}); diff --git a/spec/frontend/image_diff/mock_data.js b/spec/frontend/image_diff/mock_data.js new file mode 100644 index 00000000000..a0d1732dd0a --- /dev/null +++ b/spec/frontend/image_diff/mock_data.js @@ -0,0 +1,28 @@ +export const noteId = 'noteId'; +export const discussionId = 'discussionId'; +export const badgeText = 'badgeText'; +export const badgeNumber = 5; + +export const coordinate = { + x: 100, + y: 100, +}; + +export const image = { + width: 100, + height: 100, +}; + +export const imageProperties = { + width: image.width, + height: image.height, + naturalWidth: image.width * 2, + naturalHeight: image.height * 2, +}; + +export const imageMeta = { + x: coordinate.x, + y: coordinate.y, + width: imageProperties.naturalWidth, + height: imageProperties.naturalHeight, +}; diff --git a/spec/frontend/image_diff/replaced_image_diff_spec.js b/spec/frontend/image_diff/replaced_image_diff_spec.js new file mode 100644 index 00000000000..f2a7b7f8406 --- /dev/null +++ b/spec/frontend/image_diff/replaced_image_diff_spec.js @@ -0,0 +1,356 @@ +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'; + +describe('ReplacedImageDiff', () => { + let element; + let replacedImageDiff; + + beforeEach(() => { + setFixtures(` + <div id="element"> + <div class="two-up"> + <div class="js-image-frame"> + <img src="${gl.TEST_HOST}/image.png"> + </div> + </div> + <div class="swipe"> + <div class="js-image-frame"> + <img src="${gl.TEST_HOST}/image.png"> + </div> + </div> + <div class="onion-skin"> + <div class="js-image-frame"> + <img src="${gl.TEST_HOST}/image.png"> + </div> + </div> + <div class="view-modes-menu"> + <div class="two-up">2-up</div> + <div class="swipe">Swipe</div> + <div class="onion-skin">Onion skin</div> + </div> + </div> + `); + element = document.getElementById('element'); + }); + + function setupImageFrameEls() { + replacedImageDiff.imageFrameEls = []; + replacedImageDiff.imageFrameEls[viewTypes.TWO_UP] = element.querySelector( + '.two-up .js-image-frame', + ); + replacedImageDiff.imageFrameEls[viewTypes.SWIPE] = element.querySelector( + '.swipe .js-image-frame', + ); + replacedImageDiff.imageFrameEls[viewTypes.ONION_SKIN] = element.querySelector( + '.onion-skin .js-image-frame', + ); + } + + function setupViewModesEls() { + replacedImageDiff.viewModesEls = []; + replacedImageDiff.viewModesEls[viewTypes.TWO_UP] = element.querySelector( + '.view-modes-menu .two-up', + ); + replacedImageDiff.viewModesEls[viewTypes.SWIPE] = element.querySelector( + '.view-modes-menu .swipe', + ); + replacedImageDiff.viewModesEls[viewTypes.ONION_SKIN] = element.querySelector( + '.view-modes-menu .onion-skin', + ); + } + + function setupImageEls() { + replacedImageDiff.imageEls = []; + replacedImageDiff.imageEls[viewTypes.TWO_UP] = element.querySelector('.two-up img'); + replacedImageDiff.imageEls[viewTypes.SWIPE] = element.querySelector('.swipe img'); + replacedImageDiff.imageEls[viewTypes.ONION_SKIN] = element.querySelector('.onion-skin img'); + } + + it('should extend ImageDiff', () => { + replacedImageDiff = new ReplacedImageDiff(element); + + expect(replacedImageDiff instanceof ImageDiff).toEqual(true); + }); + + describe('init', () => { + beforeEach(() => { + jest.spyOn(ReplacedImageDiff.prototype, 'bindEvents').mockImplementation(() => {}); + jest.spyOn(ReplacedImageDiff.prototype, 'generateImageEls').mockImplementation(() => {}); + + replacedImageDiff = new ReplacedImageDiff(element); + replacedImageDiff.init(); + }); + + it('should set imageFrameEls', () => { + const { imageFrameEls } = replacedImageDiff; + + expect(imageFrameEls).toBeDefined(); + expect(imageFrameEls[viewTypes.TWO_UP]).toEqual( + element.querySelector('.two-up .js-image-frame'), + ); + + expect(imageFrameEls[viewTypes.SWIPE]).toEqual( + element.querySelector('.swipe .js-image-frame'), + ); + + expect(imageFrameEls[viewTypes.ONION_SKIN]).toEqual( + element.querySelector('.onion-skin .js-image-frame'), + ); + }); + + it('should set viewModesEls', () => { + const { viewModesEls } = replacedImageDiff; + + expect(viewModesEls).toBeDefined(); + expect(viewModesEls[viewTypes.TWO_UP]).toEqual( + element.querySelector('.view-modes-menu .two-up'), + ); + + expect(viewModesEls[viewTypes.SWIPE]).toEqual( + element.querySelector('.view-modes-menu .swipe'), + ); + + expect(viewModesEls[viewTypes.ONION_SKIN]).toEqual( + element.querySelector('.view-modes-menu .onion-skin'), + ); + }); + + it('should generateImageEls', () => { + expect(ReplacedImageDiff.prototype.generateImageEls).toHaveBeenCalled(); + }); + + it('should bindEvents', () => { + expect(ReplacedImageDiff.prototype.bindEvents).toHaveBeenCalled(); + }); + + describe('currentView', () => { + it('should set currentView', () => { + replacedImageDiff.init(viewTypes.ONION_SKIN); + + expect(replacedImageDiff.currentView).toEqual(viewTypes.ONION_SKIN); + }); + + it('should default to viewTypes.TWO_UP', () => { + expect(replacedImageDiff.currentView).toEqual(viewTypes.TWO_UP); + }); + }); + }); + + describe('generateImageEls', () => { + beforeEach(() => { + jest.spyOn(ReplacedImageDiff.prototype, 'bindEvents').mockImplementation(() => {}); + + replacedImageDiff = new ReplacedImageDiff(element, { + canCreateNote: false, + renderCommentBadge: false, + }); + + setupImageFrameEls(); + }); + + it('should set imageEls', () => { + replacedImageDiff.generateImageEls(); + const { imageEls } = replacedImageDiff; + + expect(imageEls).toBeDefined(); + expect(imageEls[viewTypes.TWO_UP]).toEqual(element.querySelector('.two-up img')); + expect(imageEls[viewTypes.SWIPE]).toEqual(element.querySelector('.swipe img')); + expect(imageEls[viewTypes.ONION_SKIN]).toEqual(element.querySelector('.onion-skin img')); + }); + }); + + describe('bindEvents', () => { + beforeEach(() => { + jest.spyOn(ImageDiff.prototype, 'bindEvents').mockImplementation(() => {}); + replacedImageDiff = new ReplacedImageDiff(element); + + setupViewModesEls(); + }); + + it('should call super.bindEvents', () => { + replacedImageDiff.bindEvents(); + + expect(ImageDiff.prototype.bindEvents).toHaveBeenCalled(); + }); + + it('should register click eventlistener to 2-up view mode', done => { + jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation(viewMode => { + expect(viewMode).toEqual(viewTypes.TWO_UP); + done(); + }); + + replacedImageDiff.bindEvents(); + replacedImageDiff.viewModesEls[viewTypes.TWO_UP].click(); + }); + + it('should register click eventlistener to swipe view mode', done => { + jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation(viewMode => { + expect(viewMode).toEqual(viewTypes.SWIPE); + done(); + }); + + replacedImageDiff.bindEvents(); + replacedImageDiff.viewModesEls[viewTypes.SWIPE].click(); + }); + + it('should register click eventlistener to onion skin view mode', done => { + jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation(viewMode => { + expect(viewMode).toEqual(viewTypes.SWIPE); + done(); + }); + + replacedImageDiff.bindEvents(); + replacedImageDiff.viewModesEls[viewTypes.SWIPE].click(); + }); + }); + + describe('getters', () => { + describe('imageEl', () => { + beforeEach(() => { + replacedImageDiff = new ReplacedImageDiff(element); + replacedImageDiff.currentView = viewTypes.TWO_UP; + setupImageEls(); + }); + + it('should return imageEl based on currentView', () => { + expect(replacedImageDiff.imageEl).toEqual(element.querySelector('.two-up img')); + + replacedImageDiff.currentView = viewTypes.SWIPE; + + expect(replacedImageDiff.imageEl).toEqual(element.querySelector('.swipe img')); + }); + }); + + describe('imageFrameEl', () => { + beforeEach(() => { + replacedImageDiff = new ReplacedImageDiff(element); + replacedImageDiff.currentView = viewTypes.TWO_UP; + setupImageFrameEls(); + }); + + it('should return imageFrameEl based on currentView', () => { + expect(replacedImageDiff.imageFrameEl).toEqual( + element.querySelector('.two-up .js-image-frame'), + ); + + replacedImageDiff.currentView = viewTypes.ONION_SKIN; + + expect(replacedImageDiff.imageFrameEl).toEqual( + element.querySelector('.onion-skin .js-image-frame'), + ); + }); + }); + }); + + describe('changeView', () => { + beforeEach(() => { + replacedImageDiff = new ReplacedImageDiff(element); + jest.spyOn(imageDiffHelper, 'removeCommentIndicator').mockReturnValue({ + removed: false, + }); + setupImageFrameEls(); + }); + + describe('invalid viewType', () => { + beforeEach(() => { + replacedImageDiff.changeView('some-view-name'); + }); + + it('should not call removeCommentIndicator', () => { + expect(imageDiffHelper.removeCommentIndicator).not.toHaveBeenCalled(); + }); + }); + + describe('valid viewType', () => { + beforeEach(() => { + jest.spyOn(ReplacedImageDiff.prototype, 'renderNewView').mockImplementation(() => {}); + replacedImageDiff.changeView(viewTypes.ONION_SKIN); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('should call removeCommentIndicator', () => { + expect(imageDiffHelper.removeCommentIndicator).toHaveBeenCalled(); + }); + + it('should update currentView to newView', () => { + expect(replacedImageDiff.currentView).toEqual(viewTypes.ONION_SKIN); + }); + + it('should clear imageBadges', () => { + expect(replacedImageDiff.imageBadges.length).toEqual(0); + }); + + it('should call renderNewView', () => { + jest.advanceTimersByTime(251); + + expect(replacedImageDiff.renderNewView).toHaveBeenCalled(); + }); + }); + }); + + describe('renderNewView', () => { + beforeEach(() => { + replacedImageDiff = new ReplacedImageDiff(element); + }); + + it('should call renderBadges', () => { + jest.spyOn(ReplacedImageDiff.prototype, 'renderBadges').mockImplementation(() => {}); + + replacedImageDiff.renderNewView({ + removed: false, + }); + + expect(replacedImageDiff.renderBadges).toHaveBeenCalled(); + }); + + describe('removeIndicator', () => { + const indicator = { + removed: true, + x: 0, + y: 1, + image: { + width: 50, + height: 100, + }, + }; + + beforeEach(() => { + setupImageEls(); + setupImageFrameEls(); + }); + + it('should pass showCommentIndicator normalized indicator values', done => { + jest.spyOn(imageDiffHelper, 'showCommentIndicator').mockImplementation(() => {}); + jest + .spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement') + .mockImplementation((imageEl, meta) => { + expect(meta.x).toEqual(indicator.x); + expect(meta.y).toEqual(indicator.y); + expect(meta.width).toEqual(indicator.image.width); + expect(meta.height).toEqual(indicator.image.height); + done(); + }); + replacedImageDiff.renderNewView(indicator); + }); + + it('should call showCommentIndicator', done => { + const normalized = { + normalized: true, + }; + jest.spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement').mockReturnValue(normalized); + jest + .spyOn(imageDiffHelper, 'showCommentIndicator') + .mockImplementation((imageFrameEl, normalizedIndicator) => { + expect(normalizedIndicator).toEqual(normalized); + done(); + }); + replacedImageDiff.renderNewView(indicator); + }); + }); + }); +}); diff --git a/spec/frontend/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_projects/components/import_projects_table_spec.js index 8f60823ee72..9491b52c888 100644 --- a/spec/frontend/import_projects/components/import_projects_table_spec.js +++ b/spec/frontend/import_projects/components/import_projects_table_spec.js @@ -17,11 +17,12 @@ describe('ImportProjectsTable', () => { }; function initStore() { - const stubbedActions = Object.assign({}, actions, { + const stubbedActions = { + ...actions, fetchJobs: jest.fn(), fetchRepos: jest.fn(actions.requestRepos), fetchImport: jest.fn(actions.requestImport), - }); + }; const store = new Vuex.Store({ state: state(), diff --git a/spec/frontend/import_projects/components/provider_repo_table_row_spec.js b/spec/frontend/import_projects/components/provider_repo_table_row_spec.js index 8efd526e360..8be645c496f 100644 --- a/spec/frontend/import_projects/components/provider_repo_table_row_spec.js +++ b/spec/frontend/import_projects/components/provider_repo_table_row_spec.js @@ -18,9 +18,7 @@ describe('ProviderRepoTableRow', () => { }; function initStore() { - const stubbedActions = Object.assign({}, actions, { - fetchImport, - }); + const stubbedActions = { ...actions, fetchImport }; const store = new Vuex.Store({ state: state(), diff --git a/spec/frontend/integrations/edit/components/active_toggle_spec.js b/spec/frontend/integrations/edit/components/active_toggle_spec.js index 8a11c200c15..5469b45f708 100644 --- a/spec/frontend/integrations/edit/components/active_toggle_spec.js +++ b/spec/frontend/integrations/edit/components/active_toggle_spec.js @@ -9,17 +9,19 @@ describe('ActiveToggle', () => { const defaultProps = { initialActivated: true, - disabled: false, }; const createComponent = props => { wrapper = mount(ActiveToggle, { - propsData: Object.assign({}, defaultProps, props), + propsData: { ...defaultProps, ...props }, }); }; afterEach(() => { - if (wrapper) wrapper.destroy(); + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } }); const findGlToggle = () => wrapper.find(GlToggle); diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js new file mode 100644 index 00000000000..c93f63b11d0 --- /dev/null +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -0,0 +1,99 @@ +import { shallowMount } from '@vue/test-utils'; +import IntegrationForm from '~/integrations/edit/components/integration_form.vue'; +import ActiveToggle from '~/integrations/edit/components/active_toggle.vue'; +import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue'; +import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; + +describe('IntegrationForm', () => { + let wrapper; + + const defaultProps = { + activeToggleProps: { + initialActivated: true, + }, + showActive: true, + triggerFieldsProps: { + initialTriggerCommit: false, + initialTriggerMergeRequest: false, + initialEnableComments: false, + }, + type: '', + }; + + const createComponent = props => { + wrapper = shallowMount(IntegrationForm, { + propsData: { ...defaultProps, ...props }, + stubs: { + ActiveToggle, + JiraTriggerFields, + }, + }); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + const findActiveToggle = () => wrapper.find(ActiveToggle); + const findJiraTriggerFields = () => wrapper.find(JiraTriggerFields); + const findTriggerFields = () => wrapper.find(TriggerFields); + + describe('template', () => { + describe('showActive is true', () => { + it('renders ActiveToggle', () => { + createComponent(); + + expect(findActiveToggle().exists()).toBe(true); + }); + }); + + describe('showActive is false', () => { + it('does not render ActiveToggle', () => { + createComponent({ + showActive: false, + }); + + expect(findActiveToggle().exists()).toBe(false); + }); + }); + + describe('type is "slack"', () => { + it('does not render JiraTriggerFields', () => { + createComponent({ + type: 'slack', + }); + + expect(findJiraTriggerFields().exists()).toBe(false); + }); + }); + + describe('type is "jira"', () => { + it('renders JiraTriggerFields', () => { + createComponent({ + type: 'jira', + }); + + expect(findJiraTriggerFields().exists()).toBe(true); + }); + }); + + describe('triggerEvents is present', () => { + it('renders TriggerFields', () => { + const events = [{ title: 'push' }]; + const type = 'slack'; + + createComponent({ + triggerEvents: events, + type, + }); + + expect(findTriggerFields().exists()).toBe(true); + expect(findTriggerFields().props('events')).toBe(events); + expect(findTriggerFields().props('type')).toBe(type); + }); + }); + }); +}); diff --git a/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js new file mode 100644 index 00000000000..e4c2a0be6a3 --- /dev/null +++ b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js @@ -0,0 +1,97 @@ +import { mount } from '@vue/test-utils'; +import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue'; +import { GlFormCheckbox } from '@gitlab/ui'; + +describe('JiraTriggerFields', () => { + let wrapper; + + const defaultProps = { + initialTriggerCommit: false, + initialTriggerMergeRequest: false, + initialEnableComments: false, + }; + + const createComponent = props => { + wrapper = mount(JiraTriggerFields, { + propsData: { ...defaultProps, ...props }, + }); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + const findCommentSettings = () => wrapper.find('[data-testid="comment-settings"]'); + const findCommentDetail = () => wrapper.find('[data-testid="comment-detail"]'); + const findCommentSettingsCheckbox = () => findCommentSettings().find(GlFormCheckbox); + + describe('template', () => { + describe('initialTriggerCommit and initialTriggerMergeRequest are false', () => { + it('does not show comment settings', () => { + createComponent(); + + expect(findCommentSettings().isVisible()).toBe(false); + expect(findCommentDetail().isVisible()).toBe(false); + }); + }); + + describe('initialTriggerCommit is true', () => { + beforeEach(() => { + createComponent({ + initialTriggerCommit: true, + }); + }); + + it('shows comment settings', () => { + expect(findCommentSettings().isVisible()).toBe(true); + expect(findCommentDetail().isVisible()).toBe(false); + }); + + // As per https://vuejs.org/v2/guide/forms.html#Checkbox-1, + // browsers don't include unchecked boxes in form submissions. + it('includes comment settings as false even if unchecked', () => { + expect( + findCommentSettings() + .find('input[name="service[comment_on_event_enabled]"]') + .exists(), + ).toBe(true); + }); + + describe('on enable comments', () => { + it('shows comment detail', () => { + findCommentSettingsCheckbox().vm.$emit('input', true); + + return wrapper.vm.$nextTick().then(() => { + expect(findCommentDetail().isVisible()).toBe(true); + }); + }); + }); + }); + + describe('initialTriggerMergeRequest is true', () => { + it('shows comment settings', () => { + createComponent({ + initialTriggerMergeRequest: true, + }); + + expect(findCommentSettings().isVisible()).toBe(true); + expect(findCommentDetail().isVisible()).toBe(false); + }); + }); + + describe('initialTriggerCommit is true, initialEnableComments is true', () => { + it('shows comment settings and comment detail', () => { + createComponent({ + initialTriggerCommit: true, + initialEnableComments: true, + }); + + expect(findCommentSettings().isVisible()).toBe(true); + expect(findCommentDetail().isVisible()).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/integrations/edit/components/trigger_fields_spec.js b/spec/frontend/integrations/edit/components/trigger_fields_spec.js new file mode 100644 index 00000000000..337876c6d16 --- /dev/null +++ b/spec/frontend/integrations/edit/components/trigger_fields_spec.js @@ -0,0 +1,136 @@ +import { mount } from '@vue/test-utils'; +import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; +import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui'; + +describe('TriggerFields', () => { + let wrapper; + + const defaultProps = { + type: 'slack', + }; + + const createComponent = props => { + wrapper = mount(TriggerFields, { + propsData: { ...defaultProps, ...props }, + }); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + const findAllGlFormCheckboxes = () => wrapper.findAll(GlFormCheckbox); + const findAllGlFormInputs = () => wrapper.findAll(GlFormInput); + + describe('template', () => { + it('renders a label with text "Trigger"', () => { + createComponent(); + + const triggerLabel = wrapper.find('[data-testid="trigger-fields-group"]').find('label'); + expect(triggerLabel.exists()).toBe(true); + expect(triggerLabel.text()).toBe('Trigger'); + }); + + describe('events without field property', () => { + const events = [ + { + title: 'push', + name: 'push_event', + description: 'Event on push', + value: true, + }, + { + title: 'merge_request', + name: 'merge_requests_event', + description: 'Event on merge_request', + value: false, + }, + ]; + + beforeEach(() => { + createComponent({ + events, + }); + }); + + it('does not render GlFormInput for each event', () => { + expect(findAllGlFormInputs().exists()).toBe(false); + }); + + it('renders GlFormInput with description for each event', () => { + const groups = wrapper.find('#trigger-fields').findAll(GlFormGroup); + + expect(groups).toHaveLength(2); + groups.wrappers.forEach((group, index) => { + expect(group.find('small').text()).toBe(events[index].description); + }); + }); + + it('renders GlFormCheckbox for each event', () => { + const checkboxes = findAllGlFormCheckboxes(); + const expectedResults = [ + { labelText: 'Push', inputName: 'service[push_event]' }, + { labelText: 'Merge Request', inputName: 'service[merge_requests_event]' }, + ]; + expect(checkboxes).toHaveLength(2); + + checkboxes.wrappers.forEach((checkbox, index) => { + 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); + }); + }); + }); + + describe('events with field property', () => { + const events = [ + { + field: { + name: 'push_channel', + value: '', + }, + }, + { + field: { + name: 'merge_request_channel', + value: 'gitlab-development', + }, + }, + ]; + + beforeEach(() => { + createComponent({ + events, + }); + }); + + it('renders GlFormCheckbox for each event', () => { + expect(findAllGlFormCheckboxes()).toHaveLength(2); + }); + + it('renders GlFormInput for each event', () => { + const fields = findAllGlFormInputs(); + const expectedResults = [ + { + name: 'service[push_channel]', + placeholder: 'Slack channels (e.g. general, development)', + }, + { + name: 'service[merge_request_channel]', + placeholder: 'Slack channels (e.g. general, development)', + }, + ]; + + expect(fields).toHaveLength(2); + + fields.wrappers.forEach((field, index) => { + expect(field.attributes()).toMatchObject(expectedResults[index]); + expect(field.vm.$attrs.value).toBe(events[index].field.value); + }); + }); + }); + }); +}); diff --git a/spec/frontend/integrations/integration_settings_form_spec.js b/spec/frontend/integrations/integration_settings_form_spec.js new file mode 100644 index 00000000000..c117a37ff2f --- /dev/null +++ b/spec/frontend/integrations/integration_settings_form_spec.js @@ -0,0 +1,268 @@ +import $ from 'jquery'; +import MockAdaptor from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import IntegrationSettingsForm from '~/integrations/integration_settings_form'; + +describe('IntegrationSettingsForm', () => { + const FIXTURE = 'services/edit_service.html'; + preloadFixtures(FIXTURE); + + beforeEach(() => { + loadFixtures(FIXTURE); + }); + + describe('contructor', () => { + let integrationSettingsForm; + + beforeEach(() => { + integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + jest.spyOn(integrationSettingsForm, 'init').mockImplementation(() => {}); + }); + + it('should initialize form element refs on class object', () => { + // Form Reference + expect(integrationSettingsForm.$form).toBeDefined(); + expect(integrationSettingsForm.$form.prop('nodeName')).toEqual('FORM'); + expect(integrationSettingsForm.formActive).toBeDefined(); + + // Form Child Elements + expect(integrationSettingsForm.$submitBtn).toBeDefined(); + expect(integrationSettingsForm.$submitBtnLoader).toBeDefined(); + expect(integrationSettingsForm.$submitBtnLabel).toBeDefined(); + }); + + it('should initialize form metadata on class object', () => { + expect(integrationSettingsForm.testEndPoint).toBeDefined(); + expect(integrationSettingsForm.canTestService).toBeDefined(); + }); + }); + + describe('toggleServiceState', () => { + let integrationSettingsForm; + + beforeEach(() => { + integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + }); + + it('should remove `novalidate` attribute to form when called with `true`', () => { + integrationSettingsForm.formActive = true; + integrationSettingsForm.toggleServiceState(); + + expect(integrationSettingsForm.$form.attr('novalidate')).not.toBeDefined(); + }); + + it('should set `novalidate` attribute to form when called with `false`', () => { + integrationSettingsForm.formActive = false; + integrationSettingsForm.toggleServiceState(); + + expect(integrationSettingsForm.$form.attr('novalidate')).toBeDefined(); + }); + }); + + describe('toggleSubmitBtnLabel', () => { + let integrationSettingsForm; + + beforeEach(() => { + integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + }); + + it('should set Save button label to "Test settings and save changes" when serviceActive & canTestService are `true`', () => { + integrationSettingsForm.canTestService = true; + integrationSettingsForm.formActive = true; + + integrationSettingsForm.toggleSubmitBtnLabel(); + + expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual( + 'Test settings and save changes', + ); + }); + + it('should set Save button label to "Save changes" when either serviceActive or canTestService (or both) is `false`', () => { + integrationSettingsForm.canTestService = false; + integrationSettingsForm.formActive = false; + + integrationSettingsForm.toggleSubmitBtnLabel(); + + expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes'); + + integrationSettingsForm.formActive = true; + + integrationSettingsForm.toggleSubmitBtnLabel(); + + expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes'); + + integrationSettingsForm.canTestService = true; + integrationSettingsForm.formActive = false; + + integrationSettingsForm.toggleSubmitBtnLabel(); + + expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes'); + }); + }); + + describe('toggleSubmitBtnState', () => { + let integrationSettingsForm; + + beforeEach(() => { + integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + }); + + it('should disable Save button and show loader animation when called with `true`', () => { + integrationSettingsForm.toggleSubmitBtnState(true); + + expect(integrationSettingsForm.$submitBtn.is(':disabled')).toBeTruthy(); + expect(integrationSettingsForm.$submitBtnLoader.hasClass('hidden')).toBeFalsy(); + }); + + it('should enable Save button and hide loader animation when called with `false`', () => { + integrationSettingsForm.toggleSubmitBtnState(false); + + expect(integrationSettingsForm.$submitBtn.is(':disabled')).toBeFalsy(); + expect(integrationSettingsForm.$submitBtnLoader.hasClass('hidden')).toBeTruthy(); + }); + }); + + describe('testSettings', () => { + let integrationSettingsForm; + let formData; + let mock; + + beforeEach(() => { + mock = new MockAdaptor(axios); + + jest.spyOn(axios, 'put'); + + integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + // eslint-disable-next-line no-jquery/no-serialize + formData = integrationSettingsForm.$form.serialize(); + }); + + afterEach(() => { + mock.restore(); + }); + + it('should make an ajax request with provided `formData`', () => { + return integrationSettingsForm.testSettings(formData).then(() => { + expect(axios.put).toHaveBeenCalledWith(integrationSettingsForm.testEndPoint, formData); + }); + }); + + it('should show error Flash with `Save anyway` action if ajax request responds with error in test', () => { + const errorMessage = 'Test failed.'; + mock.onPut(integrationSettingsForm.testEndPoint).reply(200, { + error: true, + message: errorMessage, + service_response: 'some error', + test_failed: true, + }); + + return integrationSettingsForm.testSettings(formData).then(() => { + const $flashContainer = $('.flash-container'); + + expect( + $flashContainer + .find('.flash-text') + .text() + .trim(), + ).toEqual('Test failed. some error'); + + expect($flashContainer.find('.flash-action')).toBeDefined(); + expect( + $flashContainer + .find('.flash-action') + .text() + .trim(), + ).toEqual('Save anyway'); + }); + }); + + it('should not show error Flash with `Save anyway` action if ajax request responds with error in validation', () => { + const errorMessage = 'Validations failed.'; + mock.onPut(integrationSettingsForm.testEndPoint).reply(200, { + error: true, + message: errorMessage, + service_response: 'some error', + test_failed: false, + }); + + return integrationSettingsForm.testSettings(formData).then(() => { + const $flashContainer = $('.flash-container'); + + expect( + $flashContainer + .find('.flash-text') + .text() + .trim(), + ).toEqual('Validations failed. some error'); + + expect($flashContainer.find('.flash-action')).toBeDefined(); + expect( + $flashContainer + .find('.flash-action') + .text() + .trim(), + ).toEqual(''); + }); + }); + + it('should submit form if ajax request responds without any error in test', () => { + jest.spyOn(integrationSettingsForm.$form, 'submit').mockImplementation(() => {}); + + mock.onPut(integrationSettingsForm.testEndPoint).reply(200, { + error: false, + }); + + return integrationSettingsForm.testSettings(formData).then(() => { + expect(integrationSettingsForm.$form.submit).toHaveBeenCalled(); + }); + }); + + it('should submit form when clicked on `Save anyway` action of error Flash', () => { + jest.spyOn(integrationSettingsForm.$form, 'submit').mockImplementation(() => {}); + + const errorMessage = 'Test failed.'; + mock.onPut(integrationSettingsForm.testEndPoint).reply(200, { + error: true, + message: errorMessage, + test_failed: true, + }); + + return integrationSettingsForm + .testSettings(formData) + .then(() => { + const $flashAction = $('.flash-container .flash-action'); + + expect($flashAction).toBeDefined(); + + $flashAction.get(0).click(); + }) + .then(() => { + expect(integrationSettingsForm.$form.submit).toHaveBeenCalled(); + }); + }); + + it('should show error Flash if ajax request failed', () => { + const errorMessage = 'Something went wrong on our end.'; + + mock.onPut(integrationSettingsForm.testEndPoint).networkError(); + + return integrationSettingsForm.testSettings(formData).then(() => { + expect( + $('.flash-container .flash-text') + .text() + .trim(), + ).toEqual(errorMessage); + }); + }); + + it('should always call `toggleSubmitBtnState` with `false` once request is completed', () => { + mock.onPut(integrationSettingsForm.testEndPoint).networkError(); + + jest.spyOn(integrationSettingsForm, 'toggleSubmitBtnState').mockImplementation(() => {}); + + return integrationSettingsForm.testSettings(formData).then(() => { + expect(integrationSettingsForm.toggleSubmitBtnState).toHaveBeenCalledWith(false); + }); + }); + }); +}); diff --git a/spec/frontend/issuable_spec.js b/spec/frontend/issuable_spec.js new file mode 100644 index 00000000000..63c1fda2fb4 --- /dev/null +++ b/spec/frontend/issuable_spec.js @@ -0,0 +1,64 @@ +import $ from 'jquery'; +import MockAdaptor from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import IssuableIndex from '~/issuable_index'; +import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar'; + +describe('Issuable', () => { + describe('initBulkUpdate', () => { + it('should not set bulkUpdateSidebar', () => { + new IssuableIndex('issue_'); // eslint-disable-line no-new + + expect(issuableInitBulkUpdateSidebar.bulkUpdateSidebar).toBeNull(); + }); + + it('should set bulkUpdateSidebar', () => { + const element = document.createElement('div'); + element.classList.add('issues-bulk-update'); + document.body.appendChild(element); + + new IssuableIndex('issue_'); // eslint-disable-line no-new + + expect(issuableInitBulkUpdateSidebar.bulkUpdateSidebar).toBeDefined(); + }); + }); + + describe('resetIncomingEmailToken', () => { + let mock; + + beforeEach(() => { + const element = document.createElement('a'); + element.classList.add('incoming-email-token-reset'); + element.setAttribute('href', 'foo'); + document.body.appendChild(element); + + const input = document.createElement('input'); + input.setAttribute('id', 'issuable_email'); + document.body.appendChild(input); + + new IssuableIndex('issue_'); // eslint-disable-line no-new + + mock = new MockAdaptor(axios); + + mock.onPut('foo').reply(200, { + new_address: 'testing123', + }); + }); + + afterEach(() => { + mock.restore(); + }); + + it('should send request to reset email token', done => { + jest.spyOn(axios, 'put'); + document.querySelector('.incoming-email-token-reset').click(); + + setImmediate(() => { + expect(axios.put).toHaveBeenCalledWith('foo'); + expect($('#issuable_email').val()).toBe('testing123'); + + done(); + }); + }); + }); +}); 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 new file mode 100644 index 00000000000..899010bdb0f --- /dev/null +++ b/spec/frontend/issuables_list/components/issuable_list_root_app_spec.js @@ -0,0 +1,121 @@ +import { GlAlert, GlLabel } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import IssuableListRootApp from '~/issuables_list/components/issuable_list_root_app.vue'; + +describe('IssuableListRootApp', () => { + const issuesPath = 'gitlab-org/gitlab-test/-/issues'; + const label = { + color: '#333', + title: 'jira-import::MTG-3', + }; + let wrapper; + + const findAlert = () => wrapper.find(GlAlert); + + const findAlertLabel = () => wrapper.find(GlAlert).find(GlLabel); + + const mountComponent = ({ + isFinishedAlertShowing = false, + isInProgressAlertShowing = false, + isInProgress = false, + isFinished = false, + } = {}) => + shallowMount(IssuableListRootApp, { + propsData: { + canEdit: true, + isJiraConfigured: true, + issuesPath, + projectPath: 'gitlab-org/gitlab-test', + }, + data() { + return { + isFinishedAlertShowing, + isInProgressAlertShowing, + jiraImport: { + isInProgress, + isFinished, + label, + }, + }; + }, + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when Jira import is not in progress', () => { + it('does not show an alert', () => { + wrapper = mountComponent(); + + expect(wrapper.contains(GlAlert)).toBe(false); + }); + }); + + 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, + }); + + expect(findAlert().text()).toBe( + 'Import in progress. Refresh page to see newly added issues.', + ); + }); + }); + + describe('when Jira import has finished', () => { + beforeEach(() => { + wrapper = mountComponent({ + isFinishedAlertShowing: true, + isFinished: true, + }); + }); + + describe('shows an alert', () => { + it('tells the user the Jira import has finished', () => { + expect(findAlert().text()).toBe('Issues successfully imported with the label'); + }); + + it('contains the label title associated with the Jira import', () => { + const alertLabelTitle = findAlertLabel().props('title'); + + expect(alertLabelTitle).toBe(label.title); + }); + + it('contains the correct label color', () => { + const alertLabelTitle = findAlertLabel().props('backgroundColor'); + + expect(alertLabelTitle).toBe(label.color); + }); + + it('contains a link within the label', () => { + const alertLabelTarget = findAlertLabel().props('target'); + + expect(alertLabelTarget).toBe( + `${issuesPath}?label_name[]=${encodeURIComponent(label.title)}`, + ); + }); + }); + }); + + describe('alert message', () => { + it('is hidden when dismissed', () => { + wrapper = mountComponent({ + isInProgressAlertShowing: true, + isInProgress: true, + }); + + expect(wrapper.contains(GlAlert)).toBe(true); + + findAlert().vm.$emit('dismiss'); + + return Vue.nextTick(() => { + expect(wrapper.contains(GlAlert)).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js new file mode 100644 index 00000000000..a59d6d35ded --- /dev/null +++ b/spec/frontend/issue_show/components/app_spec.js @@ -0,0 +1,497 @@ +import Vue from 'vue'; +import MockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'helpers/test_constants'; +import axios from '~/lib/utils/axios_utils'; +import { visitUrl } from '~/lib/utils/url_utility'; +import '~/behaviors/markdown/render_gfm'; +import issuableApp from '~/issue_show/components/app.vue'; +import eventHub from '~/issue_show/event_hub'; +import { initialRequest, secondRequest } from '../mock_data'; + +function formatText(text) { + return text.trim().replace(/\s\s+/g, ' '); +} + +jest.mock('~/lib/utils/url_utility'); +jest.mock('~/issue_show/event_hub'); + +const REALTIME_REQUEST_STACK = [initialRequest, secondRequest]; + +describe('Issuable output', () => { + let mock; + let realtimeRequestCount = 0; + let vm; + + beforeEach(() => { + setFixtures(` + <div> + <title>Title</title> + <div class="detail-page-description content-block"> + <details open> + <summary>One</summary> + </details> + <details> + <summary>Two</summary> + </details> + </div> + <div class="flash-container"></div> + <span id="task_status"></span> + </div> + `); + + const IssuableDescriptionComponent = Vue.extend(issuableApp); + + mock = new MockAdapter(axios); + mock + .onGet('/gitlab-org/gitlab-shell/-/issues/9/realtime_changes/realtime_changes') + .reply(() => { + const res = Promise.resolve([200, REALTIME_REQUEST_STACK[realtimeRequestCount]]); + realtimeRequestCount += 1; + return res; + }); + + vm = new IssuableDescriptionComponent({ + propsData: { + canUpdate: true, + canDestroy: true, + endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes', + updateEndpoint: TEST_HOST, + issuableRef: '#1', + initialTitleHtml: '', + initialTitleText: '', + initialDescriptionHtml: 'test', + initialDescriptionText: 'test', + lockVersion: 1, + markdownPreviewPath: '/', + markdownDocsPath: '/', + projectNamespace: '/', + projectPath: '/', + issuableTemplateNamesPath: '/issuable-templates-path', + }, + }).$mount(); + }); + + afterEach(() => { + mock.restore(); + realtimeRequestCount = 0; + + vm.poll.stop(); + vm.$destroy(); + }); + + it('should render a title/description/edited and update title/description/edited on update', () => { + let editedText; + return axios + .waitForAll() + .then(() => { + editedText = vm.$el.querySelector('.edited-text'); + }) + .then(() => { + expect(document.querySelector('title').innerText).toContain('this is a title (#1)'); + expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>'); + expect(vm.$el.querySelector('.md').innerHTML).toContain('<p>this is a description!</p>'); + expect(vm.$el.querySelector('.js-task-list-field').value).toContain( + 'this is a description', + ); + + expect(formatText(editedText.innerText)).toMatch(/Edited[\s\S]+?by Some User/); + expect(editedText.querySelector('.author-link').href).toMatch(/\/some_user$/); + expect(editedText.querySelector('time')).toBeTruthy(); + expect(vm.state.lock_version).toEqual(1); + }) + .then(() => { + vm.poll.makeRequest(); + return axios.waitForAll(); + }) + .then(() => { + expect(document.querySelector('title').innerText).toContain('2 (#1)'); + expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>2</p>'); + expect(vm.$el.querySelector('.md').innerHTML).toContain('<p>42</p>'); + expect(vm.$el.querySelector('.js-task-list-field').value).toContain('42'); + expect(vm.$el.querySelector('.edited-text')).toBeTruthy(); + expect(formatText(vm.$el.querySelector('.edited-text').innerText)).toMatch( + /Edited[\s\S]+?by Other User/, + ); + + expect(editedText.querySelector('.author-link').href).toMatch(/\/other_user$/); + expect(editedText.querySelector('time')).toBeTruthy(); + expect(vm.state.lock_version).toEqual(2); + }); + }); + + it('shows actions if permissions are correct', () => { + vm.showForm = true; + + return vm.$nextTick().then(() => { + expect(vm.$el.querySelector('.btn')).not.toBeNull(); + }); + }); + + it('does not show actions if permissions are incorrect', () => { + vm.showForm = true; + vm.canUpdate = false; + + return vm.$nextTick().then(() => { + expect(vm.$el.querySelector('.btn')).toBeNull(); + }); + }); + + it('does not update formState if form is already open', () => { + vm.updateAndShowForm(); + + vm.state.titleText = 'testing 123'; + + vm.updateAndShowForm(); + + return vm.$nextTick().then(() => { + expect(vm.store.formState.title).not.toBe('testing 123'); + }); + }); + + it('opens reCAPTCHA modal if update rejected as spam', () => { + let modal; + + jest.spyOn(vm.service, 'updateIssuable').mockResolvedValue({ + data: { + recaptcha_html: '<div class="g-recaptcha">recaptcha_html</div>', + }, + }); + + vm.canUpdate = true; + vm.showForm = true; + + return vm + .$nextTick() + .then(() => { + vm.$refs.recaptchaModal.scriptSrc = '//scriptsrc'; + return vm.updateIssuable(); + }) + .then(() => { + modal = vm.$el.querySelector('.js-recaptcha-modal'); + + expect(modal.style.display).not.toEqual('none'); + expect(modal.querySelector('.g-recaptcha').textContent).toEqual('recaptcha_html'); + expect(document.body.querySelector('.js-recaptcha-script').src).toMatch('//scriptsrc'); + }) + .then(() => { + modal.querySelector('.close').click(); + return vm.$nextTick(); + }) + .then(() => { + expect(modal.style.display).toEqual('none'); + expect(document.body.querySelector('.js-recaptcha-script')).toBeNull(); + }); + }); + + describe('updateIssuable', () => { + it('fetches new data after update', () => { + const updateStoreSpy = jest.spyOn(vm, 'updateStoreState'); + const getDataSpy = jest.spyOn(vm.service, 'getData'); + jest.spyOn(vm.service, 'updateIssuable').mockResolvedValue({ + data: { web_url: window.location.pathname }, + }); + + return vm.updateIssuable().then(() => { + expect(updateStoreSpy).toHaveBeenCalled(); + expect(getDataSpy).toHaveBeenCalled(); + }); + }); + + it('correctly updates issuable data', () => { + const spy = jest.spyOn(vm.service, 'updateIssuable').mockResolvedValue({ + data: { web_url: window.location.pathname }, + }); + + return vm.updateIssuable().then(() => { + expect(spy).toHaveBeenCalledWith(vm.formState); + expect(eventHub.$emit).toHaveBeenCalledWith('close.form'); + }); + }); + + it('does not redirect if issue has not moved', () => { + jest.spyOn(vm.service, 'updateIssuable').mockResolvedValue({ + data: { + web_url: window.location.pathname, + confidential: vm.isConfidential, + }, + }); + + return vm.updateIssuable().then(() => { + expect(visitUrl).not.toHaveBeenCalled(); + }); + }); + + it('does not redirect if issue has not moved and user has switched tabs', () => { + jest.spyOn(vm.service, 'updateIssuable').mockResolvedValue({ + data: { + web_url: '', + confidential: vm.isConfidential, + }, + }); + + return vm.updateIssuable().then(() => { + expect(visitUrl).not.toHaveBeenCalled(); + }); + }); + + it('redirects if returned web_url has changed', () => { + jest.spyOn(vm.service, 'updateIssuable').mockResolvedValue({ + data: { + web_url: '/testing-issue-move', + confidential: vm.isConfidential, + }, + }); + + vm.updateIssuable(); + + return vm.updateIssuable().then(() => { + expect(visitUrl).toHaveBeenCalledWith('/testing-issue-move'); + }); + }); + + describe('shows dialog when issue has unsaved changed', () => { + it('confirms on title change', () => { + vm.showForm = true; + vm.state.titleText = 'title has changed'; + const e = { returnValue: null }; + vm.handleBeforeUnloadEvent(e); + return vm.$nextTick().then(() => { + expect(e.returnValue).not.toBeNull(); + }); + }); + + it('confirms on description change', () => { + vm.showForm = true; + vm.state.descriptionText = 'description has changed'; + const e = { returnValue: null }; + vm.handleBeforeUnloadEvent(e); + return vm.$nextTick().then(() => { + expect(e.returnValue).not.toBeNull(); + }); + }); + + it('does nothing when nothing has changed', () => { + const e = { returnValue: null }; + vm.handleBeforeUnloadEvent(e); + return vm.$nextTick().then(() => { + expect(e.returnValue).toBeNull(); + }); + }); + }); + + describe('error when updating', () => { + it('closes form on error', () => { + jest.spyOn(vm.service, 'updateIssuable').mockRejectedValue(); + return vm.updateIssuable().then(() => { + expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form'); + expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe( + `Error updating issue`, + ); + }); + }); + + it('returns the correct error message for issuableType', () => { + jest.spyOn(vm.service, 'updateIssuable').mockRejectedValue(); + vm.issuableType = 'merge request'; + + return vm + .$nextTick() + .then(vm.updateIssuable) + .then(() => { + expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form'); + expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe( + `Error updating merge request`, + ); + }); + }); + + it('shows error message from backend if exists', () => { + const msg = 'Custom error message from backend'; + jest + .spyOn(vm.service, 'updateIssuable') + .mockRejectedValue({ response: { data: { errors: [msg] } } }); + + return vm.updateIssuable().then(() => { + expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe( + `${vm.defaultErrorMessage}. ${msg}`, + ); + }); + }); + }); + }); + + describe('deleteIssuable', () => { + it('changes URL when deleted', () => { + jest.spyOn(vm.service, 'deleteIssuable').mockResolvedValue({ + data: { + web_url: '/test', + }, + }); + + return vm.deleteIssuable().then(() => { + expect(visitUrl).toHaveBeenCalledWith('/test'); + }); + }); + + it('stops polling when deleting', () => { + const spy = jest.spyOn(vm.poll, 'stop'); + jest.spyOn(vm.service, 'deleteIssuable').mockResolvedValue({ + data: { + web_url: '/test', + }, + }); + + return vm.deleteIssuable().then(() => { + expect(spy).toHaveBeenCalledWith(); + }); + }); + + it('closes form on error', () => { + jest.spyOn(vm.service, 'deleteIssuable').mockRejectedValue(); + + return vm.deleteIssuable().then(() => { + expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form'); + expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe( + 'Error deleting issue', + ); + }); + }); + }); + + describe('updateAndShowForm', () => { + it('shows locked warning if form is open & data is different', () => { + return vm + .$nextTick() + .then(() => { + vm.updateAndShowForm(); + + vm.poll.makeRequest(); + + return new Promise(resolve => { + vm.$watch('formState.lockedWarningVisible', value => { + if (value) resolve(); + }); + }); + }) + .then(() => { + expect(vm.formState.lockedWarningVisible).toEqual(true); + expect(vm.formState.lock_version).toEqual(1); + expect(vm.$el.querySelector('.alert')).not.toBeNull(); + }); + }); + }); + + describe('requestTemplatesAndShowForm', () => { + let formSpy; + + beforeEach(() => { + formSpy = jest.spyOn(vm, 'updateAndShowForm'); + }); + + it('shows the form if template names request is successful', () => { + const mockData = [{ name: 'Bug' }]; + mock.onGet('/issuable-templates-path').reply(() => Promise.resolve([200, mockData])); + + return vm.requestTemplatesAndShowForm().then(() => { + expect(formSpy).toHaveBeenCalledWith(mockData); + }); + }); + + it('shows the form if template names request failed', () => { + mock + .onGet('/issuable-templates-path') + .reply(() => Promise.reject(new Error('something went wrong'))); + + return vm.requestTemplatesAndShowForm().then(() => { + expect(document.querySelector('.flash-container .flash-text').textContent).toContain( + 'Error updating issue', + ); + + expect(formSpy).toHaveBeenCalledWith(); + }); + }); + }); + + describe('show inline edit button', () => { + it('should not render by default', () => { + expect(vm.$el.querySelector('.title-container .note-action-button')).toBeDefined(); + }); + + it('should render if showInlineEditButton', () => { + vm.showInlineEditButton = true; + + expect(vm.$el.querySelector('.title-container .note-action-button')).toBeDefined(); + }); + }); + + describe('updateStoreState', () => { + it('should make a request and update the state of the store', () => { + const data = { foo: 1 }; + const getDataSpy = jest.spyOn(vm.service, 'getData').mockResolvedValue({ data }); + const updateStateSpy = jest.spyOn(vm.store, 'updateState').mockImplementation(jest.fn); + + return vm.updateStoreState().then(() => { + expect(getDataSpy).toHaveBeenCalled(); + expect(updateStateSpy).toHaveBeenCalledWith(data); + }); + }); + + it('should show error message if store update fails', () => { + jest.spyOn(vm.service, 'getData').mockRejectedValue(); + vm.issuableType = 'merge request'; + + return vm.updateStoreState().then(() => { + expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe( + `Error updating ${vm.issuableType}`, + ); + }); + }); + }); + + describe('issueChanged', () => { + beforeEach(() => { + vm.store.formState.title = ''; + vm.store.formState.description = ''; + vm.initialDescriptionText = ''; + vm.initialTitleText = ''; + }); + + it('returns true when title is changed', () => { + vm.store.formState.title = 'RandomText'; + + expect(vm.issueChanged).toBe(true); + }); + + it('returns false when title is empty null', () => { + vm.store.formState.title = null; + + expect(vm.issueChanged).toBe(false); + }); + + it('returns false when `initialTitleText` is null and `formState.title` is empty string', () => { + vm.store.formState.title = ''; + vm.initialTitleText = null; + + expect(vm.issueChanged).toBe(false); + }); + + it('returns true when description is changed', () => { + vm.store.formState.description = 'RandomText'; + + expect(vm.issueChanged).toBe(true); + }); + + it('returns false when description is empty null', () => { + vm.store.formState.title = null; + + expect(vm.issueChanged).toBe(false); + }); + + it('returns false when `initialDescriptionText` is null and `formState.description` is empty string', () => { + vm.store.formState.description = ''; + vm.initialDescriptionText = null; + + expect(vm.issueChanged).toBe(false); + }); + }); +}); diff --git a/spec/frontend/issue_show/components/description_spec.js b/spec/frontend/issue_show/components/description_spec.js new file mode 100644 index 00000000000..0053475dd13 --- /dev/null +++ b/spec/frontend/issue_show/components/description_spec.js @@ -0,0 +1,188 @@ +import $ from 'jquery'; +import Vue from 'vue'; +import '~/behaviors/markdown/render_gfm'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import { TEST_HOST } from 'helpers/test_constants'; +import Description from '~/issue_show/components/description.vue'; +import TaskList from '~/task_list'; + +jest.mock('~/task_list'); + +describe('Description component', () => { + let vm; + let DescriptionComponent; + const props = { + canUpdate: true, + descriptionHtml: 'test', + descriptionText: 'test', + updatedAt: new Date().toString(), + taskStatus: '', + updateUrl: TEST_HOST, + }; + + beforeEach(() => { + DescriptionComponent = Vue.extend(Description); + + if (!document.querySelector('.issuable-meta')) { + const metaData = document.createElement('div'); + metaData.classList.add('issuable-meta'); + metaData.innerHTML = + '<div class="flash-container"></div><span id="task_status"></span><span id="task_status_short"></span>'; + + document.body.appendChild(metaData); + } + + vm = mountComponent(DescriptionComponent, props); + }); + + afterEach(() => { + vm.$destroy(); + }); + + afterAll(() => { + $('.issuable-meta .flash-container').remove(); + }); + + it('animates description changes', () => { + vm.descriptionHtml = 'changed'; + + return vm + .$nextTick() + .then(() => { + expect( + vm.$el.querySelector('.md').classList.contains('issue-realtime-pre-pulse'), + ).toBeTruthy(); + jest.runAllTimers(); + return vm.$nextTick(); + }) + .then(() => { + expect( + vm.$el.querySelector('.md').classList.contains('issue-realtime-trigger-pulse'), + ).toBeTruthy(); + }); + }); + + it('opens reCAPTCHA dialog if update rejected as spam', () => { + let modal; + const recaptchaChild = vm.$children.find( + // eslint-disable-next-line no-underscore-dangle + child => child.$options._componentTag === 'recaptcha-modal', + ); + + recaptchaChild.scriptSrc = '//scriptsrc'; + + vm.taskListUpdateSuccess({ + recaptcha_html: '<div class="g-recaptcha">recaptcha_html</div>', + }); + + return vm + .$nextTick() + .then(() => { + modal = vm.$el.querySelector('.js-recaptcha-modal'); + + expect(modal.style.display).not.toEqual('none'); + expect(modal.querySelector('.g-recaptcha').textContent).toEqual('recaptcha_html'); + expect(document.body.querySelector('.js-recaptcha-script').src).toMatch('//scriptsrc'); + }) + .then(() => modal.querySelector('.close').click()) + .then(() => vm.$nextTick()) + .then(() => { + expect(modal.style.display).toEqual('none'); + expect(document.body.querySelector('.js-recaptcha-script')).toBeNull(); + }); + }); + + it('applies syntax highlighting and math when description changed', () => { + const vmSpy = jest.spyOn(vm, 'renderGFM'); + const prototypeSpy = jest.spyOn($.prototype, 'renderGFM'); + vm.descriptionHtml = 'changed'; + + return vm.$nextTick().then(() => { + expect(vm.$refs['gfm-content']).toBeDefined(); + expect(vmSpy).toHaveBeenCalled(); + expect(prototypeSpy).toHaveBeenCalled(); + expect($.prototype.renderGFM).toHaveBeenCalled(); + }); + }); + + it('sets data-update-url', () => { + expect(vm.$el.querySelector('textarea').dataset.updateUrl).toEqual(TEST_HOST); + }); + + describe('TaskList', () => { + beforeEach(() => { + vm.$destroy(); + TaskList.mockClear(); + vm = mountComponent(DescriptionComponent, { ...props, issuableType: 'issuableType' }); + }); + + it('re-inits the TaskList when description changed', () => { + vm.descriptionHtml = 'changed'; + + expect(TaskList).toHaveBeenCalled(); + }); + + it('does not re-init the TaskList when canUpdate is false', () => { + vm.canUpdate = false; + vm.descriptionHtml = 'changed'; + + expect(TaskList).toHaveBeenCalledTimes(1); + }); + + it('calls with issuableType dataType', () => { + vm.descriptionHtml = 'changed'; + + expect(TaskList).toHaveBeenCalledWith({ + dataType: 'issuableType', + fieldName: 'description', + selector: '.detail-page-description', + onSuccess: expect.any(Function), + onError: expect.any(Function), + lockVersion: 0, + }); + }); + }); + + describe('taskStatus', () => { + it('adds full taskStatus', () => { + vm.taskStatus = '1 of 1'; + + return vm.$nextTick().then(() => { + expect(document.querySelector('.issuable-meta #task_status').textContent.trim()).toBe( + '1 of 1', + ); + }); + }); + + it('adds short taskStatus', () => { + vm.taskStatus = '1 of 1'; + + return vm.$nextTick().then(() => { + expect(document.querySelector('.issuable-meta #task_status_short').textContent.trim()).toBe( + '1/1 task', + ); + }); + }); + + it('clears task status text when no tasks are present', () => { + vm.taskStatus = '0 of 0'; + + return vm.$nextTick().then(() => { + expect(document.querySelector('.issuable-meta #task_status').textContent.trim()).toBe(''); + }); + }); + }); + + describe('taskListUpdateError', () => { + it('should create flash notification and emit an event to parent', () => { + const msg = + 'Someone edited this issue at the same time you did. The description has been updated and you will need to make your changes again.'; + const spy = jest.spyOn(vm, '$emit'); + + vm.taskListUpdateError(); + + expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(msg); + expect(spy).toHaveBeenCalledWith('taskListUpdateFailed'); + }); + }); +}); diff --git a/spec/frontend/issue_show/components/edited_spec.js b/spec/frontend/issue_show/components/edited_spec.js new file mode 100644 index 00000000000..a1683f060c0 --- /dev/null +++ b/spec/frontend/issue_show/components/edited_spec.js @@ -0,0 +1,49 @@ +import Vue from 'vue'; +import edited from '~/issue_show/components/edited.vue'; + +function formatText(text) { + return text.trim().replace(/\s\s+/g, ' '); +} + +describe('edited', () => { + const EditedComponent = Vue.extend(edited); + + it('should render an edited at+by string', () => { + const editedComponent = new EditedComponent({ + propsData: { + updatedAt: '2017-05-15T12:31:04.428Z', + updatedByName: 'Some User', + updatedByPath: '/some_user', + }, + }).$mount(); + + expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited[\s\S]+?by Some User/); + expect(editedComponent.$el.querySelector('.author-link').href).toMatch(/\/some_user$/); + expect(editedComponent.$el.querySelector('time')).toBeTruthy(); + }); + + it('if no updatedAt is provided, no time element will be rendered', () => { + const editedComponent = new EditedComponent({ + propsData: { + updatedByName: 'Some User', + updatedByPath: '/some_user', + }, + }).$mount(); + + expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited by Some User/); + expect(editedComponent.$el.querySelector('.author-link').href).toMatch(/\/some_user$/); + expect(editedComponent.$el.querySelector('time')).toBeFalsy(); + }); + + it('if no updatedByName and updatedByPath is provided, no user element will be rendered', () => { + const editedComponent = new EditedComponent({ + propsData: { + updatedAt: '2017-05-15T12:31:04.428Z', + }, + }).$mount(); + + expect(formatText(editedComponent.$el.innerText)).not.toMatch(/by Some User/); + expect(editedComponent.$el.querySelector('.author-link')).toBeFalsy(); + expect(editedComponent.$el.querySelector('time')).toBeTruthy(); + }); +}); diff --git a/spec/frontend/issue_show/components/fields/description_template_spec.js b/spec/frontend/issue_show/components/fields/description_template_spec.js new file mode 100644 index 00000000000..9ebab31f1ad --- /dev/null +++ b/spec/frontend/issue_show/components/fields/description_template_spec.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import descriptionTemplate from '~/issue_show/components/fields/description_template.vue'; + +describe('Issue description template component', () => { + let vm; + let formState; + + beforeEach(() => { + const Component = Vue.extend(descriptionTemplate); + formState = { + description: 'test', + }; + + vm = new Component({ + propsData: { + formState, + issuableTemplates: [{ name: 'test' }], + projectPath: '/', + projectNamespace: '/', + }, + }).$mount(); + }); + + it('renders templates as JSON array in data attribute', () => { + expect(vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data')).toBe( + '[{"name":"test"}]', + ); + }); + + it('updates formState when changing template', () => { + vm.issuableTemplate.editor.setValue('test new template'); + + expect(formState.description).toBe('test new template'); + }); + + it('returns formState description with editor getValue', () => { + formState.description = 'testing new template'; + + expect(vm.issuableTemplate.editor.getValue()).toBe('testing new template'); + }); +}); diff --git a/spec/frontend/issue_show/components/form_spec.js b/spec/frontend/issue_show/components/form_spec.js new file mode 100644 index 00000000000..b06a3a89d3b --- /dev/null +++ b/spec/frontend/issue_show/components/form_spec.js @@ -0,0 +1,99 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import formComponent from '~/issue_show/components/form.vue'; +import Autosave from '~/autosave'; +import eventHub from '~/issue_show/event_hub'; + +jest.mock('~/autosave'); + +describe('Inline edit form component', () => { + let vm; + const defaultProps = { + canDestroy: true, + formState: { + title: 'b', + description: 'a', + lockedWarningVisible: false, + }, + issuableType: 'issue', + markdownPreviewPath: '/', + markdownDocsPath: '/', + projectPath: '/', + projectNamespace: '/', + }; + + afterEach(() => { + vm.$destroy(); + }); + + const createComponent = props => { + const Component = Vue.extend(formComponent); + + vm = mountComponent(Component, { + ...defaultProps, + ...props, + }); + }; + + it('does not render template selector if no templates exist', () => { + createComponent(); + + expect(vm.$el.querySelector('.js-issuable-selector-wrap')).toBeNull(); + }); + + it('renders template selector when templates exists', () => { + createComponent({ issuableTemplates: ['test'] }); + + expect(vm.$el.querySelector('.js-issuable-selector-wrap')).not.toBeNull(); + }); + + it('hides locked warning by default', () => { + createComponent(); + + expect(vm.$el.querySelector('.alert')).toBeNull(); + }); + + it('shows locked warning if formState is different', () => { + createComponent({ formState: { ...defaultProps.formState, lockedWarningVisible: true } }); + + expect(vm.$el.querySelector('.alert')).not.toBeNull(); + }); + + it('hides locked warning when currently saving', () => { + createComponent({ + formState: { ...defaultProps.formState, updateLoading: true, lockedWarningVisible: true }, + }); + + expect(vm.$el.querySelector('.alert')).toBeNull(); + }); + + describe('autosave', () => { + let spy; + + beforeEach(() => { + spy = jest.spyOn(Autosave.prototype, 'reset'); + }); + + it('initialized Autosave on mount', () => { + createComponent(); + + expect(Autosave).toHaveBeenCalledTimes(2); + }); + + it('calls reset on autosave when eventHub emits appropriate events', () => { + createComponent(); + + eventHub.$emit('close.form'); + + expect(spy).toHaveBeenCalledTimes(2); + + eventHub.$emit('delete.issuable'); + + expect(spy).toHaveBeenCalledTimes(4); + + eventHub.$emit('update.issuable'); + + expect(spy).toHaveBeenCalledTimes(6); + }); + }); +}); diff --git a/spec/frontend/issue_show/components/title_spec.js b/spec/frontend/issue_show/components/title_spec.js new file mode 100644 index 00000000000..c274048fdd5 --- /dev/null +++ b/spec/frontend/issue_show/components/title_spec.js @@ -0,0 +1,95 @@ +import Vue from 'vue'; +import Store from '~/issue_show/stores'; +import titleComponent from '~/issue_show/components/title.vue'; +import eventHub from '~/issue_show/event_hub'; + +describe('Title component', () => { + let vm; + beforeEach(() => { + setFixtures(`<title />`); + + const Component = Vue.extend(titleComponent); + const store = new Store({ + titleHtml: '', + descriptionHtml: '', + issuableRef: '', + }); + vm = new Component({ + propsData: { + issuableRef: '#1', + titleHtml: 'Testing <img />', + titleText: 'Testing', + showForm: false, + formState: store.formState, + }, + }).$mount(); + }); + + it('renders title HTML', () => { + expect(vm.$el.querySelector('.title').innerHTML.trim()).toBe('Testing <img>'); + }); + + it('updates page title when changing titleHtml', () => { + const spy = jest.spyOn(vm, 'setPageTitle'); + vm.titleHtml = 'test'; + + return vm.$nextTick().then(() => { + expect(spy).toHaveBeenCalled(); + }); + }); + + it('animates title changes', () => { + vm.titleHtml = 'test'; + return vm + .$nextTick() + .then(() => { + expect(vm.$el.querySelector('.title').classList).toContain('issue-realtime-pre-pulse'); + jest.runAllTimers(); + return vm.$nextTick(); + }) + .then(() => { + expect(vm.$el.querySelector('.title').classList).toContain('issue-realtime-trigger-pulse'); + }); + }); + + it('updates page title after changing title', () => { + vm.titleHtml = 'changed'; + vm.titleText = 'changed'; + + return vm.$nextTick().then(() => { + expect(document.querySelector('title').textContent.trim()).toContain('changed'); + }); + }); + + describe('inline edit button', () => { + it('should not show by default', () => { + expect(vm.$el.querySelector('.btn-edit')).toBeNull(); + }); + + it('should not show if canUpdate is false', () => { + vm.showInlineEditButton = true; + vm.canUpdate = false; + + expect(vm.$el.querySelector('.btn-edit')).toBeNull(); + }); + + it('should show if showInlineEditButton and canUpdate', () => { + vm.showInlineEditButton = true; + vm.canUpdate = true; + + expect(vm.$el.querySelector('.btn-edit')).toBeDefined(); + }); + + it('should trigger open.form event when clicked', () => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + vm.showInlineEditButton = true; + vm.canUpdate = true; + + Vue.nextTick(() => { + vm.$el.querySelector('.btn-edit').click(); + + expect(eventHub.$emit).toHaveBeenCalledWith('open.form'); + }); + }); + }); +}); 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 ce32559d5c9..0040e71c192 100644 --- a/spec/frontend/jira_import/components/jira_import_app_spec.js +++ b/spec/frontend/jira_import/components/jira_import_app_spec.js @@ -1,5 +1,5 @@ import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { mount, shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import JiraImportApp from '~/jira_import/components/jira_import_app.vue'; import JiraImportForm from '~/jira_import/components/jira_import_form.vue'; @@ -11,12 +11,16 @@ import { IMPORT_STATE } from '~/jira_import/utils'; const mountComponent = ({ isJiraConfigured = true, errorMessage = '', - showAlert = true, + selectedProject = 'MTG', + showAlert = false, status = IMPORT_STATE.NONE, loading = false, mutate = jest.fn(() => Promise.resolve()), -} = {}) => - shallowMount(JiraImportApp, { + mountType, +} = {}) => { + const mountFunction = mountType === 'mount' ? mount : shallowMount; + + return mountFunction(JiraImportApp, { propsData: { isJiraConfigured, inProgressIllustration: 'in-progress-illustration.svg', @@ -26,6 +30,7 @@ const mountComponent = ({ ['My Second Jira Project', 'MSJP'], ['Migrate to GitLab', 'MTG'], ], + jiraIntegrationPath: 'gitlab-org/gitlab-test/-/services/jira/edit', projectPath: 'gitlab-org/gitlab-test', setupIllustration: 'setup-illustration.svg', }, @@ -33,15 +38,32 @@ const mountComponent = ({ return { errorMessage, showAlert, + selectedProject, jiraImportDetails: { status, - import: { - jiraProjectKey: 'MTG', - scheduledAt: '2020-04-08T12:17:25+00:00', - scheduledBy: { - name: 'Jane Doe', + 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', + }, + }, + ], }, }; }, @@ -52,6 +74,7 @@ const mountComponent = ({ }, }, }); +}; describe('JiraImportApp', () => { let wrapper; @@ -159,6 +182,64 @@ describe('JiraImportApp', () => { }); }); + describe('import in progress screen', () => { + beforeEach(() => { + wrapper = mountComponent({ status: IMPORT_STATE.SCHEDULED }); + }); + + it('shows the illustration', () => { + expect(getProgressComponent().props('illustration')).toBe('in-progress-illustration.svg'); + }); + + it('shows the name of the most recent import initiator', () => { + expect(getProgressComponent().props('importInitiator')).toBe('Jane Doe'); + }); + + it('shows the name of the most recent imported project', () => { + expect(getProgressComponent().props('importProject')).toBe('MTG'); + }); + + it('shows the time of the most recent import', () => { + expect(getProgressComponent().props('importTime')).toBe('2020-04-09T16:17:18+00:00'); + }); + + it('has the path to the issues page', () => { + expect(getProgressComponent().props('issuesPath')).toBe('gitlab-org/gitlab-test/-/issues'); + }); + }); + + describe('jira import form screen', () => { + describe('when selected project has been imported before', () => { + it('shows jira-import::MTG-3 label since project MTG has been imported 2 time before', () => { + wrapper = mountComponent(); + + expect(getFormComponent().props('importLabel')).toBe('jira-import::MTG-3'); + }); + + it('shows warning alert to explain project MTG has been imported 2 times before', () => { + wrapper = mountComponent({ mountType: 'mount' }); + + expect(getAlert().text()).toBe( + 'You have imported from this project 2 times before. Each new import will create duplicate issues.', + ); + }); + }); + + describe('when selected project has not been imported before', () => { + beforeEach(() => { + wrapper = mountComponent({ selectedProject: 'MJP' }); + }); + + it('shows jira-import::MJP-1 label since project MJP has not been imported before', () => { + expect(getFormComponent().props('importLabel')).toBe('jira-import::MJP-1'); + }); + + it('does not show warning alert since project MJP has not been imported before', () => { + expect(getAlert().exists()).toBe(false); + }); + }); + }); + describe('initiating a Jira import', () => { it('calls the mutation with the expected arguments', () => { const mutate = jest.fn(() => Promise.resolve()); @@ -200,6 +281,7 @@ describe('JiraImportApp', () => { wrapper = mountComponent({ errorMessage: 'There was an error importing the Jira project.', showAlert: true, + selectedProject: null, }); 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 0987eb11693..dea94e7bf1f 100644 --- a/spec/frontend/jira_import/components/jira_import_form_spec.js +++ b/spec/frontend/jira_import/components/jira_import_form_spec.js @@ -2,11 +2,15 @@ import { GlAvatar, GlButton, GlFormSelect, GlLabel } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-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: [ { @@ -22,6 +26,7 @@ const mountComponent = ({ mountType } = {}) => { value: 'MTG', }, ], + value, }, }); }; @@ -29,6 +34,8 @@ const mountComponent = ({ mountType } = {}) => { describe('JiraImportForm', () => { let wrapper; + const getSelectDropdown = () => wrapper.find(GlFormSelect); + const getCancelButton = () => wrapper.findAll(GlButton).at(1); afterEach(() => { @@ -40,7 +47,7 @@ describe('JiraImportForm', () => { it('is shown', () => { wrapper = mountComponent(); - expect(wrapper.find(GlFormSelect).exists()).toBe(true); + expect(wrapper.contains(GlFormSelect)).toBe(true); }); it('contains a list of Jira projects to select from', () => { @@ -48,8 +55,7 @@ describe('JiraImportForm', () => { const optionItems = ['My Jira Project', 'My Second Jira Project', 'Migrate to GitLab']; - wrapper - .find(GlFormSelect) + getSelectDropdown() .findAll('option') .wrappers.forEach((optionEl, index) => { expect(optionEl.text()).toBe(optionItems[index]); @@ -63,7 +69,7 @@ describe('JiraImportForm', () => { }); it('shows a label which will be applied to imported Jira projects', () => { - expect(wrapper.find(GlLabel).attributes('title')).toBe('jira-import::KEY-1'); + expect(wrapper.find(GlLabel).props('title')).toBe(importLabel); }); it('shows information to the user', () => { @@ -77,7 +83,7 @@ describe('JiraImportForm', () => { }); it('shows an avatar for the Reporter', () => { - expect(wrapper.find(GlAvatar).exists()).toBe(true); + expect(wrapper.contains(GlAvatar)).toBe(true); }); it('shows jira.issue.description.content for the Description', () => { @@ -111,16 +117,19 @@ describe('JiraImportForm', () => { }); }); - it('emits an "initiateJiraImport" event with the selected dropdown value when submitted', () => { - const selectedOption = 'MTG'; + it('emits an "input" event when the input select value changes', () => { + wrapper = mountComponent({ mountType: 'mount' }); + + getSelectDropdown().vm.$emit('change', value); + expect(wrapper.emitted('input')[0]).toEqual([value]); + }); + + it('emits an "initiateJiraImport" event with the selected dropdown value when submitted', () => { wrapper = mountComponent(); - wrapper.setData({ - selectedOption, - }); wrapper.find('form').trigger('submit'); - expect(wrapper.emitted('initiateJiraImport')[0]).toEqual([selectedOption]); + 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 9a6fc3b5925..3ccf14554e1 100644 --- a/spec/frontend/jira_import/components/jira_import_progress_spec.js +++ b/spec/frontend/jira_import/components/jira_import_progress_spec.js @@ -2,10 +2,14 @@ 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'; + describe('JiraImportProgress', () => { let wrapper; - const getGlEmptyStateAttribute = attribute => wrapper.find(GlEmptyState).attributes(attribute); + const getGlEmptyStateProp = attribute => wrapper.find(GlEmptyState).props(attribute); const getParagraphText = () => wrapper.find('p').text(); @@ -13,11 +17,11 @@ describe('JiraImportProgress', () => { const mountFunction = mountType === 'shallowMount' ? shallowMount : mount; return mountFunction(JiraImportProgress, { propsData: { - illustration: 'illustration.svg', + illustration, importInitiator: 'Jane Doe', - importProject: 'JIRAPROJECT', + importProject, importTime: '2020-04-08T12:17:25+00:00', - issuesPath: 'gitlab-org/gitlab-test/-/issues', + issuesPath, }, }); }; @@ -33,20 +37,21 @@ describe('JiraImportProgress', () => { }); it('contains illustration', () => { - expect(getGlEmptyStateAttribute('svgpath')).toBe('illustration.svg'); + expect(getGlEmptyStateProp('svgPath')).toBe(illustration); }); it('contains a title', () => { const title = 'Import in progress'; - expect(getGlEmptyStateAttribute('title')).toBe(title); + expect(getGlEmptyStateProp('title')).toBe(title); }); it('contains button text', () => { - expect(getGlEmptyStateAttribute('primarybuttontext')).toBe('View issues'); + expect(getGlEmptyStateProp('primaryButtonText')).toBe('View issues'); }); it('contains button url', () => { - expect(getGlEmptyStateAttribute('primarybuttonlink')).toBe('gitlab-org/gitlab-test/-/issues'); + const expected = `${issuesPath}?search=${importProject}`; + expect(getGlEmptyStateProp('primaryButtonLink')).toBe(expected); }); }); 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 834c14b512e..aa94dc4f503 100644 --- a/spec/frontend/jira_import/components/jira_import_setup_spec.js +++ b/spec/frontend/jira_import/components/jira_import_setup_spec.js @@ -2,15 +2,19 @@ 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'; + describe('JiraImportSetup', () => { let wrapper; - const getGlEmptyStateAttribute = attribute => wrapper.find(GlEmptyState).attributes(attribute); + const getGlEmptyStateProp = attribute => wrapper.find(GlEmptyState).props(attribute); beforeEach(() => { wrapper = shallowMount(JiraImportSetup, { propsData: { - illustration: 'illustration.svg', + illustration, + jiraIntegrationPath, }, }); }); @@ -21,15 +25,19 @@ describe('JiraImportSetup', () => { }); it('contains illustration', () => { - expect(getGlEmptyStateAttribute('svgpath')).toBe('illustration.svg'); + expect(getGlEmptyStateProp('svgPath')).toBe(illustration); }); it('contains a description', () => { const description = 'You will first need to set up Jira Integration to use this feature.'; - expect(getGlEmptyStateAttribute('description')).toBe(description); + expect(getGlEmptyStateProp('description')).toBe(description); }); it('contains button text', () => { - expect(getGlEmptyStateAttribute('primarybuttontext')).toBe('Set up Jira Integration'); + expect(getGlEmptyStateProp('primaryButtonText')).toBe('Set up Jira Integration'); + }); + + it('contains button link', () => { + expect(getGlEmptyStateProp('primaryButtonLink')).toBe(jiraIntegrationPath); }); }); diff --git a/spec/frontend/jira_import/utils_spec.js b/spec/frontend/jira_import/utils_spec.js index a14db104229..0b1edd6550a 100644 --- a/spec/frontend/jira_import/utils_spec.js +++ b/spec/frontend/jira_import/utils_spec.js @@ -1,27 +1,62 @@ -import { IMPORT_STATE, isInProgress } from '~/jira_import/utils'; +import { + calculateJiraImportLabel, + IMPORT_STATE, + isFinished, + isInProgress, +} from '~/jira_import/utils'; describe('isInProgress', () => { - it('returns true when state is IMPORT_STATE.SCHEDULED', () => { - expect(isInProgress(IMPORT_STATE.SCHEDULED)).toBe(true); + it.each` + state | result + ${IMPORT_STATE.SCHEDULED} | ${true} + ${IMPORT_STATE.STARTED} | ${true} + ${IMPORT_STATE.FAILED} | ${false} + ${IMPORT_STATE.FINISHED} | ${false} + ${IMPORT_STATE.NONE} | ${false} + ${undefined} | ${false} + `('returns $result when state is $state', ({ state, result }) => { + expect(isInProgress(state)).toBe(result); }); +}); - it('returns true when state is IMPORT_STATE.STARTED', () => { - expect(isInProgress(IMPORT_STATE.STARTED)).toBe(true); +describe('isFinished', () => { + it.each` + state | result + ${IMPORT_STATE.SCHEDULED} | ${false} + ${IMPORT_STATE.STARTED} | ${false} + ${IMPORT_STATE.FAILED} | ${false} + ${IMPORT_STATE.FINISHED} | ${true} + ${IMPORT_STATE.NONE} | ${false} + ${undefined} | ${false} + `('returns $result when state is $state', ({ state, result }) => { + expect(isFinished(state)).toBe(result); }); +}); - it('returns false when state is IMPORT_STATE.FAILED', () => { - expect(isInProgress(IMPORT_STATE.FAILED)).toBe(false); - }); +describe('calculateJiraImportLabel', () => { + const jiraImports = [ + { jiraProjectKey: 'MTG' }, + { jiraProjectKey: 'MJP' }, + { jiraProjectKey: 'MTG' }, + { jiraProjectKey: 'MSJP' }, + { jiraProjectKey: 'MTG' }, + ]; - it('returns false when state is IMPORT_STATE.FINISHED', () => { - expect(isInProgress(IMPORT_STATE.FINISHED)).toBe(false); - }); + const labels = [ + { color: '#111', title: 'jira-import::MTG-1' }, + { color: '#222', title: 'jira-import::MTG-2' }, + { color: '#333', title: 'jira-import::MTG-3' }, + ]; + + it('returns a label with the Jira project key and correct import count in the title', () => { + const label = calculateJiraImportLabel(jiraImports, labels); - it('returns false when state is IMPORT_STATE.NONE', () => { - expect(isInProgress(IMPORT_STATE.NONE)).toBe(false); + expect(label.title).toBe('jira-import::MTG-3'); }); - it('returns false when state is undefined', () => { - expect(isInProgress()).toBe(false); + it('returns a label with the correct color', () => { + const label = calculateJiraImportLabel(jiraImports, labels); + + expect(label.color).toBe('#333'); }); }); diff --git a/spec/frontend/jobs/components/artifacts_block_spec.js b/spec/frontend/jobs/components/artifacts_block_spec.js new file mode 100644 index 00000000000..9cb56737f3e --- /dev/null +++ b/spec/frontend/jobs/components/artifacts_block_spec.js @@ -0,0 +1,119 @@ +import Vue from 'vue'; +import { getTimeago } from '~/lib/utils/datetime_utility'; +import component from '~/jobs/components/artifacts_block.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; +import { trimText } from '../../helpers/text_helper'; + +describe('Artifacts block', () => { + const Component = Vue.extend(component); + let vm; + + const expireAt = '2018-08-14T09:38:49.157Z'; + const timeago = getTimeago(); + const formattedDate = timeago.format(expireAt); + + const expiredArtifact = { + expire_at: expireAt, + expired: true, + }; + + const nonExpiredArtifact = { + download_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/download', + browse_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/browse', + keep_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/keep', + expire_at: expireAt, + expired: false, + }; + + afterEach(() => { + vm.$destroy(); + }); + + describe('with expired artifacts', () => { + it('renders expired artifact date and info', () => { + vm = mountComponent(Component, { + artifact: expiredArtifact, + }); + + expect(vm.$el.querySelector('.js-artifacts-removed')).not.toBeNull(); + expect(vm.$el.querySelector('.js-artifacts-will-be-removed')).toBeNull(); + expect(trimText(vm.$el.querySelector('.js-artifacts-removed').textContent)).toEqual( + `The artifacts were removed ${formattedDate}`, + ); + }); + }); + + describe('with artifacts that will expire', () => { + it('renders will expire artifact date and info', () => { + vm = mountComponent(Component, { + artifact: nonExpiredArtifact, + }); + + expect(vm.$el.querySelector('.js-artifacts-removed')).toBeNull(); + expect(vm.$el.querySelector('.js-artifacts-will-be-removed')).not.toBeNull(); + expect(trimText(vm.$el.querySelector('.js-artifacts-will-be-removed').textContent)).toEqual( + `The artifacts will be removed ${formattedDate}`, + ); + }); + }); + + describe('with keep path', () => { + it('renders the keep button', () => { + vm = mountComponent(Component, { + artifact: nonExpiredArtifact, + }); + + expect(vm.$el.querySelector('.js-keep-artifacts')).not.toBeNull(); + }); + }); + + describe('without keep path', () => { + it('does not render the keep button', () => { + vm = mountComponent(Component, { + artifact: expiredArtifact, + }); + + expect(vm.$el.querySelector('.js-keep-artifacts')).toBeNull(); + }); + }); + + describe('with download path', () => { + it('renders the download button', () => { + vm = mountComponent(Component, { + artifact: nonExpiredArtifact, + }); + + expect(vm.$el.querySelector('.js-download-artifacts')).not.toBeNull(); + }); + }); + + describe('without download path', () => { + it('does not render the keep button', () => { + vm = mountComponent(Component, { + artifact: expiredArtifact, + }); + + expect(vm.$el.querySelector('.js-download-artifacts')).toBeNull(); + }); + }); + + describe('with browse path', () => { + it('does not render the browse button', () => { + vm = mountComponent(Component, { + artifact: nonExpiredArtifact, + }); + + expect(vm.$el.querySelector('.js-browse-artifacts')).not.toBeNull(); + }); + }); + + describe('without browse path', () => { + it('does not render the browse button', () => { + vm = mountComponent(Component, { + artifact: expiredArtifact, + }); + + expect(vm.$el.querySelector('.js-browse-artifacts')).toBeNull(); + }); + }); +}); diff --git a/spec/frontend/jobs/components/commit_block_spec.js b/spec/frontend/jobs/components/commit_block_spec.js new file mode 100644 index 00000000000..4e2d0053831 --- /dev/null +++ b/spec/frontend/jobs/components/commit_block_spec.js @@ -0,0 +1,89 @@ +import Vue from 'vue'; +import component from '~/jobs/components/commit_block.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +describe('Commit block', () => { + const Component = Vue.extend(component); + let vm; + + const props = { + commit: { + short_id: '1f0fb84f', + id: '1f0fb84fb6770d74d97eee58118fd3909cd4f48c', + commit_path: 'commit/1f0fb84fb6770d74d97eee58118fd3909cd4f48c', + title: 'Update README.md', + }, + mergeRequest: { + iid: '!21244', + path: 'merge_requests/21244', + }, + isLastBlock: true, + }; + + afterEach(() => { + vm.$destroy(); + }); + + describe('pipeline short sha', () => { + beforeEach(() => { + vm = mountComponent(Component, { + ...props, + }); + }); + + it('renders pipeline short sha link', () => { + expect(vm.$el.querySelector('.js-commit-sha').getAttribute('href')).toEqual( + props.commit.commit_path, + ); + + expect(vm.$el.querySelector('.js-commit-sha').textContent.trim()).toEqual( + props.commit.short_id, + ); + }); + + it('renders clipboard button', () => { + expect(vm.$el.querySelector('button').getAttribute('data-clipboard-text')).toEqual( + props.commit.id, + ); + }); + }); + + describe('with merge request', () => { + it('renders merge request link and reference', () => { + vm = mountComponent(Component, { + ...props, + }); + + expect(vm.$el.querySelector('.js-link-commit').getAttribute('href')).toEqual( + props.mergeRequest.path, + ); + + expect(vm.$el.querySelector('.js-link-commit').textContent.trim()).toEqual( + `!${props.mergeRequest.iid}`, + ); + }); + }); + + describe('without merge request', () => { + it('does not render merge request', () => { + const copyProps = { ...props }; + delete copyProps.mergeRequest; + + vm = mountComponent(Component, { + ...copyProps, + }); + + expect(vm.$el.querySelector('.js-link-commit')).toBeNull(); + }); + }); + + describe('git commit title', () => { + it('renders git commit title', () => { + vm = mountComponent(Component, { + ...props, + }); + + expect(vm.$el.textContent).toContain(props.commit.title); + }); + }); +}); diff --git a/spec/frontend/jobs/components/empty_state_spec.js b/spec/frontend/jobs/components/empty_state_spec.js new file mode 100644 index 00000000000..c6eac4e27b3 --- /dev/null +++ b/spec/frontend/jobs/components/empty_state_spec.js @@ -0,0 +1,141 @@ +import Vue from 'vue'; +import component from '~/jobs/components/empty_state.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +describe('Empty State', () => { + const Component = Vue.extend(component); + let vm; + + const props = { + illustrationPath: 'illustrations/pending_job_empty.svg', + illustrationSizeClass: 'svg-430', + title: 'This job has not started yet', + playable: false, + variablesSettingsUrl: '', + }; + + const content = 'This job is in pending state and is waiting to be picked by a runner'; + + afterEach(() => { + vm.$destroy(); + }); + + describe('renders image and title', () => { + beforeEach(() => { + vm = mountComponent(Component, { + ...props, + content, + }); + }); + + it('renders img with provided path and size', () => { + expect(vm.$el.querySelector('img').getAttribute('src')).toEqual(props.illustrationPath); + expect(vm.$el.querySelector('.svg-content').classList).toContain(props.illustrationSizeClass); + }); + + it('renders provided title', () => { + expect(vm.$el.querySelector('.js-job-empty-state-title').textContent.trim()).toEqual( + props.title, + ); + }); + }); + + describe('with content', () => { + it('renders content', () => { + vm = mountComponent(Component, { + ...props, + content, + }); + + expect(vm.$el.querySelector('.js-job-empty-state-content').textContent.trim()).toEqual( + content, + ); + }); + }); + + describe('without content', () => { + it('does not render content', () => { + vm = mountComponent(Component, { + ...props, + }); + + expect(vm.$el.querySelector('.js-job-empty-state-content')).toBeNull(); + }); + }); + + describe('with action', () => { + it('renders action', () => { + vm = mountComponent(Component, { + ...props, + content, + action: { + path: 'runner', + button_title: 'Check runner', + method: 'post', + }, + }); + + expect(vm.$el.querySelector('.js-job-empty-state-action').getAttribute('href')).toEqual( + 'runner', + ); + }); + }); + + describe('without action', () => { + it('does not render action', () => { + vm = mountComponent(Component, { + ...props, + content, + action: null, + }); + + expect(vm.$el.querySelector('.js-job-empty-state-action')).toBeNull(); + }); + }); + + describe('without playbale action', () => { + it('does not render manual variables form', () => { + vm = mountComponent(Component, { + ...props, + content, + }); + + expect(vm.$el.querySelector('.js-manual-vars-form')).toBeNull(); + }); + }); + + describe('with playbale action and not scheduled job', () => { + beforeEach(() => { + vm = mountComponent(Component, { + ...props, + content, + playable: true, + scheduled: false, + action: { + path: 'runner', + button_title: 'Check runner', + method: 'post', + }, + }); + }); + + it('renders manual variables form', () => { + expect(vm.$el.querySelector('.js-manual-vars-form')).not.toBeNull(); + }); + + it('does not render the empty state action', () => { + expect(vm.$el.querySelector('.js-job-empty-state-action')).toBeNull(); + }); + }); + + describe('with playbale action and scheduled job', () => { + it('does not render manual variables form', () => { + vm = mountComponent(Component, { + ...props, + content, + }); + + expect(vm.$el.querySelector('.js-manual-vars-form')).toBeNull(); + }); + }); +}); diff --git a/spec/frontend/jobs/components/environments_block_spec.js b/spec/frontend/jobs/components/environments_block_spec.js new file mode 100644 index 00000000000..4f2359e83b6 --- /dev/null +++ b/spec/frontend/jobs/components/environments_block_spec.js @@ -0,0 +1,261 @@ +import Vue from 'vue'; +import component from '~/jobs/components/environments_block.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +const TEST_CLUSTER_NAME = 'test_cluster'; +const TEST_CLUSTER_PATH = 'path/to/test_cluster'; +const TEST_KUBERNETES_NAMESPACE = 'this-is-a-kubernetes-namespace'; + +describe('Environments block', () => { + const Component = Vue.extend(component); + let vm; + const status = { + group: 'success', + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed', + }; + + const environment = { + environment_path: '/environment', + name: 'environment', + }; + + const lastDeployment = { iid: 'deployment', deployable: { build_path: 'bar' } }; + + const createEnvironmentWithLastDeployment = () => ({ + ...environment, + last_deployment: { ...lastDeployment }, + }); + + const createDeploymentWithCluster = () => ({ name: TEST_CLUSTER_NAME, path: TEST_CLUSTER_PATH }); + + const createDeploymentWithClusterAndKubernetesNamespace = () => ({ + name: TEST_CLUSTER_NAME, + path: TEST_CLUSTER_PATH, + kubernetes_namespace: TEST_KUBERNETES_NAMESPACE, + }); + + const createComponent = (deploymentStatus = {}, deploymentCluster = {}) => { + vm = mountComponent(Component, { + deploymentStatus, + deploymentCluster, + iconStatus: status, + }); + }; + + const findText = () => vm.$el.textContent.trim(); + const findJobDeploymentLink = () => vm.$el.querySelector('.js-job-deployment-link'); + const findEnvironmentLink = () => vm.$el.querySelector('.js-environment-link'); + const findClusterLink = () => vm.$el.querySelector('.js-job-cluster-link'); + + afterEach(() => { + vm.$destroy(); + }); + + describe('with last deployment', () => { + it('renders info for most recent deployment', () => { + createComponent({ + status: 'last', + environment, + }); + + expect(findText()).toEqual('This job is deployed to environment.'); + }); + + describe('when there is a cluster', () => { + it('renders info with cluster', () => { + createComponent( + { + status: 'last', + environment: createEnvironmentWithLastDeployment(), + }, + createDeploymentWithCluster(), + ); + + expect(findText()).toEqual( + `This job is deployed to environment using cluster ${TEST_CLUSTER_NAME}.`, + ); + }); + + describe('when there is a kubernetes namespace', () => { + it('renders info with cluster', () => { + createComponent( + { + status: 'last', + environment: createEnvironmentWithLastDeployment(), + }, + createDeploymentWithClusterAndKubernetesNamespace(), + ); + + expect(findText()).toEqual( + `This job is deployed to environment using cluster ${TEST_CLUSTER_NAME} and namespace ${TEST_KUBERNETES_NAMESPACE}.`, + ); + }); + }); + }); + }); + + describe('with out of date deployment', () => { + describe('with last deployment', () => { + it('renders info for out date and most recent', () => { + createComponent({ + status: 'out_of_date', + environment: createEnvironmentWithLastDeployment(), + }); + + expect(findText()).toEqual( + 'This job is an out-of-date deployment to environment. View the most recent deployment.', + ); + + expect(findJobDeploymentLink().getAttribute('href')).toEqual('bar'); + }); + + describe('when there is a cluster', () => { + it('renders info with cluster', () => { + createComponent( + { + status: 'out_of_date', + environment: createEnvironmentWithLastDeployment(), + }, + createDeploymentWithCluster(), + ); + + expect(findText()).toEqual( + `This job is an out-of-date deployment to environment using cluster ${TEST_CLUSTER_NAME}. View the most recent deployment.`, + ); + }); + + describe('when there is a kubernetes namespace', () => { + it('renders info with cluster', () => { + createComponent( + { + status: 'out_of_date', + environment: createEnvironmentWithLastDeployment(), + }, + createDeploymentWithClusterAndKubernetesNamespace(), + ); + + expect(findText()).toEqual( + `This job is an out-of-date deployment to environment using cluster ${TEST_CLUSTER_NAME} and namespace ${TEST_KUBERNETES_NAMESPACE}. View the most recent deployment.`, + ); + }); + }); + }); + }); + + describe('without last deployment', () => { + it('renders info about out of date deployment', () => { + createComponent({ + status: 'out_of_date', + environment, + }); + + expect(findText()).toEqual('This job is an out-of-date deployment to environment.'); + }); + }); + }); + + describe('with failed deployment', () => { + it('renders info about failed deployment', () => { + createComponent({ + status: 'failed', + environment, + }); + + expect(findText()).toEqual('The deployment of this job to environment did not succeed.'); + }); + }); + + describe('creating deployment', () => { + describe('with last deployment', () => { + it('renders info about creating deployment and overriding latest deployment', () => { + createComponent({ + status: 'creating', + environment: createEnvironmentWithLastDeployment(), + }); + + expect(findText()).toEqual( + 'This job is creating a deployment to environment. This will overwrite the latest deployment.', + ); + + expect(findJobDeploymentLink().getAttribute('href')).toEqual('bar'); + expect(findEnvironmentLink().getAttribute('href')).toEqual(environment.environment_path); + expect(findClusterLink()).toBeNull(); + }); + }); + + describe('without last deployment', () => { + it('renders info about deployment being created', () => { + createComponent({ + status: 'creating', + environment, + }); + + expect(findText()).toEqual('This job is creating a deployment to environment.'); + }); + + describe('when there is a cluster', () => { + it('inclues information about the cluster', () => { + createComponent( + { + status: 'creating', + environment, + }, + createDeploymentWithCluster(), + ); + + expect(findText()).toEqual( + `This job is creating a deployment to environment using cluster ${TEST_CLUSTER_NAME}.`, + ); + }); + }); + }); + + describe('without environment', () => { + it('does not render environment link', () => { + createComponent({ + status: 'creating', + environment: null, + }); + + expect(findEnvironmentLink()).toBeNull(); + }); + }); + }); + + describe('with a cluster', () => { + it('renders the cluster link', () => { + createComponent( + { + status: 'last', + environment: createEnvironmentWithLastDeployment(), + }, + createDeploymentWithCluster(), + ); + + expect(findText()).toEqual( + `This job is deployed to environment using cluster ${TEST_CLUSTER_NAME}.`, + ); + + expect(findClusterLink().getAttribute('href')).toEqual(TEST_CLUSTER_PATH); + }); + + describe('when the cluster is missing the path', () => { + it('renders the name without a link', () => { + createComponent( + { + status: 'last', + environment: createEnvironmentWithLastDeployment(), + }, + { name: 'the-cluster' }, + ); + + expect(findText()).toContain('using cluster the-cluster.'); + + expect(findClusterLink()).toBeNull(); + }); + }); + }); +}); diff --git a/spec/frontend/jobs/components/job_container_item_spec.js b/spec/frontend/jobs/components/job_container_item_spec.js new file mode 100644 index 00000000000..9019504d22d --- /dev/null +++ b/spec/frontend/jobs/components/job_container_item_spec.js @@ -0,0 +1,101 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import JobContainerItem from '~/jobs/components/job_container_item.vue'; +import job from '../mock_data'; + +describe('JobContainerItem', () => { + const delayedJobFixture = getJSONFixture('jobs/delayed.json'); + const Component = Vue.extend(JobContainerItem); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + const sharedTests = () => { + it('displays a status icon', () => { + expect(vm.$el).toHaveSpriteIcon(job.status.icon); + }); + + it('displays the job name', () => { + expect(vm.$el.innerText).toContain(job.name); + }); + + it('displays a link to the job', () => { + const link = vm.$el.querySelector('.js-job-link'); + + expect(link.href).toBe(job.status.details_path); + }); + }; + + describe('when a job is not active and not retied', () => { + beforeEach(() => { + vm = mountComponent(Component, { + job, + isActive: false, + }); + }); + + sharedTests(); + }); + + describe('when a job is active', () => { + beforeEach(() => { + vm = mountComponent(Component, { + job, + isActive: true, + }); + }); + + sharedTests(); + + it('displays an arrow', () => { + expect(vm.$el).toHaveSpriteIcon('arrow-right'); + }); + }); + + describe('when a job is retried', () => { + beforeEach(() => { + vm = mountComponent(Component, { + job: { + ...job, + retried: true, + }, + isActive: false, + }); + }); + + sharedTests(); + + it('displays an icon', () => { + expect(vm.$el).toHaveSpriteIcon('retry'); + }); + }); + + describe('for delayed job', () => { + beforeEach(() => { + const remainingMilliseconds = 1337000; + jest + .spyOn(Date, 'now') + .mockImplementation( + () => new Date(delayedJobFixture.scheduled_at).getTime() - remainingMilliseconds, + ); + }); + + it('displays remaining time in tooltip', done => { + vm = mountComponent(Component, { + job: delayedJobFixture, + isActive: false, + }); + + Vue.nextTick() + .then(() => { + expect(vm.$el.querySelector('.js-job-link').getAttribute('data-original-title')).toEqual( + 'delayed job - delayed manual action (00:22:17)', + ); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/frontend/jobs/components/job_log_spec.js b/spec/frontend/jobs/components/job_log_spec.js new file mode 100644 index 00000000000..2bb1e0af3a2 --- /dev/null +++ b/spec/frontend/jobs/components/job_log_spec.js @@ -0,0 +1,65 @@ +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="append-right-8" 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/jobs_container_spec.js b/spec/frontend/jobs/components/jobs_container_spec.js new file mode 100644 index 00000000000..119b18b7557 --- /dev/null +++ b/spec/frontend/jobs/components/jobs_container_spec.js @@ -0,0 +1,131 @@ +import Vue from 'vue'; +import component from '~/jobs/components/jobs_container.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +describe('Jobs List block', () => { + const Component = Vue.extend(component); + let vm; + + const retried = { + status: { + details_path: '/gitlab-org/gitlab-foss/pipelines/28029444', + group: 'success', + has_details: true, + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed', + }, + id: 233432756, + tooltip: 'build - passed', + retried: true, + }; + + const active = { + name: 'test', + status: { + details_path: '/gitlab-org/gitlab-foss/pipelines/28029444', + group: 'success', + has_details: true, + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed', + }, + id: 2322756, + tooltip: 'build - passed', + active: true, + }; + + const job = { + name: 'build', + status: { + details_path: '/gitlab-org/gitlab-foss/pipelines/28029444', + group: 'success', + has_details: true, + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed', + }, + id: 232153, + tooltip: 'build - passed', + }; + + afterEach(() => { + vm.$destroy(); + }); + + it('renders list of jobs', () => { + vm = mountComponent(Component, { + jobs: [job, retried, active], + jobId: 12313, + }); + + expect(vm.$el.querySelectorAll('a').length).toEqual(3); + }); + + it('renders arrow right when job id matches `jobId`', () => { + vm = mountComponent(Component, { + jobs: [active], + jobId: active.id, + }); + + expect(vm.$el.querySelector('a .js-arrow-right')).not.toBeNull(); + }); + + it('does not render arrow right when job is not active', () => { + vm = mountComponent(Component, { + jobs: [job], + jobId: active.id, + }); + + expect(vm.$el.querySelector('a .js-arrow-right')).toBeNull(); + }); + + it('renders job name when present', () => { + vm = mountComponent(Component, { + jobs: [job], + jobId: active.id, + }); + + expect(vm.$el.querySelector('a').textContent.trim()).toContain(job.name); + expect(vm.$el.querySelector('a').textContent.trim()).not.toContain(job.id); + }); + + it('renders job id when job name is not available', () => { + vm = mountComponent(Component, { + jobs: [retried], + jobId: active.id, + }); + + expect(vm.$el.querySelector('a').textContent.trim()).toContain(retried.id); + }); + + it('links to the job page', () => { + vm = mountComponent(Component, { + jobs: [job], + jobId: active.id, + }); + + expect(vm.$el.querySelector('a').getAttribute('href')).toEqual(job.status.details_path); + }); + + it('renders retry icon when job was retried', () => { + vm = mountComponent(Component, { + jobs: [retried], + jobId: active.id, + }); + + expect(vm.$el.querySelector('.js-retry-icon')).not.toBeNull(); + }); + + it('does not render retry icon when job was not retried', () => { + vm = mountComponent(Component, { + jobs: [job], + jobId: active.id, + }); + + expect(vm.$el.querySelector('.js-retry-icon')).toBeNull(); + }); +}); diff --git a/spec/frontend/jobs/components/log/line_header_spec.js b/spec/frontend/jobs/components/log/line_header_spec.js index f2e202674ee..5ce69221dab 100644 --- a/spec/frontend/jobs/components/log/line_header_spec.js +++ b/spec/frontend/jobs/components/log/line_header_spec.js @@ -86,7 +86,7 @@ describe('Job Log Header Line', () => { describe('with duration', () => { beforeEach(() => { - createComponent(Object.assign({}, data, { duration: '00:10' })); + createComponent({ ...data, duration: '00:10' }); }); it('renders the duration badge', () => { diff --git a/spec/frontend/jobs/components/manual_variables_form_spec.js b/spec/frontend/jobs/components/manual_variables_form_spec.js new file mode 100644 index 00000000000..82fd73ef033 --- /dev/null +++ b/spec/frontend/jobs/components/manual_variables_form_spec.js @@ -0,0 +1,103 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlDeprecatedButton } from '@gitlab/ui'; +import Form from '~/jobs/components/manual_variables_form.vue'; + +const localVue = createLocalVue(); + +describe('Manual Variables Form', () => { + let wrapper; + + const requiredProps = { + action: { + path: '/play', + method: 'post', + button_title: 'Trigger this manual action', + }, + variablesSettingsUrl: '/settings', + }; + + const factory = (props = {}) => { + wrapper = shallowMount(localVue.extend(Form), { + propsData: props, + localVue, + }); + }; + + beforeEach(() => { + factory(requiredProps); + }); + + afterEach(done => { + // The component has a `nextTick` callback after some events so we need + // to wait for those to finish before destroying. + setImmediate(() => { + wrapper.destroy(); + wrapper = null; + + done(); + }); + }); + + it('renders empty form with correct placeholders', () => { + expect(wrapper.find({ ref: 'inputKey' }).attributes('placeholder')).toBe('Input variable key'); + expect(wrapper.find({ ref: 'inputSecretValue' }).attributes('placeholder')).toBe( + 'Input variable value', + ); + }); + + it('renders help text with provided link', () => { + expect(wrapper.find('p').text()).toBe( + 'Specify variable values to be used in this run. The values specified in CI/CD settings will be used as default', + ); + + expect(wrapper.find('a').attributes('href')).toBe(requiredProps.variablesSettingsUrl); + }); + + describe('when adding a new variable', () => { + it('creates a new variable when user types a new key and resets the form', done => { + wrapper.vm + .$nextTick() + .then(() => wrapper.find({ ref: 'inputKey' }).setValue('new key')) + .then(() => { + expect(wrapper.vm.variables.length).toBe(1); + expect(wrapper.vm.variables[0].key).toBe('new key'); + expect(wrapper.find({ ref: 'inputKey' }).attributes('value')).toBe(undefined); + }) + .then(done) + .catch(done.fail); + }); + + it('creates a new variable when user types a new value and resets the form', done => { + wrapper.vm + .$nextTick() + .then(() => wrapper.find({ ref: 'inputSecretValue' }).setValue('new value')) + .then(() => { + expect(wrapper.vm.variables.length).toBe(1); + expect(wrapper.vm.variables[0].secret_value).toBe('new value'); + expect(wrapper.find({ ref: 'inputSecretValue' }).attributes('value')).toBe(undefined); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('when deleting a variable', () => { + beforeEach(done => { + wrapper.vm.variables = [ + { + key: 'new key', + secret_value: 'value', + id: '1', + }, + ]; + + wrapper.vm.$nextTick(done); + }); + + it('removes the variable row', () => { + wrapper.find(GlDeprecatedButton).vm.$emit('click'); + + expect(wrapper.vm.variables.length).toBe(0); + }); + }); +}); diff --git a/spec/frontend/jobs/components/sidebar_spec.js b/spec/frontend/jobs/components/sidebar_spec.js new file mode 100644 index 00000000000..0c8e2dc3aef --- /dev/null +++ b/spec/frontend/jobs/components/sidebar_spec.js @@ -0,0 +1,166 @@ +import Vue from 'vue'; +import sidebarDetailsBlock from '~/jobs/components/sidebar.vue'; +import createStore from '~/jobs/store'; +import job, { jobsInStage } from '../mock_data'; +import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { trimText } from '../../helpers/text_helper'; + +describe('Sidebar details block', () => { + const SidebarComponent = Vue.extend(sidebarDetailsBlock); + let vm; + let store; + + beforeEach(() => { + store = createStore(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('when there is no retry path retry', () => { + it('should not render a retry button', () => { + const copy = { ...job }; + delete copy.retry_path; + + store.dispatch('receiveJobSuccess', copy); + vm = mountComponentWithStore(SidebarComponent, { + store, + }); + + expect(vm.$el.querySelector('.js-retry-button')).toBeNull(); + }); + }); + + describe('without terminal path', () => { + it('does not render terminal link', () => { + store.dispatch('receiveJobSuccess', job); + vm = mountComponentWithStore(SidebarComponent, { store }); + + expect(vm.$el.querySelector('.js-terminal-link')).toBeNull(); + }); + }); + + describe('with terminal path', () => { + it('renders terminal link', () => { + store.dispatch('receiveJobSuccess', { ...job, terminal_path: 'job/43123/terminal' }); + vm = mountComponentWithStore(SidebarComponent, { + store, + }); + + expect(vm.$el.querySelector('.js-terminal-link')).not.toBeNull(); + }); + }); + + beforeEach(() => { + store.dispatch('receiveJobSuccess', job); + vm = mountComponentWithStore(SidebarComponent, { store }); + }); + + describe('actions', () => { + it('should render link to new issue', () => { + expect(vm.$el.querySelector('.js-new-issue').getAttribute('href')).toEqual( + job.new_issue_path, + ); + + expect(vm.$el.querySelector('.js-new-issue').textContent.trim()).toEqual('New issue'); + }); + + it('should render link to retry job', () => { + expect(vm.$el.querySelector('.js-retry-button').getAttribute('href')).toEqual(job.retry_path); + }); + + it('should render link to cancel job', () => { + expect(vm.$el.querySelector('.js-cancel-job').getAttribute('href')).toEqual(job.cancel_path); + }); + }); + + describe('information', () => { + it('should render job duration', () => { + expect(trimText(vm.$el.querySelector('.js-job-duration').textContent)).toEqual( + 'Duration: 6 seconds', + ); + }); + + it('should render erased date', () => { + expect(trimText(vm.$el.querySelector('.js-job-erased').textContent)).toEqual( + 'Erased: 3 weeks ago', + ); + }); + + it('should render finished date', () => { + expect(trimText(vm.$el.querySelector('.js-job-finished').textContent)).toEqual( + 'Finished: 3 weeks ago', + ); + }); + + it('should render queued date', () => { + expect(trimText(vm.$el.querySelector('.js-job-queued').textContent)).toEqual( + 'Queued: 9 seconds', + ); + }); + + it('should render runner ID', () => { + expect(trimText(vm.$el.querySelector('.js-job-runner').textContent)).toEqual( + 'Runner: local ci runner (#1)', + ); + }); + + it('should render timeout information', () => { + expect(trimText(vm.$el.querySelector('.js-job-timeout').textContent)).toEqual( + 'Timeout: 1m 40s (from runner)', + ); + }); + + it('should render coverage', () => { + expect(trimText(vm.$el.querySelector('.js-job-coverage').textContent)).toEqual( + 'Coverage: 20%', + ); + }); + + it('should render tags', () => { + expect(trimText(vm.$el.querySelector('.js-job-tags').textContent)).toEqual('Tags: tag'); + }); + }); + + describe('stages dropdown', () => { + beforeEach(() => { + store.dispatch('receiveJobSuccess', job); + }); + + describe('with stages', () => { + beforeEach(() => { + vm = mountComponentWithStore(SidebarComponent, { store }); + }); + + it('renders value provided as selectedStage as selected', () => { + expect(vm.$el.querySelector('.js-selected-stage').textContent.trim()).toEqual( + vm.selectedStage, + ); + }); + }); + + describe('without jobs for stages', () => { + beforeEach(() => { + store.dispatch('receiveJobSuccess', job); + vm = mountComponentWithStore(SidebarComponent, { store }); + }); + + it('does not render job container', () => { + expect(vm.$el.querySelector('.js-jobs-container')).toBeNull(); + }); + }); + + describe('with jobs for stages', () => { + beforeEach(() => { + store.dispatch('receiveJobSuccess', job); + store.dispatch('receiveJobsForStageSuccess', jobsInStage.latest_statuses); + vm = mountComponentWithStore(SidebarComponent, { store }); + }); + + it('renders list of jobs', () => { + expect(vm.$el.querySelector('.js-jobs-container')).not.toBeNull(); + }); + }); + }); +}); diff --git a/spec/frontend/jobs/components/stages_dropdown_spec.js b/spec/frontend/jobs/components/stages_dropdown_spec.js new file mode 100644 index 00000000000..e8fa6094c25 --- /dev/null +++ b/spec/frontend/jobs/components/stages_dropdown_spec.js @@ -0,0 +1,163 @@ +import Vue from 'vue'; +import { trimText } from 'helpers/text_helper'; +import component from '~/jobs/components/stages_dropdown.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +describe('Stages Dropdown', () => { + const Component = Vue.extend(component); + let vm; + + const mockPipelineData = { + id: 28029444, + details: { + status: { + details_path: '/gitlab-org/gitlab-foss/pipelines/28029444', + group: 'success', + has_details: true, + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed', + }, + }, + path: 'pipeline/28029444', + flags: { + merge_request_pipeline: true, + detached_merge_request_pipeline: false, + }, + merge_request: { + iid: 1234, + path: '/root/detached-merge-request-pipelines/-/merge_requests/1', + title: 'Update README.md', + source_branch: 'feature-1234', + source_branch_path: '/root/detached-merge-request-pipelines/branches/feature-1234', + target_branch: 'master', + target_branch_path: '/root/detached-merge-request-pipelines/branches/master', + }, + ref: { + name: 'test-branch', + }, + }; + + describe('without a merge request pipeline', () => { + let pipeline; + + beforeEach(() => { + pipeline = JSON.parse(JSON.stringify(mockPipelineData)); + delete pipeline.merge_request; + delete pipeline.flags.merge_request_pipeline; + delete pipeline.flags.detached_merge_request_pipeline; + + vm = mountComponent(Component, { + pipeline, + stages: [{ name: 'build' }, { name: 'test' }], + selectedStage: 'deploy', + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders pipeline status', () => { + expect(vm.$el.querySelector('.js-ci-status-icon-success')).not.toBeNull(); + }); + + it('renders pipeline link', () => { + expect(vm.$el.querySelector('.js-pipeline-path').getAttribute('href')).toEqual( + 'pipeline/28029444', + ); + }); + + it('renders dropdown with stages', () => { + expect(vm.$el.querySelector('.dropdown .js-stage-item').textContent).toContain('build'); + }); + + it('rendes selected stage', () => { + expect(vm.$el.querySelector('.dropdown .js-selected-stage').textContent).toContain('deploy'); + }); + + it(`renders the pipeline info text like "Pipeline #123 for source_branch"`, () => { + const expected = `Pipeline #${pipeline.id} for ${pipeline.ref.name}`; + const actual = trimText(vm.$el.querySelector('.js-pipeline-info').innerText); + + expect(actual).toBe(expected); + }); + }); + + describe('with an "attached" merge request pipeline', () => { + let pipeline; + + beforeEach(() => { + pipeline = JSON.parse(JSON.stringify(mockPipelineData)); + pipeline.flags.merge_request_pipeline = true; + pipeline.flags.detached_merge_request_pipeline = false; + + vm = mountComponent(Component, { + pipeline, + stages: [], + selectedStage: 'deploy', + }); + }); + + it(`renders the pipeline info text like "Pipeline #123 for !456 with source_branch into target_branch"`, () => { + const expected = `Pipeline #${pipeline.id} for !${pipeline.merge_request.iid} with ${pipeline.merge_request.source_branch} into ${pipeline.merge_request.target_branch}`; + const actual = trimText(vm.$el.querySelector('.js-pipeline-info').innerText); + + expect(actual).toBe(expected); + }); + + it(`renders the correct merge request link`, () => { + const actual = vm.$el.querySelector('.js-mr-link').href; + + expect(actual).toContain(pipeline.merge_request.path); + }); + + it(`renders the correct source branch link`, () => { + const actual = vm.$el.querySelector('.js-source-branch-link').href; + + expect(actual).toContain(pipeline.merge_request.source_branch_path); + }); + + it(`renders the correct target branch link`, () => { + const actual = vm.$el.querySelector('.js-target-branch-link').href; + + expect(actual).toContain(pipeline.merge_request.target_branch_path); + }); + }); + + describe('with a detached merge request pipeline', () => { + let pipeline; + + beforeEach(() => { + pipeline = JSON.parse(JSON.stringify(mockPipelineData)); + pipeline.flags.merge_request_pipeline = false; + pipeline.flags.detached_merge_request_pipeline = true; + + vm = mountComponent(Component, { + pipeline, + stages: [], + selectedStage: 'deploy', + }); + }); + + it(`renders the pipeline info like "Pipeline #123 for !456 with source_branch"`, () => { + const expected = `Pipeline #${pipeline.id} for !${pipeline.merge_request.iid} with ${pipeline.merge_request.source_branch}`; + const actual = trimText(vm.$el.querySelector('.js-pipeline-info').innerText); + + expect(actual).toBe(expected); + }); + + it(`renders the correct merge request link`, () => { + const actual = vm.$el.querySelector('.js-mr-link').href; + + expect(actual).toContain(pipeline.merge_request.path); + }); + + it(`renders the correct source branch link`, () => { + const actual = vm.$el.querySelector('.js-source-branch-link').href; + + expect(actual).toContain(pipeline.merge_request.source_branch_path); + }); + }); +}); diff --git a/spec/frontend/jobs/components/trigger_block_spec.js b/spec/frontend/jobs/components/trigger_block_spec.js new file mode 100644 index 00000000000..448197b82c0 --- /dev/null +++ b/spec/frontend/jobs/components/trigger_block_spec.js @@ -0,0 +1,100 @@ +import Vue from 'vue'; +import component from '~/jobs/components/trigger_block.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +describe('Trigger block', () => { + const Component = Vue.extend(component); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + describe('with short token', () => { + it('renders short token', () => { + vm = mountComponent(Component, { + trigger: { + short_token: '0a666b2', + }, + }); + + expect(vm.$el.querySelector('.js-short-token').textContent).toContain('0a666b2'); + }); + }); + + describe('without short token', () => { + it('does not render short token', () => { + vm = mountComponent(Component, { trigger: {} }); + + expect(vm.$el.querySelector('.js-short-token')).toBeNull(); + }); + }); + + describe('with variables', () => { + describe('hide/reveal variables', () => { + it('should toggle variables on click', done => { + vm = mountComponent(Component, { + trigger: { + short_token: 'bd7e', + variables: [ + { key: 'UPLOAD_TO_GCS', value: 'false', public: false }, + { key: 'UPLOAD_TO_S3', value: 'true', public: false }, + ], + }, + }); + + vm.$el.querySelector('.js-reveal-variables').click(); + + vm.$nextTick() + .then(() => { + expect(vm.$el.querySelector('.js-build-variables')).not.toBeNull(); + expect(vm.$el.querySelector('.js-reveal-variables').textContent.trim()).toEqual( + 'Hide values', + ); + + expect(vm.$el.querySelector('.js-build-variables').textContent).toContain( + 'UPLOAD_TO_GCS', + ); + + expect(vm.$el.querySelector('.js-build-variables').textContent).toContain('false'); + expect(vm.$el.querySelector('.js-build-variables').textContent).toContain( + 'UPLOAD_TO_S3', + ); + + expect(vm.$el.querySelector('.js-build-variables').textContent).toContain('true'); + + vm.$el.querySelector('.js-reveal-variables').click(); + }) + .then(vm.$nextTick) + .then(() => { + expect(vm.$el.querySelector('.js-reveal-variables').textContent.trim()).toEqual( + 'Reveal values', + ); + + expect(vm.$el.querySelector('.js-build-variables').textContent).toContain( + 'UPLOAD_TO_GCS', + ); + + expect(vm.$el.querySelector('.js-build-value').textContent).toContain('••••••'); + + expect(vm.$el.querySelector('.js-build-variables').textContent).toContain( + 'UPLOAD_TO_S3', + ); + + expect(vm.$el.querySelector('.js-build-value').textContent).toContain('••••••'); + }) + .then(done) + .catch(done.fail); + }); + }); + }); + + describe('without variables', () => { + it('does not render variables', () => { + vm = mountComponent(Component, { trigger: {} }); + + expect(vm.$el.querySelector('.js-reveal-variables')).toBeNull(); + expect(vm.$el.querySelector('.js-build-variables')).toBeNull(); + }); + }); +}); diff --git a/spec/frontend/jobs/components/unmet_prerequisites_block_spec.js b/spec/frontend/jobs/components/unmet_prerequisites_block_spec.js new file mode 100644 index 00000000000..68fcb321214 --- /dev/null +++ b/spec/frontend/jobs/components/unmet_prerequisites_block_spec.js @@ -0,0 +1,37 @@ +import Vue from 'vue'; +import component from '~/jobs/components/unmet_prerequisites_block.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +describe('Unmet Prerequisites Block Job component', () => { + const Component = Vue.extend(component); + let vm; + const helpPath = '/user/project/clusters/index.html#troubleshooting-failed-deployment-jobs'; + + beforeEach(() => { + vm = mountComponent(Component, { + hasNoRunnersForProject: true, + helpPath, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders an alert with the correct message', () => { + const container = vm.$el.querySelector('.js-failed-unmet-prerequisites'); + const alertMessage = + 'This job failed because the necessary resources were not successfully created.'; + + expect(container).not.toBeNull(); + expect(container.innerHTML).toContain(alertMessage); + }); + + it('renders link to help page', () => { + const helpLink = vm.$el.querySelector('.js-help-path'); + + expect(helpLink).not.toBeNull(); + expect(helpLink.innerHTML).toContain('More information'); + expect(helpLink.getAttribute('href')).toEqual(helpPath); + }); +}); diff --git a/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js b/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js new file mode 100644 index 00000000000..2f7a6030650 --- /dev/null +++ b/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js @@ -0,0 +1,79 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; + +describe('DelayedJobMixin', () => { + const delayedJobFixture = getJSONFixture('jobs/delayed.json'); + const dummyComponent = Vue.extend({ + mixins: [delayedJobMixin], + props: { + job: { + type: Object, + required: true, + }, + }, + render(createElement) { + return createElement('div', this.remainingTime); + }, + }); + + let vm; + + afterEach(() => { + vm.$destroy(); + jest.clearAllTimers(); + }); + + describe('if job is empty object', () => { + beforeEach(() => { + vm = mountComponent(dummyComponent, { + job: {}, + }); + }); + + it('sets remaining time to 00:00:00', () => { + expect(vm.$el.innerText).toBe('00:00:00'); + }); + + describe('after mounting', () => { + beforeEach(() => vm.$nextTick()); + + it('does not update remaining time', () => { + expect(vm.$el.innerText).toBe('00:00:00'); + }); + }); + }); + + describe('if job is delayed job', () => { + let remainingTimeInMilliseconds = 42000; + + beforeEach(() => { + jest + .spyOn(Date, 'now') + .mockImplementation( + () => new Date(delayedJobFixture.scheduled_at).getTime() - remainingTimeInMilliseconds, + ); + + vm = mountComponent(dummyComponent, { + job: delayedJobFixture, + }); + }); + + describe('after mounting', () => { + beforeEach(() => vm.$nextTick()); + + it('sets remaining time', () => { + expect(vm.$el.innerText).toBe('00:00:42'); + }); + + it('updates remaining time', () => { + remainingTimeInMilliseconds = 41000; + jest.advanceTimersByTime(1000); + + return vm.$nextTick().then(() => { + expect(vm.$el.innerText).toBe('00:00:41'); + }); + }); + }); + }); +}); diff --git a/spec/frontend/jobs/store/actions_spec.js b/spec/frontend/jobs/store/actions_spec.js new file mode 100644 index 00000000000..91bd5521f70 --- /dev/null +++ b/spec/frontend/jobs/store/actions_spec.js @@ -0,0 +1,512 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { TEST_HOST } from '../../helpers/test_constants'; +import axios from '~/lib/utils/axios_utils'; +import { + setJobEndpoint, + setTraceOptions, + clearEtagPoll, + stopPolling, + requestJob, + fetchJob, + receiveJobSuccess, + receiveJobError, + scrollTop, + scrollBottom, + requestTrace, + fetchTrace, + startPollingTrace, + stopPollingTrace, + receiveTraceSuccess, + receiveTraceError, + toggleCollapsibleLine, + requestJobsForStage, + fetchJobsForStage, + receiveJobsForStageSuccess, + receiveJobsForStageError, + hideSidebar, + showSidebar, + toggleSidebar, +} from '~/jobs/store/actions'; +import state from '~/jobs/store/state'; +import * as types from '~/jobs/store/mutation_types'; + +describe('Job State actions', () => { + let mockedState; + + beforeEach(() => { + mockedState = state(); + }); + + describe('setJobEndpoint', () => { + it('should commit SET_JOB_ENDPOINT mutation', done => { + testAction( + setJobEndpoint, + 'job/872324.json', + mockedState, + [{ type: types.SET_JOB_ENDPOINT, payload: 'job/872324.json' }], + [], + done, + ); + }); + }); + + describe('setTraceOptions', () => { + it('should commit SET_TRACE_OPTIONS mutation', done => { + testAction( + setTraceOptions, + { pagePath: 'job/872324/trace.json' }, + mockedState, + [{ type: types.SET_TRACE_OPTIONS, payload: { pagePath: 'job/872324/trace.json' } }], + [], + done, + ); + }); + }); + + describe('hideSidebar', () => { + it('should commit HIDE_SIDEBAR mutation', done => { + testAction(hideSidebar, null, mockedState, [{ type: types.HIDE_SIDEBAR }], [], done); + }); + }); + + describe('showSidebar', () => { + it('should commit HIDE_SIDEBAR mutation', done => { + testAction(showSidebar, null, mockedState, [{ type: types.SHOW_SIDEBAR }], [], done); + }); + }); + + describe('toggleSidebar', () => { + describe('when isSidebarOpen is true', () => { + it('should dispatch hideSidebar', done => { + testAction(toggleSidebar, null, mockedState, [], [{ type: 'hideSidebar' }], done); + }); + }); + + describe('when isSidebarOpen is false', () => { + it('should dispatch showSidebar', done => { + mockedState.isSidebarOpen = false; + + testAction(toggleSidebar, null, mockedState, [], [{ type: 'showSidebar' }], done); + }); + }); + }); + + describe('requestJob', () => { + it('should commit REQUEST_JOB mutation', done => { + testAction(requestJob, null, mockedState, [{ type: types.REQUEST_JOB }], [], done); + }); + }); + + describe('fetchJob', () => { + let mock; + + beforeEach(() => { + mockedState.jobEndpoint = `${TEST_HOST}/endpoint.json`; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + stopPolling(); + clearEtagPoll(); + }); + + describe('success', () => { + it('dispatches requestJob and receiveJobSuccess ', done => { + mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, { id: 121212, name: 'karma' }); + + testAction( + fetchJob, + null, + mockedState, + [], + [ + { + type: 'requestJob', + }, + { + payload: { id: 121212, name: 'karma' }, + type: 'receiveJobSuccess', + }, + ], + done, + ); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500); + }); + + it('dispatches requestJob and receiveJobError ', done => { + testAction( + fetchJob, + null, + mockedState, + [], + [ + { + type: 'requestJob', + }, + { + type: 'receiveJobError', + }, + ], + done, + ); + }); + }); + }); + + describe('receiveJobSuccess', () => { + it('should commit RECEIVE_JOB_SUCCESS mutation', done => { + testAction( + receiveJobSuccess, + { id: 121232132 }, + mockedState, + [{ type: types.RECEIVE_JOB_SUCCESS, payload: { id: 121232132 } }], + [], + done, + ); + }); + }); + + describe('receiveJobError', () => { + it('should commit RECEIVE_JOB_ERROR mutation', done => { + testAction(receiveJobError, null, mockedState, [{ type: types.RECEIVE_JOB_ERROR }], [], done); + }); + }); + + describe('scrollTop', () => { + it('should dispatch toggleScrollButtons action', done => { + testAction(scrollTop, null, mockedState, [], [{ type: 'toggleScrollButtons' }], done); + }); + }); + + describe('scrollBottom', () => { + it('should dispatch toggleScrollButtons action', done => { + testAction(scrollBottom, null, mockedState, [], [{ type: 'toggleScrollButtons' }], done); + }); + }); + + describe('requestTrace', () => { + it('should commit REQUEST_TRACE mutation', done => { + testAction(requestTrace, null, mockedState, [{ type: types.REQUEST_TRACE }], [], done); + }); + }); + + describe('fetchTrace', () => { + let mock; + + beforeEach(() => { + mockedState.traceEndpoint = `${TEST_HOST}/endpoint`; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + stopPolling(); + clearEtagPoll(); + }); + + describe('success', () => { + it('dispatches requestTrace, receiveTraceSuccess and stopPollingTrace when job is complete', done => { + mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(200, { + html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', + complete: true, + }); + + testAction( + fetchTrace, + null, + mockedState, + [], + [ + { + type: 'toggleScrollisInBottom', + payload: true, + }, + { + payload: { + html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', + complete: true, + }, + type: 'receiveTraceSuccess', + }, + { + type: 'stopPollingTrace', + }, + ], + done, + ); + }); + + describe('when job is incomplete', () => { + let tracePayload; + + beforeEach(() => { + tracePayload = { + html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', + complete: false, + }; + + mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(200, tracePayload); + }); + + it('dispatches startPollingTrace', done => { + testAction( + fetchTrace, + null, + mockedState, + [], + [ + { type: 'toggleScrollisInBottom', payload: true }, + { type: 'receiveTraceSuccess', payload: tracePayload }, + { type: 'startPollingTrace' }, + ], + done, + ); + }); + + it('does not dispatch startPollingTrace when timeout is non-empty', done => { + mockedState.traceTimeout = 1; + + testAction( + fetchTrace, + null, + mockedState, + [], + [ + { type: 'toggleScrollisInBottom', payload: true }, + { type: 'receiveTraceSuccess', payload: tracePayload }, + ], + done, + ); + }); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(`${TEST_HOST}/endpoint/trace.json`).reply(500); + }); + + it('dispatches requestTrace and receiveTraceError ', done => { + testAction( + fetchTrace, + null, + mockedState, + [], + [ + { + type: 'receiveTraceError', + }, + ], + done, + ); + }); + }); + }); + + describe('startPollingTrace', () => { + let dispatch; + let commit; + + beforeEach(() => { + dispatch = jest.fn(); + commit = jest.fn(); + + startPollingTrace({ dispatch, commit }); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('should save the timeout id but not call fetchTrace', () => { + expect(commit).toHaveBeenCalledWith(types.SET_TRACE_TIMEOUT, expect.any(Number)); + expect(commit.mock.calls[0][1]).toBeGreaterThan(0); + + expect(dispatch).not.toHaveBeenCalledWith('fetchTrace'); + }); + + describe('after timeout has passed', () => { + beforeEach(() => { + jest.advanceTimersByTime(4000); + }); + + it('should clear the timeout id and fetchTrace', () => { + expect(commit).toHaveBeenCalledWith(types.SET_TRACE_TIMEOUT, 0); + expect(dispatch).toHaveBeenCalledWith('fetchTrace'); + }); + }); + }); + + describe('stopPollingTrace', () => { + let origTimeout; + + beforeEach(() => { + // Can't use spyOn(window, 'clearTimeout') because this caused unrelated specs to timeout + // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23838#note_280277727 + origTimeout = window.clearTimeout; + window.clearTimeout = jest.fn(); + }); + + afterEach(() => { + window.clearTimeout = origTimeout; + }); + + it('should commit STOP_POLLING_TRACE mutation ', done => { + const traceTimeout = 7; + + testAction( + stopPollingTrace, + null, + { ...mockedState, traceTimeout }, + [{ type: types.SET_TRACE_TIMEOUT, payload: 0 }, { type: types.STOP_POLLING_TRACE }], + [], + ) + .then(() => { + expect(window.clearTimeout).toHaveBeenCalledWith(traceTimeout); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('receiveTraceSuccess', () => { + it('should commit RECEIVE_TRACE_SUCCESS mutation ', done => { + testAction( + receiveTraceSuccess, + 'hello world', + mockedState, + [{ type: types.RECEIVE_TRACE_SUCCESS, payload: 'hello world' }], + [], + done, + ); + }); + }); + + describe('receiveTraceError', () => { + it('should commit stop polling trace', done => { + testAction(receiveTraceError, null, mockedState, [], [{ type: 'stopPollingTrace' }], done); + }); + }); + + describe('toggleCollapsibleLine', () => { + it('should commit TOGGLE_COLLAPSIBLE_LINE mutation ', done => { + testAction( + toggleCollapsibleLine, + { isClosed: true }, + mockedState, + [{ type: types.TOGGLE_COLLAPSIBLE_LINE, payload: { isClosed: true } }], + [], + done, + ); + }); + }); + + describe('requestJobsForStage', () => { + it('should commit REQUEST_JOBS_FOR_STAGE mutation ', done => { + testAction( + requestJobsForStage, + { name: 'deploy' }, + mockedState, + [{ type: types.REQUEST_JOBS_FOR_STAGE, payload: { name: 'deploy' } }], + [], + done, + ); + }); + }); + + describe('fetchJobsForStage', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('success', () => { + it('dispatches requestJobsForStage and receiveJobsForStageSuccess ', done => { + mock + .onGet(`${TEST_HOST}/jobs.json`) + .replyOnce(200, { latest_statuses: [{ id: 121212, name: 'build' }], retried: [] }); + + testAction( + fetchJobsForStage, + { dropdown_path: `${TEST_HOST}/jobs.json` }, + mockedState, + [], + [ + { + type: 'requestJobsForStage', + payload: { dropdown_path: `${TEST_HOST}/jobs.json` }, + }, + { + payload: [{ id: 121212, name: 'build' }], + type: 'receiveJobsForStageSuccess', + }, + ], + done, + ); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(`${TEST_HOST}/jobs.json`).reply(500); + }); + + it('dispatches requestJobsForStage and receiveJobsForStageError', done => { + testAction( + fetchJobsForStage, + { dropdown_path: `${TEST_HOST}/jobs.json` }, + mockedState, + [], + [ + { + type: 'requestJobsForStage', + payload: { dropdown_path: `${TEST_HOST}/jobs.json` }, + }, + { + type: 'receiveJobsForStageError', + }, + ], + done, + ); + }); + }); + }); + + describe('receiveJobsForStageSuccess', () => { + it('should commit RECEIVE_JOBS_FOR_STAGE_SUCCESS mutation ', done => { + testAction( + receiveJobsForStageSuccess, + [{ id: 121212, name: 'karma' }], + mockedState, + [{ type: types.RECEIVE_JOBS_FOR_STAGE_SUCCESS, payload: [{ id: 121212, name: 'karma' }] }], + [], + done, + ); + }); + }); + + describe('receiveJobsForStageError', () => { + it('should commit RECEIVE_JOBS_FOR_STAGE_ERROR mutation ', done => { + testAction( + receiveJobsForStageError, + null, + mockedState, + [{ type: types.RECEIVE_JOBS_FOR_STAGE_ERROR }], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/jobs/store/helpers.js b/spec/frontend/jobs/store/helpers.js new file mode 100644 index 00000000000..81a769b4a6e --- /dev/null +++ b/spec/frontend/jobs/store/helpers.js @@ -0,0 +1,6 @@ +import state from '~/jobs/store/state'; + +// eslint-disable-next-line import/prefer-default-export +export const resetStore = store => { + store.replaceState(state()); +}; diff --git a/spec/frontend/jobs/store/mutations_spec.js b/spec/frontend/jobs/store/mutations_spec.js index d77690ffac0..3557d3b94b6 100644 --- a/spec/frontend/jobs/store/mutations_spec.js +++ b/spec/frontend/jobs/store/mutations_spec.js @@ -59,7 +59,7 @@ describe('Jobs Store Mutations', () => { describe('when traceSize is bigger than the total size', () => { it('sets isTraceSizeVisible to false', () => { - const copy = Object.assign({}, stateCopy, { traceSize: 5118460, size: 2321312 }); + const copy = { ...stateCopy, traceSize: 5118460, size: 2321312 }; mutations[types.RECEIVE_TRACE_SUCCESS](copy, { total: 511846 }); diff --git a/spec/frontend/labels_select_spec.js b/spec/frontend/labels_select_spec.js index 5f48bad4970..8b08eb9e124 100644 --- a/spec/frontend/labels_select_spec.js +++ b/spec/frontend/labels_select_spec.js @@ -45,7 +45,6 @@ describe('LabelsSelect', () => { labels: mockLabels, issueUpdateURL: mockUrl, enableScopedLabels: true, - scopedLabelsDocumentationLink: 'docs-link', }), ); }); @@ -71,10 +70,6 @@ describe('LabelsSelect', () => { it('generated label item has a gl-label-text class', () => { expect($labelEl.find('span').hasClass('gl-label-text')).toEqual(true); }); - - it('generated label item template does not have gl-label-icon class', () => { - expect($labelEl.find('.gl-label-icon')).toHaveLength(0); - }); }); describe('when scoped label is present', () => { @@ -87,7 +82,6 @@ describe('LabelsSelect', () => { labels: mockScopedLabels, issueUpdateURL: mockUrl, enableScopedLabels: true, - scopedLabelsDocumentationLink: 'docs-link', }), ); }); @@ -106,14 +100,6 @@ describe('LabelsSelect', () => { expect($labelEl.find('a').attr('data-html')).toBe('true'); }); - it('generated label item template has question icon', () => { - expect($labelEl.find('i.fa-question-circle')).toHaveLength(1); - }); - - it('generated label item template has gl-label-icon class', () => { - expect($labelEl.find('.gl-label-icon')).toHaveLength(1); - }); - it('generated label item template has correct label styles', () => { expect($labelEl.find('span.gl-label-text').attr('style')).toBe( `background-color: ${label.color}; color: ${label.text_color};`, @@ -141,7 +127,6 @@ describe('LabelsSelect', () => { labels: mockScopedLabels2, issueUpdateURL: mockUrl, enableScopedLabels: true, - scopedLabelsDocumentationLink: 'docs-link', }), ); }); diff --git a/spec/frontend/landing_spec.js b/spec/frontend/landing_spec.js new file mode 100644 index 00000000000..448d8ee2e81 --- /dev/null +++ b/spec/frontend/landing_spec.js @@ -0,0 +1,184 @@ +import Cookies from 'js-cookie'; +import Landing from '~/landing'; + +describe('Landing', () => { + const test = {}; + + describe('class constructor', () => { + beforeEach(() => { + test.landingElement = {}; + test.dismissButton = {}; + test.cookieName = 'cookie_name'; + + test.landing = new Landing(test.landingElement, test.dismissButton, test.cookieName); + }); + + it('should set .landing', () => { + expect(test.landing.landingElement).toBe(test.landingElement); + }); + + it('should set .cookieName', () => { + expect(test.landing.cookieName).toBe(test.cookieName); + }); + + it('should set .dismissButton', () => { + expect(test.landing.dismissButton).toBe(test.dismissButton); + }); + + it('should set .eventWrapper', () => { + expect(test.landing.eventWrapper).toEqual({}); + }); + }); + + describe('toggle', () => { + beforeEach(() => { + test.isDismissed = false; + test.landingElement = { + classList: { + toggle: jest.fn(), + }, + }; + test.landing = { + isDismissed: () => {}, + addEvents: () => {}, + landingElement: test.landingElement, + }; + + jest.spyOn(test.landing, 'isDismissed').mockReturnValue(test.isDismissed); + jest.spyOn(test.landing, 'addEvents').mockImplementation(() => {}); + + Landing.prototype.toggle.call(test.landing); + }); + + it('should call .isDismissed', () => { + expect(test.landing.isDismissed).toHaveBeenCalled(); + }); + + it('should call .classList.toggle', () => { + expect(test.landingElement.classList.toggle).toHaveBeenCalledWith('hidden', test.isDismissed); + }); + + it('should call .addEvents', () => { + expect(test.landing.addEvents).toHaveBeenCalled(); + }); + + describe('if isDismissed is true', () => { + beforeEach(() => { + test.isDismissed = true; + test.landingElement = { + classList: { + toggle: jest.fn(), + }, + }; + test.landing = { + isDismissed: () => {}, + addEvents: () => {}, + landingElement: test.landingElement, + }; + + jest.spyOn(test.landing, 'isDismissed').mockReturnValue(test.isDismissed); + jest.spyOn(test.landing, 'addEvents').mockImplementation(() => {}); + + test.landing.isDismissed.mockClear(); + + Landing.prototype.toggle.call(test.landing); + }); + + it('should not call .addEvents', () => { + expect(test.landing.addEvents).not.toHaveBeenCalled(); + }); + }); + }); + + describe('addEvents', () => { + beforeEach(() => { + test.dismissButton = { + addEventListener: jest.fn(), + }; + test.eventWrapper = {}; + test.landing = { + eventWrapper: test.eventWrapper, + dismissButton: test.dismissButton, + dismissLanding: () => {}, + }; + + Landing.prototype.addEvents.call(test.landing); + }); + + it('should set .eventWrapper.dismissLanding', () => { + expect(test.eventWrapper.dismissLanding).toEqual(expect.any(Function)); + }); + + it('should call .addEventListener', () => { + expect(test.dismissButton.addEventListener).toHaveBeenCalledWith( + 'click', + test.eventWrapper.dismissLanding, + ); + }); + }); + + describe('removeEvents', () => { + beforeEach(() => { + test.dismissButton = { + removeEventListener: jest.fn(), + }; + test.eventWrapper = { dismissLanding: () => {} }; + test.landing = { + eventWrapper: test.eventWrapper, + dismissButton: test.dismissButton, + }; + + Landing.prototype.removeEvents.call(test.landing); + }); + + it('should call .removeEventListener', () => { + expect(test.dismissButton.removeEventListener).toHaveBeenCalledWith( + 'click', + test.eventWrapper.dismissLanding, + ); + }); + }); + + describe('dismissLanding', () => { + beforeEach(() => { + test.landingElement = { + classList: { + add: jest.fn(), + }, + }; + test.cookieName = 'cookie_name'; + test.landing = { landingElement: test.landingElement, cookieName: test.cookieName }; + + jest.spyOn(Cookies, 'set').mockImplementation(() => {}); + + Landing.prototype.dismissLanding.call(test.landing); + }); + + it('should call .classList.add', () => { + expect(test.landingElement.classList.add).toHaveBeenCalledWith('hidden'); + }); + + it('should call Cookies.set', () => { + expect(Cookies.set).toHaveBeenCalledWith(test.cookieName, 'true', { expires: 365 }); + }); + }); + + describe('isDismissed', () => { + beforeEach(() => { + test.cookieName = 'cookie_name'; + test.landing = { cookieName: test.cookieName }; + + jest.spyOn(Cookies, 'get').mockReturnValue('true'); + + test.isDismissed = Landing.prototype.isDismissed.call(test.landing); + }); + + it('should call Cookies.get', () => { + expect(Cookies.get).toHaveBeenCalledWith(test.cookieName); + }); + + it('should return a boolean', () => { + expect(typeof test.isDismissed).toEqual('boolean'); + }); + }); +}); diff --git a/spec/frontend/lib/utils/axios_utils_spec.js b/spec/frontend/lib/utils/axios_utils_spec.js index d5c39567f06..1585a38ae86 100644 --- a/spec/frontend/lib/utils/axios_utils_spec.js +++ b/spec/frontend/lib/utils/axios_utils_spec.js @@ -11,6 +11,7 @@ describe('axios_utils', () => { mock = new AxiosMockAdapter(axios); mock.onAny('/ok').reply(200); mock.onAny('/err').reply(500); + // eslint-disable-next-line jest/no-standalone-expect expect(axios.countActiveRequests()).toBe(0); }); diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js index 1edfda30fec..c8dc90c9ace 100644 --- a/spec/frontend/lib/utils/common_utils_spec.js +++ b/spec/frontend/lib/utils/common_utils_spec.js @@ -503,7 +503,7 @@ describe('common_utils', () => { beforeEach(() => { window.gon = window.gon || {}; - beforeGon = Object.assign({}, window.gon); + beforeGon = { ...window.gon }; window.gon.sprite_icons = 'icons.svg'; }); diff --git a/spec/frontend/lib/utils/csrf_token_spec.js b/spec/frontend/lib/utils/csrf_token_spec.js new file mode 100644 index 00000000000..1b98ef126e9 --- /dev/null +++ b/spec/frontend/lib/utils/csrf_token_spec.js @@ -0,0 +1,57 @@ +import csrf from '~/lib/utils/csrf'; +import { setHTMLFixture } from 'helpers/fixtures'; + +describe('csrf', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + beforeEach(() => { + testContext.tokenKey = 'X-CSRF-Token'; + testContext.token = + 'pH1cvjnP9grx2oKlhWEDvUZnJ8x2eXsIs1qzyHkF3DugSG5yTxR76CWeEZRhML2D1IeVB7NEW0t5l/axE4iJpQ=='; + }); + + it('returns the correct headerKey', () => { + expect(csrf.headerKey).toBe(testContext.tokenKey); + }); + + describe('when csrf token is in the DOM', () => { + beforeEach(() => { + setHTMLFixture(` + <meta name="csrf-token" content="${testContext.token}"> + `); + + csrf.init(); + }); + + it('returns the csrf token', () => { + expect(csrf.token).toBe(testContext.token); + }); + + it('returns the csrf headers object', () => { + expect(csrf.headers[testContext.tokenKey]).toBe(testContext.token); + }); + }); + + describe('when csrf token is not in the DOM', () => { + beforeEach(() => { + setHTMLFixture(` + <meta name="some-other-token"> + `); + + csrf.init(); + }); + + it('returns null for token', () => { + expect(csrf.token).toBeNull(); + }); + + it('returns empty object for headers', () => { + expect(typeof csrf.headers).toBe('object'); + expect(Object.keys(csrf.headers).length).toBe(0); + }); + }); +}); diff --git a/spec/frontend/lib/utils/downloader_spec.js b/spec/frontend/lib/utils/downloader_spec.js new file mode 100644 index 00000000000..c14cba3a62b --- /dev/null +++ b/spec/frontend/lib/utils/downloader_spec.js @@ -0,0 +1,40 @@ +import downloader from '~/lib/utils/downloader'; + +describe('Downloader', () => { + let a; + + beforeEach(() => { + a = { click: jest.fn() }; + jest.spyOn(document, 'createElement').mockImplementation(() => a); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when inline file content is provided', () => { + const fileData = 'inline content'; + const fileName = 'test.csv'; + + it('uses the data urls to download the file', () => { + downloader({ fileName, fileData }); + expect(document.createElement).toHaveBeenCalledWith('a'); + expect(a.download).toBe(fileName); + expect(a.href).toBe(`data:text/plain;base64,${fileData}`); + expect(a.click).toHaveBeenCalledTimes(1); + }); + }); + + describe('when an endpoint is provided', () => { + const url = 'https://gitlab.com/test.csv'; + const fileName = 'test.csv'; + + it('uses the endpoint to download the file', () => { + downloader({ fileName, url }); + expect(document.createElement).toHaveBeenCalledWith('a'); + expect(a.download).toBe(fileName); + expect(a.href).toBe(url); + expect(a.click).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/spec/frontend/lib/utils/navigation_utility_spec.js b/spec/frontend/lib/utils/navigation_utility_spec.js new file mode 100644 index 00000000000..88172f38894 --- /dev/null +++ b/spec/frontend/lib/utils/navigation_utility_spec.js @@ -0,0 +1,23 @@ +import findAndFollowLink from '~/lib/utils/navigation_utility'; +import { visitUrl } from '~/lib/utils/url_utility'; + +jest.mock('~/lib/utils/url_utility'); + +describe('findAndFollowLink', () => { + it('visits a link when the selector exists', () => { + const href = '/some/path'; + + setFixtures(`<a class="my-shortcut" href="${href}">link</a>`); + + findAndFollowLink('.my-shortcut'); + + expect(visitUrl).toHaveBeenCalledWith(href); + }); + + it('does not throw an exception when the selector does not exist', () => { + // this should not throw an exception + findAndFollowLink('.this-selector-does-not-exist'); + + expect(visitUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/lib/utils/poll_spec.js b/spec/frontend/lib/utils/poll_spec.js new file mode 100644 index 00000000000..5ee9738ebf3 --- /dev/null +++ b/spec/frontend/lib/utils/poll_spec.js @@ -0,0 +1,225 @@ +import Poll from '~/lib/utils/poll'; +import { successCodes } from '~/lib/utils/http_status'; +import waitForPromises from 'helpers/wait_for_promises'; + +describe('Poll', () => { + let callbacks; + let service; + + function setup() { + return new Poll({ + resource: service, + method: 'fetch', + successCallback: callbacks.success, + errorCallback: callbacks.error, + notificationCallback: callbacks.notification, + }).makeRequest(); + } + + const mockServiceCall = (response, shouldFail = false) => { + const value = { + ...response, + header: response.header || {}, + }; + + if (shouldFail) { + service.fetch.mockRejectedValue(value); + } else { + service.fetch.mockResolvedValue(value); + } + }; + + const waitForAllCallsToFinish = (waitForCount, successCallback) => { + if (!waitForCount) { + return Promise.resolve().then(successCallback()); + } + + jest.runOnlyPendingTimers(); + + return waitForPromises().then(() => waitForAllCallsToFinish(waitForCount - 1, successCallback)); + }; + + beforeEach(() => { + service = { + fetch: jest.fn(), + }; + callbacks = { + success: jest.fn(), + error: jest.fn(), + notification: jest.fn(), + }; + }); + + it('calls the success callback when no header for interval is provided', done => { + mockServiceCall({ status: 200 }); + setup(); + + waitForAllCallsToFinish(1, () => { + expect(callbacks.success).toHaveBeenCalled(); + expect(callbacks.error).not.toHaveBeenCalled(); + + done(); + }); + }); + + it('calls the error callback when the http request returns an error', done => { + mockServiceCall({ status: 500 }, true); + setup(); + + waitForAllCallsToFinish(1, () => { + expect(callbacks.success).not.toHaveBeenCalled(); + expect(callbacks.error).toHaveBeenCalled(); + + done(); + }); + }); + + it('skips the error callback when request is aborted', done => { + mockServiceCall({ status: 0 }, true); + setup(); + + waitForAllCallsToFinish(1, () => { + expect(callbacks.success).not.toHaveBeenCalled(); + expect(callbacks.error).not.toHaveBeenCalled(); + expect(callbacks.notification).toHaveBeenCalled(); + + done(); + }); + }); + + it('should call the success callback when the interval header is -1', done => { + mockServiceCall({ status: 200, headers: { 'poll-interval': -1 } }); + setup() + .then(() => { + expect(callbacks.success).toHaveBeenCalled(); + expect(callbacks.error).not.toHaveBeenCalled(); + + done(); + }) + .catch(done.fail); + }); + + describe('for 2xx status code', () => { + successCodes.forEach(httpCode => { + it(`starts polling when http status is ${httpCode} and interval header is provided`, done => { + mockServiceCall({ status: httpCode, headers: { 'poll-interval': 1 } }); + + const Polling = new Poll({ + resource: service, + method: 'fetch', + data: { page: 1 }, + successCallback: callbacks.success, + errorCallback: callbacks.error, + }); + + Polling.makeRequest(); + + waitForAllCallsToFinish(2, () => { + Polling.stop(); + + expect(service.fetch.mock.calls).toHaveLength(2); + expect(service.fetch).toHaveBeenCalledWith({ page: 1 }); + expect(callbacks.success).toHaveBeenCalled(); + expect(callbacks.error).not.toHaveBeenCalled(); + + done(); + }); + }); + }); + }); + + describe('stop', () => { + it('stops polling when method is called', done => { + mockServiceCall({ status: 200, headers: { 'poll-interval': 1 } }); + + const Polling = new Poll({ + resource: service, + method: 'fetch', + data: { page: 1 }, + successCallback: () => { + Polling.stop(); + }, + errorCallback: callbacks.error, + }); + + jest.spyOn(Polling, 'stop'); + + Polling.makeRequest(); + + waitForAllCallsToFinish(1, () => { + expect(service.fetch.mock.calls).toHaveLength(1); + expect(service.fetch).toHaveBeenCalledWith({ page: 1 }); + expect(Polling.stop).toHaveBeenCalled(); + + done(); + }); + }); + }); + + describe('enable', () => { + it('should enable polling upon a response', done => { + mockServiceCall({ status: 200 }); + const Polling = new Poll({ + resource: service, + method: 'fetch', + data: { page: 1 }, + successCallback: () => {}, + }); + + Polling.enable({ + data: { page: 4 }, + response: { status: 200, headers: { 'poll-interval': 1 } }, + }); + + waitForAllCallsToFinish(1, () => { + Polling.stop(); + + expect(service.fetch.mock.calls).toHaveLength(1); + expect(service.fetch).toHaveBeenCalledWith({ page: 4 }); + expect(Polling.options.data).toEqual({ page: 4 }); + done(); + }); + }); + }); + + describe('restart', () => { + it('should restart polling when its called', done => { + mockServiceCall({ status: 200, headers: { 'poll-interval': 1 } }); + + const Polling = new Poll({ + resource: service, + method: 'fetch', + data: { page: 1 }, + successCallback: () => { + Polling.stop(); + + // Let's pretend that we asynchronously restart this. + // setTimeout is mocked but this will actually get triggered + // in waitForAllCalssToFinish. + setTimeout(() => { + Polling.restart({ data: { page: 4 } }); + }, 1); + }, + errorCallback: callbacks.error, + }); + + jest.spyOn(Polling, 'stop'); + jest.spyOn(Polling, 'enable'); + jest.spyOn(Polling, 'restart'); + + Polling.makeRequest(); + + waitForAllCallsToFinish(2, () => { + Polling.stop(); + + expect(service.fetch.mock.calls).toHaveLength(2); + expect(service.fetch).toHaveBeenCalledWith({ page: 4 }); + expect(Polling.stop).toHaveBeenCalled(); + expect(Polling.enable).toHaveBeenCalled(); + expect(Polling.restart).toHaveBeenCalled(); + expect(Polling.options.data).toEqual({ page: 4 }); + done(); + }); + }); + }); +}); diff --git a/spec/frontend/lib/utils/sticky_spec.js b/spec/frontend/lib/utils/sticky_spec.js new file mode 100644 index 00000000000..4ad68cc9ff6 --- /dev/null +++ b/spec/frontend/lib/utils/sticky_spec.js @@ -0,0 +1,77 @@ +import { isSticky } from '~/lib/utils/sticky'; +import { setHTMLFixture } from 'helpers/fixtures'; + +const TEST_OFFSET_TOP = 500; + +describe('sticky', () => { + let el; + let offsetTop; + + beforeEach(() => { + setHTMLFixture( + ` + <div class="parent"> + <div id="js-sticky"></div> + </div> + `, + ); + + offsetTop = TEST_OFFSET_TOP; + el = document.getElementById('js-sticky'); + Object.defineProperty(el, 'offsetTop', { + get() { + return offsetTop; + }, + }); + }); + + afterEach(() => { + el = null; + }); + + describe('when stuck', () => { + it('does not remove is-stuck class', () => { + isSticky(el, 0, el.offsetTop); + isSticky(el, 0, el.offsetTop); + + expect(el.classList.contains('is-stuck')).toBeTruthy(); + }); + + it('adds is-stuck class', () => { + isSticky(el, 0, el.offsetTop); + + expect(el.classList.contains('is-stuck')).toBeTruthy(); + }); + + it('inserts placeholder element', () => { + isSticky(el, 0, el.offsetTop, true); + + expect(document.querySelector('.sticky-placeholder')).not.toBeNull(); + }); + }); + + describe('when not stuck', () => { + it('removes is-stuck class', () => { + jest.spyOn(el.classList, 'remove'); + + isSticky(el, 0, el.offsetTop); + isSticky(el, 0, 0); + + expect(el.classList.remove).toHaveBeenCalledWith('is-stuck'); + expect(el.classList.contains('is-stuck')).toBe(false); + }); + + it('does not add is-stuck class', () => { + isSticky(el, 0, 0); + + expect(el.classList.contains('is-stuck')).toBeFalsy(); + }); + + it('removes placeholder', () => { + isSticky(el, 0, el.offsetTop, true); + isSticky(el, 0, 0, true); + + expect(document.querySelector('.sticky-placeholder')).toBeNull(); + }); + }); +}); diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js index ba3e4020e66..1d616a7da0b 100644 --- a/spec/frontend/lib/utils/text_markdown_spec.js +++ b/spec/frontend/lib/utils/text_markdown_spec.js @@ -25,7 +25,7 @@ describe('init markdown', () => { insertMarkdownText({ textArea, text: textArea.value, - tag: '*', + tag: '* ', blockTag: null, selected: '', wrap: false, @@ -43,7 +43,7 @@ describe('init markdown', () => { insertMarkdownText({ textArea, text: textArea.value, - tag: '*', + tag: '* ', blockTag: null, selected: '', wrap: false, @@ -61,7 +61,7 @@ describe('init markdown', () => { insertMarkdownText({ textArea, text: textArea.value, - tag: '*', + tag: '* ', blockTag: null, selected: '', wrap: false, @@ -79,7 +79,7 @@ describe('init markdown', () => { insertMarkdownText({ textArea, text: textArea.value, - tag: '*', + tag: '* ', blockTag: null, selected: '', wrap: false, diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index 4960895890f..c494033badd 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -91,36 +91,75 @@ describe('URL utility', () => { }); describe('mergeUrlParams', () => { + const { mergeUrlParams } = urlUtils; + it('adds w', () => { - expect(urlUtils.mergeUrlParams({ w: 1 }, '#frag')).toBe('?w=1#frag'); - expect(urlUtils.mergeUrlParams({ w: 1 }, '/path#frag')).toBe('/path?w=1#frag'); - expect(urlUtils.mergeUrlParams({ w: 1 }, 'https://host/path')).toBe('https://host/path?w=1'); - expect(urlUtils.mergeUrlParams({ w: 1 }, 'https://host/path#frag')).toBe( - 'https://host/path?w=1#frag', - ); + expect(mergeUrlParams({ w: 1 }, '#frag')).toBe('?w=1#frag'); + expect(mergeUrlParams({ w: 1 }, '')).toBe('?w=1'); + expect(mergeUrlParams({ w: 1 }, '/path#frag')).toBe('/path?w=1#frag'); + expect(mergeUrlParams({ w: 1 }, 'https://host/path')).toBe('https://host/path?w=1'); + expect(mergeUrlParams({ w: 1 }, 'https://host/path#frag')).toBe('https://host/path?w=1#frag'); + expect(mergeUrlParams({ w: 1 }, 'https://h/p?k1=v1#frag')).toBe('https://h/p?k1=v1&w=1#frag'); + expect(mergeUrlParams({ w: 'null' }, '')).toBe('?w=null'); + }); - expect(urlUtils.mergeUrlParams({ w: 1 }, 'https://h/p?k1=v1#frag')).toBe( - 'https://h/p?k1=v1&w=1#frag', - ); + it('adds multiple params', () => { + expect(mergeUrlParams({ a: 1, b: 2, c: 3 }, '#frag')).toBe('?a=1&b=2&c=3#frag'); }); it('updates w', () => { - expect(urlUtils.mergeUrlParams({ w: 1 }, '?k1=v1&w=0#frag')).toBe('?k1=v1&w=1#frag'); + expect(mergeUrlParams({ w: 2 }, '/path?w=1#frag')).toBe('/path?w=2#frag'); + expect(mergeUrlParams({ w: 2 }, 'https://host/path?w=1')).toBe('https://host/path?w=2'); }); - it('adds multiple params', () => { - expect(urlUtils.mergeUrlParams({ a: 1, b: 2, c: 3 }, '#frag')).toBe('?a=1&b=2&c=3#frag'); + it('removes null w', () => { + expect(mergeUrlParams({ w: null }, '?w=1#frag')).toBe('#frag'); + expect(mergeUrlParams({ w: null }, '/path?w=1#frag')).toBe('/path#frag'); + expect(mergeUrlParams({ w: null }, 'https://host/path?w=1')).toBe('https://host/path'); + expect(mergeUrlParams({ w: null }, 'https://host/path?w=1#frag')).toBe( + 'https://host/path#frag', + ); + expect(mergeUrlParams({ w: null }, 'https://h/p?k1=v1&w=1#frag')).toBe( + 'https://h/p?k1=v1#frag', + ); }); - it('adds and updates encoded params', () => { - expect(urlUtils.mergeUrlParams({ a: '&', q: '?' }, '?a=%23#frag')).toBe('?a=%26&q=%3F#frag'); + it('adds and updates encoded param values', () => { + expect(mergeUrlParams({ foo: '&', q: '?' }, '?foo=%23#frag')).toBe('?foo=%26&q=%3F#frag'); + expect(mergeUrlParams({ foo: 'a value' }, '')).toBe('?foo=a%20value'); + expect(mergeUrlParams({ foo: 'a value' }, '?foo=1')).toBe('?foo=a%20value'); + }); + + it('adds and updates encoded param names', () => { + expect(mergeUrlParams({ 'a name': 1 }, '')).toBe('?a%20name=1'); + expect(mergeUrlParams({ 'a name': 2 }, '?a%20name=1')).toBe('?a%20name=2'); + expect(mergeUrlParams({ 'a name': null }, '?a%20name=1')).toBe(''); }); it('treats "+" as "%20"', () => { - expect(urlUtils.mergeUrlParams({ ref: 'bogus' }, '?a=lorem+ipsum&ref=charlie')).toBe( + expect(mergeUrlParams({ ref: 'bogus' }, '?a=lorem+ipsum&ref=charlie')).toBe( '?a=lorem%20ipsum&ref=bogus', ); }); + + it('treats question marks and slashes as part of the query', () => { + expect(mergeUrlParams({ ending: '!' }, '?ending=?&foo=bar')).toBe('?ending=!&foo=bar'); + expect(mergeUrlParams({ ending: '!' }, 'https://host/path?ending=?&foo=bar')).toBe( + 'https://host/path?ending=!&foo=bar', + ); + expect(mergeUrlParams({ ending: '?' }, '?ending=!&foo=bar')).toBe('?ending=%3F&foo=bar'); + expect(mergeUrlParams({ ending: '?' }, 'https://host/path?ending=!&foo=bar')).toBe( + 'https://host/path?ending=%3F&foo=bar', + ); + expect(mergeUrlParams({ ending: '!', op: '+' }, '?ending=?&op=/')).toBe('?ending=!&op=%2B'); + expect(mergeUrlParams({ ending: '!', op: '+' }, 'https://host/path?ending=?&op=/')).toBe( + 'https://host/path?ending=!&op=%2B', + ); + expect(mergeUrlParams({ op: '+' }, '?op=/&foo=bar')).toBe('?op=%2B&foo=bar'); + expect(mergeUrlParams({ op: '+' }, 'https://host/path?op=/&foo=bar')).toBe( + 'https://host/path?op=%2B&foo=bar', + ); + }); }); describe('removeParams', () => { @@ -284,20 +323,76 @@ describe('URL utility', () => { }); }); - describe('isAbsoluteOrRootRelative', () => { - const validUrls = ['https://gitlab.com/', 'http://gitlab.com/', '/users/sign_in']; - - const invalidUrls = [' https://gitlab.com/', './file/path', 'notanurl', '<a></a>']; + describe('isAbsolute', () => { + it.each` + url | valid + ${'https://gitlab.com/'} | ${true} + ${'http://gitlab.com/'} | ${true} + ${'/users/sign_in'} | ${false} + ${' https://gitlab.com'} | ${false} + ${'somepath.php?url=https://gitlab.com'} | ${false} + ${'notaurl'} | ${false} + ${'../relative_url'} | ${false} + ${'<a></a>'} | ${false} + `('returns $valid for $url', ({ url, valid }) => { + expect(urlUtils.isAbsolute(url)).toBe(valid); + }); + }); - it.each(validUrls)(`returns true for %s`, url => { - expect(urlUtils.isAbsoluteOrRootRelative(url)).toBe(true); + describe('isRootRelative', () => { + it.each` + url | valid + ${'https://gitlab.com/'} | ${false} + ${'http://gitlab.com/'} | ${false} + ${'/users/sign_in'} | ${true} + ${' https://gitlab.com'} | ${false} + ${'/somepath.php?url=https://gitlab.com'} | ${true} + ${'notaurl'} | ${false} + ${'../relative_url'} | ${false} + ${'<a></a>'} | ${false} + `('returns $valid for $url', ({ url, valid }) => { + expect(urlUtils.isRootRelative(url)).toBe(valid); }); + }); - it.each(invalidUrls)(`returns false for %s`, url => { - expect(urlUtils.isAbsoluteOrRootRelative(url)).toBe(false); + describe('isAbsoluteOrRootRelative', () => { + it.each` + url | valid + ${'https://gitlab.com/'} | ${true} + ${'http://gitlab.com/'} | ${true} + ${'/users/sign_in'} | ${true} + ${' https://gitlab.com'} | ${false} + ${'/somepath.php?url=https://gitlab.com'} | ${true} + ${'notaurl'} | ${false} + ${'../relative_url'} | ${false} + ${'<a></a>'} | ${false} + `('returns $valid for $url', ({ url, valid }) => { + expect(urlUtils.isAbsoluteOrRootRelative(url)).toBe(valid); }); }); + describe('relativePathToAbsolute', () => { + it.each` + path | base | result + ${'./foo'} | ${'bar/'} | ${'/bar/foo'} + ${'../john.md'} | ${'bar/baz/foo.php'} | ${'/bar/john.md'} + ${'../images/img.png'} | ${'bar/baz/foo.php'} | ${'/bar/images/img.png'} + ${'../images/Image 1.png'} | ${'bar/baz/foo.php'} | ${'/bar/images/Image 1.png'} + ${'/images/img.png'} | ${'bar/baz/foo.php'} | ${'/images/img.png'} + ${'/images/img.png'} | ${'/bar/baz/foo.php'} | ${'/images/img.png'} + ${'../john.md'} | ${'/bar/baz/foo.php'} | ${'/bar/john.md'} + ${'../john.md'} | ${'///bar/baz/foo.php'} | ${'/bar/john.md'} + ${'/images/img.png'} | ${'https://gitlab.com/user/project/'} | ${'https://gitlab.com/images/img.png'} + ${'../images/img.png'} | ${'https://gitlab.com/user/project/'} | ${'https://gitlab.com/user/images/img.png'} + ${'../images/Image 1.png'} | ${'https://gitlab.com/user/project/'} | ${'https://gitlab.com/user/images/Image%201.png'} + `( + 'converts relative path "$path" with base "$base" to absolute path => "expected"', + ({ path, base, result }) => { + expect(urlUtils.relativePathToAbsolute(path, base)).toBe(result); + }, + ); + }); + describe('isSafeUrl', () => { const absoluteUrls = [ 'http://example.org', @@ -386,6 +481,12 @@ describe('URL utility', () => { expect(urlUtils.queryToObject(searchQuery)).toEqual({ one: '1', two: '2' }); }); + + it('removes undefined values from the search query', () => { + const searchQuery = '?one=1&two=2&three'; + + expect(urlUtils.queryToObject(searchQuery)).toEqual({ one: '1', two: '2' }); + }); }); describe('objectToQuery', () => { diff --git a/spec/frontend/milestones/mock_data.js b/spec/frontend/milestones/mock_data.js new file mode 100644 index 00000000000..c64eeeba663 --- /dev/null +++ b/spec/frontend/milestones/mock_data.js @@ -0,0 +1,82 @@ +export const milestones = [ + { + id: 41, + iid: 6, + project_id: 8, + title: 'v0.1', + description: '', + state: 'active', + created_at: '2020-04-04T01:30:40.051Z', + updated_at: '2020-04-04T01:30:40.051Z', + due_date: null, + start_date: null, + web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/6', + }, + { + id: 40, + iid: 5, + project_id: 8, + title: 'v4.0', + description: 'Laboriosam nisi sapiente dolores et magnam nobis ad earum.', + state: 'closed', + created_at: '2020-01-13T19:39:15.191Z', + updated_at: '2020-01-13T19:39:15.191Z', + due_date: null, + start_date: null, + web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/5', + }, + { + id: 39, + iid: 4, + project_id: 8, + title: 'v3.0', + description: 'Necessitatibus illo alias et repellat dolorum assumenda ut.', + state: 'closed', + created_at: '2020-01-13T19:39:15.176Z', + updated_at: '2020-01-13T19:39:15.176Z', + due_date: null, + start_date: null, + web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/4', + }, + { + id: 38, + iid: 3, + project_id: 8, + title: 'v2.0', + description: 'Doloribus qui repudiandae iste sit.', + state: 'closed', + created_at: '2020-01-13T19:39:15.161Z', + updated_at: '2020-01-13T19:39:15.161Z', + due_date: null, + start_date: null, + web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/3', + }, + { + id: 37, + iid: 2, + project_id: 8, + title: 'v1.0', + description: 'Illo sint odio officia ea.', + state: 'closed', + created_at: '2020-01-13T19:39:15.146Z', + updated_at: '2020-01-13T19:39:15.146Z', + due_date: null, + start_date: null, + web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/2', + }, + { + id: 36, + iid: 1, + project_id: 8, + title: 'v0.0', + description: 'Sed quae facilis deleniti at delectus assumenda nobis veritatis.', + state: 'active', + created_at: '2020-01-13T19:39:15.127Z', + updated_at: '2020-01-13T19:39:15.127Z', + due_date: null, + start_date: null, + web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/1', + }, +]; + +export default milestones; diff --git a/spec/frontend/milestones/project_milestone_combobox_spec.js b/spec/frontend/milestones/project_milestone_combobox_spec.js new file mode 100644 index 00000000000..a7321d21559 --- /dev/null +++ b/spec/frontend/milestones/project_milestone_combobox_spec.js @@ -0,0 +1,150 @@ +import { milestones as projectMilestones } from './mock_data'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { shallowMount } from '@vue/test-utils'; +import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue'; +import { GlNewDropdown, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; + +const TEST_SEARCH_ENDPOINT = '/api/v4/projects/8/search'; + +const extraLinks = [ + { text: 'Create new', url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/new' }, + { text: 'Manage milestones', url: '/h5bp/html5-boilerplate/-/milestones' }, +]; + +const preselectedMilestones = []; +const projectId = '8'; + +describe('Milestone selector', () => { + let wrapper; + let mock; + + const findNoResultsMessage = () => wrapper.find({ ref: 'noResults' }); + + const factory = (options = {}) => { + wrapper = shallowMount(MilestoneCombobox, { + ...options, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + gon.api_version = 'v4'; + + mock.onGet('/api/v4/projects/8/milestones').reply(200, projectMilestones); + + factory({ + propsData: { + projectId, + preselectedMilestones, + extraLinks, + }, + }); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + wrapper = null; + }); + + it('renders the dropdown', () => { + expect(wrapper.find(GlNewDropdown)).toExist(); + }); + + it('renders additional links', () => { + const links = wrapper.findAll('[href]'); + links.wrappers.forEach((item, idx) => { + expect(item.text()).toBe(extraLinks[idx].text); + expect(item.attributes('href')).toBe(extraLinks[idx].url); + }); + }); + + describe('before results', () => { + it('should show a loading icon', () => { + const request = mock.onGet(TEST_SEARCH_ENDPOINT, { + params: { search: 'TEST_SEARCH', scope: 'milestones' }, + }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + + return wrapper.vm.$nextTick().then(() => { + request.reply(200, []); + }); + }); + + it('should not show any dropdown items', () => { + expect(wrapper.findAll('[role="milestone option"]')).toHaveLength(0); + }); + + it('should have "No milestone" as the button text', () => { + expect(wrapper.find({ ref: 'buttonText' }).text()).toBe('No milestone'); + }); + }); + + describe('with empty results', () => { + beforeEach(() => { + mock + .onGet(TEST_SEARCH_ENDPOINT, { params: { search: 'TEST_SEARCH', scope: 'milestones' } }) + .reply(200, []); + wrapper.find(GlSearchBoxByType).vm.$emit('input', 'TEST_SEARCH'); + return axios.waitForAll(); + }); + + it('should display that no matching items are found', () => { + expect(findNoResultsMessage().exists()).toBe(true); + }); + }); + + describe('with results', () => { + let items; + beforeEach(() => { + mock + .onGet(TEST_SEARCH_ENDPOINT, { params: { search: 'v0.1', scope: 'milestones' } }) + .reply(200, [ + { + id: 41, + iid: 6, + project_id: 8, + title: 'v0.1', + description: '', + state: 'active', + created_at: '2020-04-04T01:30:40.051Z', + updated_at: '2020-04-04T01:30:40.051Z', + due_date: null, + start_date: null, + web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/6', + }, + ]); + wrapper.find(GlSearchBoxByType).vm.$emit('input', 'v0.1'); + return axios.waitForAll().then(() => { + items = wrapper.findAll('[role="milestone option"]'); + }); + }); + + it('should display one item per result', () => { + expect(items).toHaveLength(1); + }); + + it('should emit a change if an item is clicked', () => { + items.at(0).vm.$emit('click'); + expect(wrapper.emitted().change.length).toBe(1); + expect(wrapper.emitted().change[0]).toEqual([[{ title: 'v0.1' }]]); + }); + + it('should not have a selecton icon on any item', () => { + items.wrappers.forEach(item => { + expect(item.find('.selected-item').exists()).toBe(false); + }); + }); + + it('should have a selecton icon if an item is clicked', () => { + items.at(0).vm.$emit('click'); + expect(wrapper.find('.selected-item').exists()).toBe(true); + }); + + it('should not display a message about no results', () => { + expect(findNoResultsMessage().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/mocks/ce/diffs/workers/tree_worker.js b/spec/frontend/mocks/ce/diffs/workers/tree_worker.js index a33ddbbfe63..5532a22f8e6 100644 --- a/spec/frontend/mocks/ce/diffs/workers/tree_worker.js +++ b/spec/frontend/mocks/ce/diffs/workers/tree_worker.js @@ -1,8 +1 @@ -/* eslint-disable class-methods-use-this */ -export default class TreeWorkerMock { - addEventListener() {} - - terminate() {} - - postMessage() {} -} +export { default } from 'helpers/web_worker_mock'; diff --git a/spec/frontend/mocks/ce/ide/lib/diff/diff_worker.js b/spec/frontend/mocks/ce/ide/lib/diff/diff_worker.js new file mode 100644 index 00000000000..5532a22f8e6 --- /dev/null +++ b/spec/frontend/mocks/ce/ide/lib/diff/diff_worker.js @@ -0,0 +1 @@ +export { default } from 'helpers/web_worker_mock'; diff --git a/spec/frontend/mocks_spec.js b/spec/frontend/mocks_spec.js index a4a1fdea396..110c418e579 100644 --- a/spec/frontend/mocks_spec.js +++ b/spec/frontend/mocks_spec.js @@ -8,12 +8,13 @@ describe('Mock auto-injection', () => { failMock = jest.spyOn(global, 'fail').mockImplementation(); }); - it('~/lib/utils/axios_utils', done => { - expect(axios.get('http://gitlab.com')).rejects.toThrow('Unexpected unmocked request'); - setImmediate(() => { - expect(failMock).toHaveBeenCalledTimes(1); - done(); - }); + it('~/lib/utils/axios_utils', () => { + return Promise.all([ + expect(axios.get('http://gitlab.com')).rejects.toThrow('Unexpected unmocked request'), + setImmediate(() => { + expect(failMock).toHaveBeenCalledTimes(1); + }), + ]); }); it('jQuery.ajax()', () => { diff --git a/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap b/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap new file mode 100644 index 00000000000..2179e7b4ab5 --- /dev/null +++ b/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AlertWidget Alert firing displays a warning icon and matches snapshot 1`] = ` +<gl-badge-stub + class="d-flex-center text-truncate" + pill="" + variant="danger" +> + <gl-icon-stub + class="flex-shrink-0" + name="warning" + size="16" + /> + + <span + class="text-truncate gl-pl-1-deprecated-no-really-do-not-use-me" + > + Firing: + alert-label > 42 + + </span> +</gl-badge-stub> +`; + +exports[`AlertWidget Alert not firing displays a warning icon and matches snapshot 1`] = ` +<gl-badge-stub + class="d-flex-center text-truncate" + pill="" + variant="secondary" +> + <gl-icon-stub + class="flex-shrink-0" + name="warning" + size="16" + /> + + <span + class="text-truncate gl-pl-1-deprecated-no-really-do-not-use-me" + > + alert-label > 42 + </span> +</gl-badge-stub> +`; diff --git a/spec/frontend/monitoring/alert_widget_spec.js b/spec/frontend/monitoring/alert_widget_spec.js new file mode 100644 index 00000000000..f0355dfa01b --- /dev/null +++ b/spec/frontend/monitoring/alert_widget_spec.js @@ -0,0 +1,422 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon, GlTooltip, GlSprintf, GlBadge } from '@gitlab/ui'; +import AlertWidget from '~/monitoring/components/alert_widget.vue'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; + +const mockReadAlert = jest.fn(); +const mockCreateAlert = jest.fn(); +const mockUpdateAlert = jest.fn(); +const mockDeleteAlert = jest.fn(); + +jest.mock('~/flash'); +jest.mock( + '~/monitoring/services/alerts_service', + () => + function AlertsServiceMock() { + return { + readAlert: mockReadAlert, + createAlert: mockCreateAlert, + updateAlert: mockUpdateAlert, + deleteAlert: mockDeleteAlert, + }; + }, +); + +describe('AlertWidget', () => { + let wrapper; + + const nonFiringAlertResult = [ + { + values: [[0, 1], [1, 42], [2, 41]], + }, + ]; + const firingAlertResult = [ + { + values: [[0, 42], [1, 43], [2, 44]], + }, + ]; + const metricId = '5'; + const alertPath = 'my/alert.json'; + + const relevantQueries = [ + { + metricId, + label: 'alert-label', + alert_path: alertPath, + result: nonFiringAlertResult, + }, + ]; + + const firingRelevantQueries = [ + { + metricId, + label: 'alert-label', + alert_path: alertPath, + result: firingAlertResult, + }, + ]; + + const defaultProps = { + alertsEndpoint: '', + relevantQueries, + alertsToManage: {}, + modalId: 'alert-modal-1', + }; + + const propsWithAlert = { + relevantQueries, + }; + + const propsWithAlertData = { + relevantQueries, + alertsToManage: { + [alertPath]: { operator: '>', threshold: 42, alert_path: alertPath, metricId }, + }, + }; + + const createComponent = propsData => { + wrapper = shallowMount(AlertWidget, { + stubs: { GlTooltip, GlSprintf }, + propsData: { + ...defaultProps, + ...propsData, + }, + }); + }; + const hasLoadingIcon = () => wrapper.contains(GlLoadingIcon); + const findWidgetForm = () => wrapper.find({ ref: 'widgetForm' }); + const findAlertErrorMessage = () => wrapper.find({ ref: 'alertErrorMessage' }); + const findCurrentSettingsText = () => + wrapper + .find({ ref: 'alertCurrentSetting' }) + .text() + .replace(/\s\s+/g, ' '); + const findBadge = () => wrapper.find(GlBadge); + const findTooltip = () => wrapper.find(GlTooltip); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('displays a loading spinner and disables form when fetching alerts', () => { + let resolveReadAlert; + mockReadAlert.mockReturnValue( + new Promise(resolve => { + resolveReadAlert = resolve; + }), + ); + createComponent(defaultProps); + return wrapper.vm + .$nextTick() + .then(() => { + expect(hasLoadingIcon()).toBe(true); + expect(findWidgetForm().props('disabled')).toBe(true); + + resolveReadAlert({ operator: '==', threshold: 42 }); + }) + .then(() => waitForPromises()) + .then(() => { + expect(hasLoadingIcon()).toBe(false); + expect(findWidgetForm().props('disabled')).toBe(false); + }); + }); + + it('does not render loading spinner if showLoadingState is false', () => { + let resolveReadAlert; + mockReadAlert.mockReturnValue( + new Promise(resolve => { + resolveReadAlert = resolve; + }), + ); + createComponent({ + ...defaultProps, + showLoadingState: false, + }); + return wrapper.vm + .$nextTick() + .then(() => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + + resolveReadAlert({ operator: '==', threshold: 42 }); + }) + .then(() => waitForPromises()) + .then(() => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + }); + }); + + it('displays an error message when fetch fails', () => { + mockReadAlert.mockRejectedValue(); + createComponent(propsWithAlert); + expect(hasLoadingIcon()).toBe(true); + + return waitForPromises().then(() => { + expect(createFlash).toHaveBeenCalled(); + expect(hasLoadingIcon()).toBe(false); + }); + }); + + describe('Alert not firing', () => { + it('displays a warning icon and matches snapshot', () => { + mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); + createComponent(propsWithAlertData); + + return waitForPromises().then(() => { + expect(findBadge().element).toMatchSnapshot(); + }); + }); + + it('displays an alert summary when there is a single alert', () => { + mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); + createComponent(propsWithAlertData); + return waitForPromises().then(() => { + expect(findCurrentSettingsText()).toEqual('alert-label > 42'); + }); + }); + + it('displays a combined alert summary when there are multiple alerts', () => { + mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); + const propsWithManyAlerts = { + relevantQueries: [ + ...relevantQueries, + ...[ + { + metricId: '6', + alert_path: 'my/alert2.json', + label: 'alert-label2', + result: [{ values: [] }], + }, + ], + ], + alertsToManage: { + 'my/alert.json': { + operator: '>', + threshold: 42, + alert_path: alertPath, + metricId, + }, + 'my/alert2.json': { + operator: '==', + threshold: 900, + alert_path: 'my/alert2.json', + metricId: '6', + }, + }, + }; + createComponent(propsWithManyAlerts); + return waitForPromises().then(() => { + expect(findCurrentSettingsText()).toContain('2 alerts applied'); + }); + }); + }); + + describe('Alert firing', () => { + it('displays a warning icon and matches snapshot', () => { + mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); + propsWithAlertData.relevantQueries = firingRelevantQueries; + createComponent(propsWithAlertData); + + return waitForPromises().then(() => { + expect(findBadge().element).toMatchSnapshot(); + }); + }); + + it('displays an alert summary when there is a single alert', () => { + mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); + propsWithAlertData.relevantQueries = firingRelevantQueries; + createComponent(propsWithAlertData); + return waitForPromises().then(() => { + expect(findCurrentSettingsText()).toEqual('Firing: alert-label > 42'); + }); + }); + + it('displays a combined alert summary when there are multiple alerts', () => { + mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); + const propsWithManyAlerts = { + relevantQueries: [ + ...firingRelevantQueries, + ...[ + { + metricId: '6', + alert_path: 'my/alert2.json', + label: 'alert-label2', + result: [{ values: [] }], + }, + ], + ], + alertsToManage: { + 'my/alert.json': { + operator: '>', + threshold: 42, + alert_path: alertPath, + metricId, + }, + 'my/alert2.json': { + operator: '==', + threshold: 900, + alert_path: 'my/alert2.json', + metricId: '6', + }, + }, + }; + createComponent(propsWithManyAlerts); + + return waitForPromises().then(() => { + expect(findCurrentSettingsText()).toContain('2 alerts applied, 1 firing'); + }); + }); + + it('should display tooltip with thresholds summary', () => { + mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); + const propsWithManyAlerts = { + relevantQueries: [ + ...firingRelevantQueries, + ...[ + { + metricId: '6', + alert_path: 'my/alert2.json', + label: 'alert-label2', + result: [{ values: [] }], + }, + ], + ], + alertsToManage: { + 'my/alert.json': { + operator: '>', + threshold: 42, + alert_path: alertPath, + metricId, + }, + 'my/alert2.json': { + operator: '==', + threshold: 900, + alert_path: 'my/alert2.json', + metricId: '6', + }, + }, + }; + createComponent(propsWithManyAlerts); + + return waitForPromises().then(() => { + expect( + findTooltip() + .text() + .replace(/\s\s+/g, ' '), + ).toEqual('Firing: alert-label > 42'); + }); + }); + }); + + it('creates an alert with an appropriate handler', () => { + const alertParams = { + operator: '<', + threshold: 4, + prometheus_metric_id: '5', + }; + mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); + const fakeAlertPath = 'foo/bar'; + mockCreateAlert.mockResolvedValue({ alert_path: fakeAlertPath, ...alertParams }); + createComponent({ + alertsToManage: { + [fakeAlertPath]: { + alert_path: fakeAlertPath, + operator: '<', + threshold: 4, + prometheus_metric_id: '5', + metricId: '5', + }, + }, + }); + + findWidgetForm().vm.$emit('create', alertParams); + + expect(mockCreateAlert).toHaveBeenCalledWith(alertParams); + }); + + it('updates an alert with an appropriate handler', () => { + const alertParams = { operator: '<', threshold: 4, alert_path: alertPath }; + const newAlertParams = { operator: '==', threshold: 12 }; + mockReadAlert.mockResolvedValue(alertParams); + mockUpdateAlert.mockResolvedValue({ ...alertParams, ...newAlertParams }); + createComponent({ + ...propsWithAlertData, + alertsToManage: { + [alertPath]: { + alert_path: alertPath, + operator: '==', + threshold: 12, + metricId: '5', + }, + }, + }); + + findWidgetForm().vm.$emit('update', { + alert: alertPath, + ...newAlertParams, + prometheus_metric_id: '5', + }); + + expect(mockUpdateAlert).toHaveBeenCalledWith(alertPath, newAlertParams); + }); + + it('deletes an alert with an appropriate handler', () => { + const alertParams = { alert_path: alertPath, operator: '>', threshold: 42 }; + mockReadAlert.mockResolvedValue(alertParams); + mockDeleteAlert.mockResolvedValue({}); + createComponent({ + ...propsWithAlert, + alertsToManage: { + [alertPath]: { + alert_path: alertPath, + operator: '>', + threshold: 42, + metricId: '5', + }, + }, + }); + + findWidgetForm().vm.$emit('delete', { alert: alertPath }); + + return wrapper.vm.$nextTick().then(() => { + expect(mockDeleteAlert).toHaveBeenCalledWith(alertPath); + expect(findAlertErrorMessage().exists()).toBe(false); + }); + }); + + describe('when delete fails', () => { + beforeEach(() => { + const alertParams = { alert_path: alertPath, operator: '>', threshold: 42 }; + mockReadAlert.mockResolvedValue(alertParams); + mockDeleteAlert.mockRejectedValue(); + + createComponent({ + ...propsWithAlert, + alertsToManage: { + [alertPath]: { + alert_path: alertPath, + operator: '>', + threshold: 42, + metricId: '5', + }, + }, + }); + + findWidgetForm().vm.$emit('delete', { alert: alertPath }); + return wrapper.vm.$nextTick(); + }); + + it('shows error message', () => { + expect(findAlertErrorMessage().text()).toEqual('Error deleting alert'); + }); + + it('dismisses error message on cancel', () => { + findWidgetForm().vm.$emit('cancel'); + + return wrapper.vm.$nextTick().then(() => { + expect(findAlertErrorMessage().exists()).toBe(false); + }); + }); + }); +}); 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 1906ad7c6ed..9be5fa72110 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -16,7 +16,6 @@ exports[`Dashboard template matches the default snapshot 1`] = ` data-qa-selector="dashboards_filter_dropdown" defaultbranch="master" id="monitor-dashboards-dropdown" - selecteddashboard="[object Object]" toggle-class="dropdown-menu-toggle" /> </div> @@ -72,7 +71,7 @@ exports[`Dashboard template matches the default snapshot 1`] = ` <date-time-picker-stub class="flex-grow-1 show-last-dropdown" customenabled="true" - data-qa-selector="show_last_dropdown" + data-qa-selector="range_picker_dropdown" options="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]" value="[object Object]" /> @@ -101,6 +100,26 @@ exports[`Dashboard template matches the default snapshot 1`] = ` <div class="d-sm-flex" > + <div + class="mb-2 mr-2 d-flex" + > + <div + class="flex-grow-1" + title="Star dashboard" + > + <gl-deprecated-button-stub + class="w-100" + size="md" + variant="default" + > + <gl-icon-stub + name="star-o" + size="16" + /> + </gl-deprecated-button-stub> + </div> + </div> + <!----> <!----> @@ -111,6 +130,8 @@ exports[`Dashboard template matches the default snapshot 1`] = ` </div> </div> + <!----> + <empty-state-stub clusterspath="/path/to/clusters" documentationpath="/path/to/docs" diff --git a/spec/frontend/monitoring/components/alert_widget_form_spec.js b/spec/frontend/monitoring/components/alert_widget_form_spec.js new file mode 100644 index 00000000000..a8416216a94 --- /dev/null +++ b/spec/frontend/monitoring/components/alert_widget_form_spec.js @@ -0,0 +1,220 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink } from '@gitlab/ui'; +import AlertWidgetForm from '~/monitoring/components/alert_widget_form.vue'; +import ModalStub from '../stubs/modal_stub'; + +describe('AlertWidgetForm', () => { + let wrapper; + + const metricId = '8'; + const alertPath = 'alert'; + const relevantQueries = [{ metricId, alert_path: alertPath, label: 'alert-label' }]; + const dataTrackingOptions = { + create: { action: 'click_button', label: 'create_alert' }, + delete: { action: 'click_button', label: 'delete_alert' }, + update: { action: 'click_button', label: 'update_alert' }, + }; + + const defaultProps = { + disabled: false, + relevantQueries, + modalId: 'alert-modal-1', + }; + + const propsWithAlertData = { + ...defaultProps, + alertsToManage: { + alert: { alert_path: alertPath, operator: '<', threshold: 5, metricId }, + }, + configuredAlert: metricId, + }; + + function createComponent(props = {}) { + const propsData = { + ...defaultProps, + ...props, + }; + + wrapper = shallowMount(AlertWidgetForm, { + propsData, + stubs: { + GlModal: ModalStub, + }, + }); + } + + const modal = () => wrapper.find(ModalStub); + const modalTitle = () => modal().attributes('title'); + const submitButton = () => modal().find(GlLink); + const submitButtonTrackingOpts = () => + JSON.parse(submitButton().attributes('data-tracking-options')); + const e = { + preventDefault: jest.fn(), + }; + + beforeEach(() => { + e.preventDefault.mockReset(); + }); + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + it('disables the form when disabled prop is set', () => { + createComponent({ disabled: true }); + + expect(modal().attributes('ok-disabled')).toBe('true'); + }); + + it('disables the form if no query is selected', () => { + createComponent(); + + expect(modal().attributes('ok-disabled')).toBe('true'); + }); + + it('shows correct title and button text', () => { + expect(modalTitle()).toBe('Add alert'); + expect(submitButton().text()).toBe('Add'); + }); + + it('sets tracking options for create alert', () => { + expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.create); + }); + + it('emits a "create" event when form submitted without existing alert', () => { + createComponent(); + + wrapper.vm.selectQuery('9'); + wrapper.setData({ + threshold: 900, + }); + + wrapper.vm.handleSubmit(e); + + expect(wrapper.emitted().create[0]).toEqual([ + { + alert: undefined, + operator: '>', + threshold: 900, + prometheus_metric_id: '9', + }, + ]); + expect(e.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('resets form when modal is dismissed (hidden)', () => { + createComponent(); + + wrapper.vm.selectQuery('9'); + wrapper.vm.selectQuery('>'); + wrapper.setData({ + threshold: 800, + }); + + modal().vm.$emit('hidden'); + + expect(wrapper.vm.selectedAlert).toEqual({}); + expect(wrapper.vm.operator).toBe(null); + expect(wrapper.vm.threshold).toBe(null); + expect(wrapper.vm.prometheusMetricId).toBe(null); + }); + + it('sets selectedAlert to the provided configuredAlert on modal show', () => { + createComponent(propsWithAlertData); + + modal().vm.$emit('shown'); + + expect(wrapper.vm.selectedAlert).toEqual(propsWithAlertData.alertsToManage[alertPath]); + }); + + it('sets selectedAlert to the first relevantQueries if there is only one option on modal show', () => { + createComponent({ + ...propsWithAlertData, + configuredAlert: '', + }); + + modal().vm.$emit('shown'); + + expect(wrapper.vm.selectedAlert).toEqual(propsWithAlertData.alertsToManage[alertPath]); + }); + + it('does not set selectedAlert to the first relevantQueries if there is more than one option on modal show', () => { + createComponent({ + relevantQueries: [ + { + metricId: '8', + alertPath: 'alert', + label: 'alert-label', + }, + { + metricId: '9', + alertPath: 'alert', + label: 'alert-label', + }, + ], + }); + + modal().vm.$emit('shown'); + + expect(wrapper.vm.selectedAlert).toEqual({}); + }); + + describe('with existing alert', () => { + beforeEach(() => { + createComponent(propsWithAlertData); + + wrapper.vm.selectQuery(metricId); + }); + + it('sets tracking options for delete alert', () => { + expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.delete); + }); + + it('updates button text', () => { + expect(modalTitle()).toBe('Edit alert'); + expect(submitButton().text()).toBe('Delete'); + }); + + it('emits "delete" event when form values unchanged', () => { + wrapper.vm.handleSubmit(e); + + expect(wrapper.emitted().delete[0]).toEqual([ + { + alert: 'alert', + operator: '<', + threshold: 5, + prometheus_metric_id: '8', + }, + ]); + expect(e.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('emits "update" event when form changed', () => { + wrapper.setData({ + threshold: 11, + }); + + wrapper.vm.handleSubmit(e); + + expect(wrapper.emitted().update[0]).toEqual([ + { + alert: 'alert', + operator: '<', + threshold: 11, + prometheus_metric_id: '8', + }, + ]); + expect(e.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('sets tracking options for update alert', () => { + wrapper.setData({ + threshold: 11, + }); + + return wrapper.vm.$nextTick(() => { + expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.update); + }); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/charts/single_stat_spec.js b/spec/frontend/monitoring/components/charts/single_stat_spec.js index fb0682d0338..9cc5970da82 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 { graphDataPrometheusQuery } from '../../mock_data'; +import { singleStatMetricsResult } from '../../mock_data'; describe('Single Stat Chart component', () => { let singleStatChart; @@ -8,7 +8,7 @@ describe('Single Stat Chart component', () => { beforeEach(() => { singleStatChart = shallowMount(SingleStatChart, { propsData: { - graphData: graphDataPrometheusQuery, + graphData: singleStatMetricsResult, }, }); }); @@ -26,7 +26,7 @@ describe('Single Stat Chart component', () => { it('should change the value representation to a percentile one', () => { singleStatChart.setProps({ graphData: { - ...graphDataPrometheusQuery, + ...singleStatMetricsResult, maxValue: 120, }, }); @@ -37,7 +37,7 @@ describe('Single Stat Chart component', () => { it('should display NaN for non numeric maxValue values', () => { singleStatChart.setProps({ graphData: { - ...graphDataPrometheusQuery, + ...singleStatMetricsResult, maxValue: 'not a number', }, }); @@ -48,13 +48,13 @@ describe('Single Stat Chart component', () => { it('should display NaN for missing query values', () => { singleStatChart.setProps({ graphData: { - ...graphDataPrometheusQuery, + ...singleStatMetricsResult, metrics: [ { - ...graphDataPrometheusQuery.metrics[0], + ...singleStatMetricsResult.metrics[0], result: [ { - ...graphDataPrometheusQuery.metrics[0].result[0], + ...singleStatMetricsResult.metrics[0].result[0], value: [''], }, ], diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js index 5ac716b0c63..7d5a08bc4a1 100644 --- a/spec/frontend/monitoring/components/charts/time_series_spec.js +++ b/spec/frontend/monitoring/components/charts/time_series_spec.js @@ -1,4 +1,4 @@ -import { mount } from '@vue/test-utils'; +import { mount, shallowMount } from '@vue/test-utils'; import { setTestTimeout } from 'helpers/timeout'; import { GlLink } from '@gitlab/ui'; import { TEST_HOST } from 'jest/helpers/test_constants'; @@ -11,6 +11,7 @@ import { 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'; @@ -39,10 +40,10 @@ describe('Time series component', () => { let mockGraphData; let store; - const makeTimeSeriesChart = (graphData, type) => - mount(TimeSeries, { + const createWrapper = (graphData = mockGraphData, mountingMethod = shallowMount) => + mountingMethod(TimeSeries, { propsData: { - graphData: { ...graphData, type }, + graphData, deploymentData: store.state.monitoringDashboard.deploymentData, annotations: store.state.monitoringDashboard.annotations, projectPath: `${TEST_HOST}${mockProjectDir}`, @@ -79,9 +80,9 @@ describe('Time series component', () => { const findChart = () => timeSeriesChart.find({ ref: 'chart' }); - beforeEach(done => { - timeSeriesChart = makeTimeSeriesChart(mockGraphData, 'area-chart'); - timeSeriesChart.vm.$nextTick(done); + beforeEach(() => { + timeSeriesChart = createWrapper(mockGraphData, mount); + return timeSeriesChart.vm.$nextTick(); }); it('allows user to override max value label text using prop', () => { @@ -100,6 +101,21 @@ describe('Time series component', () => { }); }); + it('chart sets a default height', () => { + const wrapper = createWrapper(); + expect(wrapper.props('height')).toBe(chartHeight); + }); + + it('chart has a configurable height', () => { + const mockHeight = 599; + const wrapper = createWrapper(); + + wrapper.setProps({ height: mockHeight }); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.props('height')).toBe(mockHeight); + }); + }); + describe('events', () => { describe('datazoom', () => { let eChartMock; @@ -125,7 +141,7 @@ describe('Time series component', () => { }), }; - timeSeriesChart = makeTimeSeriesChart(mockGraphData); + timeSeriesChart = createWrapper(mockGraphData, mount); timeSeriesChart.vm.$nextTick(() => { findChart().vm.$emit('created', eChartMock); done(); @@ -535,11 +551,11 @@ describe('Time series component', () => { describe('wrapped components', () => { const glChartComponents = [ { - chartType: 'area-chart', + chartType: panelTypes.AREA_CHART, component: GlAreaChart, }, { - chartType: 'line-chart', + chartType: panelTypes.LINE_CHART, component: GlLineChart, }, ]; @@ -550,7 +566,10 @@ describe('Time series component', () => { const findChartComponent = () => timeSeriesAreaChart.find(dynamicComponent.component); beforeEach(done => { - timeSeriesAreaChart = makeTimeSeriesChart(mockGraphData, dynamicComponent.chartType); + timeSeriesAreaChart = createWrapper( + { ...mockGraphData, type: dynamicComponent.chartType }, + mount, + ); timeSeriesAreaChart.vm.$nextTick(done); }); @@ -632,7 +651,7 @@ describe('Time series component', () => { Object.assign(metric, { result: metricResultStatus.result }), ); - timeSeriesChart = makeTimeSeriesChart(graphData, 'area-chart'); + timeSeriesChart = createWrapper({ ...graphData, type: 'area-chart' }, mount); timeSeriesChart.vm.$nextTick(done); }); diff --git a/spec/frontend/monitoring/components/panel_type_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js index 819b5235284..f8c9bd56721 100644 --- a/spec/frontend/monitoring/components/panel_type_spec.js +++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js @@ -1,13 +1,13 @@ +import Vuex from 'vuex'; import { shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import { setTestTimeout } from 'helpers/timeout'; import invalidUrl from '~/lib/utils/invalid_url'; import axios from '~/lib/utils/axios_utils'; +import { GlDropdownItem } from '@gitlab/ui'; +import AlertWidget from '~/monitoring/components/alert_widget.vue'; -import PanelType from '~/monitoring/components/panel_type.vue'; -import EmptyChart from '~/monitoring/components/charts/empty_chart.vue'; -import TimeSeriesChart from '~/monitoring/components/charts/time_series.vue'; -import AnomalyChart from '~/monitoring/components/charts/anomaly.vue'; +import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; import { anomalyMockGraphData, mockLogsHref, @@ -15,8 +15,23 @@ import { mockNamespace, mockNamespacedData, mockTimeRange, + singleStatMetricsResult, + graphDataPrometheusQueryRangeMultiTrack, + barMockData, + propsData, } from '../mock_data'; +import { panelTypes } from '~/monitoring/constants'; + +import MonitorEmptyChart from '~/monitoring/components/charts/empty_chart.vue'; +import MonitorTimeSeriesChart from '~/monitoring/components/charts/time_series.vue'; +import MonitorAnomalyChart from '~/monitoring/components/charts/anomaly.vue'; +import MonitorSingleStatChart from '~/monitoring/components/charts/single_stat.vue'; +import MonitorHeatmapChart from '~/monitoring/components/charts/heatmap.vue'; +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'; @@ -29,7 +44,7 @@ const mocks = { }, }; -describe('Panel Type component', () => { +describe('Dashboard Panel', () => { let axiosMock; let store; let state; @@ -38,18 +53,20 @@ describe('Panel Type component', () => { const exampleText = 'example_text'; const findCopyLink = () => wrapper.find({ ref: 'copyChartLink' }); - const findTimeChart = () => wrapper.find({ ref: 'timeChart' }); + const findTimeChart = () => wrapper.find({ ref: 'timeSeriesChart' }); const findTitle = () => wrapper.find({ ref: 'graphTitle' }); const findContextualMenu = () => wrapper.find({ ref: 'contextualMenu' }); - const createWrapper = props => { - wrapper = shallowMount(PanelType, { + const createWrapper = (props, options) => { + wrapper = shallowMount(DashboardPanel, { propsData: { graphData, + settingsPath: propsData.settingsPath, ...props, }, store, mocks, + ...options, }); }; @@ -66,6 +83,22 @@ describe('Panel Type component', () => { axiosMock.reset(); }); + describe('Renders slots', () => { + it('renders "topLeft" slot', () => { + createWrapper( + {}, + { + slots: { + topLeft: `<div class="top-left-content">OK</div>`, + }, + }, + ); + + expect(wrapper.find('.top-left-content').exists()).toBe(true); + expect(wrapper.find('.top-left-content').text()).toBe('OK'); + }); + }); + describe('When no graphData is available', () => { beforeEach(() => { createWrapper({ @@ -77,27 +110,54 @@ describe('Panel Type component', () => { wrapper.destroy(); }); - describe('Empty Chart component', () => { - it('renders the chart title', () => { - expect(findTitle().text()).toBe(graphDataEmpty.title); - }); + it('renders the chart title', () => { + expect(findTitle().text()).toBe(graphDataEmpty.title); + }); - it('renders the no download csv link', () => { - expect(wrapper.find({ ref: 'downloadCsvLink' }).exists()).toBe(false); - }); + it('renders no download csv link', () => { + expect(wrapper.find({ ref: 'downloadCsvLink' }).exists()).toBe(false); + }); - it('does not contain graph widgets', () => { - expect(findContextualMenu().exists()).toBe(false); - }); + it('does not contain graph widgets', () => { + expect(findContextualMenu().exists()).toBe(false); + }); - it('is a Vue instance', () => { - expect(wrapper.find(EmptyChart).exists()).toBe(true); - expect(wrapper.find(EmptyChart).isVueInstance()).toBe(true); + it('The Empty Chart component is rendered and is a Vue instance', () => { + expect(wrapper.find(MonitorEmptyChart).exists()).toBe(true); + expect(wrapper.find(MonitorEmptyChart).isVueInstance()).toBe(true); + }); + }); + + describe('When graphData is null', () => { + beforeEach(() => { + createWrapper({ + graphData: null, }); }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders no chart title', () => { + expect(findTitle().text()).toBe(''); + }); + + it('renders no download csv link', () => { + expect(wrapper.find({ ref: 'downloadCsvLink' }).exists()).toBe(false); + }); + + it('does not contain graph widgets', () => { + expect(findContextualMenu().exists()).toBe(false); + }); + + it('The Empty Chart component is rendered and is a Vue instance', () => { + expect(wrapper.find(MonitorEmptyChart).exists()).toBe(true); + expect(wrapper.find(MonitorEmptyChart).isVueInstance()).toBe(true); + }); }); - describe('when graph data is available', () => { + describe('When graphData is available', () => { beforeEach(() => { createWrapper(); }); @@ -134,34 +194,54 @@ describe('Panel Type component', () => { }); }); - describe('Time Series Chart panel type', () => { - it('is rendered', () => { - expect(wrapper.find(TimeSeriesChart).isVueInstance()).toBe(true); - expect(wrapper.find(TimeSeriesChart).exists()).toBe(true); - }); + it('includes a default group id', () => { + expect(wrapper.vm.groupId).toBe('dashboard-panel'); + }); + + describe('Supports different panel types', () => { + const dataWithType = type => { + return { + ...graphData, + type, + }; + }; - it('includes a default group id', () => { - expect(wrapper.vm.groupId).toBe('panel-type-chart'); + it('empty chart is rendered for empty results', () => { + createWrapper({ graphData: graphDataEmpty }); + expect(wrapper.find(MonitorEmptyChart).exists()).toBe(true); + expect(wrapper.find(MonitorEmptyChart).isVueInstance()).toBe(true); }); - }); - describe('Anomaly Chart panel type', () => { - beforeEach(() => { - wrapper.setProps({ - graphData: anomalyMockGraphData, - }); - return wrapper.vm.$nextTick(); + it('area chart is rendered by default', () => { + createWrapper(); + expect(wrapper.find(MonitorTimeSeriesChart).exists()).toBe(true); + expect(wrapper.find(MonitorTimeSeriesChart).isVueInstance()).toBe(true); }); - it('is rendered with an anomaly chart', () => { - expect(wrapper.find(AnomalyChart).isVueInstance()).toBe(true); - expect(wrapper.find(AnomalyChart).exists()).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 }) => { + 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); }); }); }); describe('Edit custom metric dropdown item', () => { const findEditCustomMetricLink = () => wrapper.find({ ref: 'editMetricLink' }); + const mockEditPath = '/root/kubernetes-gke-project/prometheus/metrics/23/edit'; beforeEach(() => { createWrapper(); @@ -180,7 +260,7 @@ describe('Panel Type component', () => { metrics: [ { ...graphData.metrics[0], - edit_path: '/root/kubernetes-gke-project/prometheus/metrics/23/edit', + edit_path: mockEditPath, }, ], }, @@ -189,10 +269,11 @@ describe('Panel Type component', () => { return wrapper.vm.$nextTick(() => { expect(findEditCustomMetricLink().exists()).toBe(true); expect(findEditCustomMetricLink().text()).toBe('Edit metric'); + expect(findEditCustomMetricLink().attributes('href')).toBe(mockEditPath); }); }); - it('shows an "Edit metrics" link for a panel with multiple metrics', () => { + it('shows an "Edit metrics" link pointing to settingsPath for a panel with multiple metrics', () => { wrapper.setProps({ graphData: { ...graphData, @@ -211,6 +292,7 @@ describe('Panel Type component', () => { return wrapper.vm.$nextTick(() => { expect(findEditCustomMetricLink().text()).toBe('Edit metrics'); + expect(findEditCustomMetricLink().attributes('href')).toBe(propsData.settingsPath); }); }); }); @@ -294,10 +376,6 @@ describe('Panel Type component', () => { }); }); - afterEach(() => { - wrapper.destroy(); - }); - it('sets clipboard text on the dropdown', () => { expect(findCopyLink().exists()).toBe(true); expect(findCopyLink().element.dataset.clipboardText).toBe(clipboardText); @@ -314,11 +392,24 @@ describe('Panel Type component', () => { }); }); + describe('when cliboard data is not available', () => { + it('there is no "copy to clipboard" link for a null value', () => { + createWrapper({ clipboardText: null }); + expect(findCopyLink().exists()).toBe(false); + }); + + it('there is no "copy to clipboard" link for an empty value', () => { + createWrapper({ clipboardText: '' }); + expect(findCopyLink().exists()).toBe(false); + }); + }); + describe('when downloading metrics data as CSV', () => { beforeEach(() => { - wrapper = shallowMount(PanelType, { + wrapper = shallowMount(DashboardPanel, { propsData: { clipboardText: exampleText, + settingsPath: propsData.settingsPath, graphData: { y_label: 'metric', ...graphData, @@ -365,9 +456,10 @@ describe('Panel Type component', () => { store.registerModule(mockNamespace, monitoringDashboard); store.state.embedGroup.modules.push(mockNamespace); - wrapper = shallowMount(PanelType, { + wrapper = shallowMount(DashboardPanel, { propsData: { graphData, + settingsPath: propsData.settingsPath, namespace: mockNamespace, }, store, @@ -401,8 +493,84 @@ describe('Panel Type component', () => { }); it('it renders a time series chart with no errors', () => { - expect(wrapper.find(TimeSeriesChart).isVueInstance()).toBe(true); - expect(wrapper.find(TimeSeriesChart).exists()).toBe(true); + expect(wrapper.find(MonitorTimeSeriesChart).isVueInstance()).toBe(true); + expect(wrapper.find(MonitorTimeSeriesChart).exists()).toBe(true); + }); + }); + + describe('Expand to full screen', () => { + const findExpandBtn = () => wrapper.find({ ref: 'expandBtn' }); + + describe('when there is no @expand listener', () => { + it('does not show `View full screen` option', () => { + createWrapper(); + expect(findExpandBtn().exists()).toBe(false); + }); + }); + + describe('when there is an @expand listener', () => { + beforeEach(() => { + createWrapper({}, { listeners: { expand: () => {} } }); + }); + + it('shows the `expand` option', () => { + expect(findExpandBtn().exists()).toBe(true); + }); + + it('emits the `expand` event', () => { + const preventDefault = jest.fn(); + findExpandBtn().vm.$emit('click', { preventDefault }); + expect(wrapper.emitted('expand')).toHaveLength(1); + expect(preventDefault).toHaveBeenCalled(); + }); + }); + }); + + describe('panel alerts', () => { + const setMetricsSavedToDb = val => + monitoringDashboard.getters.metricsSavedToDb.mockReturnValue(val); + const findAlertsWidget = () => wrapper.find(AlertWidget); + const findMenuItemAlert = () => + wrapper.findAll(GlDropdownItem).filter(i => i.text() === 'Alerts'); + + beforeEach(() => { + jest.spyOn(monitoringDashboard.getters, 'metricsSavedToDb').mockReturnValue([]); + + store = new Vuex.Store({ + modules: { + monitoringDashboard, + }, + }); + + createWrapper(); + }); + + describe.each` + desc | metricsSavedToDb | props | isShown + ${'with permission and no metrics in db'} | ${[]} | ${{}} | ${false} + ${'with permission and related metrics in db'} | ${[graphData.metrics[0].metricId]} | ${{}} | ${true} + ${'without permission and related metrics in db'} | ${[graphData.metrics[0].metricId]} | ${{ prometheusAlertsAvailable: false }} | ${false} + ${'with permission and unrelated metrics in db'} | ${['another_metric_id']} | ${{}} | ${false} + `('$desc', ({ metricsSavedToDb, isShown, props }) => { + const showsDesc = isShown ? 'shows' : 'does not show'; + + beforeEach(() => { + setMetricsSavedToDb(metricsSavedToDb); + createWrapper({ + alertsEndpoint: '/endpoint', + prometheusAlertsAvailable: true, + ...props, + }); + return wrapper.vm.$nextTick(); + }); + + it(`${showsDesc} alert widget`, () => { + expect(findAlertsWidget().exists()).toBe(isShown); + }); + + it(`${showsDesc} alert configuration`, () => { + expect(findMenuItemAlert().exists()).toBe(isShown); + }); }); }); }); diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index 8b6ee9b3bf6..b2c9fe93cde 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -1,6 +1,8 @@ import { shallowMount, mount } from '@vue/test-utils'; import Tracking from '~/tracking'; -import { GlModal, GlDropdownItem, GlDeprecatedButton } from '@gitlab/ui'; +import { ESC_KEY, ESC_KEY_IE11 } from '~/lib/utils/keys'; +import { GlModal, GlDropdownItem, GlDeprecatedButton, GlIcon } from '@gitlab/ui'; +import { objectToQuery } from '~/lib/utils/url_utility'; import VueDraggable from 'vuedraggable'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; @@ -11,13 +13,23 @@ import Dashboard from '~/monitoring/components/dashboard.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 PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; +import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; import { createStore } from '~/monitoring/stores'; import * as types from '~/monitoring/stores/mutation_types'; -import { setupStoreWithDashboard, setMetricResult, setupStoreWithData } from '../store_utils'; +import { + setupAllDashboards, + setupStoreWithDashboard, + setMetricResult, + setupStoreWithData, + setupStoreWithVariable, +} from '../store_utils'; import { environmentData, dashboardGitResponse, propsData } from '../mock_data'; import { metricsDashboardViewModel, metricsDashboardPanelCount } from '../fixture_data'; +import createFlash from '~/flash'; + +jest.mock('~/flash'); describe('Dashboard', () => { let store; @@ -27,15 +39,12 @@ describe('Dashboard', () => { const findEnvironmentsDropdown = () => wrapper.find({ ref: 'monitorEnvironmentsDropdown' }); const findAllEnvironmentsDropdownItems = () => findEnvironmentsDropdown().findAll(GlDropdownItem); const setSearchTerm = searchTerm => { - wrapper.vm.$store.commit(`monitoringDashboard/${types.SET_ENVIRONMENTS_FILTER}`, searchTerm); + store.commit(`monitoringDashboard/${types.SET_ENVIRONMENTS_FILTER}`, searchTerm); }; const createShallowWrapper = (props = {}, options = {}) => { wrapper = shallowMount(Dashboard, { propsData: { ...propsData, ...props }, - methods: { - fetchData: jest.fn(), - }, store, ...options, }); @@ -44,10 +53,8 @@ describe('Dashboard', () => { const createMountedWrapper = (props = {}, options = {}) => { wrapper = mount(Dashboard, { propsData: { ...propsData, ...props }, - methods: { - fetchData: jest.fn(), - }, store, + stubs: ['graph-group', 'dashboard-panel'], ...options, }); }; @@ -55,19 +62,18 @@ describe('Dashboard', () => { beforeEach(() => { store = createStore(); mock = new MockAdapter(axios); + jest.spyOn(store, 'dispatch').mockResolvedValue(); }); afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } mock.restore(); + if (store.dispatch.mockReset) { + store.dispatch.mockReset(); + } }); describe('no metrics are available yet', () => { beforeEach(() => { - jest.spyOn(store, 'dispatch'); createShallowWrapper(); }); @@ -103,9 +109,7 @@ describe('Dashboard', () => { describe('request information to the server', () => { it('calls to set time range and fetch data', () => { - jest.spyOn(store, 'dispatch'); - - createShallowWrapper({ hasMetrics: true }, { methods: {} }); + createShallowWrapper({ hasMetrics: true }); return wrapper.vm.$nextTick().then(() => { expect(store.dispatch).toHaveBeenCalledWith( @@ -118,20 +122,20 @@ describe('Dashboard', () => { }); it('shows up a loading state', () => { - createShallowWrapper({ hasMetrics: true }, { methods: {} }); + store.state.monitoringDashboard.emptyState = 'loading'; + + createShallowWrapper({ hasMetrics: true }); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.emptyState).toEqual('loading'); + expect(wrapper.find(EmptyState).exists()).toBe(true); + expect(wrapper.find(EmptyState).props('selectedState')).toBe('loading'); }); }); it('hides the group panels when showPanels is false', () => { - createMountedWrapper( - { hasMetrics: true, showPanels: false }, - { stubs: ['graph-group', 'panel-type'] }, - ); + createMountedWrapper({ hasMetrics: true, showPanels: false }); - setupStoreWithData(wrapper.vm.$store); + setupStoreWithData(store); return wrapper.vm.$nextTick().then(() => { expect(wrapper.vm.showEmptyState).toEqual(false); @@ -142,9 +146,9 @@ describe('Dashboard', () => { it('fetches the metrics data with proper time window', () => { jest.spyOn(store, 'dispatch'); - createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); + createMountedWrapper({ hasMetrics: true }); - wrapper.vm.$store.commit( + store.commit( `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, environmentData, ); @@ -155,11 +159,176 @@ describe('Dashboard', () => { }); }); + describe('when the URL contains a reference to a panel', () => { + let location; + + const setSearch = search => { + window.location = { ...location, search }; + }; + + beforeEach(() => { + location = window.location; + delete window.location; + }); + + afterEach(() => { + window.location = location; + }); + + it('when the URL points to a panel it expands', () => { + const panelGroup = metricsDashboardViewModel.panelGroups[0]; + const panel = panelGroup.panels[0]; + + setSearch( + objectToQuery({ + group: panelGroup.group, + title: panel.title, + y_label: panel.y_label, + }), + ); + + createMountedWrapper({ hasMetrics: true }); + setupStoreWithData(store); + + return wrapper.vm.$nextTick().then(() => { + expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setExpandedPanel', { + group: panelGroup.group, + panel: expect.objectContaining({ + title: panel.title, + y_label: panel.y_label, + }), + }); + }); + }); + + it('when the URL does not link to any panel, no panel is expanded', () => { + setSearch(''); + + createMountedWrapper({ hasMetrics: true }); + setupStoreWithData(store); + + return wrapper.vm.$nextTick().then(() => { + expect(store.dispatch).not.toHaveBeenCalledWith( + 'monitoringDashboard/setExpandedPanel', + expect.anything(), + ); + }); + }); + + it('when the URL points to an incorrect panel it shows an error', () => { + const panelGroup = metricsDashboardViewModel.panelGroups[0]; + const panel = panelGroup.panels[0]; + + setSearch( + objectToQuery({ + group: panelGroup.group, + title: 'incorrect', + y_label: panel.y_label, + }), + ); + + createMountedWrapper({ hasMetrics: true }); + setupStoreWithData(store); + + return wrapper.vm.$nextTick().then(() => { + expect(createFlash).toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalledWith( + 'monitoringDashboard/setExpandedPanel', + expect.anything(), + ); + }); + }); + }); + + describe('when the panel is expanded', () => { + let group; + let panel; + + const expandPanel = (mockGroup, mockPanel) => { + store.commit(`monitoringDashboard/${types.SET_EXPANDED_PANEL}`, { + group: mockGroup, + panel: mockPanel, + }); + }; + + beforeEach(() => { + setupStoreWithData(store); + + const { panelGroups } = store.state.monitoringDashboard.dashboard; + group = panelGroups[0].group; + [panel] = panelGroups[0].panels; + + jest.spyOn(window.history, 'pushState').mockImplementation(); + }); + + afterEach(() => { + window.history.pushState.mockRestore(); + }); + + it('URL is updated with panel parameters', () => { + createMountedWrapper({ hasMetrics: true }); + expandPanel(group, panel); + + const expectedSearch = objectToQuery({ + group, + title: panel.title, + y_label: panel.y_label, + }); + + return wrapper.vm.$nextTick(() => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), // state + expect.any(String), // document title + expect.stringContaining(`${expectedSearch}`), + ); + }); + }); + + it('URL is updated with panel parameters and custom dashboard', () => { + const dashboard = 'dashboard.yml'; + + createMountedWrapper({ hasMetrics: true, currentDashboard: dashboard }); + expandPanel(group, panel); + + const expectedSearch = objectToQuery({ + dashboard, + group, + title: panel.title, + y_label: panel.y_label, + }); + + return wrapper.vm.$nextTick(() => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), // state + expect.any(String), // document title + expect.stringContaining(`${expectedSearch}`), + ); + }); + }); + + it('URL is updated with no parameters', () => { + expandPanel(group, panel); + createMountedWrapper({ hasMetrics: true }); + expandPanel(null, null); + + return wrapper.vm.$nextTick(() => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), // state + expect.any(String), // document title + expect.not.stringMatching(/group|title|y_label/), // no panel params + ); + }); + }); + }); + describe('when all requests have been commited by the store', () => { beforeEach(() => { - createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); + createMountedWrapper({ hasMetrics: true }); - setupStoreWithData(wrapper.vm.$store); + setupStoreWithData(store); return wrapper.vm.$nextTick(); }); @@ -185,10 +354,89 @@ describe('Dashboard', () => { }); }); + describe('star dashboards', () => { + const findToggleStar = () => wrapper.find({ ref: 'toggleStarBtn' }); + const findToggleStarIcon = () => findToggleStar().find(GlIcon); + + beforeEach(() => { + createShallowWrapper(); + setupAllDashboards(store); + }); + + it('toggle star button is shown', () => { + expect(findToggleStar().exists()).toBe(true); + expect(findToggleStar().props('disabled')).toBe(false); + }); + + it('toggle star button is disabled when starring is taking place', () => { + store.commit(`monitoringDashboard/${types.REQUEST_DASHBOARD_STARRING}`); + + return wrapper.vm.$nextTick(() => { + expect(findToggleStar().exists()).toBe(true); + expect(findToggleStar().props('disabled')).toBe(true); + }); + }); + + describe('when the dashboard list is loaded', () => { + // Tooltip element should wrap directly + const getToggleTooltip = () => findToggleStar().element.parentElement.getAttribute('title'); + + beforeEach(() => { + setupAllDashboards(store); + jest.spyOn(store, 'dispatch'); + }); + + it('dispatches a toggle star action', () => { + findToggleStar().vm.$emit('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(store.dispatch).toHaveBeenCalledWith( + 'monitoringDashboard/toggleStarredValue', + undefined, + ); + }); + }); + + describe('when dashboard is not starred', () => { + beforeEach(() => { + store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { + currentDashboard: dashboardGitResponse[0].path, + }); + return wrapper.vm.$nextTick(); + }); + + it('toggle star button shows "Star dashboard"', () => { + expect(getToggleTooltip()).toBe('Star dashboard'); + }); + + it('toggle star button shows an unstarred state', () => { + expect(findToggleStarIcon().attributes('name')).toBe('star-o'); + }); + }); + + describe('when dashboard is starred', () => { + beforeEach(() => { + store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { + currentDashboard: dashboardGitResponse[1].path, + }); + return wrapper.vm.$nextTick(); + }); + + it('toggle star button shows "Star dashboard"', () => { + expect(getToggleTooltip()).toBe('Unstar dashboard'); + }); + + it('toggle star button shows a starred state', () => { + expect(findToggleStarIcon().attributes('name')).toBe('star'); + }); + }); + }); + }); + it('hides the environments dropdown list when there is no environments', () => { - createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); + createMountedWrapper({ hasMetrics: true }); - setupStoreWithDashboard(wrapper.vm.$store); + setupStoreWithDashboard(store); return wrapper.vm.$nextTick().then(() => { expect(findAllEnvironmentsDropdownItems()).toHaveLength(0); @@ -196,9 +444,9 @@ describe('Dashboard', () => { }); it('renders the datetimepicker dropdown', () => { - createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); + createMountedWrapper({ hasMetrics: true }); - setupStoreWithData(wrapper.vm.$store); + setupStoreWithData(store); return wrapper.vm.$nextTick().then(() => { expect(wrapper.find(DateTimePicker).exists()).toBe(true); @@ -206,9 +454,9 @@ describe('Dashboard', () => { }); it('renders the refresh dashboard button', () => { - createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); + createMountedWrapper({ hasMetrics: true }); - setupStoreWithData(wrapper.vm.$store); + setupStoreWithData(store); return wrapper.vm.$nextTick().then(() => { const refreshBtn = wrapper.findAll({ ref: 'refreshDashboardBtn' }); @@ -218,14 +466,135 @@ describe('Dashboard', () => { }); }); - describe('when one of the metrics is missing', () => { + describe('variables section', () => { beforeEach(() => { createShallowWrapper({ hasMetrics: true }); + setupStoreWithData(store); + setupStoreWithVariable(store); + + return wrapper.vm.$nextTick(); + }); + + it('shows the variables section', () => { + expect(wrapper.vm.shouldShowVariablesSection).toBe(true); + }); + }); + + describe('single panel expands to "full screen" mode', () => { + const findExpandedPanel = () => wrapper.find({ ref: 'expandedPanel' }); - const { $store } = wrapper.vm; + describe('when the panel is not expanded', () => { + beforeEach(() => { + createShallowWrapper({ hasMetrics: true }); + setupStoreWithData(store); + return wrapper.vm.$nextTick(); + }); + + it('expanded panel is not visible', () => { + expect(findExpandedPanel().isVisible()).toBe(false); + }); + + it('can set a panel as expanded', () => { + const panel = wrapper.findAll(DashboardPanel).at(1); + + jest.spyOn(store, 'dispatch'); + + panel.vm.$emit('expand'); + + const groupData = metricsDashboardViewModel.panelGroups[0]; + + expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setExpandedPanel', { + group: groupData.group, + panel: expect.objectContaining({ + id: groupData.panels[0].id, + }), + }); + }); + }); + + describe('when the panel is expanded', () => { + let group; + let panel; + + const mockKeyup = key => window.dispatchEvent(new KeyboardEvent('keyup', { key })); + + const MockPanel = { + template: `<div><slot name="topLeft"/></div>`, + }; + + beforeEach(() => { + createShallowWrapper({ hasMetrics: true }, { stubs: { DashboardPanel: MockPanel } }); + setupStoreWithData(store); + + const { panelGroups } = store.state.monitoringDashboard.dashboard; + + group = panelGroups[0].group; + [panel] = panelGroups[0].panels; + + store.commit(`monitoringDashboard/${types.SET_EXPANDED_PANEL}`, { + group, + panel, + }); + + jest.spyOn(store, 'dispatch'); + + return wrapper.vm.$nextTick(); + }); - setupStoreWithDashboard($store); - setMetricResult({ $store, result: [], panel: 2 }); + it('displays a single panel and others are hidden', () => { + const panels = wrapper.findAll(MockPanel); + const visiblePanels = panels.filter(w => w.isVisible()); + + expect(findExpandedPanel().isVisible()).toBe(true); + // v-show for hiding panels is more performant than v-if + // check for panels to be hidden. + expect(panels.length).toBe(metricsDashboardPanelCount + 1); + expect(visiblePanels.length).toBe(1); + }); + + it('sets a link to the expanded panel', () => { + const searchQuery = + '?dashboard=config%2Fprometheus%2Fcommon_metrics.yml&group=System%20metrics%20(Kubernetes)&title=Memory%20Usage%20(Total)&y_label=Total%20Memory%20Used%20(GB)'; + + expect(findExpandedPanel().attributes('clipboard-text')).toEqual( + expect.stringContaining(searchQuery), + ); + }); + + it('restores full dashboard by clicking `back`', () => { + wrapper.find({ ref: 'goBackBtn' }).vm.$emit('click'); + + expect(store.dispatch).toHaveBeenCalledWith( + 'monitoringDashboard/clearExpandedPanel', + undefined, + ); + }); + + it('restores dashboard from full screen by typing the Escape key', () => { + mockKeyup(ESC_KEY); + expect(store.dispatch).toHaveBeenCalledWith( + `monitoringDashboard/clearExpandedPanel`, + undefined, + ); + }); + + it('restores dashboard from full screen by typing the Escape key on IE11', () => { + mockKeyup(ESC_KEY_IE11); + + expect(store.dispatch).toHaveBeenCalledWith( + `monitoringDashboard/clearExpandedPanel`, + undefined, + ); + }); + }); + }); + + describe('when one of the metrics is missing', () => { + beforeEach(() => { + createShallowWrapper({ hasMetrics: true }); + + setupStoreWithDashboard(store); + setMetricResult({ store, result: [], panel: 2 }); return wrapper.vm.$nextTick(); }); @@ -249,19 +618,17 @@ describe('Dashboard', () => { describe('searchable environments dropdown', () => { beforeEach(() => { - createMountedWrapper( - { hasMetrics: true }, - { - attachToDocument: true, - stubs: ['graph-group', 'panel-type'], - }, - ); + createMountedWrapper({ hasMetrics: true }, { attachToDocument: true }); - setupStoreWithData(wrapper.vm.$store); + setupStoreWithData(store); return wrapper.vm.$nextTick(); }); + afterEach(() => { + wrapper.destroy(); + }); + it('renders a search input', () => { expect(wrapper.find({ ref: 'monitorEnvironmentsDropdownSearch' }).exists()).toBe(true); }); @@ -304,7 +671,7 @@ describe('Dashboard', () => { }); it('shows loading element when environments fetch is still loading', () => { - wrapper.vm.$store.commit(`monitoringDashboard/${types.REQUEST_ENVIRONMENTS_DATA}`); + store.commit(`monitoringDashboard/${types.REQUEST_ENVIRONMENTS_DATA}`); return wrapper.vm .$nextTick() @@ -312,7 +679,7 @@ describe('Dashboard', () => { expect(wrapper.find({ ref: 'monitorEnvironmentsDropdownLoading' }).exists()).toBe(true); }) .then(() => { - wrapper.vm.$store.commit( + store.commit( `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, environmentData, ); @@ -330,9 +697,11 @@ describe('Dashboard', () => { const findRearrangeButton = () => wrapper.find('.js-rearrange-button'); beforeEach(() => { - createShallowWrapper({ hasMetrics: true }); + // call original dispatch + store.dispatch.mockRestore(); - setupStoreWithData(wrapper.vm.$store); + createShallowWrapper({ hasMetrics: true }); + setupStoreWithData(store); return wrapper.vm.$nextTick(); }); @@ -420,7 +789,7 @@ describe('Dashboard', () => { createShallowWrapper({ hasMetrics: true, showHeader: false }); // all_dashboards is not defined in health dashboards - wrapper.vm.$store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, undefined); + store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, undefined); return wrapper.vm.$nextTick(); }); @@ -440,10 +809,7 @@ describe('Dashboard', () => { beforeEach(() => { createShallowWrapper({ hasMetrics: true }); - wrapper.vm.$store.commit( - `monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, - dashboardGitResponse, - ); + setupAllDashboards(store); return wrapper.vm.$nextTick(); }); @@ -452,10 +818,11 @@ describe('Dashboard', () => { }); it('is present for a custom dashboard, and links to its edit_path', () => { - const dashboard = dashboardGitResponse[1]; // non-default dashboard - const currentDashboard = dashboard.path; + const dashboard = dashboardGitResponse[1]; + store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { + currentDashboard: dashboard.path, + }); - wrapper.setProps({ currentDashboard }); return wrapper.vm.$nextTick().then(() => { expect(findEditLink().exists()).toBe(true); expect(findEditLink().attributes('href')).toBe(dashboard.project_blob_path); @@ -465,13 +832,8 @@ describe('Dashboard', () => { describe('Dashboard dropdown', () => { beforeEach(() => { - createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); - - wrapper.vm.$store.commit( - `monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, - dashboardGitResponse, - ); - + createMountedWrapper({ hasMetrics: true }); + setupAllDashboards(store); return wrapper.vm.$nextTick(); }); @@ -484,15 +846,12 @@ describe('Dashboard', () => { describe('external dashboard link', () => { beforeEach(() => { - createMountedWrapper( - { - hasMetrics: true, - showPanels: false, - showTimeWindowDropdown: false, - externalDashboardUrl: '/mockUrl', - }, - { stubs: ['graph-group', 'panel-type'] }, - ); + createMountedWrapper({ + hasMetrics: true, + showPanels: false, + showTimeWindowDropdown: false, + externalDashboardUrl: '/mockUrl', + }); return wrapper.vm.$nextTick(); }); @@ -507,45 +866,29 @@ describe('Dashboard', () => { }); describe('Clipboard text in panels', () => { - const currentDashboard = 'TEST_DASHBOARD'; + const currentDashboard = dashboardGitResponse[1].path; + const panelIndex = 1; // skip expanded panel - const getClipboardTextAt = i => + const getClipboardTextFirstPanel = () => wrapper - .findAll(PanelType) - .at(i) + .findAll(DashboardPanel) + .at(panelIndex) .props('clipboardText'); beforeEach(() => { + setupStoreWithData(store); createShallowWrapper({ hasMetrics: true, currentDashboard }); - setupStoreWithData(wrapper.vm.$store); - return wrapper.vm.$nextTick(); }); it('contains a link to the dashboard', () => { - expect(getClipboardTextAt(0)).toContain(`dashboard=${currentDashboard}`); - expect(getClipboardTextAt(0)).toContain(`group=`); - expect(getClipboardTextAt(0)).toContain(`title=`); - expect(getClipboardTextAt(0)).toContain(`y_label=`); - }); - - it('strips the undefined parameter', () => { - wrapper.setProps({ currentDashboard: undefined }); - - return wrapper.vm.$nextTick(() => { - expect(getClipboardTextAt(0)).not.toContain(`dashboard=`); - expect(getClipboardTextAt(0)).toContain(`y_label=`); - }); - }); + const dashboardParam = `dashboard=${encodeURIComponent(currentDashboard)}`; - it('null parameter is stripped', () => { - wrapper.setProps({ currentDashboard: null }); - - return wrapper.vm.$nextTick(() => { - expect(getClipboardTextAt(0)).not.toContain(`dashboard=`); - expect(getClipboardTextAt(0)).toContain(`y_label=`); - }); + expect(getClipboardTextFirstPanel()).toContain(dashboardParam); + expect(getClipboardTextFirstPanel()).toContain(`group=`); + expect(getClipboardTextFirstPanel()).toContain(`title=`); + expect(getClipboardTextFirstPanel()).toContain(`y_label=`); }); }); @@ -572,7 +915,7 @@ describe('Dashboard', () => { customMetricsPath: '/endpoint', customMetricsAvailable: true, }); - setupStoreWithData(wrapper.vm.$store); + setupStoreWithData(store); origPage = document.body.dataset.page; document.body.dataset.page = 'projects:environments:metrics'; diff --git a/spec/frontend/monitoring/components/dashboard_template_spec.js b/spec/frontend/monitoring/components/dashboard_template_spec.js index d1790df4189..cc0ac348b11 100644 --- a/spec/frontend/monitoring/components/dashboard_template_spec.js +++ b/spec/frontend/monitoring/components/dashboard_template_spec.js @@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import Dashboard from '~/monitoring/components/dashboard.vue'; import { createStore } from '~/monitoring/stores'; +import { setupAllDashboards } from '../store_utils'; import { propsData } from '../mock_data'; jest.mock('~/lib/utils/url_utility'); @@ -15,24 +16,16 @@ describe('Dashboard template', () => { beforeEach(() => { store = createStore(); mock = new MockAdapter(axios); + + setupAllDashboards(store); }); afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } mock.restore(); }); it('matches the default snapshot', () => { - wrapper = shallowMount(Dashboard, { - propsData: { ...propsData }, - methods: { - fetchData: jest.fn(), - }, - store, - }); + wrapper = shallowMount(Dashboard, { propsData: { ...propsData }, store }); expect(wrapper.element).toMatchSnapshot(); }); diff --git a/spec/frontend/monitoring/components/dashboard_url_time_spec.js b/spec/frontend/monitoring/components/dashboard_url_time_spec.js index 65e9d036d1a..9bba5280007 100644 --- a/spec/frontend/monitoring/components/dashboard_url_time_spec.js +++ b/spec/frontend/monitoring/components/dashboard_url_time_spec.js @@ -27,7 +27,7 @@ describe('dashboard invalid url parameters', () => { wrapper = mount(Dashboard, { propsData: { ...propsData, ...props }, store, - stubs: ['graph-group', 'panel-type'], + stubs: ['graph-group', 'dashboard-panel'], ...options, }); }; diff --git a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js index 0bcfabe6415..b29d86cbc5b 100644 --- a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js +++ b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDropdownItem, GlModal, GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import { GlDropdownItem, GlModal, GlLoadingIcon, GlAlert, GlIcon } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue'; @@ -9,36 +9,48 @@ import { dashboardGitResponse } from '../mock_data'; const defaultBranch = 'master'; -function createComponent(props, opts = {}) { - const storeOpts = { - methods: { - duplicateSystemDashboard: jest.fn(), - }, - computed: { - allDashboards: () => dashboardGitResponse, - }, - }; - - return shallowMount(DashboardsDropdown, { - propsData: { - ...props, - defaultBranch, - }, - sync: false, - ...storeOpts, - ...opts, - }); -} +const starredDashboards = dashboardGitResponse.filter(({ starred }) => starred); +const notStarredDashboards = dashboardGitResponse.filter(({ starred }) => !starred); describe('DashboardsDropdown', () => { let wrapper; + let mockDashboards; + let mockSelectedDashboard; + + function createComponent(props, opts = {}) { + const storeOpts = { + methods: { + duplicateSystemDashboard: jest.fn(), + }, + computed: { + allDashboards: () => mockDashboards, + selectedDashboard: () => mockSelectedDashboard, + }, + }; + + return shallowMount(DashboardsDropdown, { + propsData: { + ...props, + defaultBranch, + }, + sync: false, + ...storeOpts, + ...opts, + }); + } const findItems = () => wrapper.findAll(GlDropdownItem); const findItemAt = i => wrapper.findAll(GlDropdownItem).at(i); const findSearchInput = () => wrapper.find({ ref: 'monitorDashboardsDropdownSearch' }); const findNoItemsMsg = () => wrapper.find({ ref: 'monitorDashboardsDropdownMsg' }); + const findStarredListDivider = () => wrapper.find({ ref: 'starredListDivider' }); const setSearchTerm = searchTerm => wrapper.setData({ searchTerm }); + beforeEach(() => { + mockDashboards = dashboardGitResponse; + mockSelectedDashboard = null; + }); + describe('when it receives dashboards data', () => { beforeEach(() => { wrapper = createComponent(); @@ -48,10 +60,14 @@ describe('DashboardsDropdown', () => { expect(findItems().length).toEqual(dashboardGitResponse.length); }); - it('displays items with the dashboard display name', () => { - expect(findItemAt(0).text()).toBe(dashboardGitResponse[0].display_name); - expect(findItemAt(1).text()).toBe(dashboardGitResponse[1].display_name); - expect(findItemAt(2).text()).toBe(dashboardGitResponse[2].display_name); + it('displays items with the dashboard display name, with starred dashboards first', () => { + expect(findItemAt(0).text()).toBe(starredDashboards[0].display_name); + expect(findItemAt(1).text()).toBe(notStarredDashboards[0].display_name); + expect(findItemAt(2).text()).toBe(notStarredDashboards[1].display_name); + }); + + it('displays separator between starred and not starred dashboards', () => { + expect(findStarredListDivider().exists()).toBe(true); }); it('displays a search input', () => { @@ -81,18 +97,71 @@ describe('DashboardsDropdown', () => { }); }); + describe('when the dashboard is missing a display name', () => { + beforeEach(() => { + mockDashboards = dashboardGitResponse.map(d => ({ ...d, display_name: undefined })); + wrapper = createComponent(); + }); + + it('displays items with the dashboard path, with starred dashboards first', () => { + expect(findItemAt(0).text()).toBe(starredDashboards[0].path); + expect(findItemAt(1).text()).toBe(notStarredDashboards[0].path); + expect(findItemAt(2).text()).toBe(notStarredDashboards[1].path); + }); + }); + + describe('when it receives starred dashboards', () => { + beforeEach(() => { + mockDashboards = starredDashboards; + wrapper = createComponent(); + }); + + it('displays an item for each dashboard', () => { + expect(findItems().length).toEqual(starredDashboards.length); + }); + + it('displays a star icon', () => { + const star = findItemAt(0).find(GlIcon); + expect(star.exists()).toBe(true); + expect(star.attributes('name')).toBe('star'); + }); + + it('displays no separator between starred and not starred dashboards', () => { + expect(findStarredListDivider().exists()).toBe(false); + }); + }); + + describe('when it receives only not-starred dashboards', () => { + beforeEach(() => { + mockDashboards = notStarredDashboards; + wrapper = createComponent(); + }); + + it('displays an item for each dashboard', () => { + expect(findItems().length).toEqual(notStarredDashboards.length); + }); + + it('displays no star icon', () => { + const star = findItemAt(0).find(GlIcon); + expect(star.exists()).toBe(false); + }); + + it('displays no separator between starred and not starred dashboards', () => { + expect(findStarredListDivider().exists()).toBe(false); + }); + }); + describe('when a system dashboard is selected', () => { let duplicateDashboardAction; let modalDirective; beforeEach(() => { + [mockSelectedDashboard] = dashboardGitResponse; modalDirective = jest.fn(); duplicateDashboardAction = jest.fn().mockResolvedValue(); wrapper = createComponent( - { - selectedDashboard: dashboardGitResponse[0], - }, + {}, { directives: { GlModal: modalDirective, @@ -260,7 +329,7 @@ describe('DashboardsDropdown', () => { expect(wrapper.emitted().selectDashboard).toBeTruthy(); }); it('emits a "selectDashboard" event with dashboard information', () => { - expect(wrapper.emitted().selectDashboard[0]).toEqual([dashboardGitResponse[1]]); + expect(wrapper.emitted().selectDashboard[0]).toEqual([dashboardGitResponse[0]]); }); }); }); diff --git a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js index 10fd58f749d..216ec345552 100644 --- a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js +++ b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js @@ -81,7 +81,8 @@ describe('DuplicateDashboardForm', () => { it('with the inital form values', () => { expect(wrapper.emitted().change).toHaveLength(1); - expect(lastChange()).resolves.toEqual({ + + return expect(lastChange()).resolves.toEqual({ branch: '', commitMessage: expect.any(String), dashboard: dashboardGitResponse[0].path, @@ -92,7 +93,7 @@ describe('DuplicateDashboardForm', () => { it('containing an inputted file name', () => { setValue('fileName', 'my_dashboard.yml'); - expect(lastChange()).resolves.toMatchObject({ + return expect(lastChange()).resolves.toMatchObject({ fileName: 'my_dashboard.yml', }); }); @@ -100,7 +101,7 @@ describe('DuplicateDashboardForm', () => { it('containing a default commit message when no message is set', () => { setValue('commitMessage', ''); - expect(lastChange()).resolves.toMatchObject({ + return expect(lastChange()).resolves.toMatchObject({ commitMessage: expect.stringContaining('Create custom dashboard'), }); }); @@ -108,7 +109,7 @@ describe('DuplicateDashboardForm', () => { it('containing an inputted commit message', () => { setValue('commitMessage', 'My commit message'); - expect(lastChange()).resolves.toMatchObject({ + return expect(lastChange()).resolves.toMatchObject({ commitMessage: expect.stringContaining('My commit message'), }); }); @@ -116,7 +117,7 @@ describe('DuplicateDashboardForm', () => { it('containing an inputted branch name', () => { setValue('branchName', 'a-new-branch'); - expect(lastChange()).resolves.toMatchObject({ + return expect(lastChange()).resolves.toMatchObject({ branch: 'a-new-branch', }); }); @@ -125,13 +126,14 @@ describe('DuplicateDashboardForm', () => { setChecked(wrapper.vm.$options.radioVals.DEFAULT); setValue('branchName', 'a-new-branch'); - expect(lastChange()).resolves.toMatchObject({ - branch: defaultBranch, - }); - - return wrapper.vm.$nextTick(() => { - expect(findByRef('branchName').isVisible()).toBe(false); - }); + return Promise.all([ + expect(lastChange()).resolves.toMatchObject({ + branch: defaultBranch, + }), + wrapper.vm.$nextTick(() => { + expect(findByRef('branchName').isVisible()).toBe(false); + }), + ]); }); it('when `new` branch option is chosen, focuses on the branch name input', () => { diff --git a/spec/frontend/monitoring/components/embeds/metric_embed_spec.js b/spec/frontend/monitoring/components/embeds/metric_embed_spec.js index b829cd53479..f23823ccad6 100644 --- a/spec/frontend/monitoring/components/embeds/metric_embed_spec.js +++ b/spec/frontend/monitoring/components/embeds/metric_embed_spec.js @@ -1,6 +1,6 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; -import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; +import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; import { TEST_HOST } from 'helpers/test_constants'; import MetricEmbed from '~/monitoring/components/embeds/metric_embed.vue'; import { groups, initialState, metricsData, metricsWithData } from './mock_data'; @@ -62,7 +62,7 @@ describe('MetricEmbed', () => { it('shows an empty state when no metrics are present', () => { expect(wrapper.find('.metrics-embed').exists()).toBe(true); - expect(wrapper.find(PanelType).exists()).toBe(false); + expect(wrapper.find(DashboardPanel).exists()).toBe(false); }); }); @@ -90,12 +90,12 @@ describe('MetricEmbed', () => { it('shows a chart when metrics are present', () => { expect(wrapper.find('.metrics-embed').exists()).toBe(true); - expect(wrapper.find(PanelType).exists()).toBe(true); - expect(wrapper.findAll(PanelType).length).toBe(2); + expect(wrapper.find(DashboardPanel).exists()).toBe(true); + expect(wrapper.findAll(DashboardPanel).length).toBe(2); }); it('includes groupId with dashboardUrl', () => { - expect(wrapper.find(PanelType).props('groupId')).toBe(TEST_HOST); + expect(wrapper.find(DashboardPanel).props('groupId')).toBe(TEST_HOST); }); }); }); diff --git a/spec/frontend/monitoring/components/variables/custom_variable_spec.js b/spec/frontend/monitoring/components/variables/custom_variable_spec.js new file mode 100644 index 00000000000..5a2b26219b6 --- /dev/null +++ b/spec/frontend/monitoring/components/variables/custom_variable_spec.js @@ -0,0 +1,52 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import CustomVariable from '~/monitoring/components/variables/custom_variable.vue'; + +describe('Custom variable component', () => { + let wrapper; + const propsData = { + name: 'env', + label: 'Select environment', + value: 'Production', + options: [{ text: 'Production', value: 'prod' }, { text: 'Canary', value: 'canary' }], + }; + const createShallowWrapper = () => { + wrapper = shallowMount(CustomVariable, { + propsData, + }); + }; + + const findDropdown = () => wrapper.find(GlDropdown); + const findDropdownItems = () => wrapper.findAll(GlDropdownItem); + + it('renders dropdown element when all necessary props are passed', () => { + createShallowWrapper(); + + expect(findDropdown()).toExist(); + }); + + it('renders dropdown element with a text', () => { + createShallowWrapper(); + + expect(findDropdown().attributes('text')).toBe(propsData.value); + }); + + it('renders all the dropdown items', () => { + createShallowWrapper(); + + expect(findDropdownItems()).toHaveLength(propsData.options.length); + }); + + it('changing dropdown items triggers update', () => { + createShallowWrapper(); + jest.spyOn(wrapper.vm, '$emit'); + + findDropdownItems() + .at(1) + .vm.$emit('click'); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('onUpdate', 'env', 'canary'); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/variables/text_variable_spec.js b/spec/frontend/monitoring/components/variables/text_variable_spec.js new file mode 100644 index 00000000000..f01584ae8bc --- /dev/null +++ b/spec/frontend/monitoring/components/variables/text_variable_spec.js @@ -0,0 +1,59 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlFormInput } from '@gitlab/ui'; +import TextVariable from '~/monitoring/components/variables/text_variable.vue'; + +describe('Text variable component', () => { + let wrapper; + const propsData = { + name: 'pod', + label: 'Select pod', + value: 'test-pod', + }; + const createShallowWrapper = () => { + wrapper = shallowMount(TextVariable, { + propsData, + }); + }; + + const findInput = () => wrapper.find(GlFormInput); + + it('renders a text input when all props are passed', () => { + createShallowWrapper(); + + expect(findInput()).toExist(); + }); + + it('always has a default value', () => { + createShallowWrapper(); + + return wrapper.vm.$nextTick(() => { + expect(findInput().attributes('value')).toBe(propsData.value); + }); + }); + + it('triggers keyup enter', () => { + createShallowWrapper(); + jest.spyOn(wrapper.vm, '$emit'); + + findInput().element.value = 'prod-pod'; + findInput().trigger('input'); + findInput().trigger('keyup.enter'); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('onUpdate', 'pod', 'prod-pod'); + }); + }); + + it('triggers blur enter', () => { + createShallowWrapper(); + jest.spyOn(wrapper.vm, '$emit'); + + findInput().element.value = 'canary-pod'; + findInput().trigger('input'); + findInput().trigger('blur'); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('onUpdate', 'pod', 'canary-pod'); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/variables_section_spec.js b/spec/frontend/monitoring/components/variables_section_spec.js new file mode 100644 index 00000000000..095d89c9231 --- /dev/null +++ b/spec/frontend/monitoring/components/variables_section_spec.js @@ -0,0 +1,126 @@ +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 { 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'; + +jest.mock('~/lib/utils/url_utility', () => ({ + updateHistory: jest.fn(), + mergeUrlParams: jest.fn(), +})); + +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, { + store, + }); + }; + + const findTextInput = () => wrapper.findAll(TextVariable); + const findCustomInput = () => wrapper.findAll(CustomVariable); + + beforeEach(() => { + store = createStore(); + + store.state.monitoringDashboard.showEmptyState = false; + }); + + it('does not show the variables section', () => { + createShallowWrapper(); + const allInputs = findTextInput().length + findCustomInput().length; + + expect(allInputs).toBe(0); + }); + + it('shows the variables section', () => { + createShallowWrapper(); + store.commit(`monitoringDashboard/${types.SET_VARIABLES}`, sampleVariables); + + return wrapper.vm.$nextTick(() => { + const allInputs = findTextInput().length + findCustomInput().length; + + expect(allInputs).toBe(Object.keys(sampleVariables).length); + }); + }); + + describe('when changing the variable inputs', () => { + const fetchDashboardData = jest.fn(); + const updateVariableValues = jest.fn(); + + beforeEach(() => { + store = new Vuex.Store({ + modules: { + monitoringDashboard: { + namespaced: true, + state: { + showEmptyState: false, + promVariables: sampleVariables, + }, + actions: { + fetchDashboardData, + updateVariableValues, + }, + }, + }, + }); + + createShallowWrapper(); + }); + + it('merges the url params and refreshes the dashboard when a text-based variables inputs are updated', () => { + const firstInput = findTextInput().at(0); + + firstInput.vm.$emit('onUpdate', 'label1', 'test'); + + return wrapper.vm.$nextTick(() => { + expect(updateVariableValues).toHaveBeenCalled(); + expect(mergeUrlParams).toHaveBeenCalledWith( + convertVariablesForURL(sampleVariables), + window.location.href, + ); + expect(updateHistory).toHaveBeenCalled(); + expect(fetchDashboardData).toHaveBeenCalled(); + }); + }); + + it('merges the url params and refreshes the dashboard when a custom-based variables inputs are updated', () => { + const firstInput = findCustomInput().at(0); + + firstInput.vm.$emit('onUpdate', 'label1', 'test'); + + return wrapper.vm.$nextTick(() => { + expect(updateVariableValues).toHaveBeenCalled(); + expect(mergeUrlParams).toHaveBeenCalledWith( + convertVariablesForURL(sampleVariables), + window.location.href, + ); + expect(updateHistory).toHaveBeenCalled(); + expect(fetchDashboardData).toHaveBeenCalled(); + }); + }); + + 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); + + firstInput.vm.$emit('onUpdate', 'label1', 'Simple text'); + + expect(updateVariableValues).not.toHaveBeenCalled(); + expect(mergeUrlParams).not.toHaveBeenCalled(); + expect(updateHistory).not.toHaveBeenCalled(); + expect(fetchDashboardData).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js index 56236918c68..4611e6f1b18 100644 --- a/spec/frontend/monitoring/mock_data.js +++ b/spec/frontend/monitoring/mock_data.js @@ -34,6 +34,7 @@ const customDashboardsData = new Array(30).fill(null).map((_, idx) => ({ system_dashboard: false, project_blob_path: `${mockProjectDir}/blob/master/dashboards/.gitlab/dashboards/dashboard_${idx}.yml`, path: `.gitlab/dashboards/dashboard_${idx}.yml`, + starred: false, })); export const mockDashboardsErrorResponse = { @@ -323,6 +324,18 @@ export const dashboardGitResponse = [ system_dashboard: true, project_blob_path: null, path: 'config/prometheus/common_metrics.yml', + starred: false, + user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=config/prometheus/common_metrics.yml`, + }, + { + default: false, + display_name: 'dashboard.yml', + can_edit: true, + system_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`, }, ...customDashboardsData, ]; @@ -341,7 +354,7 @@ export const metricsResult = [ }, ]; -export const graphDataPrometheusQuery = { +export const singleStatMetricsResult = { title: 'Super Chart A2', type: 'single-stat', weight: 2, @@ -489,7 +502,7 @@ export const stackedColumnMockedData = { export const barMockData = { title: 'SLA Trends - Primary Services', - type: 'bar-chart', + type: 'bar', xLabel: 'service', y_label: 'percentile', metrics: [ @@ -549,3 +562,217 @@ export const mockNamespacedData = { export const mockLogsPath = '/mockLogsPath'; export const mockLogsHref = `${mockLogsPath}?duration_seconds=${mockTimeRange.duration.seconds}`; + +const templatingVariableTypes = { + text: { + simple: 'Simple text', + advanced: { + label: 'Variable 4', + type: 'text', + options: { + default_value: 'default', + }, + }, + }, + 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: {}, + }, + withoutLabel: { + 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, + }, + ], + }, + }, + }, + }, +}; + +const generateMockTemplatingData = data => { + const vars = data + ? { + variables: { + ...data, + }, + } + : {}; + return { + dashboard: { + templating: vars, + }, + }; +}; + +const responseForSimpleTextVariable = { + simpleText: { + label: 'simpleText', + type: 'text', + value: 'Simple text', + }, +}; + +const responseForAdvTextVariable = { + advText: { + label: 'Variable 4', + type: 'text', + value: 'default', + }, +}; + +const responseForSimpleCustomVariable = { + simpleCustom: { + label: 'simpleCustom', + value: 'value1', + options: [ + { + default: false, + text: 'value1', + value: 'value1', + }, + { + default: false, + text: 'value2', + value: 'value2', + }, + { + default: false, + text: 'value3', + value: 'value3', + }, + ], + type: 'custom', + }, +}; + +const responseForAdvancedCustomVariableWithoutOptions = { + advCustomWithoutOpts: { + label: 'advCustomWithoutOpts', + options: [], + type: 'custom', + }, +}; + +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', + }, +}; + +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', + }, +}; + +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, + }), + 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 mockTemplatingDataResponses = { + emptyTemplatingProp: {}, + emptyVariablesProp: {}, + simpleText: responseForSimpleTextVariable, + advText: responseForAdvTextVariable, + simpleCustom: responseForSimpleCustomVariable, + advCustomWithoutOpts: responseForAdvancedCustomVariableWithoutOptions, + advCustomWithoutType: {}, + advCustomWithoutLabel: responseForAdvancedCustomVariableWithoutLabel, + simpleAndAdv: responseForAdvancedCustomVariable, + allVariableTypes: responsesForAllVariableTypes, +}; diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index f312aa1fd34..8914f2e66ea 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -11,17 +11,22 @@ import { ENVIRONMENT_AVAILABLE_STATE } from '~/monitoring/constants'; import store from '~/monitoring/stores'; import * as types from '~/monitoring/stores/mutation_types'; import { + fetchData, fetchDashboard, receiveMetricsDashboardSuccess, fetchDeploymentsData, fetchEnvironmentsData, fetchDashboardData, fetchAnnotations, + toggleStarredValue, fetchPrometheusMetric, setInitialState, filterEnvironments, + setExpandedPanel, + clearExpandedPanel, setGettingStartedEmptyState, duplicateSystemDashboard, + updateVariableValues, } from '~/monitoring/stores/actions'; import { gqClient, @@ -35,6 +40,7 @@ import { deploymentData, environmentData, annotationsData, + mockTemplatingData, dashboardGitResponse, mockDashboardsErrorResponse, } from '../mock_data'; @@ -62,9 +68,6 @@ describe('Monitoring store actions', () => { beforeEach(() => { mock = new MockAdapter(axios); - // Mock `backOff` function to remove exponential algorithm delay. - jest.useFakeTimers(); - jest.spyOn(commonUtils, 'backOff').mockImplementation(callback => { const q = new Promise((resolve, reject) => { const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg)); @@ -87,6 +90,45 @@ describe('Monitoring store actions', () => { createFlash.mockReset(); }); + describe('fetchData', () => { + it('dispatches fetchEnvironmentsData and fetchEnvironmentsData', () => { + const { state } = store; + + return testAction( + fetchData, + null, + state, + [], + [ + { type: 'fetchEnvironmentsData' }, + { type: 'fetchDashboard' }, + { type: 'fetchAnnotations' }, + ], + ); + }); + + it('dispatches when feature metricsDashboardAnnotations is on', () => { + const origGon = window.gon; + window.gon = { features: { metricsDashboardAnnotations: true } }; + + const { state } = store; + + return testAction( + fetchData, + null, + state, + [], + [ + { type: 'fetchEnvironmentsData' }, + { type: 'fetchDashboard' }, + { type: 'fetchAnnotations' }, + ], + ).then(() => { + window.gon = origGon; + }); + }); + }); + describe('fetchDeploymentsData', () => { it('dispatches receiveDeploymentsDataSuccess on success', () => { const { state } = store; @@ -310,6 +352,49 @@ describe('Monitoring store actions', () => { }); }); + describe('Toggles starred value of current dashboard', () => { + const { state } = store; + 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: true }, + ]); + }); + + 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', () => { let mockedState; beforeEach(() => { @@ -357,6 +442,29 @@ describe('Monitoring store actions', () => { ); }); }); + + describe('updateVariableValues', () => { + let mockedState; + beforeEach(() => { + mockedState = storeState(); + }); + it('should commit UPDATE_VARIABLE_VALUES mutation', done => { + testAction( + updateVariableValues, + { pod: 'POD' }, + mockedState, + [ + { + type: types.UPDATE_VARIABLE_VALUES, + payload: { pod: 'POD' }, + }, + ], + [], + done, + ); + }); + }); + describe('fetchDashboard', () => { let dispatch; let state; @@ -467,6 +575,33 @@ 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; @@ -873,4 +1008,43 @@ describe('Monitoring store actions', () => { }); }); }); + + describe('setExpandedPanel', () => { + let state; + + beforeEach(() => { + state = storeState(); + }); + + it('Sets a panel as expanded', () => { + const group = 'group_1'; + const panel = { title: 'A Panel' }; + + return testAction( + setExpandedPanel, + { group, panel }, + state, + [{ type: types.SET_EXPANDED_PANEL, payload: { group, panel } }], + [], + ); + }); + }); + + describe('clearExpandedPanel', () => { + let state; + + beforeEach(() => { + state = storeState(); + }); + + it('Clears a panel as expanded', () => { + return testAction( + clearExpandedPanel, + undefined, + state, + [{ type: types.SET_EXPANDED_PANEL, payload: { group: null, panel: null } }], + [], + ); + }); + }); }); diff --git a/spec/frontend/monitoring/store/getters_spec.js b/spec/frontend/monitoring/store/getters_spec.js index f040876b832..365052e68e3 100644 --- a/spec/frontend/monitoring/store/getters_spec.js +++ b/spec/frontend/monitoring/store/getters_spec.js @@ -3,7 +3,12 @@ import * as getters from '~/monitoring/stores/getters'; import mutations from '~/monitoring/stores/mutations'; import * as types from '~/monitoring/stores/mutation_types'; import { metricStates } from '~/monitoring/constants'; -import { environmentData, metricsResult } from '../mock_data'; +import { + environmentData, + metricsResult, + dashboardGitResponse, + mockTemplatingDataResponses, +} from '../mock_data'; import { metricsDashboardPayload, metricResultStatus, @@ -323,4 +328,81 @@ describe('Monitoring store Getters', () => { expect(metricsSavedToDb).toEqual([`${id1}_${metric1.id}`, `${id2}_${metric2.id}`]); }); }); + + describe('getCustomVariablesArray', () => { + let state; + + beforeEach(() => { + state = { + promVariables: {}, + }; + }); + + it('transforms the promVariables object to an array in the [variable, variable_value] format for all variable types', () => { + mutations[types.SET_VARIABLES](state, mockTemplatingDataResponses.allVariableTypes); + const variablesArray = getters.getCustomVariablesArray(state); + + expect(variablesArray).toEqual([ + 'simpleText', + 'Simple text', + 'advText', + 'default', + 'simpleCustom', + 'value1', + 'advCustomNormal', + 'value2', + ]); + }); + + it('transforms the promVariables object to an empty array when no keys are present', () => { + mutations[types.SET_VARIABLES](state, {}); + const variablesArray = getters.getCustomVariablesArray(state); + + expect(variablesArray).toEqual([]); + }); + }); + + describe('selectedDashboard', () => { + const { selectedDashboard } = getters; + + it('returns a dashboard', () => { + const state = { + allDashboards: dashboardGitResponse, + currentDashboard: dashboardGitResponse[0].path, + }; + expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]); + }); + + it('returns a non-default dashboard', () => { + const state = { + allDashboards: dashboardGitResponse, + currentDashboard: dashboardGitResponse[1].path, + }; + expect(selectedDashboard(state)).toEqual(dashboardGitResponse[1]); + }); + + it('returns a default dashboard when no dashboard is selected', () => { + const state = { + allDashboards: dashboardGitResponse, + currentDashboard: null, + }; + expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]); + }); + + it('returns a default dashboard when dashboard cannot be found', () => { + const state = { + allDashboards: dashboardGitResponse, + currentDashboard: 'wrong_path', + }; + expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]); + }); + + it('returns null when no dashboards are present', () => { + const state = { + allDashboards: [], + currentDashboard: dashboardGitResponse[0].path, + }; + expect(selectedDashboard(state)).toEqual(null); + }); + }); }); diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js index 1452e9bc491..4306243689a 100644 --- a/spec/frontend/monitoring/store/mutations_spec.js +++ b/spec/frontend/monitoring/store/mutations_spec.js @@ -72,6 +72,49 @@ describe('Monitoring mutations', () => { }); }); + describe('Dashboard starring mutations', () => { + it('REQUEST_DASHBOARD_STARRING', () => { + stateCopy = { isUpdatingStarredValue: false }; + mutations[types.REQUEST_DASHBOARD_STARRING](stateCopy); + + expect(stateCopy.isUpdatingStarredValue).toBe(true); + }); + + describe('RECEIVE_DASHBOARD_STARRING_SUCCESS', () => { + let allDashboards; + + beforeEach(() => { + allDashboards = [...dashboardGitResponse]; + stateCopy = { + allDashboards, + currentDashboard: allDashboards[1].path, + isUpdatingStarredValue: true, + }; + }); + + it('sets a dashboard as starred', () => { + mutations[types.RECEIVE_DASHBOARD_STARRING_SUCCESS](stateCopy, true); + + expect(stateCopy.isUpdatingStarredValue).toBe(false); + expect(stateCopy.allDashboards[1].starred).toBe(true); + }); + + it('sets a dashboard as unstarred', () => { + mutations[types.RECEIVE_DASHBOARD_STARRING_SUCCESS](stateCopy, false); + + expect(stateCopy.isUpdatingStarredValue).toBe(false); + expect(stateCopy.allDashboards[1].starred).toBe(false); + }); + }); + + it('RECEIVE_DASHBOARD_STARRING_FAILURE', () => { + stateCopy = { isUpdatingStarredValue: true }; + mutations[types.RECEIVE_DASHBOARD_STARRING_FAILURE](stateCopy); + + expect(stateCopy.isUpdatingStarredValue).toBe(false); + }); + }); + describe('RECEIVE_DEPLOYMENTS_DATA_SUCCESS', () => { it('stores the deployment data', () => { stateCopy.deploymentData = []; @@ -342,4 +385,53 @@ describe('Monitoring mutations', () => { expect(stateCopy.allDashboards).toEqual(dashboardGitResponse); }); }); + + describe('SET_EXPANDED_PANEL', () => { + it('no expanded panel is set initally', () => { + expect(stateCopy.expandedPanel.panel).toEqual(null); + expect(stateCopy.expandedPanel.group).toEqual(null); + }); + + it('sets a panel id as the expanded panel', () => { + const group = 'group_1'; + const panel = { title: 'A Panel' }; + mutations[types.SET_EXPANDED_PANEL](stateCopy, { group, panel }); + + expect(stateCopy.expandedPanel).toEqual({ group, panel }); + }); + + it('clears panel as the expanded panel', () => { + mutations[types.SET_EXPANDED_PANEL](stateCopy, { group: null, panel: null }); + + expect(stateCopy.expandedPanel.group).toEqual(null); + expect(stateCopy.expandedPanel.panel).toEqual(null); + }); + }); + + describe('SET_VARIABLES', () => { + it('stores an empty variables array when no custom variables are given', () => { + mutations[types.SET_VARIABLES](stateCopy, {}); + + expect(stateCopy.promVariables).toEqual({}); + }); + + it('stores variables in the key key_value format in the array', () => { + mutations[types.SET_VARIABLES](stateCopy, { pod: 'POD', stage: 'main ops' }); + + expect(stateCopy.promVariables).toEqual({ pod: 'POD', stage: 'main ops' }); + }); + }); + + describe('UPDATE_VARIABLE_VALUES', () => { + afterEach(() => { + mutations[types.SET_VARIABLES](stateCopy, {}); + }); + + it('updates only the value of the variable in promVariables', () => { + mutations[types.SET_VARIABLES](stateCopy, { environment: { value: 'prod', type: 'text' } }); + mutations[types.UPDATE_VARIABLE_VALUES](stateCopy, { key: 'environment', value: 'new prod' }); + + expect(stateCopy.promVariables).toEqual({ environment: { value: 'new prod', type: 'text' } }); + }); + }); }); diff --git a/spec/frontend/monitoring/store/utils_spec.js b/spec/frontend/monitoring/store/utils_spec.js index 7ee2a16b4bd..fe5754e1216 100644 --- a/spec/frontend/monitoring/store/utils_spec.js +++ b/spec/frontend/monitoring/store/utils_spec.js @@ -27,6 +27,7 @@ describe('mapToDashboardViewModel', () => { group: 'Group 1', panels: [ { + id: 'ID_ABC', title: 'Title A', xLabel: '', xAxis: { @@ -49,6 +50,7 @@ describe('mapToDashboardViewModel', () => { key: 'group-1-0', panels: [ { + id: 'ID_ABC', title: 'Title A', type: 'chart-type', xLabel: '', @@ -127,11 +129,13 @@ describe('mapToDashboardViewModel', () => { it('panel with x_label', () => { setupWithPanel({ + id: 'ID_123', title: panelTitle, x_label: 'x label', }); expect(getMappedPanel()).toEqual({ + id: 'ID_123', title: panelTitle, xLabel: 'x label', xAxis: { @@ -149,10 +153,12 @@ describe('mapToDashboardViewModel', () => { it('group y_axis defaults', () => { setupWithPanel({ + id: 'ID_456', title: panelTitle, }); expect(getMappedPanel()).toEqual({ + id: 'ID_456', title: panelTitle, xLabel: '', y_label: '', diff --git a/spec/frontend/monitoring/store/variable_mapping_spec.js b/spec/frontend/monitoring/store/variable_mapping_spec.js new file mode 100644 index 00000000000..47681ac7c65 --- /dev/null +++ b/spec/frontend/monitoring/store/variable_mapping_spec.js @@ -0,0 +1,22 @@ +import { parseTemplatingVariables } from '~/monitoring/stores/variable_mapping'; +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 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); + }); +}); diff --git a/spec/frontend/monitoring/store_utils.js b/spec/frontend/monitoring/store_utils.js index d764a79ccc3..338af79dbbe 100644 --- a/spec/frontend/monitoring/store_utils.js +++ b/spec/frontend/monitoring/store_utils.js @@ -1,34 +1,49 @@ import * as types from '~/monitoring/stores/mutation_types'; -import { metricsResult, environmentData } from './mock_data'; +import { metricsResult, environmentData, dashboardGitResponse } from './mock_data'; import { metricsDashboardPayload } from './fixture_data'; -export const setMetricResult = ({ $store, result, group = 0, panel = 0, metric = 0 }) => { - const { dashboard } = $store.state.monitoringDashboard; +export const setMetricResult = ({ store, result, group = 0, panel = 0, metric = 0 }) => { + const { dashboard } = store.state.monitoringDashboard; const { metricId } = dashboard.panelGroups[group].panels[panel].metrics[metric]; - $store.commit(`monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, { + store.commit(`monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, { metricId, result, }); }; -const setEnvironmentData = $store => { - $store.commit(`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, environmentData); +const setEnvironmentData = store => { + store.commit(`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, environmentData); }; -export const setupStoreWithDashboard = $store => { - $store.commit( +export const setupAllDashboards = store => { + store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, dashboardGitResponse); +}; + +export const setupStoreWithDashboard = store => { + store.commit( + `monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`, + metricsDashboardPayload, + ); + store.commit( `monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`, metricsDashboardPayload, ); }; -export const setupStoreWithData = $store => { - setupStoreWithDashboard($store); +export const setupStoreWithVariable = store => { + store.commit(`monitoringDashboard/${types.SET_VARIABLES}`, { + label1: 'pod', + }); +}; + +export const setupStoreWithData = store => { + setupAllDashboards(store); + setupStoreWithDashboard(store); - setMetricResult({ $store, result: [], panel: 0 }); - setMetricResult({ $store, result: metricsResult, panel: 1 }); - setMetricResult({ $store, result: metricsResult, panel: 2 }); + setMetricResult({ store, result: [], panel: 0 }); + setMetricResult({ store, result: metricsResult, panel: 1 }); + setMetricResult({ store, result: metricsResult, panel: 2 }); - setEnvironmentData($store); + setEnvironmentData(store); }; diff --git a/spec/frontend/monitoring/stubs/modal_stub.js b/spec/frontend/monitoring/stubs/modal_stub.js new file mode 100644 index 00000000000..4cd0362096e --- /dev/null +++ b/spec/frontend/monitoring/stubs/modal_stub.js @@ -0,0 +1,11 @@ +const ModalStub = { + name: 'glmodal-stub', + template: ` + <div> + <slot></slot> + <slot name="modal-ok"></slot> + </div> + `, +}; + +export default ModalStub; diff --git a/spec/frontend/monitoring/utils_spec.js b/spec/frontend/monitoring/utils_spec.js index 0bb1b987b2e..aa5a4459a72 100644 --- a/spec/frontend/monitoring/utils_spec.js +++ b/spec/frontend/monitoring/utils_spec.js @@ -1,15 +1,13 @@ import * as monitoringUtils from '~/monitoring/utils'; -import { queryToObject, mergeUrlParams, removeParams } from '~/lib/utils/url_utility'; +import * as urlUtils from '~/lib/utils/url_utility'; import { TEST_HOST } from 'jest/helpers/test_constants'; import { mockProjectDir, - graphDataPrometheusQuery, + singleStatMetricsResult, anomalyMockGraphData, barMockData, } from './mock_data'; -import { graphData } from './fixture_data'; - -jest.mock('~/lib/utils/url_utility'); +import { metricsDashboardViewModel, graphData } from './fixture_data'; const mockPath = `${TEST_HOST}${mockProjectDir}/-/environments/29/metrics`; @@ -27,11 +25,6 @@ const rollingRange = { }; describe('monitoring/utils', () => { - afterEach(() => { - mergeUrlParams.mockReset(); - queryToObject.mockReset(); - }); - describe('trackGenerateLinkToChartEventOptions', () => { it('should return Cluster Monitoring options if located on Cluster Health Dashboard', () => { document.body.dataset.page = 'groups:clusters:show'; @@ -89,7 +82,7 @@ describe('monitoring/utils', () => { it('validates data with the query format', () => { const validGraphData = monitoringUtils.graphDataValidatorForValues( true, - graphDataPrometheusQuery, + singleStatMetricsResult, ); expect(validGraphData).toBe(true); @@ -112,7 +105,7 @@ describe('monitoring/utils', () => { let threeMetrics; let fourMetrics; beforeEach(() => { - oneMetric = graphDataPrometheusQuery; + oneMetric = singleStatMetricsResult; threeMetrics = anomalyMockGraphData; const metrics = [...threeMetrics.metrics]; @@ -139,18 +132,25 @@ describe('monitoring/utils', () => { }); describe('timeRangeFromUrl', () => { - const { timeRangeFromUrl } = monitoringUtils; + beforeEach(() => { + jest.spyOn(urlUtils, 'queryToObject'); + }); + + afterEach(() => { + urlUtils.queryToObject.mockRestore(); + }); - it('returns a fixed range when query contains `start` and `end` paramters are given', () => { - queryToObject.mockReturnValueOnce(range); + const { timeRangeFromUrl } = monitoringUtils; + it('returns a fixed range when query contains `start` and `end` parameters are given', () => { + urlUtils.queryToObject.mockReturnValueOnce(range); expect(timeRangeFromUrl()).toEqual(range); }); - it('returns a rolling range when query contains `duration_seconds` paramters are given', () => { + it('returns a rolling range when query contains `duration_seconds` parameters are given', () => { const { seconds } = rollingRange.duration; - queryToObject.mockReturnValueOnce({ + urlUtils.queryToObject.mockReturnValueOnce({ dashboard: '.gitlab/dashboard/my_dashboard.yml', duration_seconds: `${seconds}`, }); @@ -158,23 +158,59 @@ describe('monitoring/utils', () => { expect(timeRangeFromUrl()).toEqual(rollingRange); }); - it('returns null when no time range paramters are given', () => { - const params = { + it('returns null when no time range parameters are given', () => { + urlUtils.queryToObject.mockReturnValueOnce({ dashboard: '.gitlab/dashboards/custom_dashboard.yml', param1: 'value1', param2: 'value2', - }; + }); - expect(timeRangeFromUrl(params, mockPath)).toBe(null); + expect(timeRangeFromUrl()).toBe(null); + }); + }); + + describe('getPromCustomVariablesFromUrl', () => { + const { getPromCustomVariablesFromUrl } = monitoringUtils; + + beforeEach(() => { + jest.spyOn(urlUtils, 'queryToObject'); + }); + + afterEach(() => { + urlUtils.queryToObject.mockRestore(); + }); + + it('returns an object with only the custom variables', () => { + urlUtils.queryToObject.mockReturnValueOnce({ + dashboard: '.gitlab/dashboards/custom_dashboard.yml', + y_label: 'memory usage', + group: 'kubernetes', + title: 'Kubernetes memory total', + start: '2020-05-06', + end: '2020-05-07', + duration_seconds: '86400', + direction: 'left', + anchor: 'top', + pod: 'POD', + 'var-pod': 'POD', + }); + + expect(getPromCustomVariablesFromUrl()).toEqual(expect.objectContaining({ pod: 'POD' })); + }); + + it('returns an empty object when no custom variables are present', () => { + urlUtils.queryToObject.mockReturnValueOnce({ + dashboard: '.gitlab/dashboards/custom_dashboard.yml', + }); + + expect(getPromCustomVariablesFromUrl()).toStrictEqual({}); }); }); describe('removeTimeRangeParams', () => { const { removeTimeRangeParams } = monitoringUtils; - it('returns when query contains `start` and `end` paramters are given', () => { - removeParams.mockReturnValueOnce(mockPath); - + it('returns when query contains `start` and `end` parameters are given', () => { expect(removeTimeRangeParams(`${mockPath}?start=${range.start}&end=${range.end}`)).toEqual( mockPath, ); @@ -184,28 +220,126 @@ describe('monitoring/utils', () => { describe('timeRangeToUrl', () => { const { timeRangeToUrl } = monitoringUtils; - it('returns a fixed range when query contains `start` and `end` paramters are given', () => { + beforeEach(() => { + jest.spyOn(urlUtils, 'mergeUrlParams'); + jest.spyOn(urlUtils, 'removeParams'); + }); + + afterEach(() => { + urlUtils.mergeUrlParams.mockRestore(); + urlUtils.removeParams.mockRestore(); + }); + + it('returns a fixed range when query contains `start` and `end` parameters are given', () => { const toUrl = `${mockPath}?start=${range.start}&end=${range.end}`; const fromUrl = mockPath; - removeParams.mockReturnValueOnce(fromUrl); - mergeUrlParams.mockReturnValueOnce(toUrl); + urlUtils.removeParams.mockReturnValueOnce(fromUrl); + urlUtils.mergeUrlParams.mockReturnValueOnce(toUrl); expect(timeRangeToUrl(range)).toEqual(toUrl); - expect(mergeUrlParams).toHaveBeenCalledWith(range, fromUrl); + expect(urlUtils.mergeUrlParams).toHaveBeenCalledWith(range, fromUrl); }); - it('returns a rolling range when query contains `duration_seconds` paramters are given', () => { + it('returns a rolling range when query contains `duration_seconds` parameters are given', () => { const { seconds } = rollingRange.duration; const toUrl = `${mockPath}?duration_seconds=${seconds}`; const fromUrl = mockPath; - removeParams.mockReturnValueOnce(fromUrl); - mergeUrlParams.mockReturnValueOnce(toUrl); + urlUtils.removeParams.mockReturnValueOnce(fromUrl); + urlUtils.mergeUrlParams.mockReturnValueOnce(toUrl); expect(timeRangeToUrl(rollingRange)).toEqual(toUrl); - expect(mergeUrlParams).toHaveBeenCalledWith({ duration_seconds: `${seconds}` }, fromUrl); + expect(urlUtils.mergeUrlParams).toHaveBeenCalledWith( + { duration_seconds: `${seconds}` }, + fromUrl, + ); + }); + }); + + describe('expandedPanelPayloadFromUrl', () => { + const { expandedPanelPayloadFromUrl } = monitoringUtils; + const [panelGroup] = metricsDashboardViewModel.panelGroups; + const [panel] = panelGroup.panels; + + const { group } = panelGroup; + const { title, y_label: yLabel } = panel; + + it('returns payload for a panel when query parameters are given', () => { + const search = `?group=${group}&title=${title}&y_label=${yLabel}`; + + expect(expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toEqual({ + group: panelGroup.group, + panel, + }); + }); + + it('returns null when no parameters are given', () => { + expect(expandedPanelPayloadFromUrl(metricsDashboardViewModel, '')).toBe(null); + }); + + it('throws an error when no group is provided', () => { + const search = `?title=${panel.title}&y_label=${yLabel}`; + expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow(); + }); + + it('throws an error when no title is provided', () => { + const search = `?title=${title}&y_label=${yLabel}`; + expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow(); + }); + + it('throws an error when no y_label group is provided', () => { + const search = `?group=${group}&title=${title}`; + expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow(); + }); + + test.each` + group | title | yLabel | missingField + ${'NOT_A_GROUP'} | ${title} | ${yLabel} | ${'group'} + ${group} | ${'NOT_A_TITLE'} | ${yLabel} | ${'title'} + ${group} | ${title} | ${'NOT_A_Y_LABEL'} | ${'y_label'} + `('throws an error when $missingField is incorrect', params => { + const search = `?group=${params.group}&title=${params.title}&y_label=${params.yLabel}`; + expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow(); + }); + }); + + describe('panelToUrl', () => { + const { panelToUrl } = monitoringUtils; + + const dashboard = 'metrics.yml'; + const [panelGroup] = metricsDashboardViewModel.panelGroups; + const [panel] = panelGroup.panels; + + const getUrlParams = url => urlUtils.queryToObject(url.split('?')[1]); + + it('returns URL for a panel when query parameters are given', () => { + const params = getUrlParams(panelToUrl(dashboard, {}, panelGroup.group, panel)); + + expect(params).toEqual( + expect.objectContaining({ + dashboard, + group: panelGroup.group, + title: panel.title, + y_label: panel.y_label, + }), + ); + }); + + it('returns a dashboard only URL if group is missing', () => { + const params = getUrlParams(panelToUrl(dashboard, {}, null, panel)); + expect(params).toEqual(expect.objectContaining({ dashboard: 'metrics.yml' })); + }); + + it('returns a dashboard only URL if panel is missing', () => { + const params = getUrlParams(panelToUrl(dashboard, {}, panelGroup.group, null)); + expect(params).toEqual(expect.objectContaining({ dashboard: 'metrics.yml' })); + }); + + it('returns URL for a panel when query paramters are given including custom variables', () => { + const params = getUrlParams(panelToUrl(dashboard, { pod: 'pod' }, panelGroup.group, null)); + expect(params).toEqual(expect.objectContaining({ dashboard: 'metrics.yml', pod: 'pod' })); }); }); @@ -271,4 +405,108 @@ describe('monitoring/utils', () => { }); }); }); + + describe('removePrefixFromLabel', () => { + it.each` + input | expected + ${undefined} | ${''} + ${null} | ${''} + ${''} | ${''} + ${' '} | ${' '} + ${'pod-1'} | ${'pod-1'} + ${'pod-var-1'} | ${'pod-var-1'} + ${'pod-1-var'} | ${'pod-1-var'} + ${'podvar--1'} | ${'podvar--1'} + ${'povar-d-1'} | ${'povar-d-1'} + ${'var-pod-1'} | ${'pod-1'} + ${'var-var-pod-1'} | ${'var-pod-1'} + ${'varvar-pod-1'} | ${'varvar-pod-1'} + ${'var-pod-1-var-'} | ${'pod-1-var-'} + `('removePrefixFromLabel returns $expected with input $input', ({ input, expected }) => { + expect(monitoringUtils.removePrefixFromLabel(input)).toEqual(expected); + }); + }); + + describe('mergeURLVariables', () => { + beforeEach(() => { + jest.spyOn(urlUtils, 'queryToObject'); + }); + + afterEach(() => { + urlUtils.queryToObject.mockRestore(); + }); + + it('returns empty object if variables are not defined in yml or URL', () => { + urlUtils.queryToObject.mockReturnValueOnce({}); + + expect(monitoringUtils.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', + }); + + expect(monitoringUtils.mergeURLVariables({})).toEqual({}); + }); + + it('returns yml variables if variables defined in yml but not in the URL', () => { + urlUtils.queryToObject.mockReturnValueOnce({}); + + const params = { + env: 'one', + instance: 'localhost', + }; + + expect(monitoringUtils.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 ymlParams = { + pod: { value: 'one' }, + service: { value: 'database' }, + }; + urlUtils.queryToObject.mockReturnValueOnce(urlParams); + + expect(monitoringUtils.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 ymlParams = { + instance: { value: 'localhost' }, + service: { value: 'database' }, + }; + + const merged = { + instance: { value: 'localhost:8080' }, + service: { value: 'database' }, + }; + + urlUtils.queryToObject.mockReturnValueOnce(urlParams); + + expect(monitoringUtils.mergeURLVariables(ymlParams)).toEqual(merged); + }); + }); + + describe('convertVariablesForURL', () => { + it.each` + input | expected + ${undefined} | ${{}} + ${null} | ${{}} + ${{}} | ${{}} + ${{ env: { value: 'prod' } }} | ${{ 'var-env': 'prod' }} + ${{ 'var-env': { value: 'prod' } }} | ${{ 'var-var-env': 'prod' }} + `('convertVariablesForURL returns $expected with input $input', ({ input, expected }) => { + expect(monitoringUtils.convertVariablesForURL(input)).toEqual(expected); + }); + }); }); diff --git a/spec/frontend/monitoring/validators_spec.js b/spec/frontend/monitoring/validators_spec.js new file mode 100644 index 00000000000..0c3d77a7d98 --- /dev/null +++ b/spec/frontend/monitoring/validators_spec.js @@ -0,0 +1,80 @@ +import { alertsValidator, queriesValidator } from '~/monitoring/validators'; + +describe('alertsValidator', () => { + const validAlert = { + alert_path: 'my/alert.json', + operator: '<', + threshold: 5, + metricId: '8', + }; + it('requires all alerts to have an alert path', () => { + const { operator, threshold, metricId } = validAlert; + const input = { + [validAlert.alert_path]: { + operator, + threshold, + metricId, + }, + }; + expect(alertsValidator(input)).toEqual(false); + }); + it('requires that the object key matches the alert path', () => { + const input = { + undefined: validAlert, + }; + expect(alertsValidator(input)).toEqual(false); + }); + it('requires all alerts to have a metric id', () => { + const input = { + [validAlert.alert_path]: { ...validAlert, metricId: undefined }, + }; + expect(alertsValidator(input)).toEqual(false); + }); + it('requires the metricId to be a string', () => { + const input = { + [validAlert.alert_path]: { ...validAlert, metricId: 8 }, + }; + expect(alertsValidator(input)).toEqual(false); + }); + it('requires all alerts to have an operator', () => { + const input = { + [validAlert.alert_path]: { ...validAlert, operator: '' }, + }; + expect(alertsValidator(input)).toEqual(false); + }); + it('requires all alerts to have an numeric threshold', () => { + const input = { + [validAlert.alert_path]: { ...validAlert, threshold: '60' }, + }; + expect(alertsValidator(input)).toEqual(false); + }); + it('correctly identifies a valid alerts object', () => { + const input = { + [validAlert.alert_path]: validAlert, + }; + expect(alertsValidator(input)).toEqual(true); + }); +}); +describe('queriesValidator', () => { + const validQuery = { + metricId: '8', + alert_path: 'alert', + label: 'alert-label', + }; + it('requires all alerts to have a metric id', () => { + const input = [{ ...validQuery, metricId: undefined }]; + expect(queriesValidator(input)).toEqual(false); + }); + it('requires the metricId to be a string', () => { + const input = [{ ...validQuery, metricId: 8 }]; + expect(queriesValidator(input)).toEqual(false); + }); + it('requires all queries to have a label', () => { + const input = [{ ...validQuery, label: undefined }]; + expect(queriesValidator(input)).toEqual(false); + }); + it('correctly identifies a valid queries array', () => { + const input = [validQuery]; + expect(queriesValidator(input)).toEqual(true); + }); +}); diff --git a/spec/frontend/notebook/cells/code_spec.js b/spec/frontend/notebook/cells/code_spec.js new file mode 100644 index 00000000000..33dabe2b6dc --- /dev/null +++ b/spec/frontend/notebook/cells/code_spec.js @@ -0,0 +1,90 @@ +import Vue from 'vue'; +import CodeComponent from '~/notebook/cells/code.vue'; + +const Component = Vue.extend(CodeComponent); + +describe('Code component', () => { + let vm; + let json; + + beforeEach(() => { + json = getJSONFixture('blob/notebook/basic.json'); + }); + + const setupComponent = cell => { + const comp = new Component({ + propsData: { + cell, + }, + }); + comp.$mount(); + return comp; + }; + + describe('without output', () => { + beforeEach(done => { + vm = setupComponent(json.cells[0]); + + setImmediate(() => { + done(); + }); + }); + + it('does not render output prompt', () => { + expect(vm.$el.querySelectorAll('.prompt').length).toBe(1); + }); + }); + + describe('with output', () => { + beforeEach(done => { + vm = setupComponent(json.cells[2]); + + setImmediate(() => { + done(); + }); + }); + + it('does not render output prompt', () => { + expect(vm.$el.querySelectorAll('.prompt').length).toBe(2); + }); + + it('renders output cell', () => { + expect(vm.$el.querySelector('.output')).toBeDefined(); + }); + }); + + describe('with string for output', () => { + // NBFormat Version 4.1 allows outputs.text to be a string + beforeEach(() => { + const cell = json.cells[2]; + cell.outputs[0].text = cell.outputs[0].text.join(''); + + vm = setupComponent(cell); + return vm.$nextTick(); + }); + + it('does not render output prompt', () => { + expect(vm.$el.querySelectorAll('.prompt').length).toBe(2); + }); + + it('renders output cell', () => { + expect(vm.$el.querySelector('.output')).toBeDefined(); + }); + }); + + describe('with string for cell.source', () => { + beforeEach(() => { + const cell = json.cells[0]; + cell.source = cell.source.join(''); + + vm = setupComponent(cell); + return vm.$nextTick(); + }); + + it('renders the same input as when cell.source is an array', () => { + const expected = "console.log('test')"; + + expect(vm.$el.querySelector('.input').innerText).toContain(expected); + }); + }); +}); diff --git a/spec/frontend/notebook/cells/markdown_spec.js b/spec/frontend/notebook/cells/markdown_spec.js new file mode 100644 index 00000000000..ad33858da22 --- /dev/null +++ b/spec/frontend/notebook/cells/markdown_spec.js @@ -0,0 +1,167 @@ +import Vue from 'vue'; +import katex from 'katex'; +import MarkdownComponent from '~/notebook/cells/markdown.vue'; + +const Component = Vue.extend(MarkdownComponent); + +window.katex = katex; + +describe('Markdown component', () => { + let vm; + let cell; + let json; + + beforeEach(() => { + json = getJSONFixture('blob/notebook/basic.json'); + + // eslint-disable-next-line prefer-destructuring + cell = json.cells[1]; + + vm = new Component({ + propsData: { + cell, + }, + }); + vm.$mount(); + + return vm.$nextTick(); + }); + + it('does not render promot', () => { + expect(vm.$el.querySelector('.prompt span')).toBeNull(); + }); + + it('does not render the markdown text', () => { + expect(vm.$el.querySelector('.markdown').innerHTML.trim()).not.toEqual(cell.source.join('')); + }); + + it('renders the markdown HTML', () => { + expect(vm.$el.querySelector('.markdown h1')).not.toBeNull(); + }); + + it('sanitizes output', () => { + Object.assign(cell, { + source: [ + '[XSS](data:text/html;base64,PHNjcmlwdD5hbGVydChkb2N1bWVudC5kb21haW4pPC9zY3JpcHQ+Cg==)\n', + ], + }); + + return vm.$nextTick().then(() => { + expect(vm.$el.querySelector('a').getAttribute('href')).toBeNull(); + }); + }); + + describe('katex', () => { + beforeEach(() => { + json = getJSONFixture('blob/notebook/math.json'); + }); + + it('renders multi-line katex', () => { + vm = new Component({ + propsData: { + cell: json.cells[0], + }, + }).$mount(); + + return vm.$nextTick().then(() => { + expect(vm.$el.querySelector('.katex')).not.toBeNull(); + }); + }); + + it('renders inline katex', () => { + vm = new Component({ + propsData: { + cell: json.cells[1], + }, + }).$mount(); + + return vm.$nextTick().then(() => { + expect(vm.$el.querySelector('p:first-child .katex')).not.toBeNull(); + }); + }); + + it('renders multiple inline katex', () => { + vm = new Component({ + propsData: { + cell: json.cells[1], + }, + }).$mount(); + + return vm.$nextTick().then(() => { + expect(vm.$el.querySelectorAll('p:nth-child(2) .katex').length).toBe(4); + }); + }); + + it('output cell in case of katex error', () => { + vm = new Component({ + propsData: { + cell: { + cell_type: 'markdown', + metadata: {}, + source: ['Some invalid $a & b$ inline formula $b & c$\n', '\n'], + }, + }, + }).$mount(); + + return vm.$nextTick().then(() => { + // expect one paragraph with no katex formula in it + expect(vm.$el.querySelectorAll('p').length).toBe(1); + expect(vm.$el.querySelectorAll('p .katex').length).toBe(0); + }); + }); + + it('output cell and render remaining formula in case of katex error', () => { + vm = new Component({ + propsData: { + cell: { + cell_type: 'markdown', + metadata: {}, + source: ['An invalid $a & b$ inline formula and a vaild one $b = c$\n', '\n'], + }, + }, + }).$mount(); + + return vm.$nextTick().then(() => { + // expect one paragraph with no katex formula in it + expect(vm.$el.querySelectorAll('p').length).toBe(1); + expect(vm.$el.querySelectorAll('p .katex').length).toBe(1); + }); + }); + + it('renders math formula in list object', () => { + vm = new Component({ + propsData: { + cell: { + cell_type: 'markdown', + metadata: {}, + source: ["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n'], + }, + }, + }).$mount(); + + return vm.$nextTick().then(() => { + // expect one list with a katex formula in it + expect(vm.$el.querySelectorAll('li').length).toBe(1); + expect(vm.$el.querySelectorAll('li .katex').length).toBe(2); + }); + }); + + it("renders math formula with tick ' in it", () => { + vm = new Component({ + propsData: { + cell: { + cell_type: 'markdown', + metadata: {}, + source: ["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n'], + }, + }, + }).$mount(); + + return vm.$nextTick().then(() => { + // expect one list with a katex formula in it + expect(vm.$el.querySelectorAll('li').length).toBe(1); + expect(vm.$el.querySelectorAll('li .katex').length).toBe(2); + }); + }); + }); +}); diff --git a/spec/frontend/notebook/cells/output/html_sanitize_tests.js b/spec/frontend/notebook/cells/output/html_sanitize_tests.js new file mode 100644 index 00000000000..74c48f04367 --- /dev/null +++ b/spec/frontend/notebook/cells/output/html_sanitize_tests.js @@ -0,0 +1,68 @@ +export default { + 'protocol-based JS injection: simple, no spaces': { + input: '<a href="javascript:alert(\'XSS\');">foo</a>', + output: '<a>foo</a>', + }, + 'protocol-based JS injection: simple, spaces before': { + input: '<a href="javascript :alert(\'XSS\');">foo</a>', + output: '<a>foo</a>', + }, + 'protocol-based JS injection: simple, spaces after': { + input: '<a href="javascript: alert(\'XSS\');">foo</a>', + output: '<a>foo</a>', + }, + 'protocol-based JS injection: simple, spaces before and after': { + input: '<a href="javascript : alert(\'XSS\');">foo</a>', + output: '<a>foo</a>', + }, + 'protocol-based JS injection: preceding colon': { + input: '<a href=":javascript:alert(\'XSS\');">foo</a>', + output: '<a>foo</a>', + }, + 'protocol-based JS injection: UTF-8 encoding': { + input: '<a href="javascript:">foo</a>', + output: '<a>foo</a>', + }, + 'protocol-based JS injection: long UTF-8 encoding': { + input: '<a href="javascript:">foo</a>', + output: '<a>foo</a>', + }, + 'protocol-based JS injection: long UTF-8 encoding without semicolons': { + input: + '<a href=javascript:alert('XSS')>foo</a>', + output: '<a>foo</a>', + }, + 'protocol-based JS injection: hex encoding': { + input: '<a href="javascript:">foo</a>', + output: '<a>foo</a>', + }, + 'protocol-based JS injection: long hex encoding': { + input: '<a href="javascript:">foo</a>', + output: '<a>foo</a>', + }, + 'protocol-based JS injection: hex encoding without semicolons': { + input: + '<a href=javascript:alert('XSS')>foo</a>', + output: '<a>foo</a>', + }, + 'protocol-based JS injection: null char': { + input: '<a href=java\0script:alert("XSS")>foo</a>', + output: '<a>foo</a>', + }, + 'protocol-based JS injection: invalid URL char': { + input: '<img src=javascript:alert("XSS")>', + output: '<img>', + }, + 'protocol-based JS injection: Unicode': { + input: '<a href="\u0001java\u0003script:alert(\'XSS\')">foo</a>', + output: '<a>foo</a>', + }, + 'protocol-based JS injection: spaces and entities': { + input: '<a href="  javascript:alert(\'XSS\');">foo</a>', + output: '<a>foo</a>', + }, + 'img on error': { + input: '<img src="x" onerror="alert(document.domain)" />', + output: '<img src="x">', + }, +}; diff --git a/spec/frontend/notebook/cells/output/html_spec.js b/spec/frontend/notebook/cells/output/html_spec.js new file mode 100644 index 00000000000..3ee404fb187 --- /dev/null +++ b/spec/frontend/notebook/cells/output/html_spec.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import htmlOutput from '~/notebook/cells/output/html.vue'; +import sanitizeTests from './html_sanitize_tests'; + +describe('html output cell', () => { + function createComponent(rawCode) { + const Component = Vue.extend(htmlOutput); + + return new Component({ + propsData: { + rawCode, + count: 0, + index: 0, + }, + }).$mount(); + } + + describe('sanitizes output', () => { + Object.keys(sanitizeTests).forEach(key => { + it(key, () => { + const test = sanitizeTests[key]; + const vm = createComponent(test.input); + const outputEl = [...vm.$el.querySelectorAll('div')].pop(); + + expect(outputEl.innerHTML).toEqual(test.output); + + vm.$destroy(); + }); + }); + }); +}); diff --git a/spec/frontend/notebook/cells/output/index_spec.js b/spec/frontend/notebook/cells/output/index_spec.js new file mode 100644 index 00000000000..2b1aa5317c5 --- /dev/null +++ b/spec/frontend/notebook/cells/output/index_spec.js @@ -0,0 +1,115 @@ +import Vue from 'vue'; +import CodeComponent from '~/notebook/cells/output/index.vue'; + +const Component = Vue.extend(CodeComponent); + +describe('Output component', () => { + let vm; + let json; + + const createComponent = output => { + vm = new Component({ + propsData: { + outputs: [].concat(output), + count: 1, + }, + }); + vm.$mount(); + }; + + beforeEach(() => { + json = getJSONFixture('blob/notebook/basic.json'); + }); + + describe('text output', () => { + beforeEach(done => { + createComponent(json.cells[2].outputs[0]); + + setImmediate(() => { + done(); + }); + }); + + it('renders as plain text', () => { + expect(vm.$el.querySelector('pre')).not.toBeNull(); + }); + + it('renders promot', () => { + expect(vm.$el.querySelector('.prompt span')).not.toBeNull(); + }); + }); + + describe('image output', () => { + beforeEach(done => { + createComponent(json.cells[3].outputs[0]); + + setImmediate(() => { + done(); + }); + }); + + it('renders as an image', () => { + expect(vm.$el.querySelector('img')).not.toBeNull(); + }); + }); + + describe('html output', () => { + it('renders raw HTML', () => { + createComponent(json.cells[4].outputs[0]); + + expect(vm.$el.querySelector('p')).not.toBeNull(); + expect(vm.$el.querySelectorAll('p').length).toBe(1); + expect(vm.$el.textContent.trim()).toContain('test'); + }); + + it('renders multiple raw HTML outputs', () => { + createComponent([json.cells[4].outputs[0], json.cells[4].outputs[0]]); + + expect(vm.$el.querySelectorAll('p').length).toBe(2); + }); + }); + + describe('svg output', () => { + beforeEach(done => { + createComponent(json.cells[5].outputs[0]); + + setImmediate(() => { + done(); + }); + }); + + it('renders as an svg', () => { + expect(vm.$el.querySelector('svg')).not.toBeNull(); + }); + }); + + describe('default to plain text', () => { + beforeEach(done => { + createComponent(json.cells[6].outputs[0]); + + setImmediate(() => { + done(); + }); + }); + + it('renders as plain text', () => { + expect(vm.$el.querySelector('pre')).not.toBeNull(); + expect(vm.$el.textContent.trim()).toContain('testing'); + }); + + it('renders promot', () => { + expect(vm.$el.querySelector('.prompt span')).not.toBeNull(); + }); + + it("renders as plain text when doesn't recognise other types", done => { + createComponent(json.cells[7].outputs[0]); + + setImmediate(() => { + expect(vm.$el.querySelector('pre')).not.toBeNull(); + expect(vm.$el.textContent.trim()).toContain('testing'); + + done(); + }); + }); + }); +}); diff --git a/spec/frontend/notebook/cells/prompt_spec.js b/spec/frontend/notebook/cells/prompt_spec.js new file mode 100644 index 00000000000..cf5a7a603c6 --- /dev/null +++ b/spec/frontend/notebook/cells/prompt_spec.js @@ -0,0 +1,56 @@ +import Vue from 'vue'; +import PromptComponent from '~/notebook/cells/prompt.vue'; + +const Component = Vue.extend(PromptComponent); + +describe('Prompt component', () => { + let vm; + + describe('input', () => { + beforeEach(done => { + vm = new Component({ + propsData: { + type: 'In', + count: 1, + }, + }); + vm.$mount(); + + setImmediate(() => { + done(); + }); + }); + + it('renders in label', () => { + expect(vm.$el.textContent.trim()).toContain('In'); + }); + + it('renders count', () => { + expect(vm.$el.textContent.trim()).toContain('1'); + }); + }); + + describe('output', () => { + beforeEach(done => { + vm = new Component({ + propsData: { + type: 'Out', + count: 1, + }, + }); + vm.$mount(); + + setImmediate(() => { + done(); + }); + }); + + it('renders in label', () => { + expect(vm.$el.textContent.trim()).toContain('Out'); + }); + + it('renders count', () => { + expect(vm.$el.textContent.trim()).toContain('1'); + }); + }); +}); diff --git a/spec/frontend/notebook/index_spec.js b/spec/frontend/notebook/index_spec.js new file mode 100644 index 00000000000..36b092be976 --- /dev/null +++ b/spec/frontend/notebook/index_spec.js @@ -0,0 +1,100 @@ +import Vue from 'vue'; +import Notebook from '~/notebook/index.vue'; + +const Component = Vue.extend(Notebook); + +describe('Notebook component', () => { + let vm; + let json; + let jsonWithWorksheet; + + beforeEach(() => { + json = getJSONFixture('blob/notebook/basic.json'); + jsonWithWorksheet = getJSONFixture('blob/notebook/worksheets.json'); + }); + + describe('without JSON', () => { + beforeEach(done => { + vm = new Component({ + propsData: { + notebook: {}, + }, + }); + vm.$mount(); + + setImmediate(() => { + done(); + }); + }); + + it('does not render', () => { + expect(vm.$el.tagName).toBeUndefined(); + }); + }); + + describe('with JSON', () => { + beforeEach(done => { + vm = new Component({ + propsData: { + notebook: json, + codeCssClass: 'js-code-class', + }, + }); + vm.$mount(); + + setImmediate(() => { + done(); + }); + }); + + it('renders cells', () => { + expect(vm.$el.querySelectorAll('.cell').length).toBe(json.cells.length); + }); + + it('renders markdown cell', () => { + expect(vm.$el.querySelector('.markdown')).not.toBeNull(); + }); + + it('renders code cell', () => { + expect(vm.$el.querySelector('pre')).not.toBeNull(); + }); + + it('add code class to code blocks', () => { + expect(vm.$el.querySelector('.js-code-class')).not.toBeNull(); + }); + }); + + describe('with worksheets', () => { + beforeEach(done => { + vm = new Component({ + propsData: { + notebook: jsonWithWorksheet, + codeCssClass: 'js-code-class', + }, + }); + vm.$mount(); + + setImmediate(() => { + done(); + }); + }); + + it('renders cells', () => { + expect(vm.$el.querySelectorAll('.cell').length).toBe( + jsonWithWorksheet.worksheets[0].cells.length, + ); + }); + + it('renders markdown cell', () => { + expect(vm.$el.querySelector('.markdown')).not.toBeNull(); + }); + + it('renders code cell', () => { + expect(vm.$el.querySelector('pre')).not.toBeNull(); + }); + + it('add code class to code blocks', () => { + expect(vm.$el.querySelector('.js-code-class')).not.toBeNull(); + }); + }); +}); diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index a2c7f0b3767..dc68c4371aa 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -9,12 +9,7 @@ import CommentForm from '~/notes/components/comment_form.vue'; import * as constants from '~/notes/constants'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { keyboardDownEvent } from '../../issue_show/helpers'; -import { - loggedOutnoteableData, - notesDataMock, - userDataMock, - noteableDataMock, -} from '../../notes/mock_data'; +import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data'; jest.mock('autosize'); jest.mock('~/commons/nav/user_merge_requests'); diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js index 5101b81e3ee..44dc148933c 100644 --- a/spec/frontend/notes/components/discussion_actions_spec.js +++ b/spec/frontend/notes/components/discussion_actions_spec.js @@ -1,5 +1,5 @@ import { shallowMount, mount } from '@vue/test-utils'; -import { discussionMock } from '../../notes/mock_data'; +import { discussionMock } from '../mock_data'; import DiscussionActions from '~/notes/components/discussion_actions.vue'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; import ResolveDiscussionButton from '~/notes/components/discussion_resolve_button.vue'; diff --git a/spec/frontend/notes/components/discussion_counter_spec.js b/spec/frontend/notes/components/discussion_counter_spec.js index 77603c16f82..04535aa17c5 100644 --- a/spec/frontend/notes/components/discussion_counter_spec.js +++ b/spec/frontend/notes/components/discussion_counter_spec.js @@ -75,15 +75,14 @@ describe('DiscussionCounter component', () => { }); it.each` - title | resolved | isActive | icon | groupLength - ${'not allResolved'} | ${false} | ${false} | ${'check-circle'} | ${3} - ${'allResolved'} | ${true} | ${true} | ${'check-circle-filled'} | ${1} - `('renders correctly if $title', ({ resolved, isActive, icon, groupLength }) => { + title | resolved | isActive | groupLength + ${'not allResolved'} | ${false} | ${false} | ${3} + ${'allResolved'} | ${true} | ${true} | ${1} + `('renders correctly if $title', ({ resolved, isActive, groupLength }) => { updateStore({ resolvable: true, resolved }); wrapper = shallowMount(DiscussionCounter, { store, localVue }); expect(wrapper.find(`.is-active`).exists()).toBe(isActive); - expect(wrapper.find({ name: icon }).exists()).toBe(true); expect(wrapper.findAll('[role="group"').length).toBe(groupLength); }); }); diff --git a/spec/frontend/notes/components/discussion_filter_spec.js b/spec/frontend/notes/components/discussion_filter_spec.js index b8d2d721443..7f042c0e9de 100644 --- a/spec/frontend/notes/components/discussion_filter_spec.js +++ b/spec/frontend/notes/components/discussion_filter_spec.js @@ -1,4 +1,4 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; import Vuex from 'vuex'; import { createLocalVue, mount } from '@vue/test-utils'; @@ -132,7 +132,7 @@ describe('DiscussionFilter component', () => { }); describe('Merge request tabs', () => { - eventHub = new Vue(); + eventHub = createEventHub(); beforeEach(() => { window.mrTabs = { diff --git a/spec/frontend/notes/components/discussion_notes_spec.js b/spec/frontend/notes/components/discussion_notes_spec.js index 81773752037..5a10deefd09 100644 --- a/spec/frontend/notes/components/discussion_notes_spec.js +++ b/spec/frontend/notes/components/discussion_notes_spec.js @@ -7,7 +7,7 @@ import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue' import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue'; import SystemNote from '~/vue_shared/components/notes/system_note.vue'; import createStore from '~/notes/stores'; -import { noteableDataMock, discussionMock, notesDataMock } from '../../notes/mock_data'; +import { noteableDataMock, discussionMock, notesDataMock } from '../mock_data'; describe('DiscussionNotes', () => { let wrapper; diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index bccac03126c..8270c148fb5 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -161,18 +161,18 @@ describe('issue_note_form component', () => { describe('actions', () => { it('should be possible to cancel', () => { - // TODO: do not spy on vm - jest.spyOn(wrapper.vm, 'cancelHandler'); + const cancelHandler = jest.fn(); wrapper.setProps({ ...props, isEditing: true, }); + wrapper.setMethods({ cancelHandler }); return wrapper.vm.$nextTick().then(() => { - const cancelButton = wrapper.find('.note-edit-cancel'); + const cancelButton = wrapper.find('[data-testid="cancel"]'); cancelButton.trigger('click'); - expect(wrapper.vm.cancelHandler).toHaveBeenCalled(); + expect(cancelHandler).toHaveBeenCalledWith(true); }); }); diff --git a/spec/frontend/notes/components/note_header_spec.js b/spec/frontend/notes/components/note_header_spec.js index d477de69716..2bb08b60569 100644 --- a/spec/frontend/notes/components/note_header_spec.js +++ b/spec/frontend/notes/components/note_header_spec.js @@ -1,7 +1,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; import Vuex from 'vuex'; import NoteHeader from '~/notes/components/note_header.vue'; -import GitlabTeamMemberBadge from '~/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -18,6 +18,7 @@ describe('NoteHeader component', () => { const findActionText = () => wrapper.find({ ref: 'actionText' }); const findTimestampLink = () => wrapper.find({ ref: 'noteTimestampLink' }); const findTimestamp = () => wrapper.find({ ref: 'noteTimestamp' }); + const findConfidentialIndicator = () => wrapper.find('[data-testid="confidentialIndicator"]'); const findSpinner = () => wrapper.find({ ref: 'spinner' }); const author = { @@ -140,20 +141,6 @@ describe('NoteHeader component', () => { }); }); - test.each` - props | expected | message1 | message2 - ${{ author: { ...author, is_gitlab_employee: true } }} | ${true} | ${'renders'} | ${'true'} - ${{ author: { ...author, is_gitlab_employee: false } }} | ${false} | ${"doesn't render"} | ${'false'} - ${{ author }} | ${false} | ${"doesn't render"} | ${'undefined'} - `( - '$message1 GitLab team member badge when `is_gitlab_employee` is $message2', - ({ props, expected }) => { - createComponent(props); - - expect(wrapper.find(GitlabTeamMemberBadge).exists()).toBe(expected); - }, - ); - describe('loading spinner', () => { it('shows spinner when showSpinner is true', () => { createComponent(); @@ -179,4 +166,81 @@ describe('NoteHeader component', () => { expect(findTimestamp().exists()).toBe(true); }); }); + + describe('author username link', () => { + it('proxies `mouseenter` event to author name link', () => { + createComponent({ author }); + + const dispatchEvent = jest.spyOn(wrapper.vm.$refs.authorNameLink, 'dispatchEvent'); + + wrapper.find({ ref: 'authorUsernameLink' }).trigger('mouseenter'); + + expect(dispatchEvent).toHaveBeenCalledWith(new Event('mouseenter')); + }); + + it('proxies `mouseleave` event to author name link', () => { + createComponent({ author }); + + const dispatchEvent = jest.spyOn(wrapper.vm.$refs.authorNameLink, 'dispatchEvent'); + + wrapper.find({ ref: 'authorUsernameLink' }).trigger('mouseleave'); + + expect(dispatchEvent).toHaveBeenCalledWith(new Event('mouseleave')); + }); + }); + + describe('when author status tooltip is opened', () => { + it('removes `title` attribute from emoji to prevent duplicate tooltips', () => { + createComponent({ + author: { + ...author, + status_tooltip_html: + '"<span class="user-status-emoji has-tooltip" title="foo bar" data-html="true" data-placement="top"><gl-emoji title="basketball and hoop" data-name="basketball" data-unicode-version="6.0">🏀</gl-emoji></span>"', + }, + }); + + return nextTick().then(() => { + const authorStatus = wrapper.find({ ref: 'authorStatus' }); + authorStatus.trigger('mouseenter'); + + expect(authorStatus.find('gl-emoji').attributes('title')).toBeUndefined(); + }); + }); + }); + + describe('when author username link is hovered', () => { + it('toggles hover specific CSS classes on author name link', done => { + createComponent({ author }); + + const authorUsernameLink = wrapper.find({ ref: 'authorUsernameLink' }); + const authorNameLink = wrapper.find({ ref: 'authorNameLink' }); + + authorUsernameLink.trigger('mouseenter'); + + nextTick(() => { + expect(authorNameLink.classes()).toContain('hover'); + expect(authorNameLink.classes()).toContain('text-underline'); + + authorUsernameLink.trigger('mouseleave'); + + nextTick(() => { + expect(authorNameLink.classes()).not.toContain('hover'); + expect(authorNameLink.classes()).not.toContain('text-underline'); + + done(); + }); + }); + }); + }); + + describe('with confidentiality indicator', () => { + it.each` + status | condition + ${true} | ${'shows'} + ${false} | ${'hides'} + `('$condition icon indicator when isConfidential is $status', ({ status }) => { + createComponent({ isConfidential: status }); + expect(findConfidentialIndicator().exists()).toBe(status); + }); + }); }); diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js index b91f599f158..b14ec2a65be 100644 --- a/spec/frontend/notes/components/noteable_discussion_spec.js +++ b/spec/frontend/notes/components/noteable_discussion_spec.js @@ -138,7 +138,7 @@ describe('noteable_discussion component', () => { describe('signout widget', () => { beforeEach(() => { - originalGon = Object.assign({}, window.gon); + originalGon = { ...window.gon }; window.gon = window.gon || {}; }); diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js index e22dd85f221..fbfba2efb1d 100644 --- a/spec/frontend/notes/components/notes_app_spec.js +++ b/spec/frontend/notes/components/notes_app_spec.js @@ -10,7 +10,7 @@ import createStore from '~/notes/stores'; import * as constants from '~/notes/constants'; import '~/behaviors/markdown/render_gfm'; // TODO: use generated fixture (https://gitlab.com/gitlab-org/gitlab-foss/issues/62491) -import * as mockData from '../../notes/mock_data'; +import * as mockData from '../mock_data'; import * as urlUtility from '~/lib/utils/url_utility'; import OrderedLayout from '~/vue_shared/components/ordered_layout.vue'; diff --git a/spec/frontend/notes/mixins/discussion_navigation_spec.js b/spec/frontend/notes/mixins/discussion_navigation_spec.js index 4e5325b8bc3..120de023099 100644 --- a/spec/frontend/notes/mixins/discussion_navigation_spec.js +++ b/spec/frontend/notes/mixins/discussion_navigation_spec.js @@ -3,6 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import * as utils from '~/lib/utils/common_utils'; import discussionNavigation from '~/notes/mixins/discussion_navigation'; import eventHub from '~/notes/event_hub'; +import createEventHub from '~/helpers/event_hub_factory'; import notesModule from '~/notes/stores/modules'; import { setHTMLFixture } from 'helpers/fixtures'; @@ -67,8 +68,7 @@ describe('Discussion navigation mixin', () => { describe('cycle through discussions', () => { beforeEach(() => { - // eslint-disable-next-line new-cap - window.mrTabs = { eventHub: new localVue(), tabShown: jest.fn() }; + window.mrTabs = { eventHub: createEventHub(), tabShown: jest.fn() }; }); describe.each` diff --git a/spec/frontend/notes/mock_data.js b/spec/frontend/notes/mock_data.js index 9ed79c61c22..980faac2b04 100644 --- a/spec/frontend/notes/mock_data.js +++ b/spec/frontend/notes/mock_data.js @@ -57,6 +57,7 @@ export const noteableDataMock = { updated_by_id: 1, web_url: '/gitlab-org/gitlab-foss/issues/26', noteableType: 'issue', + blocked_by_issues: [], }; export const lastFetchedAt = '1501862675'; diff --git a/spec/frontend/notes/old_notes_spec.js b/spec/frontend/notes/old_notes_spec.js index 49b887b21b4..cb1d563ece7 100644 --- a/spec/frontend/notes/old_notes_spec.js +++ b/spec/frontend/notes/old_notes_spec.js @@ -33,7 +33,6 @@ gl.utils.disableButtonIfEmptyField = () => {}; // eslint-disable-next-line jest/no-disabled-tests describe.skip('Old Notes (~/notes.js)', () => { beforeEach(() => { - jest.useFakeTimers(); loadFixtures(fixture); // Re-declare this here so that test_setup.js#beforeEach() doesn't @@ -194,7 +193,7 @@ describe.skip('Old Notes (~/notes.js)', () => { $('.js-comment-button').click(); const $targetNote = $notesContainer.find(`#note_${noteEntity.id}`); - const updatedNote = Object.assign({}, noteEntity); + const updatedNote = { ...noteEntity }; updatedNote.note = 'bar'; notes.updateNote(updatedNote, $targetNote); @@ -213,13 +212,6 @@ describe.skip('Old Notes (~/notes.js)', () => { jest.spyOn($note, 'toggleClass'); }); - afterEach(() => { - expect(typeof urlUtility.getLocationHash.mock).toBe('object'); - urlUtility.getLocationHash.mockRestore(); - expect(urlUtility.getLocationHash.mock).toBeUndefined(); - expect(urlUtility.getLocationHash()).toBeNull(); - }); - // urlUtility is a dependency of the notes module. Its getLocatinHash() method should be called internally. it('sets target when hash matches', () => { @@ -630,48 +622,6 @@ describe.skip('Old Notes (~/notes.js)', () => { done(); }); }); - - // This is a bad test carried over from the Karma -> Jest migration. - // The corresponding test in the Karma suite tests for - // elements and methods that don't actually exist, and gives a false - // positive pass. - /* - it('should show flash error message when comment failed to be updated', done => { - mockNotesPost(); - jest.spyOn(notes, 'addFlash').mockName('addFlash'); - - $('.js-comment-button').click(); - - deferredPromise() - .then(() => { - const $noteEl = $notesContainer.find(`#note_${note.id}`); - $noteEl.find('.js-note-edit').click(); - $noteEl.find('textarea.js-note-text').val(updatedComment); - - mockNotesPostError(); - - $noteEl.find('.js-comment-save-button').click(); - notes.updateComment({preventDefault: () => {}}); - }) - .then(() => deferredPromise()) - .then(() => { - const $updatedNoteEl = $notesContainer.find(`#note_${note.id}`); - - expect($updatedNoteEl.hasClass('.being-posted')).toEqual(false); // Remove being-posted visuals - expect( - $updatedNoteEl - .find('.note-text') - .text() - .trim(), - ).toEqual(sampleComment); // See if comment reverted back to original - - expect(notes.addFlash).toHaveBeenCalled(); - expect(notes.flashContainer.style.display).not.toBe('none'); - done(); - }) - .catch(done.fail); - }, 5000); - */ }); describe('postComment with Slash commands', () => { diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 544d482e7fc..cbfb9597159 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -34,6 +34,11 @@ describe('Actions Notes Store', () => { dispatch = jest.fn(); state = {}; axiosMock = new AxiosMockAdapter(axios); + + // This is necessary as we query Close issue button at the top of issue page when clicking bottom button + setFixtures( + '<div class="detail-page-header-actions"><button class="btn-close btn-grouped"></button></div>', + ); }); afterEach(() => { @@ -242,9 +247,31 @@ describe('Actions Notes Store', () => { }); }); - describe('poll', () => { - jest.useFakeTimers(); + describe('toggleBlockedIssueWarning', () => { + it('should set issue warning as true', done => { + testAction( + actions.toggleBlockedIssueWarning, + true, + {}, + [{ type: 'TOGGLE_BLOCKED_ISSUE_WARNING', payload: true }], + [], + done, + ); + }); + it('should set issue warning as false', done => { + testAction( + actions.toggleBlockedIssueWarning, + false, + {}, + [{ type: 'TOGGLE_BLOCKED_ISSUE_WARNING', payload: false }], + [], + done, + ); + }); + }); + + describe('poll', () => { beforeEach(done => { jest.spyOn(axios, 'get'); diff --git a/spec/frontend/notes/stores/collapse_utils_spec.js b/spec/frontend/notes/stores/collapse_utils_spec.js index d3019f4b9a4..a74809eed79 100644 --- a/spec/frontend/notes/stores/collapse_utils_spec.js +++ b/spec/frontend/notes/stores/collapse_utils_spec.js @@ -18,9 +18,7 @@ describe('Collapse utils', () => { }); it('returns false when a system note is not a description type', () => { - expect(isDescriptionSystemNote(Object.assign({}, mockSystemNote, { note: 'foo' }))).toEqual( - false, - ); + expect(isDescriptionSystemNote({ ...mockSystemNote, note: 'foo' })).toEqual(false); }); it('gets the time difference between two notes', () => { diff --git a/spec/frontend/notes/stores/mutation_spec.js b/spec/frontend/notes/stores/mutation_spec.js index 06d2654ceca..27e3490d64b 100644 --- a/spec/frontend/notes/stores/mutation_spec.js +++ b/spec/frontend/notes/stores/mutation_spec.js @@ -50,7 +50,7 @@ describe('Notes Store mutations', () => { }); describe('ADD_NEW_REPLY_TO_DISCUSSION', () => { - const newReply = Object.assign({}, note, { discussion_id: discussionMock.id }); + const newReply = { ...note, discussion_id: discussionMock.id }; let state; @@ -86,7 +86,7 @@ describe('Notes Store mutations', () => { describe('EXPAND_DISCUSSION', () => { it('should expand a collapsed discussion', () => { - const discussion = Object.assign({}, discussionMock, { expanded: false }); + const discussion = { ...discussionMock, expanded: false }; const state = { discussions: [discussion], @@ -100,7 +100,7 @@ describe('Notes Store mutations', () => { describe('COLLAPSE_DISCUSSION', () => { it('should collapse an expanded discussion', () => { - const discussion = Object.assign({}, discussionMock, { expanded: true }); + const discussion = { ...discussionMock, expanded: true }; const state = { discussions: [discussion], @@ -114,7 +114,7 @@ describe('Notes Store mutations', () => { describe('REMOVE_PLACEHOLDER_NOTES', () => { it('should remove all placeholder notes in indivudal notes and discussion', () => { - const placeholderNote = Object.assign({}, individualNote, { isPlaceholderNote: true }); + const placeholderNote = { ...individualNote, isPlaceholderNote: true }; const state = { discussions: [placeholderNote] }; mutations.REMOVE_PLACEHOLDER_NOTES(state); @@ -298,7 +298,7 @@ describe('Notes Store mutations', () => { describe('TOGGLE_DISCUSSION', () => { it('should open a closed discussion', () => { - const discussion = Object.assign({}, discussionMock, { expanded: false }); + const discussion = { ...discussionMock, expanded: false }; const state = { discussions: [discussion], @@ -348,8 +348,8 @@ describe('Notes Store mutations', () => { }); it('should open all closed discussions', () => { - const discussion1 = Object.assign({}, discussionMock, { id: 0, expanded: false }); - const discussion2 = Object.assign({}, discussionMock, { id: 1, expanded: true }); + const discussion1 = { ...discussionMock, id: 0, expanded: false }; + const discussion2 = { ...discussionMock, id: 1, expanded: true }; const discussionIds = [discussion1.id, discussion2.id]; const state = { discussions: [discussion1, discussion2] }; @@ -362,8 +362,8 @@ describe('Notes Store mutations', () => { }); it('should close all opened discussions', () => { - const discussion1 = Object.assign({}, discussionMock, { id: 0, expanded: false }); - const discussion2 = Object.assign({}, discussionMock, { id: 1, expanded: true }); + const discussion1 = { ...discussionMock, id: 0, expanded: false }; + const discussion2 = { ...discussionMock, id: 1, expanded: true }; const discussionIds = [discussion1.id, discussion2.id]; const state = { discussions: [discussion1, discussion2] }; @@ -382,7 +382,7 @@ describe('Notes Store mutations', () => { discussions: [individualNote], }; - const updated = Object.assign({}, individualNote.notes[0], { note: 'Foo' }); + const updated = { ...individualNote.notes[0], note: 'Foo' }; mutations.UPDATE_NOTE(state, updated); @@ -664,4 +664,40 @@ describe('Notes Store mutations', () => { expect(state.discussionSortOrder).toBe(DESC); }); }); + + describe('TOGGLE_BLOCKED_ISSUE_WARNING', () => { + it('should set isToggleBlockedIssueWarning as true', () => { + const state = { + discussions: [], + targetNoteHash: null, + lastFetchedAt: null, + isToggleStateButtonLoading: false, + isToggleBlockedIssueWarning: false, + notesData: {}, + userData: {}, + noteableData: {}, + }; + + mutations.TOGGLE_BLOCKED_ISSUE_WARNING(state, true); + + expect(state.isToggleBlockedIssueWarning).toEqual(true); + }); + + it('should set isToggleBlockedIssueWarning as false', () => { + const state = { + discussions: [], + targetNoteHash: null, + lastFetchedAt: null, + isToggleStateButtonLoading: false, + isToggleBlockedIssueWarning: true, + notesData: {}, + userData: {}, + noteableData: {}, + }; + + mutations.TOGGLE_BLOCKED_ISSUE_WARNING(state, false); + + expect(state.isToggleBlockedIssueWarning).toEqual(false); + }); + }); }); diff --git a/spec/frontend/oauth_remember_me_spec.js b/spec/frontend/oauth_remember_me_spec.js new file mode 100644 index 00000000000..381be82697e --- /dev/null +++ b/spec/frontend/oauth_remember_me_spec.js @@ -0,0 +1,39 @@ +import $ from 'jquery'; +import OAuthRememberMe from '~/pages/sessions/new/oauth_remember_me'; + +describe('OAuthRememberMe', () => { + preloadFixtures('static/oauth_remember_me.html'); + + beforeEach(() => { + loadFixtures('static/oauth_remember_me.html'); + + new OAuthRememberMe({ container: $('#oauth-container') }).bindEvents(); + }); + + it('adds the "remember_me" query parameter to all OAuth login buttons', () => { + $('#oauth-container #remember_me').click(); + + expect($('#oauth-container .oauth-login.twitter').attr('href')).toBe( + 'http://example.com/?remember_me=1', + ); + + expect($('#oauth-container .oauth-login.github').attr('href')).toBe( + 'http://example.com/?remember_me=1', + ); + + expect($('#oauth-container .oauth-login.facebook').attr('href')).toBe( + 'http://example.com/?redirect_fragment=L1&remember_me=1', + ); + }); + + it('removes the "remember_me" query parameter from all OAuth login buttons', () => { + $('#oauth-container #remember_me').click(); + $('#oauth-container #remember_me').click(); + + expect($('#oauth-container .oauth-login.twitter').attr('href')).toBe('http://example.com/'); + expect($('#oauth-container .oauth-login.github').attr('href')).toBe('http://example.com/'); + expect($('#oauth-container .oauth-login.facebook').attr('href')).toBe( + 'http://example.com/?redirect_fragment=L1', + ); + }); +}); diff --git a/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js new file mode 100644 index 00000000000..6a239e307e9 --- /dev/null +++ b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js @@ -0,0 +1,36 @@ +import $ from 'jquery'; +import initUserInternalRegexPlaceholder, { + PLACEHOLDER_USER_EXTERNAL_DEFAULT_FALSE, + PLACEHOLDER_USER_EXTERNAL_DEFAULT_TRUE, +} from '~/pages/admin/application_settings/account_and_limits'; + +describe('AccountAndLimits', () => { + const FIXTURE = 'application_settings/accounts_and_limit.html'; + let $userDefaultExternal; + let $userInternalRegex; + preloadFixtures(FIXTURE); + + beforeEach(() => { + loadFixtures(FIXTURE); + initUserInternalRegexPlaceholder(); + $userDefaultExternal = $('#application_setting_user_default_external'); + $userInternalRegex = document.querySelector('#application_setting_user_default_internal_regex'); + }); + + describe('Changing of userInternalRegex when userDefaultExternal', () => { + it('is unchecked', () => { + expect($userDefaultExternal.prop('checked')).toBeFalsy(); + expect($userInternalRegex.placeholder).toEqual(PLACEHOLDER_USER_EXTERNAL_DEFAULT_FALSE); + expect($userInternalRegex.readOnly).toBeTruthy(); + }); + + it('is checked', done => { + if (!$userDefaultExternal.prop('checked')) $userDefaultExternal.click(); + + expect($userDefaultExternal.prop('checked')).toBeTruthy(); + expect($userInternalRegex.placeholder).toEqual(PLACEHOLDER_USER_EXTERNAL_DEFAULT_TRUE); + expect($userInternalRegex.readOnly).toBeFalsy(); + done(); + }); + }); +}); 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 new file mode 100644 index 00000000000..fe17c03389c --- /dev/null +++ b/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js @@ -0,0 +1,64 @@ +import Vue from 'vue'; +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'; + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + redirectTo: jest.fn(), +})); + +describe('stop_jobs_modal.vue', () => { + const props = { + url: `${gl.TEST_HOST}/stop_jobs_modal.vue/stopAll`, + }; + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + beforeEach(() => { + const Component = Vue.extend(stopJobsModal); + vm = mountComponent(Component, props); + }); + + describe('onSubmit', () => { + it('stops jobs and redirects to overview page', done => { + const responseURL = `${gl.TEST_HOST}/stop_jobs_modal.vue/jobs`; + jest.spyOn(axios, 'post').mockImplementation(url => { + expect(url).toBe(props.url); + return Promise.resolve({ + request: { + responseURL, + }, + }); + }); + + vm.onSubmit() + .then(() => { + expect(redirectTo).toHaveBeenCalledWith(responseURL); + }) + .then(done) + .catch(done.fail); + }); + + it('displays error if stopping jobs failed', done => { + const dummyError = new Error('stopping jobs failed'); + jest.spyOn(axios, 'post').mockImplementation(url => { + expect(url).toBe(props.url); + return Promise.reject(dummyError); + }); + + vm.onSubmit() + .then(done.fail) + .catch(error => { + expect(error).toBe(dummyError); + expect(redirectTo).not.toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap index ea3bedf59e0..82589e5147c 100644 --- a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap +++ b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap @@ -37,7 +37,6 @@ exports[`User Operation confirmation modal renders modal with form included 1`] value="" /> </form> - <gl-deprecated-button-stub size="md" variant="secondary" diff --git a/spec/frontend/pages/admin/users/new/index_spec.js b/spec/frontend/pages/admin/users/new/index_spec.js new file mode 100644 index 00000000000..3896323eef7 --- /dev/null +++ b/spec/frontend/pages/admin/users/new/index_spec.js @@ -0,0 +1,43 @@ +import $ from 'jquery'; +import UserInternalRegexHandler from '~/pages/admin/users/new/index'; + +describe('UserInternalRegexHandler', () => { + const FIXTURE = 'admin/users/new_with_internal_user_regex.html'; + let $userExternal; + let $userEmail; + let $warningMessage; + + preloadFixtures(FIXTURE); + + beforeEach(() => { + loadFixtures(FIXTURE); + // eslint-disable-next-line no-new + new UserInternalRegexHandler(); + $userExternal = $('#user_external'); + $userEmail = $('#user_email'); + $warningMessage = $('#warning_external_automatically_set'); + if (!$userExternal.prop('checked')) $userExternal.prop('checked', 'checked'); + }); + + describe('Behaviour of userExternal checkbox when', () => { + it('matches email as internal', done => { + expect($warningMessage.hasClass('hidden')).toBeTruthy(); + + $userEmail.val('test@').trigger('input'); + + expect($userExternal.prop('checked')).toBeFalsy(); + expect($warningMessage.hasClass('hidden')).toBeFalsy(); + done(); + }); + + it('matches email as external', done => { + expect($warningMessage.hasClass('hidden')).toBeTruthy(); + + $userEmail.val('test.ext@').trigger('input'); + + expect($userExternal.prop('checked')).toBeTruthy(); + expect($warningMessage.hasClass('hidden')).toBeTruthy(); + done(); + }); + }); +}); diff --git a/spec/frontend/pages/labels/components/promote_label_modal_spec.js b/spec/frontend/pages/labels/components/promote_label_modal_spec.js new file mode 100644 index 00000000000..9d5beca70b5 --- /dev/null +++ b/spec/frontend/pages/labels/components/promote_label_modal_spec.js @@ -0,0 +1,103 @@ +import Vue from 'vue'; +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'; + +describe('Promote label modal', () => { + let vm; + const Component = Vue.extend(promoteLabelModal); + const labelMockData = { + labelTitle: 'Documentation', + labelColor: '#5cb85c', + labelTextColor: '#ffffff', + url: `${gl.TEST_HOST}/dummy/promote/labels`, + groupName: 'group', + }; + + describe('Modal title and description', () => { + beforeEach(() => { + vm = mountComponent(Component, labelMockData); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('contains the proper description', () => { + expect(vm.text).toContain( + `Promoting ${labelMockData.labelTitle} will make it available for all projects inside ${labelMockData.groupName}`, + ); + }); + + it('contains a label span with the color', () => { + const labelFromTitle = vm.$el.querySelector('.modal-header .label.color-label'); + + expect(labelFromTitle.style.backgroundColor).not.toBe(null); + expect(labelFromTitle.textContent).toContain(vm.labelTitle); + }); + }); + + describe('When requesting a label promotion', () => { + beforeEach(() => { + vm = mountComponent(Component, { + ...labelMockData, + }); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('redirects when a label is promoted', done => { + const responseURL = `${gl.TEST_HOST}/dummy/endpoint`; + jest.spyOn(axios, 'post').mockImplementation(url => { + expect(url).toBe(labelMockData.url); + expect(eventHub.$emit).toHaveBeenCalledWith( + 'promoteLabelModal.requestStarted', + labelMockData.url, + ); + return Promise.resolve({ + request: { + responseURL, + }, + }); + }); + + vm.onSubmit() + .then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', { + labelUrl: labelMockData.url, + successful: true, + }); + }) + .then(done) + .catch(done.fail); + }); + + it('displays an error if promoting a label failed', done => { + const dummyError = new Error('promoting label failed'); + dummyError.response = { status: 500 }; + jest.spyOn(axios, 'post').mockImplementation(url => { + expect(url).toBe(labelMockData.url); + expect(eventHub.$emit).toHaveBeenCalledWith( + 'promoteLabelModal.requestStarted', + labelMockData.url, + ); + return Promise.reject(dummyError); + }); + + vm.onSubmit() + .catch(error => { + expect(error).toBe(dummyError); + expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', { + labelUrl: labelMockData.url, + successful: false, + }); + }) + .then(done) + .catch(done.fail); + }); + }); +}); 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 new file mode 100644 index 00000000000..ff5dc6d8988 --- /dev/null +++ b/spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js @@ -0,0 +1,109 @@ +import Vue from 'vue'; +import { redirectTo } from '~/lib/utils/url_utility'; +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'; + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + redirectTo: jest.fn(), +})); + +describe('delete_milestone_modal.vue', () => { + const Component = Vue.extend(deleteMilestoneModal); + const props = { + issueCount: 1, + mergeRequestCount: 2, + milestoneId: 3, + milestoneTitle: 'my milestone title', + milestoneUrl: `${gl.TEST_HOST}/delete_milestone_modal.vue/milestone`, + }; + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + describe('onSubmit', () => { + beforeEach(() => { + vm = mountComponent(Component, props); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + }); + + it('deletes milestone and redirects to overview page', done => { + const responseURL = `${gl.TEST_HOST}/delete_milestone_modal.vue/milestoneOverview`; + jest.spyOn(axios, 'delete').mockImplementation(url => { + expect(url).toBe(props.milestoneUrl); + expect(eventHub.$emit).toHaveBeenCalledWith( + 'deleteMilestoneModal.requestStarted', + props.milestoneUrl, + ); + eventHub.$emit.mockReset(); + return Promise.resolve({ + request: { + responseURL, + }, + }); + }); + + vm.onSubmit() + .then(() => { + expect(redirectTo).toHaveBeenCalledWith(responseURL); + expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', { + milestoneUrl: props.milestoneUrl, + successful: true, + }); + }) + .then(done) + .catch(done.fail); + }); + + it('displays error if deleting milestone failed', done => { + const dummyError = new Error('deleting milestone failed'); + dummyError.response = { status: 418 }; + jest.spyOn(axios, 'delete').mockImplementation(url => { + expect(url).toBe(props.milestoneUrl); + expect(eventHub.$emit).toHaveBeenCalledWith( + 'deleteMilestoneModal.requestStarted', + props.milestoneUrl, + ); + eventHub.$emit.mockReset(); + return Promise.reject(dummyError); + }); + + vm.onSubmit() + .catch(error => { + expect(error).toBe(dummyError); + expect(redirectTo).not.toHaveBeenCalled(); + expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', { + milestoneUrl: props.milestoneUrl, + successful: false, + }); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('text', () => { + it('contains the issue and milestone count', () => { + vm = mountComponent(Component, props); + const value = vm.text; + + expect(value).toContain('remove it from 1 issue and 2 merge requests'); + }); + + it('contains neither issue nor milestone count', () => { + vm = mountComponent(Component, { + ...props, + issueCount: 0, + mergeRequestCount: 0, + }); + + const value = vm.text; + + expect(value).toContain('is not currently used'); + }); + }); +}); 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 new file mode 100644 index 00000000000..ff896354d96 --- /dev/null +++ b/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js @@ -0,0 +1,98 @@ +import Vue from 'vue'; +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'; + +describe('Promote milestone modal', () => { + let vm; + const Component = Vue.extend(promoteMilestoneModal); + const milestoneMockData = { + milestoneTitle: 'v1.0', + url: `${gl.TEST_HOST}/dummy/promote/milestones`, + groupName: 'group', + }; + + describe('Modal title and description', () => { + beforeEach(() => { + vm = mountComponent(Component, milestoneMockData); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('contains the proper description', () => { + expect(vm.text).toContain( + `Promoting ${milestoneMockData.milestoneTitle} will make it available for all projects inside ${milestoneMockData.groupName}.`, + ); + }); + + it('contains the correct title', () => { + expect(vm.title).toEqual('Promote v1.0 to group milestone?'); + }); + }); + + describe('When requesting a milestone promotion', () => { + beforeEach(() => { + vm = mountComponent(Component, { + ...milestoneMockData, + }); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('redirects when a milestone is promoted', done => { + const responseURL = `${gl.TEST_HOST}/dummy/endpoint`; + jest.spyOn(axios, 'post').mockImplementation(url => { + expect(url).toBe(milestoneMockData.url); + expect(eventHub.$emit).toHaveBeenCalledWith( + 'promoteMilestoneModal.requestStarted', + milestoneMockData.url, + ); + return Promise.resolve({ + request: { + responseURL, + }, + }); + }); + + vm.onSubmit() + .then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('promoteMilestoneModal.requestFinished', { + milestoneUrl: milestoneMockData.url, + successful: true, + }); + }) + .then(done) + .catch(done.fail); + }); + + it('displays an error if promoting a milestone failed', done => { + const dummyError = new Error('promoting milestone failed'); + dummyError.response = { status: 500 }; + jest.spyOn(axios, 'post').mockImplementation(url => { + expect(url).toBe(milestoneMockData.url); + expect(eventHub.$emit).toHaveBeenCalledWith( + 'promoteMilestoneModal.requestStarted', + milestoneMockData.url, + ); + return Promise.reject(dummyError); + }); + + vm.onSubmit() + .catch(error => { + expect(error).toBe(dummyError); + expect(eventHub.$emit).toHaveBeenCalledWith('promoteMilestoneModal.requestFinished', { + milestoneUrl: milestoneMockData.url, + successful: false, + }); + }) + .then(done) + .catch(done.fail); + }); + }); +}); 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 new file mode 100644 index 00000000000..9cc1d6eeb5a --- /dev/null +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js @@ -0,0 +1,154 @@ +import { shallowMount } from '@vue/test-utils'; +import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue'; + +describe('Interval Pattern Input Component', () => { + let oldWindowGl; + let wrapper; + + const mockHour = 4; + const mockWeekDayIndex = 1; + const mockDay = 1; + + const cronIntervalPresets = { + everyDay: `0 ${mockHour} * * *`, + 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 findCustomInput = () => wrapper.find('#schedule_cron'); + const selectEveryDayRadio = () => findEveryDayRadio().setChecked(); + const selectEveryWeekRadio = () => findEveryWeekRadio().setChecked(); + const selectEveryMonthRadio = () => findEveryMonthRadio().setChecked(); + const selectCustomRadio = () => findCustomRadio().trigger('click'); + + const createWrapper = (props = {}, data = {}) => { + if (wrapper) { + throw new Error('A wrapper already exists'); + } + + wrapper = shallowMount(IntervalPatternInput, { + propsData: { ...props }, + data() { + return { + randomHour: data?.hour || mockHour, + randomWeekDayIndex: mockWeekDayIndex, + randomDay: mockDay, + }; + }, + }); + }; + + beforeEach(() => { + oldWindowGl = window.gl; + window.gl = { + ...(window.gl || {}), + pipelineScheduleFieldErrors: { + updateFormValidityState: jest.fn(), + }, + }; + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + window.gl = oldWindowGl; + }); + + describe('the input field defaults', () => { + beforeEach(() => { + createWrapper(); + }); + + it('to a non empty string when no initial value is not passed', () => { + expect(findCustomInput()).not.toBe(''); + }); + }); + + describe('the input field', () => { + const initialCron = '0 * * * *'; + + beforeEach(() => { + createWrapper({ initialCronInterval: initialCron }); + }); + + it('is equal to the prop `initialCronInterval` when passed', () => { + expect(findCustomInput().element.value).toBe(initialCron); + }); + }); + + describe('The input field is enabled', () => { + beforeEach(() => { + createWrapper(); + }); + + it('when a default option is selected', () => { + selectEveryDayRadio(); + + return wrapper.vm.$nextTick().then(() => { + expect(findCustomInput().attributes('disabled')).toBeUndefined(); + }); + }); + + it('when the custom option is selected', () => { + selectCustomRadio(); + + return wrapper.vm.$nextTick().then(() => { + expect(findCustomInput().attributes('disabled')).toBeUndefined(); + }); + }); + }); + + describe('formattedTime computed property', () => { + it.each` + desc | hour | expectedValue + ${'returns a time in the afternoon if the value of `random time` is higher than 12'} | ${13} | ${'1:00pm'} + ${'returns a time in the morning if the value of `random time` is lower than 12'} | ${11} | ${'11:00am'} + ${'returns "12:00pm" if the value of `random time` is exactly 12'} | ${12} | ${'12:00pm'} + `('$desc', ({ hour, expectedValue }) => { + createWrapper({}, { hour }); + + expect(wrapper.vm.formattedTime).toBe(expectedValue); + }); + }); + + 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 }); + + act(); + + 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', () => { + const newValue = '0 * * * *'; + + findCustomInput().setValue(newValue); + + expect(wrapper.vm.cronInterval).toBe(newValue); + }); + + it('when value of input is one of the defaults, it selects the corresponding radio button', () => { + findCustomInput().setValue(cronIntervalPresets.everyWeek); + + expect(wrapper.vm.cronInterval).toBe(cronIntervalPresets.everyWeek); + }); + }); +}); diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js new file mode 100644 index 00000000000..5a61f9fca69 --- /dev/null +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js @@ -0,0 +1,114 @@ +import Vue from 'vue'; +import Cookies from 'js-cookie'; +import PipelineSchedulesCallout from '~/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue'; +import '~/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg'; + +jest.mock( + '~/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg', + () => '<svg></svg>', +); + +const PipelineSchedulesCalloutComponent = Vue.extend(PipelineSchedulesCallout); +const cookieKey = 'pipeline_schedules_callout_dismissed'; +const docsUrl = 'help/ci/scheduled_pipelines'; + +describe('Pipeline Schedule Callout', () => { + let calloutComponent; + + beforeEach(() => { + setFixtures(` + <div id='pipeline-schedules-callout' data-docs-url=${docsUrl}></div> + `); + }); + + describe('independent of cookies', () => { + beforeEach(() => { + calloutComponent = new PipelineSchedulesCalloutComponent().$mount(); + }); + + it('the component can be initialized', () => { + expect(calloutComponent).toBeDefined(); + }); + + it('correctly sets illustrationSvg', () => { + expect(calloutComponent.illustrationSvg).toContain('<svg'); + }); + + it('correctly sets docsUrl', () => { + expect(calloutComponent.docsUrl).toContain(docsUrl); + }); + }); + + describe(`when ${cookieKey} cookie is set`, () => { + beforeEach(() => { + Cookies.set(cookieKey, true); + calloutComponent = new PipelineSchedulesCalloutComponent().$mount(); + }); + + it('correctly sets calloutDismissed to true', () => { + expect(calloutComponent.calloutDismissed).toBe(true); + }); + + it('does not render the callout', () => { + expect(calloutComponent.$el.childNodes.length).toBe(0); + }); + }); + + describe('when cookie is not set', () => { + beforeEach(() => { + Cookies.remove(cookieKey); + calloutComponent = new PipelineSchedulesCalloutComponent().$mount(); + }); + + it('correctly sets calloutDismissed to false', () => { + expect(calloutComponent.calloutDismissed).toBe(false); + }); + + it('renders the callout container', () => { + expect(calloutComponent.$el.querySelector('.bordered-box')).not.toBeNull(); + }); + + it('renders the callout svg', () => { + expect(calloutComponent.$el.outerHTML).toContain('<svg'); + }); + + it('renders the callout title', () => { + expect(calloutComponent.$el.outerHTML).toContain('Scheduling Pipelines'); + }); + + it('renders the callout text', () => { + expect(calloutComponent.$el.outerHTML).toContain('runs pipelines in the future'); + }); + + it('renders the documentation url', () => { + expect(calloutComponent.$el.outerHTML).toContain(docsUrl); + }); + + it('updates calloutDismissed when close button is clicked', done => { + calloutComponent.$el.querySelector('#dismiss-callout-btn').click(); + + Vue.nextTick(() => { + expect(calloutComponent.calloutDismissed).toBe(true); + done(); + }); + }); + + it('#dismissCallout updates calloutDismissed', done => { + calloutComponent.dismissCallout(); + + Vue.nextTick(() => { + expect(calloutComponent.calloutDismissed).toBe(true); + done(); + }); + }); + + it('is hidden when close button is clicked', done => { + calloutComponent.$el.querySelector('#dismiss-callout-btn').click(); + + Vue.nextTick(() => { + expect(calloutComponent.$el.childNodes.length).toBe(0); + done(); + }); + }); + }); +}); diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js index 9c292fa0f2b..1f7eec567b8 100644 --- a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js +++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js @@ -23,6 +23,7 @@ const defaultProps = { lfsEnabled: true, emailsDisabled: false, packagesEnabled: true, + showDefaultAwardEmojis: true, }, canDisableEmails: true, canChangeVisibilityLevel: true, @@ -57,9 +58,6 @@ describe('Settings Panel', () => { return mountFn(settingsPanel, { propsData, - provide: { - glFeatures: { metricsDashboardVisibilitySwitchingAvailable: true }, - }, }); }; @@ -477,6 +475,18 @@ describe('Settings Panel', () => { }); }); + describe('Default award emojis', () => { + it('should show the "Show default award emojis" input', () => { + return wrapper.vm.$nextTick(() => { + expect( + wrapper + .find('input[name="project[project_setting_attributes][show_default_award_emojis]"]') + .exists(), + ).toBe(true); + }); + }); + }); + describe('Metrics dashboard', () => { it('should show the metrics dashboard access toggle', () => { return wrapper.vm.$nextTick(() => { @@ -489,15 +499,22 @@ describe('Settings Panel', () => { .find('[name="project[project_feature_attributes][metrics_dashboard_access_level]"]') .setValue(visibilityOptions.PUBLIC); - expect(wrapper.vm.metricsAccessLevel).toBe(visibilityOptions.PUBLIC); + expect(wrapper.vm.metricsDashboardAccessLevel).toBe(visibilityOptions.PUBLIC); }); it('should contain help text', () => { - wrapper = overrideCurrentSettings({ visibilityLevel: visibilityOptions.PRIVATE }); - expect(wrapper.find({ ref: 'metrics-visibility-settings' }).props().helpText).toEqual( 'With Metrics Dashboard you can visualize this project performance metrics', ); }); + + it('should disable the metrics visibility dropdown when the project visibility level changes to private', () => { + wrapper = overrideCurrentSettings({ visibilityLevel: visibilityOptions.PRIVATE }); + + const metricsSettingsRow = wrapper.find({ ref: 'metrics-visibility-settings' }); + + expect(wrapper.vm.metricsOptionsDropdownEnabled).toBe(true); + expect(metricsSettingsRow.find('select').attributes('disabled')).toEqual('disabled'); + }); }); }); diff --git a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js new file mode 100644 index 00000000000..1809e92e1d9 --- /dev/null +++ b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js @@ -0,0 +1,61 @@ +import $ from 'jquery'; +import preserveUrlFragment from '~/pages/sessions/new/preserve_url_fragment'; + +describe('preserve_url_fragment', () => { + preloadFixtures('sessions/new.html'); + + beforeEach(() => { + loadFixtures('sessions/new.html'); + }); + + it('adds the url fragment to all login and sign up form actions', () => { + preserveUrlFragment('#L65'); + + expect($('#new_user').attr('action')).toBe('http://test.host/users/sign_in#L65'); + expect($('#new_new_user').attr('action')).toBe('http://test.host/users#L65'); + }); + + it('does not add an empty url fragment to login and sign up form actions', () => { + preserveUrlFragment(); + + expect($('#new_user').attr('action')).toBe('http://test.host/users/sign_in'); + expect($('#new_new_user').attr('action')).toBe('http://test.host/users'); + }); + + it('does not add an empty query parameter to OmniAuth login buttons', () => { + preserveUrlFragment(); + + expect($('#oauth-login-cas3').attr('href')).toBe('http://test.host/users/auth/cas3'); + + expect($('.omniauth-container #oauth-login-auth0').attr('href')).toBe( + 'http://test.host/users/auth/auth0', + ); + }); + + describe('adds "redirect_fragment" query parameter to OmniAuth login buttons', () => { + it('when "remember_me" is not present', () => { + preserveUrlFragment('#L65'); + + expect($('#oauth-login-cas3').attr('href')).toBe( + 'http://test.host/users/auth/cas3?redirect_fragment=L65', + ); + + expect($('.omniauth-container #oauth-login-auth0').attr('href')).toBe( + 'http://test.host/users/auth/auth0?redirect_fragment=L65', + ); + }); + + it('when "remember-me" is present', () => { + $('a.omniauth-btn').attr('href', (i, href) => `${href}?remember_me=1`); + preserveUrlFragment('#L65'); + + expect($('#oauth-login-cas3').attr('href')).toBe( + 'http://test.host/users/auth/cas3?remember_me=1&redirect_fragment=L65', + ); + + expect($('#oauth-login-auth0').attr('href')).toBe( + 'http://test.host/users/auth/auth0?remember_me=1&redirect_fragment=L65', + ); + }); + }); +}); diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js new file mode 100644 index 00000000000..12c6fab9c41 --- /dev/null +++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js @@ -0,0 +1,97 @@ +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 { + users, + mockSearch, + pipelineWithStages, + branches, + mockBranchesAfterMap, +} from '../mock_data'; +import { GlFilteredSearch } from '@gitlab/ui'; + +describe('Pipelines filtered search', () => { + let wrapper; + let mock; + + const findFilteredSearch = () => wrapper.find(GlFilteredSearch); + const getSearchToken = type => + findFilteredSearch() + .props('availableTokens') + .find(token => token.type === type); + + const createComponent = () => { + wrapper = mount(PipelinesFilteredSearch, { + propsData: { + pipelines: [pipelineWithStages], + projectId: '21', + }, + attachToDocument: true, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + + jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); + jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches }); + + createComponent(); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + wrapper = null; + }); + + it('displays UI elements', () => { + expect(wrapper.isVueInstance()).toBe(true); + expect(wrapper.isEmpty()).toBe(false); + + expect(findFilteredSearch().exists()).toBe(true); + }); + + it('displays search tokens', () => { + expect(getSearchToken('username')).toMatchObject({ + type: 'username', + icon: 'user', + title: 'Trigger author', + unique: true, + triggerAuthors: users, + projectId: '21', + operators: [expect.objectContaining({ value: '=' })], + }); + + expect(getSearchToken('ref')).toMatchObject({ + type: 'ref', + icon: 'branch', + title: 'Branch name', + unique: true, + branches: mockBranchesAfterMap, + projectId: '21', + operators: [expect.objectContaining({ value: '=' })], + }); + }); + + it('fetches and sets project users', () => { + expect(Api.projectUsers).toHaveBeenCalled(); + + expect(wrapper.vm.projectUsers).toEqual(users); + }); + + it('fetches and sets branches', () => { + expect(Api.branches).toHaveBeenCalled(); + + expect(wrapper.vm.projectBranches).toEqual(mockBranchesAfterMap); + }); + + it('emits filterPipelines on submit with correct filter', () => { + findFilteredSearch().vm.$emit('submit', mockSearch); + + expect(wrapper.emitted('filterPipelines')).toBeTruthy(); + expect(wrapper.emitted('filterPipelines')[0]).toEqual([mockSearch]); + }); +}); diff --git a/spec/frontend/pipelines/graph/stage_column_component_spec.js b/spec/frontend/pipelines/graph/stage_column_component_spec.js index 88e56eee1d6..d32534326c5 100644 --- a/spec/frontend/pipelines/graph/stage_column_component_spec.js +++ b/spec/frontend/pipelines/graph/stage_column_component_spec.js @@ -26,7 +26,7 @@ describe('stage column component', () => { beforeEach(() => { const mockGroups = []; for (let i = 0; i < 3; i += 1) { - const mockedJob = Object.assign({}, mockJob); + const mockedJob = { ...mockJob }; mockedJob.id += i; mockGroups.push(mockedJob); } diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js new file mode 100644 index 00000000000..1c3a6c545a0 --- /dev/null +++ b/spec/frontend/pipelines/header_component_spec.js @@ -0,0 +1,116 @@ +import { shallowMount } from '@vue/test-utils'; +import HeaderComponent from '~/pipelines/components/header_component.vue'; +import CiHeader from '~/vue_shared/components/header_ci_component.vue'; +import eventHub from '~/pipelines/event_hub'; +import { GlModal } from '@gitlab/ui'; + +describe('Pipeline details header', () => { + let wrapper; + let glModalDirective; + + const threeWeeksAgo = new Date(); + threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); + + const findDeleteModal = () => wrapper.find(GlModal); + + const defaultProps = { + pipeline: { + details: { + status: { + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + details_path: 'path', + }, + }, + id: 123, + created_at: threeWeeksAgo.toISOString(), + user: { + web_url: 'path', + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatar_url: 'link', + }, + retry_path: 'retry', + cancel_path: 'cancel', + delete_path: 'delete', + }, + isLoading: false, + }; + + const createComponent = (props = {}) => { + glModalDirective = jest.fn(); + + wrapper = shallowMount(HeaderComponent, { + propsData: { + ...props, + }, + directives: { + glModal: { + bind(el, { value }) { + glModalDirective(value); + }, + }, + }, + }); + }; + + beforeEach(() => { + jest.spyOn(eventHub, '$emit'); + + createComponent(defaultProps); + }); + + afterEach(() => { + eventHub.$off(); + + wrapper.destroy(); + wrapper = null; + }); + + it('should render provided pipeline info', () => { + expect(wrapper.find(CiHeader).props()).toMatchObject({ + status: defaultProps.pipeline.details.status, + itemId: defaultProps.pipeline.id, + time: defaultProps.pipeline.created_at, + user: defaultProps.pipeline.user, + }); + }); + + describe('action buttons', () => { + it('should not trigger eventHub when nothing happens', () => { + expect(eventHub.$emit).not.toHaveBeenCalled(); + }); + + it('should call postAction when retry button action is clicked', () => { + wrapper.find('.js-retry-button').vm.$emit('click'); + + expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'retry'); + }); + + it('should call postAction when cancel button action is clicked', () => { + wrapper.find('.js-btn-cancel-pipeline').vm.$emit('click'); + + expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'cancel'); + }); + + it('does not show delete modal', () => { + expect(findDeleteModal()).not.toBeVisible(); + }); + + describe('when delete button action is clicked', () => { + it('displays delete modal', () => { + expect(findDeleteModal().props('modalId')).toBe(wrapper.vm.$options.DELETE_MODAL_ID); + expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.$options.DELETE_MODAL_ID); + }); + + it('should call delete when modal is submitted', () => { + findDeleteModal().vm.$emit('ok'); + + expect(eventHub.$emit).toHaveBeenCalledWith('headerDeleteAction', 'delete'); + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/linked_pipelines_mock.json b/spec/frontend/pipelines/linked_pipelines_mock.json new file mode 100644 index 00000000000..8ad19ef4865 --- /dev/null +++ b/spec/frontend/pipelines/linked_pipelines_mock.json @@ -0,0 +1,3536 @@ +{ + "id": 23211253, + "user": { + "id": 3585, + "name": "Achilleas Pipinellis", + "username": "axil", + "state": "active", + "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png", + "web_url": "https://gitlab.com/axil", + "status_tooltip_html": "\u003cspan class=\"user-status-emoji has-tooltip\" title=\"I like pizza\" data-html=\"true\" data-placement=\"top\"\u003e\u003cgl-emoji title=\"slice of pizza\" data-name=\"pizza\" data-unicode-version=\"6.0\"\u003e🍕\u003c/gl-emoji\u003e\u003c/span\u003e", + "path": "/axil" + }, + "active": false, + "coverage": null, + "source": "push", + "created_at": "2018-06-05T11:31:30.452Z", + "updated_at": "2018-10-31T16:35:31.305Z", + "path": "/gitlab-org/gitlab-runner/pipelines/23211253", + "flags": { + "latest": false, + "stuck": false, + "auto_devops": false, + "merge_request": false, + "yaml_errors": false, + "retryable": false, + "cancelable": false, + "failure_reason": false + }, + "details": { + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "duration": 53, + "finished_at": "2018-10-31T16:35:31.299Z", + "stages": [ + { + "name": "prebuild", + "title": "prebuild: passed", + "groups": [ + { + "name": "review-docs-deploy", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "manual play action", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469032", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play", + "method": "post", + "button_title": "Trigger this manual action" + } + }, + "jobs": [ + { + "id": 72469032, + "name": "review-docs-deploy", + "started": "2018-10-31T16:34:58.778Z", + "archived": false, + "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469032", + "retry_path": "/gitlab-org/gitlab-runner/-/jobs/72469032/retry", + "play_path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play", + "playable": true, + "scheduled": false, + "created_at": "2018-06-05T11:31:30.495Z", + "updated_at": "2018-10-31T16:35:31.251Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "manual play action", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469032", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play", + "method": "post", + "button_title": "Trigger this manual action" + } + } + } + ] + } + ], + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#prebuild", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/gitlab-org/gitlab-runner/pipelines/23211253#prebuild", + "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=prebuild" + }, + { + "name": "test", + "title": "test: passed", + "groups": [ + { + "name": "docs check links", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469033", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 72469033, + "name": "docs check links", + "started": "2018-06-05T11:31:33.240Z", + "archived": false, + "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469033", + "retry_path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry", + "playable": false, + "scheduled": false, + "created_at": "2018-06-05T11:31:30.627Z", + "updated_at": "2018-06-05T11:31:54.363Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469033", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + } + ], + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#test", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/gitlab-org/gitlab-runner/pipelines/23211253#test", + "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=test" + }, + { + "name": "cleanup", + "title": "cleanup: skipped", + "groups": [ + { + "name": "review-docs-cleanup", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual stop action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469034", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "stop", + "title": "Stop", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play", + "method": "post", + "button_title": "Stop this environment" + } + }, + "jobs": [ + { + "id": 72469034, + "name": "review-docs-cleanup", + "started": null, + "archived": false, + "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469034", + "play_path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play", + "playable": true, + "scheduled": false, + "created_at": "2018-06-05T11:31:30.760Z", + "updated_at": "2018-06-05T11:31:56.037Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual stop action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469034", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "stop", + "title": "Stop", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play", + "method": "post", + "button_title": "Stop this environment" + } + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#cleanup", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-org/gitlab-runner/pipelines/23211253#cleanup", + "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=cleanup" + } + ], + "artifacts": [], + "manual_actions": [ + { + "name": "review-docs-cleanup", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play", + "playable": true, + "scheduled": false + }, + { + "name": "review-docs-deploy", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play", + "playable": true, + "scheduled": false + } + ], + "scheduled_actions": [] + }, + "ref": { + "name": "docs/add-development-guide-to-readme", + "path": "/gitlab-org/gitlab-runner/commits/docs/add-development-guide-to-readme", + "tag": false, + "branch": true, + "merge_request": false + }, + "commit": { + "id": "8083eb0a920572214d0dccedd7981f05d535ad46", + "short_id": "8083eb0a", + "title": "Add link to development guide in readme", + "created_at": "2018-06-05T11:30:48.000Z", + "parent_ids": ["1d7cf79b5a1a2121b9474ac20d61c1b8f621289d"], + "message": "Add link to development guide in readme\n\nCloses https://gitlab.com/gitlab-org/gitlab-runner/issues/3122\n", + "author_name": "Achilleas Pipinellis", + "author_email": "axil@gitlab.com", + "authored_date": "2018-06-05T11:30:48.000Z", + "committer_name": "Achilleas Pipinellis", + "committer_email": "axil@gitlab.com", + "committed_date": "2018-06-05T11:30:48.000Z", + "author": { + "id": 3585, + "name": "Achilleas Pipinellis", + "username": "axil", + "state": "active", + "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png", + "web_url": "https://gitlab.com/axil", + "status_tooltip_html": null, + "path": "/axil" + }, + "author_gravatar_url": "https://secure.gravatar.com/avatar/1d37af00eec153a8333a4ce18e9aea41?s=80\u0026d=identicon", + "commit_url": "https://gitlab.com/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46", + "commit_path": "/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46" + }, + "project": { + "id": 1794617 + }, + "triggered_by": { + "id": 12, + "user": { + "id": 376774, + "name": "Alessio Caiazza", + "username": "nolith", + "state": "active", + "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png", + "web_url": "https://gitlab.com/nolith", + "status_tooltip_html": null, + "path": "/nolith" + }, + "active": false, + "coverage": null, + "source": "pipeline", + "path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "details": { + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "duration": 118, + "finished_at": "2018-10-31T16:41:40.615Z", + "stages": [ + { + "name": "build-images", + "title": "build-images: skipped", + "groups": [ + { + "name": "image:bootstrap", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Trigger this manual action" + } + }, + "jobs": [ + { + "id": 11421321982853, + "name": "image:bootstrap", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.704Z", + "updated_at": "2018-10-31T16:35:24.118Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Trigger this manual action" + } + } + } + ] + }, + { + "name": "image:builder-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Trigger this manual action" + } + }, + "jobs": [ + { + "id": 1149822131854, + "name": "image:builder-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.728Z", + "updated_at": "2018-10-31T16:35:24.070Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Trigger this manual action" + } + } + } + ] + }, + { + "name": "image:nginx-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Trigger this manual action" + } + }, + "jobs": [ + { + "id": 11498285523424, + "name": "image:nginx-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.753Z", + "updated_at": "2018-10-31T16:35:24.033Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Trigger this manual action" + } + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images" + }, + { + "name": "build", + "title": "build: failed", + "groups": [ + { + "name": "compile_dev", + "size": 1, + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/528/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 1149846949786, + "name": "compile_dev", + "started": "2018-10-31T16:39:41.598Z", + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:39:41.138Z", + "updated_at": "2018-10-31T16:41:40.072Z", + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/528/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "recoverable": false + } + ] + } + ], + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build" + }, + { + "name": "deploy", + "title": "deploy: skipped", + "groups": [ + { + "name": "review", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 11498282342357, + "name": "review", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.805Z", + "updated_at": "2018-10-31T16:41:40.569Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + }, + { + "name": "review_stop", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 114982858, + "name": "review_stop", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.840Z", + "updated_at": "2018-10-31T16:41:40.480Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy" + } + ], + "artifacts": [], + "manual_actions": [ + { + "name": "image:bootstrap", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:builder-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:nginx-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false + }, + { + "name": "review_stop", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play", + "playable": false, + "scheduled": false + } + ], + "scheduled_actions": [] + }, + "project": { + "id": 1794617, + "name": "Test", + "full_path": "/gitlab-com/gitlab-docs", + "full_name": "GitLab.com / GitLab Docs" + }, + "triggered_by": { + "id": 349932310342451, + "user": { + "id": 376774, + "name": "Alessio Caiazza", + "username": "nolith", + "state": "active", + "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png", + "web_url": "https://gitlab.com/nolith", + "status_tooltip_html": null, + "path": "/nolith" + }, + "active": false, + "coverage": null, + "source": "pipeline", + "path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "details": { + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "duration": 118, + "finished_at": "2018-10-31T16:41:40.615Z", + "stages": [ + { + "name": "build-images", + "title": "build-images: skipped", + "groups": [ + { + "name": "image:bootstrap", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Trigger this manual action" + } + }, + "jobs": [ + { + "id": 11421321982853, + "name": "image:bootstrap", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.704Z", + "updated_at": "2018-10-31T16:35:24.118Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Trigger this manual action" + } + } + } + ] + }, + { + "name": "image:builder-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Trigger this manual action" + } + }, + "jobs": [ + { + "id": 1149822131854, + "name": "image:builder-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.728Z", + "updated_at": "2018-10-31T16:35:24.070Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Trigger this manual action" + } + } + } + ] + }, + { + "name": "image:nginx-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Trigger this manual action" + } + }, + "jobs": [ + { + "id": 11498285523424, + "name": "image:nginx-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.753Z", + "updated_at": "2018-10-31T16:35:24.033Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Trigger this manual action" + } + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images" + }, + { + "name": "build", + "title": "build: failed", + "groups": [ + { + "name": "compile_dev", + "size": 1, + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 1149846949786, + "name": "compile_dev", + "started": "2018-10-31T16:39:41.598Z", + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:39:41.138Z", + "updated_at": "2018-10-31T16:41:40.072Z", + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "recoverable": false + } + ] + } + ], + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build" + }, + { + "name": "deploy", + "title": "deploy: skipped", + "groups": [ + { + "name": "review", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 11498282342357, + "name": "review", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.805Z", + "updated_at": "2018-10-31T16:41:40.569Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + }, + { + "name": "review_stop", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 114982858, + "name": "review_stop", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.840Z", + "updated_at": "2018-10-31T16:41:40.480Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy" + } + ], + "artifacts": [], + "manual_actions": [ + { + "name": "image:bootstrap", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:builder-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:nginx-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false + }, + { + "name": "review_stop", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play", + "playable": false, + "scheduled": false + } + ], + "scheduled_actions": [] + }, + "project": { + "id": 1794617, + "name": "GitLab Docs", + "full_path": "/gitlab-com/gitlab-docs", + "full_name": "GitLab.com / GitLab Docs" + } + }, + "triggered": [] + }, + "triggered": [ + { + "id": 34993051, + "user": { + "id": 376774, + "name": "Alessio Caiazza", + "username": "nolith", + "state": "active", + "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png", + "web_url": "https://gitlab.com/nolith", + "status_tooltip_html": null, + "path": "/nolith" + }, + "active": false, + "coverage": null, + "source": "pipeline", + "path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "details": { + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "duration": 118, + "finished_at": "2018-10-31T16:41:40.615Z", + "stages": [ + { + "name": "build-images", + "title": "build-images: skipped", + "groups": [ + { + "name": "image:bootstrap", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Trigger this manual action" + } + }, + "jobs": [ + { + "id": 114982853, + "name": "image:bootstrap", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.704Z", + "updated_at": "2018-10-31T16:35:24.118Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Trigger this manual action" + } + } + } + ] + }, + { + "name": "image:builder-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Trigger this manual action" + } + }, + "jobs": [ + { + "id": 114982854, + "name": "image:builder-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.728Z", + "updated_at": "2018-10-31T16:35:24.070Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Trigger this manual action" + } + } + } + ] + }, + { + "name": "image:nginx-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Trigger this manual action" + } + }, + "jobs": [ + { + "id": 114982855, + "name": "image:nginx-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.753Z", + "updated_at": "2018-10-31T16:35:24.033Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Trigger this manual action" + } + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images" + }, + { + "name": "build", + "title": "build: failed", + "groups": [ + { + "name": "compile_dev", + "size": 1, + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/528/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 114984694, + "name": "compile_dev", + "started": "2018-10-31T16:39:41.598Z", + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:39:41.138Z", + "updated_at": "2018-10-31T16:41:40.072Z", + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/528/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "recoverable": false + } + ] + } + ], + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build" + }, + { + "name": "deploy", + "title": "deploy: skipped", + "groups": [ + { + "name": "review", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 114982857, + "name": "review", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.805Z", + "updated_at": "2018-10-31T16:41:40.569Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + }, + { + "name": "review_stop", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 114982858, + "name": "review_stop", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.840Z", + "updated_at": "2018-10-31T16:41:40.480Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy" + } + ], + "artifacts": [], + "manual_actions": [ + { + "name": "image:bootstrap", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:builder-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:nginx-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false + }, + { + "name": "review_stop", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play", + "playable": false, + "scheduled": false + } + ], + "scheduled_actions": [] + }, + "project": { + "id": 1794617, + "name": "GitLab Docs", + "full_path": "/gitlab-com/gitlab-docs", + "full_name": "GitLab.com / GitLab Docs" + }, + "triggered": [{}] + }, + { + "id": 34993052, + "user": { + "id": 376774, + "name": "Alessio Caiazza", + "username": "nolith", + "state": "active", + "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png", + "web_url": "https://gitlab.com/nolith", + "status_tooltip_html": null, + "path": "/nolith" + }, + "active": false, + "coverage": null, + "source": "pipeline", + "path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "details": { + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "duration": 118, + "finished_at": "2018-10-31T16:41:40.615Z", + "stages": [ + { + "name": "build-images", + "title": "build-images: skipped", + "groups": [ + { + "name": "image:bootstrap", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Trigger this manual action" + } + }, + "jobs": [ + { + "id": 114982853, + "name": "image:bootstrap", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.704Z", + "updated_at": "2018-10-31T16:35:24.118Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Trigger this manual action" + } + } + } + ] + }, + { + "name": "image:builder-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Trigger this manual action" + } + }, + "jobs": [ + { + "id": 114982854, + "name": "image:builder-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.728Z", + "updated_at": "2018-10-31T16:35:24.070Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Trigger this manual action" + } + } + } + ] + }, + { + "name": "image:nginx-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Trigger this manual action" + } + }, + "jobs": [ + { + "id": 1224982855, + "name": "image:nginx-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.753Z", + "updated_at": "2018-10-31T16:35:24.033Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Trigger this manual action" + } + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images" + }, + { + "name": "build", + "title": "build: failed", + "groups": [ + { + "name": "compile_dev", + "size": 1, + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 1123984694, + "name": "compile_dev", + "started": "2018-10-31T16:39:41.598Z", + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:39:41.138Z", + "updated_at": "2018-10-31T16:41:40.072Z", + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "recoverable": false + } + ] + } + ], + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build" + }, + { + "name": "deploy", + "title": "deploy: skipped", + "groups": [ + { + "name": "review", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 1143232982857, + "name": "review", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.805Z", + "updated_at": "2018-10-31T16:41:40.569Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + }, + { + "name": "review_stop", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 114921313182858, + "name": "review_stop", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.840Z", + "updated_at": "2018-10-31T16:41:40.480Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy" + } + ], + "artifacts": [], + "manual_actions": [ + { + "name": "image:bootstrap", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:builder-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:nginx-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false + }, + { + "name": "review_stop", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play", + "playable": false, + "scheduled": false + } + ], + "scheduled_actions": [] + }, + "project": { + "id": 1794617, + "name": "GitLab Docs", + "full_path": "/gitlab-com/gitlab-docs", + "full_name": "GitLab.com / GitLab Docs" + }, + "triggered": [ + { + "id": 26, + "user": null, + "active": false, + "coverage": null, + "source": "push", + "created_at": "2019-01-06T17:48:37.599Z", + "updated_at": "2019-01-06T17:48:38.371Z", + "path": "/h5bp/html5-boilerplate/pipelines/26", + "flags": { + "latest": true, + "stuck": false, + "auto_devops": false, + "merge_request": false, + "yaml_errors": false, + "retryable": true, + "cancelable": false, + "failure_reason": false + }, + "details": { + "status": { + "icon": "status_warning", + "text": "passed", + "label": "passed with warnings", + "group": "success-with-warnings", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/pipelines/26", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "duration": null, + "finished_at": "2019-01-06T17:48:38.370Z", + "stages": [ + { + "name": "build", + "title": "build: passed", + "groups": [ + { + "name": "build:linux", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/526", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/526/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 526, + "name": "build:linux", + "started": "2019-01-06T08:48:20.236Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/526", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/526/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.806Z", + "updated_at": "2019-01-06T17:48:37.806Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/526", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/526/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "build:osx", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/527", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/527/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 527, + "name": "build:osx", + "started": "2019-01-06T07:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/527", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/527/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.846Z", + "updated_at": "2019-01-06T17:48:37.846Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/527", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/527/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + } + ], + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/pipelines/26#build", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/h5bp/html5-boilerplate/pipelines/26#build", + "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=build" + }, + { + "name": "test", + "title": "test: passed with warnings", + "groups": [ + { + "name": "jenkins", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": null, + "group": "success", + "tooltip": null, + "has_details": false, + "details_path": null, + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "jobs": [ + { + "id": 546, + "name": "jenkins", + "started": "2019-01-06T11:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/546", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.359Z", + "updated_at": "2019-01-06T17:48:38.359Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": null, + "group": "success", + "tooltip": null, + "has_details": false, + "details_path": null, + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + } + } + ] + }, + { + "name": "rspec:linux", + "size": 3, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": false, + "details_path": null, + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "jobs": [ + { + "id": 528, + "name": "rspec:linux 0 3", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/528", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/528/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.885Z", + "updated_at": "2019-01-06T17:48:37.885Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/528", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/528/retry", + "method": "post", + "button_title": "Retry this job" + } + } + }, + { + "id": 529, + "name": "rspec:linux 1 3", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/529", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/529/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.907Z", + "updated_at": "2019-01-06T17:48:37.907Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/529", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/529/retry", + "method": "post", + "button_title": "Retry this job" + } + } + }, + { + "id": 530, + "name": "rspec:linux 2 3", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/530", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/530/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.927Z", + "updated_at": "2019-01-06T17:48:37.927Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/530", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/530/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "rspec:osx", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/535", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/535/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 535, + "name": "rspec:osx", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/535", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/535/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.018Z", + "updated_at": "2019-01-06T17:48:38.018Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/535", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/535/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "rspec:windows", + "size": 3, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": false, + "details_path": null, + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "jobs": [ + { + "id": 531, + "name": "rspec:windows 0 3", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/531", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/531/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.944Z", + "updated_at": "2019-01-06T17:48:37.944Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/531", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/531/retry", + "method": "post", + "button_title": "Retry this job" + } + } + }, + { + "id": 532, + "name": "rspec:windows 1 3", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/532", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/532/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.962Z", + "updated_at": "2019-01-06T17:48:37.962Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/532", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/532/retry", + "method": "post", + "button_title": "Retry this job" + } + } + }, + { + "id": 534, + "name": "rspec:windows 2 3", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/534", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/534/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.999Z", + "updated_at": "2019-01-06T17:48:37.999Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/534", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/534/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "spinach:linux", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/536", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/536/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 536, + "name": "spinach:linux", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/536", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/536/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.050Z", + "updated_at": "2019-01-06T17:48:38.050Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/536", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/536/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "spinach:osx", + "size": 1, + "status": { + "icon": "status_warning", + "text": "failed", + "label": "failed (allowed to fail)", + "group": "failed-with-warnings", + "tooltip": "failed - (unknown failure) (allowed to fail)", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/537", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/537/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 537, + "name": "spinach:osx", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/537", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/537/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.069Z", + "updated_at": "2019-01-06T17:48:38.069Z", + "status": { + "icon": "status_warning", + "text": "failed", + "label": "failed (allowed to fail)", + "group": "failed-with-warnings", + "tooltip": "failed - (unknown failure) (allowed to fail)", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/537", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/537/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "callout_message": "There is an unknown failure, please try again", + "recoverable": true + } + ] + } + ], + "status": { + "icon": "status_warning", + "text": "passed", + "label": "passed with warnings", + "group": "success-with-warnings", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/pipelines/26#test", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/h5bp/html5-boilerplate/pipelines/26#test", + "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=test" + }, + { + "name": "security", + "title": "security: passed", + "groups": [ + { + "name": "container_scanning", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/541", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/541/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 541, + "name": "container_scanning", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/541", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/541/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.186Z", + "updated_at": "2019-01-06T17:48:38.186Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/541", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/541/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "dast", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/538", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/538/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 538, + "name": "dast", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/538", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/538/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.087Z", + "updated_at": "2019-01-06T17:48:38.087Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/538", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/538/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "dependency_scanning", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/540", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/540/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 540, + "name": "dependency_scanning", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/540", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/540/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.153Z", + "updated_at": "2019-01-06T17:48:38.153Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/540", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/540/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "sast", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/539", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/539/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 539, + "name": "sast", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/539", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/539/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.121Z", + "updated_at": "2019-01-06T17:48:38.121Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/539", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/539/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + } + ], + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/pipelines/26#security", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/h5bp/html5-boilerplate/pipelines/26#security", + "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=security" + }, + { + "name": "deploy", + "title": "deploy: passed", + "groups": [ + { + "name": "production", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/544", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 544, + "name": "production", + "started": null, + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/544", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.313Z", + "updated_at": "2019-01-06T17:48:38.313Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/544", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + }, + { + "name": "staging", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/542", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/542/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 542, + "name": "staging", + "started": "2019-01-06T11:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/542", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/542/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.219Z", + "updated_at": "2019-01-06T17:48:38.219Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/542", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/542/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "stop staging", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/543", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 543, + "name": "stop staging", + "started": null, + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/543", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.283Z", + "updated_at": "2019-01-06T17:48:38.283Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/543", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + } + ], + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/pipelines/26#deploy", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/h5bp/html5-boilerplate/pipelines/26#deploy", + "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=deploy" + }, + { + "name": "notify", + "title": "notify: passed", + "groups": [ + { + "name": "slack", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "manual play action", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/545", + "illustration": { + "image": "/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/h5bp/html5-boilerplate/-/jobs/545/play", + "method": "post", + "button_title": "Trigger this manual action" + } + }, + "jobs": [ + { + "id": 545, + "name": "slack", + "started": null, + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/545", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/545/retry", + "play_path": "/h5bp/html5-boilerplate/-/jobs/545/play", + "playable": true, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.341Z", + "updated_at": "2019-01-06T17:48:38.341Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "manual play action", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/545", + "illustration": { + "image": "/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/h5bp/html5-boilerplate/-/jobs/545/play", + "method": "post", + "button_title": "Trigger this manual action" + } + } + } + ] + } + ], + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/pipelines/26#notify", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/h5bp/html5-boilerplate/pipelines/26#notify", + "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=notify" + } + ], + "artifacts": [ + { + "name": "build:linux", + "expired": null, + "expire_at": null, + "path": "/h5bp/html5-boilerplate/-/jobs/526/artifacts/download", + "browse_path": "/h5bp/html5-boilerplate/-/jobs/526/artifacts/browse" + }, + { + "name": "build:osx", + "expired": null, + "expire_at": null, + "path": "/h5bp/html5-boilerplate/-/jobs/527/artifacts/download", + "browse_path": "/h5bp/html5-boilerplate/-/jobs/527/artifacts/browse" + } + ], + "manual_actions": [ + { + "name": "stop staging", + "path": "/h5bp/html5-boilerplate/-/jobs/543/play", + "playable": false, + "scheduled": false + }, + { + "name": "production", + "path": "/h5bp/html5-boilerplate/-/jobs/544/play", + "playable": false, + "scheduled": false + }, + { + "name": "slack", + "path": "/h5bp/html5-boilerplate/-/jobs/545/play", + "playable": true, + "scheduled": false + } + ], + "scheduled_actions": [] + }, + "ref": { + "name": "master", + "path": "/h5bp/html5-boilerplate/commits/master", + "tag": false, + "branch": true, + "merge_request": false + }, + "commit": { + "id": "bad98c453eab56d20057f3929989251d45cd1a8b", + "short_id": "bad98c45", + "title": "remove instances of shrink-to-fit=no (#2103)", + "created_at": "2018-12-17T20:52:18.000Z", + "parent_ids": ["49130f6cfe9ff1f749015d735649a2bc6f66cf3a"], + "message": "remove instances of shrink-to-fit=no (#2103)\n\ncloses #2102\r\n\r\nPer my findings, the need for it as a default was rectified with the release of iOS 9.3, where the viewport no longer shrunk to accommodate overflow, as was introduced in iOS 9.", + "author_name": "Scott O'Hara", + "author_email": "scottaohara@users.noreply.github.com", + "authored_date": "2018-12-17T20:52:18.000Z", + "committer_name": "Rob Larsen", + "committer_email": "rob@drunkenfist.com", + "committed_date": "2018-12-17T20:52:18.000Z", + "author": null, + "author_gravatar_url": "https://www.gravatar.com/avatar/6d597df7cf998d16cbe00ccac063b31e?s=80\u0026d=identicon", + "commit_url": "http://localhost:3001/h5bp/html5-boilerplate/commit/bad98c453eab56d20057f3929989251d45cd1a8b", + "commit_path": "/h5bp/html5-boilerplate/commit/bad98c453eab56d20057f3929989251d45cd1a8b" + }, + "retry_path": "/h5bp/html5-boilerplate/pipelines/26/retry", + "triggered_by": { + "id": 4, + "user": null, + "active": false, + "coverage": null, + "source": "push", + "path": "/gitlab-org/gitlab-test/pipelines/4", + "details": { + "status": { + "icon": "status_warning", + "text": "passed", + "label": "passed with warnings", + "group": "success-with-warnings", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-test/pipelines/4", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + } + }, + "project": { + "id": 1, + "name": "Gitlab Test", + "full_path": "/gitlab-org/gitlab-test", + "full_name": "Gitlab Org / Gitlab Test" + } + }, + "triggered": [], + "project": { + "id": 1794617, + "name": "GitLab Docs", + "full_path": "/gitlab-com/gitlab-docs", + "full_name": "GitLab.com / GitLab Docs" + } + } + ] + } + ] +} diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js new file mode 100644 index 00000000000..37c1e471415 --- /dev/null +++ b/spec/frontend/pipelines/mock_data.js @@ -0,0 +1,568 @@ +export const pipelineWithStages = { + id: 20333396, + user: { + id: 128633, + name: 'Rémy Coutable', + username: 'rymai', + state: 'active', + avatar_url: + 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon', + web_url: 'https://gitlab.com/rymai', + path: '/rymai', + }, + active: true, + coverage: '58.24', + source: 'push', + created_at: '2018-04-11T14:04:53.881Z', + updated_at: '2018-04-11T14:05:00.792Z', + path: '/gitlab-org/gitlab/pipelines/20333396', + flags: { + latest: true, + stuck: false, + auto_devops: false, + yaml_errors: false, + retryable: false, + cancelable: true, + failure_reason: false, + }, + details: { + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + has_details: true, + details_path: '/gitlab-org/gitlab/pipelines/20333396', + favicon: + 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_running-2eb56be2871937954b2ba6d6f4ee9fdf7e5e1c146ac45f7be98119ccaca1aca9.ico', + }, + duration: null, + finished_at: null, + stages: [ + { + name: 'build', + title: 'build: skipped', + status: { + icon: 'status_skipped', + text: 'skipped', + label: 'skipped', + group: 'skipped', + has_details: true, + details_path: '/gitlab-org/gitlab/pipelines/20333396#build', + favicon: + 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_skipped-a2eee568a5bffdb494050c7b62dde241de9189280836288ac8923d369f16222d.ico', + }, + path: '/gitlab-org/gitlab/pipelines/20333396#build', + dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=build', + }, + { + name: 'prepare', + title: 'prepare: passed', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/gitlab-org/gitlab/pipelines/20333396#prepare', + favicon: + 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_success-26f59841becbef8c6fe414e9e74471d8bfd6a91b5855c19fe7f5923a40a7da47.ico', + }, + path: '/gitlab-org/gitlab/pipelines/20333396#prepare', + dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=prepare', + }, + { + name: 'test', + title: 'test: running', + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + has_details: true, + details_path: '/gitlab-org/gitlab/pipelines/20333396#test', + favicon: + 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_running-2eb56be2871937954b2ba6d6f4ee9fdf7e5e1c146ac45f7be98119ccaca1aca9.ico', + }, + path: '/gitlab-org/gitlab/pipelines/20333396#test', + dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=test', + }, + { + name: 'post-test', + title: 'post-test: created', + status: { + icon: 'status_created', + text: 'created', + label: 'created', + group: 'created', + has_details: true, + details_path: '/gitlab-org/gitlab/pipelines/20333396#post-test', + favicon: + 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico', + }, + path: '/gitlab-org/gitlab/pipelines/20333396#post-test', + dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=post-test', + }, + { + name: 'pages', + title: 'pages: created', + status: { + icon: 'status_created', + text: 'created', + label: 'created', + group: 'created', + has_details: true, + details_path: '/gitlab-org/gitlab/pipelines/20333396#pages', + favicon: + 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico', + }, + path: '/gitlab-org/gitlab/pipelines/20333396#pages', + dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=pages', + }, + { + name: 'post-cleanup', + title: 'post-cleanup: created', + status: { + icon: 'status_created', + text: 'created', + label: 'created', + group: 'created', + has_details: true, + details_path: '/gitlab-org/gitlab/pipelines/20333396#post-cleanup', + favicon: + 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico', + }, + path: '/gitlab-org/gitlab/pipelines/20333396#post-cleanup', + dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=post-cleanup', + }, + ], + artifacts: [ + { + name: 'gitlab:assets:compile', + expired: false, + expire_at: '2018-05-12T14:22:54.730Z', + path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/download', + keep_path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/keep', + browse_path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/browse', + }, + { + name: 'rspec-mysql 12 28', + expired: false, + expire_at: '2018-05-12T14:22:45.136Z', + path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/download', + keep_path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/keep', + browse_path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/browse', + }, + { + name: 'rspec-mysql 6 28', + expired: false, + expire_at: '2018-05-12T14:22:41.523Z', + path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/download', + keep_path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/keep', + browse_path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/browse', + }, + { + name: 'rspec-pg geo 0 1', + expired: false, + expire_at: '2018-05-12T14:22:13.287Z', + path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/download', + keep_path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/keep', + browse_path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/browse', + }, + { + name: 'rspec-mysql 0 28', + expired: false, + expire_at: '2018-05-12T14:22:06.834Z', + path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/download', + keep_path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/keep', + browse_path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/browse', + }, + { + name: 'spinach-mysql 0 2', + expired: false, + expire_at: '2018-05-12T14:21:51.409Z', + path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/download', + keep_path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/keep', + browse_path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/browse', + }, + { + name: 'karma', + expired: false, + expire_at: '2018-05-12T14:21:20.934Z', + path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/download', + keep_path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/keep', + browse_path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/browse', + }, + { + name: 'spinach-pg 0 2', + expired: false, + expire_at: '2018-05-12T14:20:01.028Z', + path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/download', + keep_path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/keep', + browse_path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/browse', + }, + { + name: 'spinach-pg 1 2', + expired: false, + expire_at: '2018-05-12T14:19:04.336Z', + path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/download', + keep_path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/keep', + browse_path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/browse', + }, + { + name: 'sast', + expired: null, + expire_at: null, + path: '/gitlab-org/gitlab/-/jobs/62411442/artifacts/download', + browse_path: '/gitlab-org/gitlab/-/jobs/62411442/artifacts/browse', + }, + { + name: 'code_quality', + expired: false, + expire_at: '2018-04-18T14:16:24.484Z', + path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/download', + keep_path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/keep', + browse_path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/browse', + }, + { + name: 'cache gems', + expired: null, + expire_at: null, + path: '/gitlab-org/gitlab/-/jobs/62411447/artifacts/download', + browse_path: '/gitlab-org/gitlab/-/jobs/62411447/artifacts/browse', + }, + { + name: 'dependency_scanning', + expired: null, + expire_at: null, + path: '/gitlab-org/gitlab/-/jobs/62411443/artifacts/download', + browse_path: '/gitlab-org/gitlab/-/jobs/62411443/artifacts/browse', + }, + { + name: 'compile-assets', + expired: false, + expire_at: '2018-04-18T14:12:07.638Z', + path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/download', + keep_path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/keep', + browse_path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/browse', + }, + { + name: 'setup-test-env', + expired: false, + expire_at: '2018-04-18T14:10:27.024Z', + path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/download', + keep_path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/keep', + browse_path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/browse', + }, + { + name: 'retrieve-tests-metadata', + expired: false, + expire_at: '2018-05-12T14:06:35.926Z', + path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/download', + keep_path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/keep', + browse_path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/browse', + }, + ], + manual_actions: [ + { + name: 'package-and-qa', + path: '/gitlab-org/gitlab/-/jobs/62411330/play', + playable: true, + }, + { + name: 'review-docs-deploy', + path: '/gitlab-org/gitlab/-/jobs/62411332/play', + playable: true, + }, + ], + }, + ref: { + name: 'master', + path: '/gitlab-org/gitlab/commits/master', + tag: false, + branch: true, + }, + commit: { + id: 'e6a2885c503825792cb8a84a8731295e361bd059', + short_id: 'e6a2885c', + title: "Merge branch 'ce-to-ee-2018-04-11' into 'master'", + created_at: '2018-04-11T14:04:39.000Z', + parent_ids: [ + '5d9b5118f6055f72cff1a82b88133609912f2c1d', + '6fdc6ee76a8062fe41b1a33f7c503334a6ebdc02', + ], + message: + "Merge branch 'ce-to-ee-2018-04-11' into 'master'\n\nCE upstream - 2018-04-11 12:26 UTC\n\nSee merge request gitlab-org/gitlab-ee!5326", + author_name: 'Rémy Coutable', + author_email: 'remy@rymai.me', + authored_date: '2018-04-11T14:04:39.000Z', + committer_name: 'Rémy Coutable', + committer_email: 'remy@rymai.me', + committed_date: '2018-04-11T14:04:39.000Z', + author: { + id: 128633, + name: 'Rémy Coutable', + username: 'rymai', + state: 'active', + avatar_url: + 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon', + web_url: 'https://gitlab.com/rymai', + path: '/rymai', + }, + author_gravatar_url: + 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon', + commit_url: + 'https://gitlab.com/gitlab-org/gitlab/commit/e6a2885c503825792cb8a84a8731295e361bd059', + commit_path: '/gitlab-org/gitlab/commit/e6a2885c503825792cb8a84a8731295e361bd059', + }, + cancel_path: '/gitlab-org/gitlab/pipelines/20333396/cancel', + triggered_by: null, + triggered: [], +}; + +export const stageReply = { + name: 'deploy', + title: 'deploy: running', + latest_statuses: [ + { + id: 928, + name: 'stop staging', + started: false, + build_path: '/twitter/flight/-/jobs/928', + cancel_path: '/twitter/flight/-/jobs/928/cancel', + playable: false, + created_at: '2018-04-04T20:02:02.728Z', + updated_at: '2018-04-04T20:02:02.766Z', + status: { + icon: 'status_pending', + text: 'pending', + label: 'pending', + group: 'pending', + tooltip: 'pending', + has_details: true, + details_path: '/twitter/flight/-/jobs/928', + favicon: + '/assets/ci_favicons/dev/favicon_status_pending-db32e1faf94b9f89530ac519790920d1f18ea8f6af6cd2e0a26cd6840cacf101.ico', + action: { + icon: 'cancel', + title: 'Cancel', + path: '/twitter/flight/-/jobs/928/cancel', + method: 'post', + }, + }, + }, + { + id: 926, + name: 'production', + started: false, + build_path: '/twitter/flight/-/jobs/926', + retry_path: '/twitter/flight/-/jobs/926/retry', + play_path: '/twitter/flight/-/jobs/926/play', + playable: true, + created_at: '2018-04-04T20:00:57.202Z', + updated_at: '2018-04-04T20:11:13.110Z', + status: { + icon: 'status_canceled', + text: 'canceled', + label: 'manual play action', + group: 'canceled', + tooltip: 'canceled', + has_details: true, + details_path: '/twitter/flight/-/jobs/926', + favicon: + '/assets/ci_favicons/dev/favicon_status_canceled-5491840b9b6feafba0bc599cbd49ee9580321dc809683856cf1b0d51532b1af6.ico', + action: { + icon: 'play', + title: 'Play', + path: '/twitter/flight/-/jobs/926/play', + method: 'post', + }, + }, + }, + { + id: 217, + name: 'staging', + started: '2018-03-07T08:41:46.234Z', + build_path: '/twitter/flight/-/jobs/217', + retry_path: '/twitter/flight/-/jobs/217/retry', + playable: false, + created_at: '2018-03-07T14:41:58.093Z', + updated_at: '2018-03-07T14:41:58.093Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/twitter/flight/-/jobs/217', + favicon: + '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico', + action: { + icon: 'retry', + title: 'Retry', + path: '/twitter/flight/-/jobs/217/retry', + method: 'post', + }, + }, + }, + ], + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + tooltip: 'running', + has_details: true, + details_path: '/twitter/flight/pipelines/13#deploy', + favicon: + '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', + }, + path: '/twitter/flight/pipelines/13#deploy', + dropdown_path: '/twitter/flight/pipelines/13/stage.json?stage=deploy', +}; + +export const users = [ + { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://192.168.1.22:3000/root', + }, + { + id: 10, + name: 'Angel Spinka', + username: 'shalonda', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/709df1b65ad06764ee2b0edf1b49fc27?s=80\u0026d=identicon', + web_url: 'http://192.168.1.22:3000/shalonda', + }, + { + id: 11, + name: 'Art Davis', + username: 'deja.green', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/bb56834c061522760e7a6dd7d431a306?s=80\u0026d=identicon', + web_url: 'http://192.168.1.22:3000/deja.green', + }, + { + id: 32, + name: 'Arnold Mante', + username: 'reported_user_10', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/ab558033a82466d7905179e837d7723a?s=80\u0026d=identicon', + web_url: 'http://192.168.1.22:3000/reported_user_10', + }, + { + id: 38, + name: 'Cher Wintheiser', + username: 'reported_user_16', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/2640356e8b5bc4314133090994ed162b?s=80\u0026d=identicon', + web_url: 'http://192.168.1.22:3000/reported_user_16', + }, + { + id: 39, + name: 'Bethel Wolf', + username: 'reported_user_17', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/4b948694fadba4b01e4acfc06b065e8e?s=80\u0026d=identicon', + web_url: 'http://192.168.1.22:3000/reported_user_17', + }, +]; + +export const branches = [ + { + name: 'branch-1', + commit: { + id: '21fb056cc47dcf706670e6de635b1b326490ebdc', + short_id: '21fb056c', + created_at: '2020-05-07T10:58:28.000-04:00', + parent_ids: null, + title: 'Add new file', + message: 'Add new file', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2020-05-07T10:58:28.000-04:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2020-05-07T10:58:28.000-04:00', + web_url: + 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/21fb056cc47dcf706670e6de635b1b326490ebdc', + }, + merged: false, + protected: false, + developers_can_push: false, + developers_can_merge: false, + can_push: true, + default: false, + web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-1', + }, + { + name: 'branch-10', + commit: { + id: '66673b07efef254dab7d537f0433a40e61cf84fe', + short_id: '66673b07', + created_at: '2020-03-16T11:04:46.000-04:00', + parent_ids: null, + title: 'Update .gitlab-ci.yml', + message: 'Update .gitlab-ci.yml', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2020-03-16T11:04:46.000-04:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2020-03-16T11:04:46.000-04:00', + web_url: + 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe', + }, + merged: false, + protected: false, + developers_can_push: false, + developers_can_merge: false, + can_push: true, + default: false, + web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-10', + }, + { + name: 'branch-11', + commit: { + id: '66673b07efef254dab7d537f0433a40e61cf84fe', + short_id: '66673b07', + created_at: '2020-03-16T11:04:46.000-04:00', + parent_ids: null, + title: 'Update .gitlab-ci.yml', + message: 'Update .gitlab-ci.yml', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2020-03-16T11:04:46.000-04:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2020-03-16T11:04:46.000-04:00', + web_url: + 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe', + }, + merged: false, + protected: false, + developers_can_push: false, + developers_can_merge: false, + can_push: true, + default: false, + web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-11', + }, +]; + +export const mockSearch = [ + { type: 'username', value: { data: 'root', operator: '=' } }, + { type: 'ref', value: { data: 'master', operator: '=' } }, +]; + +export const mockBranchesAfterMap = ['branch-1', 'branch-10', 'branch-11']; diff --git a/spec/frontend/pipelines/pipeline_details_mediator_spec.js b/spec/frontend/pipelines/pipeline_details_mediator_spec.js new file mode 100644 index 00000000000..083e97666ed --- /dev/null +++ b/spec/frontend/pipelines/pipeline_details_mediator_spec.js @@ -0,0 +1,36 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import PipelineMediator from '~/pipelines/pipeline_details_mediator'; +import waitForPromises from 'helpers/wait_for_promises'; + +describe('PipelineMdediator', () => { + let mediator; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + mediator = new PipelineMediator({ endpoint: 'foo.json' }); + }); + + afterEach(() => { + mock.restore(); + }); + + it('should set defaults', () => { + expect(mediator.options).toEqual({ endpoint: 'foo.json' }); + expect(mediator.state.isLoading).toEqual(false); + expect(mediator.store).toBeDefined(); + expect(mediator.service).toBeDefined(); + }); + + describe('request and store data', () => { + it('should store received data', () => { + mock.onGet('foo.json').reply(200, { id: '121123' }); + mediator.fetchPipeline(); + + return waitForPromises().then(() => { + expect(mediator.store.state.pipeline).toEqual({ id: '121123' }); + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/pipelines_actions_spec.js b/spec/frontend/pipelines/pipelines_actions_spec.js new file mode 100644 index 00000000000..5e8d21660de --- /dev/null +++ b/spec/frontend/pipelines/pipelines_actions_spec.js @@ -0,0 +1,142 @@ +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 { GlDeprecatedButton } from '@gitlab/ui'; +import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; +import waitForPromises from 'helpers/wait_for_promises'; + +describe('Pipelines Actions dropdown', () => { + let wrapper; + let mock; + + const createComponent = (actions = []) => { + wrapper = shallowMount(PipelinesActions, { + propsData: { + actions, + }, + }); + }; + + const findAllDropdownItems = () => wrapper.findAll(GlDeprecatedButton); + const findAllCountdowns = () => wrapper.findAll(GlCountdown); + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + + mock.restore(); + }); + + describe('manual actions', () => { + const mockActions = [ + { + name: 'stop_review', + path: `${TEST_HOST}/root/review-app/builds/1893/play`, + }, + { + name: 'foo', + path: `${TEST_HOST}/disabled/pipeline/action`, + playable: false, + }, + ]; + + beforeEach(() => { + createComponent(mockActions); + }); + + it('renders a dropdown with the provided actions', () => { + expect(findAllDropdownItems()).toHaveLength(mockActions.length); + }); + + it("renders a disabled action when it's not playable", () => { + expect( + findAllDropdownItems() + .at(1) + .attributes('disabled'), + ).toBe('true'); + }); + + describe('on click', () => { + it('makes a request and toggles the loading state', () => { + mock.onPost(mockActions.path).reply(200); + + wrapper.find(GlDeprecatedButton).vm.$emit('click'); + + expect(wrapper.vm.isLoading).toBe(true); + + return waitForPromises().then(() => { + expect(wrapper.vm.isLoading).toBe(false); + }); + }); + }); + }); + + describe('scheduled jobs', () => { + const scheduledJobAction = { + name: 'scheduled action', + path: `${TEST_HOST}/scheduled/job/action`, + playable: true, + scheduled_at: '2063-04-05T00:42:00Z', + }; + const expiredJobAction = { + name: 'expired action', + path: `${TEST_HOST}/expired/job/action`, + playable: true, + scheduled_at: '2018-10-05T08:23:00Z', + }; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime()); + createComponent([scheduledJobAction, expiredJobAction]); + }); + + it('makes post request after confirming', () => { + mock.onPost(scheduledJobAction.path).reply(200); + jest.spyOn(window, 'confirm').mockReturnValue(true); + + findAllDropdownItems() + .at(0) + .vm.$emit('click'); + + expect(window.confirm).toHaveBeenCalled(); + + return waitForPromises().then(() => { + expect(mock.history.post.length).toBe(1); + }); + }); + + it('does not make post request if confirmation is cancelled', () => { + mock.onPost(scheduledJobAction.path).reply(200); + jest.spyOn(window, 'confirm').mockReturnValue(false); + + findAllDropdownItems() + .at(0) + .vm.$emit('click'); + + expect(window.confirm).toHaveBeenCalled(); + expect(mock.history.post.length).toBe(0); + }); + + it('displays the remaining time in the dropdown', () => { + expect( + findAllCountdowns() + .at(0) + .props('endDateString'), + ).toBe(scheduledJobAction.scheduled_at); + }); + + it('displays 00:00:00 for expired jobs in the dropdown', () => { + expect( + findAllCountdowns() + .at(1) + .props('endDateString'), + ).toBe(expiredJobAction.scheduled_at); + }); + }); +}); diff --git a/spec/frontend/pipelines/pipelines_artifacts_spec.js b/spec/frontend/pipelines/pipelines_artifacts_spec.js new file mode 100644 index 00000000000..a93cc8a62ab --- /dev/null +++ b/spec/frontend/pipelines/pipelines_artifacts_spec.js @@ -0,0 +1,46 @@ +import { shallowMount } from '@vue/test-utils'; +import PipelineArtifacts from '~/pipelines/components/pipelines_artifacts.vue'; +import { GlLink } from '@gitlab/ui'; + +describe('Pipelines Artifacts dropdown', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(PipelineArtifacts, { + propsData: { + artifacts: [ + { + name: 'artifact', + path: '/download/path', + }, + { + name: 'artifact two', + path: '/download/path-two', + }, + ], + }, + }); + }; + + const findGlLink = () => wrapper.find(GlLink); + const findAllGlLinks = () => wrapper.find('.dropdown-menu').findAll(GlLink); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('should render a dropdown with all the provided artifacts', () => { + expect(findAllGlLinks()).toHaveLength(2); + }); + + it('should render a link with the provided path', () => { + expect(findGlLink().attributes('href')).toEqual('/download/path'); + + expect(findGlLink().text()).toContain('artifact'); + }); +}); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js new file mode 100644 index 00000000000..2ddd2116e2c --- /dev/null +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -0,0 +1,710 @@ +import Api from '~/api'; +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 Store from '~/pipelines/stores/pipelines_store'; +import { pipelineWithStages, stageReply, users, mockSearch, branches } from './mock_data'; +import { RAW_TEXT_WARNING } from '~/pipelines/constants'; +import { GlFilteredSearch } from '@gitlab/ui'; +import createFlash from '~/flash'; + +jest.mock('~/flash', () => jest.fn()); + +describe('Pipelines', () => { + const jsonFixtureName = 'pipelines/pipelines.json'; + + preloadFixtures(jsonFixtureName); + + let pipelines; + let wrapper; + let mock; + + const paths = { + endpoint: 'twitter/flight/pipelines.json', + autoDevopsPath: '/help/topics/autodevops/index.md', + helpPagePath: '/help/ci/quick_start/README', + emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg', + errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', + noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg', + ciLintPath: '/ci/lint', + resetCachePath: '/twitter/flight/settings/ci_cd/reset_cache', + newPipelinePath: '/twitter/flight/pipelines/new', + }; + + const noPermissions = { + endpoint: 'twitter/flight/pipelines.json', + autoDevopsPath: '/help/topics/autodevops/index.md', + helpPagePath: '/help/ci/quick_start/README', + emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg', + errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', + noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg', + }; + + const defaultProps = { + hasGitlabCi: true, + canCreatePipeline: true, + ...paths, + }; + + const findFilteredSearch = () => wrapper.find(GlFilteredSearch); + + const createComponent = (props = defaultProps, methods) => { + wrapper = mount(PipelinesComponent, { + provide: { glFeatures: { filterPipelinesSearch: true } }, + propsData: { + store: new Store(), + projectId: '21', + ...props, + }, + methods: { + ...methods, + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + pipelines = getJSONFixture(jsonFixtureName); + + jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); + jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches }); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + describe('With permission', () => { + describe('With pipelines in main tab', () => { + beforeEach(() => { + mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines); + createComponent(); + return waitForPromises(); + }); + + it('renders tabs', () => { + expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All'); + }); + + it('renders Run Pipeline link', () => { + expect(wrapper.find('.js-run-pipeline').attributes('href')).toBe(paths.newPipelinePath); + }); + + it('renders CI Lint link', () => { + expect(wrapper.find('.js-ci-lint').attributes('href')).toBe(paths.ciLintPath); + }); + + it('renders Clear Runner Cache button', () => { + expect(wrapper.find('.js-clear-cache').text()).toBe('Clear Runner Caches'); + }); + + it('renders pipelines table', () => { + expect(wrapper.findAll('.gl-responsive-table-row')).toHaveLength( + pipelines.pipelines.length + 1, + ); + }); + }); + + describe('Without pipelines on main tab with CI', () => { + beforeEach(() => { + mock.onGet('twitter/flight/pipelines.json').reply(200, { + pipelines: [], + count: { + all: 0, + pending: 0, + running: 0, + finished: 0, + }, + }); + + createComponent(); + + return waitForPromises(); + }); + + it('renders tabs', () => { + expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All'); + }); + + it('renders Run Pipeline link', () => { + expect(wrapper.find('.js-run-pipeline').attributes('href')).toEqual(paths.newPipelinePath); + }); + + it('renders CI Lint link', () => { + expect(wrapper.find('.js-ci-lint').attributes('href')).toEqual(paths.ciLintPath); + }); + + it('renders Clear Runner Cache button', () => { + expect(wrapper.find('.js-clear-cache').text()).toEqual('Clear Runner Caches'); + }); + + it('renders tab empty state', () => { + expect(wrapper.find('.empty-state h4').text()).toEqual('There are currently no pipelines.'); + }); + }); + + describe('Without pipelines nor CI', () => { + beforeEach(() => { + mock.onGet('twitter/flight/pipelines.json').reply(200, { + pipelines: [], + count: { + all: 0, + pending: 0, + running: 0, + finished: 0, + }, + }); + + createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths }); + + return waitForPromises(); + }); + + it('renders empty state', () => { + expect(wrapper.find('.js-empty-state h4').text()).toEqual('Build with confidence'); + + expect(wrapper.find('.js-get-started-pipelines').attributes('href')).toEqual( + paths.helpPagePath, + ); + }); + + it('does not render tabs nor buttons', () => { + expect(wrapper.find('.js-pipelines-tab-all').exists()).toBeFalsy(); + expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy(); + expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy(); + expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy(); + }); + }); + + describe('When API returns error', () => { + beforeEach(() => { + mock.onGet('twitter/flight/pipelines.json').reply(500, {}); + createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths }); + + return waitForPromises(); + }); + + it('renders tabs', () => { + expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All'); + }); + + it('renders buttons', () => { + expect(wrapper.find('.js-run-pipeline').attributes('href')).toEqual(paths.newPipelinePath); + + expect(wrapper.find('.js-ci-lint').attributes('href')).toEqual(paths.ciLintPath); + expect(wrapper.find('.js-clear-cache').text()).toEqual('Clear Runner Caches'); + }); + + it('renders error state', () => { + expect(wrapper.find('.empty-state').text()).toContain( + 'There was an error fetching the pipelines.', + ); + }); + }); + }); + + describe('Without permission', () => { + describe('With pipelines in main tab', () => { + beforeEach(() => { + mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines); + + createComponent({ hasGitlabCi: false, canCreatePipeline: false, ...noPermissions }); + + return waitForPromises(); + }); + + it('renders tabs', () => { + expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All'); + }); + + it('does not render buttons', () => { + expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy(); + expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy(); + expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy(); + }); + + it('renders pipelines table', () => { + expect(wrapper.findAll('.gl-responsive-table-row')).toHaveLength( + pipelines.pipelines.length + 1, + ); + }); + }); + + describe('Without pipelines on main tab with CI', () => { + beforeEach(() => { + mock.onGet('twitter/flight/pipelines.json').reply(200, { + pipelines: [], + count: { + all: 0, + pending: 0, + running: 0, + finished: 0, + }, + }); + + createComponent({ hasGitlabCi: true, canCreatePipeline: false, ...noPermissions }); + + return waitForPromises(); + }); + + it('renders tabs', () => { + expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All'); + }); + + it('does not render buttons', () => { + expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy(); + expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy(); + expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy(); + }); + + it('renders tab empty state', () => { + expect(wrapper.find('.empty-state h4').text()).toEqual('There are currently no pipelines.'); + }); + }); + + describe('Without pipelines nor CI', () => { + beforeEach(() => { + mock.onGet('twitter/flight/pipelines.json').reply(200, { + pipelines: [], + count: { + all: 0, + pending: 0, + running: 0, + finished: 0, + }, + }); + + createComponent({ hasGitlabCi: false, canCreatePipeline: false, ...noPermissions }); + + return waitForPromises(); + }); + + it('renders empty state without button to set CI', () => { + expect(wrapper.find('.js-empty-state').text()).toEqual( + 'This project is not currently set up to run pipelines.', + ); + + expect(wrapper.find('.js-get-started-pipelines').exists()).toBeFalsy(); + }); + + it('does not render tabs or buttons', () => { + expect(wrapper.find('.js-pipelines-tab-all').exists()).toBeFalsy(); + expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy(); + expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy(); + expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy(); + }); + }); + + describe('When API returns error', () => { + beforeEach(() => { + mock.onGet('twitter/flight/pipelines.json').reply(500, {}); + + createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...noPermissions }); + + return waitForPromises(); + }); + + it('renders tabs', () => { + expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All'); + }); + + it('does not renders buttons', () => { + expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy(); + expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy(); + expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy(); + }); + + it('renders error state', () => { + expect(wrapper.find('.empty-state').text()).toContain( + 'There was an error fetching the pipelines.', + ); + }); + }); + }); + + describe('successful request', () => { + describe('with pipelines', () => { + beforeEach(() => { + mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines); + + createComponent(); + return waitForPromises(); + }); + + it('should render table', () => { + expect(wrapper.find('.table-holder').exists()).toBe(true); + expect(wrapper.findAll('.gl-responsive-table-row')).toHaveLength( + pipelines.pipelines.length + 1, + ); + }); + + 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'); + + expect(wrapper.find('.js-pipelines-tab-tags').text()).toContain('Tags'); + }); + + it('should make an API request when using tabs', () => { + const updateContentMock = jest.fn(() => {}); + createComponent( + { hasGitlabCi: true, canCreatePipeline: true, ...paths }, + { + updateContent: updateContentMock, + }, + ); + + return waitForPromises().then(() => { + wrapper.find('.js-pipelines-tab-finished').trigger('click'); + + expect(updateContentMock).toHaveBeenCalledWith({ scope: 'finished', page: '1' }); + }); + }); + + describe('with pagination', () => { + it('should make an API request when using pagination', () => { + const updateContentMock = jest.fn(() => {}); + createComponent( + { hasGitlabCi: true, canCreatePipeline: true, ...paths }, + { + updateContent: updateContentMock, + }, + ); + + return waitForPromises() + .then(() => { + // Mock pagination + wrapper.vm.store.state.pageInfo = { + page: 1, + total: 10, + perPage: 2, + nextPage: 2, + totalPages: 5, + }; + + return wrapper.vm.$nextTick(); + }) + .then(() => { + wrapper.find('.next-page-item').trigger('click'); + + expect(updateContentMock).toHaveBeenCalledWith({ scope: 'all', page: '2' }); + }); + }); + }); + }); + }); + + describe('methods', () => { + beforeEach(() => { + jest.spyOn(window.history, 'pushState').mockImplementation(() => null); + }); + + describe('onChangeTab', () => { + it('should set page to 1', () => { + const updateContentMock = jest.fn(() => {}); + createComponent( + { hasGitlabCi: true, canCreatePipeline: true, ...paths }, + { + updateContent: updateContentMock, + }, + ); + + wrapper.vm.onChangeTab('running'); + + expect(updateContentMock).toHaveBeenCalledWith({ scope: 'running', page: '1' }); + }); + }); + + describe('onChangePage', () => { + it('should update page and keep scope', () => { + const updateContentMock = jest.fn(() => {}); + createComponent( + { hasGitlabCi: true, canCreatePipeline: true, ...paths }, + { + updateContent: updateContentMock, + }, + ); + + wrapper.vm.onChangePage(4); + + expect(updateContentMock).toHaveBeenCalledWith({ scope: wrapper.vm.scope, page: '4' }); + }); + }); + }); + + describe('computed properties', () => { + beforeEach(() => { + createComponent(); + }); + + describe('tabs', () => { + 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 }, + ]); + }); + }); + + describe('emptyTabMessage', () => { + it('returns message with scope', () => { + wrapper.vm.scope = 'pending'; + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.emptyTabMessage).toEqual('There are currently no pending pipelines.'); + }); + }); + + it('returns message without scope when scope is `all`', () => { + expect(wrapper.vm.emptyTabMessage).toEqual('There are currently no pipelines.'); + }); + }); + + describe('stateToRender', () => { + it('returns loading state when the app is loading', () => { + expect(wrapper.vm.stateToRender).toEqual('loading'); + }); + + it('returns error state when app has error', () => { + wrapper.vm.hasError = true; + wrapper.vm.isLoading = false; + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.stateToRender).toEqual('error'); + }); + }); + + it('returns table list when app has pipelines', () => { + wrapper.vm.isLoading = false; + wrapper.vm.hasError = false; + wrapper.vm.state.pipelines = pipelines.pipelines; + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.stateToRender).toEqual('tableList'); + }); + }); + + it('returns empty tab when app does not have pipelines but project has pipelines', () => { + wrapper.vm.state.count.all = 10; + wrapper.vm.isLoading = false; + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.stateToRender).toEqual('emptyTab'); + }); + }); + + it('returns empty tab when project has CI', () => { + wrapper.vm.isLoading = false; + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.stateToRender).toEqual('emptyTab'); + }); + }); + + it('returns empty state when project does not have pipelines nor CI', () => { + createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths }); + + wrapper.vm.isLoading = false; + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.stateToRender).toEqual('emptyState'); + }); + }); + }); + + describe('shouldRenderTabs', () => { + it('returns true when state is loading & has already made the first request', () => { + wrapper.vm.isLoading = true; + wrapper.vm.hasMadeRequest = true; + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.shouldRenderTabs).toEqual(true); + }); + }); + + it('returns true when state is tableList & has already made the first request', () => { + wrapper.vm.isLoading = false; + wrapper.vm.state.pipelines = pipelines.pipelines; + wrapper.vm.hasMadeRequest = true; + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.shouldRenderTabs).toEqual(true); + }); + }); + + it('returns true when state is error & has already made the first request', () => { + wrapper.vm.isLoading = false; + wrapper.vm.hasError = true; + wrapper.vm.hasMadeRequest = true; + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.shouldRenderTabs).toEqual(true); + }); + }); + + it('returns true when state is empty tab & has already made the first request', () => { + wrapper.vm.isLoading = false; + wrapper.vm.state.count.all = 10; + wrapper.vm.hasMadeRequest = true; + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.shouldRenderTabs).toEqual(true); + }); + }); + + it('returns false when has not made first request', () => { + wrapper.vm.hasMadeRequest = false; + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.shouldRenderTabs).toEqual(false); + }); + }); + + it('returns false when state is empty state', () => { + createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths }); + + wrapper.vm.isLoading = false; + wrapper.vm.hasMadeRequest = true; + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.shouldRenderTabs).toEqual(false); + }); + }); + }); + + describe('shouldRenderButtons', () => { + it('returns true when it has paths & has made the first request', () => { + wrapper.vm.hasMadeRequest = true; + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.shouldRenderButtons).toEqual(true); + }); + }); + + it('returns false when it has not made the first request', () => { + wrapper.vm.hasMadeRequest = false; + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.shouldRenderButtons).toEqual(false); + }); + }); + }); + }); + + describe('updates results when a staged is clicked', () => { + beforeEach(() => { + const copyPipeline = { ...pipelineWithStages }; + copyPipeline.id += 1; + mock + .onGet('twitter/flight/pipelines.json') + .reply( + 200, + { + pipelines: [pipelineWithStages], + count: { + all: 1, + finished: 1, + pending: 0, + running: 0, + }, + }, + { + 'POLL-INTERVAL': 100, + }, + ) + .onGet(pipelineWithStages.details.stages[0].dropdown_path) + .reply(200, stageReply); + + createComponent(); + }); + + describe('when a request is being made', () => { + it('stops polling, cancels the request, & restarts polling', () => { + const stopMock = jest.spyOn(wrapper.vm.poll, 'stop'); + const restartMock = jest.spyOn(wrapper.vm.poll, 'restart'); + const cancelMock = jest.spyOn(wrapper.vm.service.cancelationSource, 'cancel'); + mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines); + + return waitForPromises() + .then(() => { + wrapper.vm.isMakingRequest = true; + wrapper.find('.js-builds-dropdown-button').trigger('click'); + }) + .then(() => { + expect(cancelMock).toHaveBeenCalled(); + expect(stopMock).toHaveBeenCalled(); + expect(restartMock).toHaveBeenCalled(); + }); + }); + }); + + describe('when no request is being made', () => { + it('stops polling & restarts polling', () => { + const stopMock = jest.spyOn(wrapper.vm.poll, 'stop'); + const restartMock = jest.spyOn(wrapper.vm.poll, 'restart'); + mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines); + + return waitForPromises() + .then(() => { + wrapper.find('.js-builds-dropdown-button').trigger('click'); + expect(stopMock).toHaveBeenCalled(); + }) + .then(() => { + expect(restartMock).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('Pipeline filters', () => { + let updateContentMock; + + beforeEach(() => { + mock.onGet(paths.endpoint).reply(200, pipelines); + createComponent(); + + updateContentMock = jest.spyOn(wrapper.vm, 'updateContent'); + + return waitForPromises(); + }); + + it('updates request data and query params on filter submit', () => { + const expectedQueryParams = { page: '1', scope: 'all', username: 'root', ref: 'master' }; + + findFilteredSearch().vm.$emit('submit', mockSearch); + + expect(wrapper.vm.requestData).toEqual(expectedQueryParams); + expect(updateContentMock).toHaveBeenCalledWith(expectedQueryParams); + }); + + it('does not add query params if raw text search is used', () => { + const expectedQueryParams = { page: '1', scope: 'all' }; + + findFilteredSearch().vm.$emit('submit', ['rawText']); + + expect(wrapper.vm.requestData).toEqual(expectedQueryParams); + expect(updateContentMock).toHaveBeenCalledWith(expectedQueryParams); + }); + + it('displays a warning message if raw text search is used', () => { + findFilteredSearch().vm.$emit('submit', ['rawText']); + + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith(RAW_TEXT_WARNING, 'warning'); + }); + }); +}); diff --git a/spec/frontend/pipelines/pipelines_table_row_spec.js b/spec/frontend/pipelines/pipelines_table_row_spec.js index c43210c5350..3d564c8758c 100644 --- a/spec/frontend/pipelines/pipelines_table_row_spec.js +++ b/spec/frontend/pipelines/pipelines_table_row_spec.js @@ -169,7 +169,7 @@ describe('Pipelines Table Row', () => { }; beforeEach(() => { - const withActions = Object.assign({}, pipeline); + const withActions = { ...pipeline }; withActions.details.scheduled_actions = [scheduledJobAction]; withActions.flags.cancelable = true; withActions.flags.retryable = true; diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js new file mode 100644 index 00000000000..b0ab250dd16 --- /dev/null +++ b/spec/frontend/pipelines/pipelines_table_spec.js @@ -0,0 +1,66 @@ +import { mount } from '@vue/test-utils'; +import PipelinesTable from '~/pipelines/components/pipelines_table.vue'; + +describe('Pipelines Table', () => { + let pipeline; + let wrapper; + + const jsonFixtureName = 'pipelines/pipelines.json'; + + const defaultProps = { + pipelines: [], + autoDevopsHelpPath: 'foo', + viewType: 'root', + }; + + const createComponent = (props = defaultProps) => { + wrapper = mount(PipelinesTable, { + propsData: props, + }); + }; + const findRows = () => wrapper.findAll('.commit.gl-responsive-table-row'); + + preloadFixtures(jsonFixtureName); + + beforeEach(() => { + const { pipelines } = getJSONFixture(jsonFixtureName); + pipeline = pipelines.find(p => p.user !== null && p.commit !== null); + + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('table', () => { + it('should render a table', () => { + expect(wrapper.classes()).toContain('ci-table'); + }); + + it('should render table head with correct columns', () => { + expect(wrapper.find('.table-section.js-pipeline-status').text()).toEqual('Status'); + + expect(wrapper.find('.table-section.js-pipeline-info').text()).toEqual('Pipeline'); + + expect(wrapper.find('.table-section.js-pipeline-commit').text()).toEqual('Commit'); + + expect(wrapper.find('.table-section.js-pipeline-stages').text()).toEqual('Stages'); + }); + }); + + describe('without data', () => { + it('should render an empty table', () => { + expect(findRows()).toHaveLength(0); + }); + }); + + describe('with data', () => { + it('should render rows', () => { + createComponent({ pipelines: [pipeline], autoDevopsHelpPath: 'foo', viewType: 'root' }); + + expect(findRows()).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/pipelines/stage_spec.js b/spec/frontend/pipelines/stage_spec.js new file mode 100644 index 00000000000..6aa041bcb7f --- /dev/null +++ b/spec/frontend/pipelines/stage_spec.js @@ -0,0 +1,156 @@ +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 eventHub from '~/pipelines/event_hub'; +import { stageReply } from './mock_data'; +import waitForPromises from 'helpers/wait_for_promises'; + +describe('Pipelines stage component', () => { + let wrapper; + let mock; + + const defaultProps = { + stage: { + status: { + group: 'success', + icon: 'status_success', + title: 'success', + }, + dropdown_path: 'path.json', + }, + updateDropdown: false, + }; + + const createComponent = (props = {}) => { + wrapper = mount(StageComponent, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + + mock.restore(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('should render a dropdown with the status icon', () => { + expect(wrapper.attributes('class')).toEqual('dropdown'); + expect(wrapper.find('svg').exists()).toBe(true); + expect(wrapper.find('button').attributes('data-toggle')).toEqual('dropdown'); + }); + }); + + describe('with successful request', () => { + beforeEach(() => { + mock.onGet('path.json').reply(200, stageReply); + createComponent(); + }); + + it('should render the received data and emit `clickedDropdown` event', () => { + jest.spyOn(eventHub, '$emit'); + wrapper.find('button').trigger('click'); + + return waitForPromises().then(() => { + expect(wrapper.find('.js-builds-dropdown-container ul').text()).toContain( + stageReply.latest_statuses[0].name, + ); + + expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown'); + }); + }); + }); + + describe('when request fails', () => { + beforeEach(() => { + mock.onGet('path.json').reply(500); + createComponent(); + }); + + it('should close the dropdown', () => { + wrapper.setMethods({ + closeDropdown: jest.fn(), + isDropdownOpen: jest.fn().mockReturnValue(false), + }); + + wrapper.find('button').trigger('click'); + + return waitForPromises().then(() => { + expect(wrapper.vm.closeDropdown).toHaveBeenCalled(); + }); + }); + }); + + describe('update endpoint correctly', () => { + beforeEach(() => { + const copyStage = { ...stageReply }; + copyStage.latest_statuses[0].name = 'this is the updated content'; + mock.onGet('bar.json').reply(200, copyStage); + createComponent({ + stage: { + status: { + group: 'running', + icon: 'status_running', + title: 'running', + }, + dropdown_path: 'bar.json', + }, + }); + }); + + it('should update the stage to request the new endpoint provided', () => { + return wrapper.vm + .$nextTick() + .then(() => { + wrapper.find('button').trigger('click'); + return waitForPromises(); + }) + .then(() => { + expect(wrapper.find('.js-builds-dropdown-container ul').text()).toContain( + 'this is the updated content', + ); + }); + }); + }); + + describe('pipelineActionRequestComplete', () => { + beforeEach(() => { + mock.onGet('path.json').reply(200, stageReply); + + mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(200); + + createComponent({ type: 'PIPELINES_TABLE' }); + }); + + describe('within pipeline table', () => { + it('emits `refreshPipelinesTable` event when `pipelineActionRequestComplete` is triggered', () => { + jest.spyOn(eventHub, '$emit'); + + wrapper.find('button').trigger('click'); + + return waitForPromises() + .then(() => { + wrapper.find('.js-ci-action').trigger('click'); + + return waitForPromises(); + }) + .then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('refreshPipelinesTable'); + }); + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/stores/pipeline_store_spec.js b/spec/frontend/pipelines/stores/pipeline_store_spec.js new file mode 100644 index 00000000000..68d438109b3 --- /dev/null +++ b/spec/frontend/pipelines/stores/pipeline_store_spec.js @@ -0,0 +1,135 @@ +import PipelineStore from '~/pipelines/stores/pipeline_store'; +import LinkedPipelines from '../linked_pipelines_mock.json'; + +describe('EE Pipeline store', () => { + let store; + let data; + + beforeEach(() => { + store = new PipelineStore(); + data = { ...LinkedPipelines }; + + store.storePipeline(data); + }); + + describe('storePipeline', () => { + describe('triggered_by', () => { + it('sets triggered_by as an array', () => { + expect(store.state.pipeline.triggered_by.length).toEqual(1); + }); + + it('adds isExpanding & isLoading keys set to false', () => { + expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false); + expect(store.state.pipeline.triggered_by[0].isLoading).toEqual(false); + }); + + it('parses nested triggered_by', () => { + expect(store.state.pipeline.triggered_by[0].triggered_by.length).toEqual(1); + expect(store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded).toEqual(false); + expect(store.state.pipeline.triggered_by[0].triggered_by[0].isLoading).toEqual(false); + }); + }); + + describe('triggered', () => { + it('adds isExpanding & isLoading keys set to false for each triggered pipeline', () => { + store.state.pipeline.triggered.forEach(pipeline => { + expect(pipeline.isExpanded).toEqual(false); + expect(pipeline.isLoading).toEqual(false); + }); + }); + + it('parses nested triggered pipelines', () => { + store.state.pipeline.triggered[1].triggered.forEach(pipeline => { + expect(pipeline.isExpanded).toEqual(false); + expect(pipeline.isLoading).toEqual(false); + }); + }); + }); + }); + + describe('resetTriggeredByPipeline', () => { + it('closes the pipeline & nested ones', () => { + store.state.pipeline.triggered_by[0].isExpanded = true; + store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded = true; + + store.resetTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]); + + expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false); + expect(store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded).toEqual(false); + }); + }); + + describe('openTriggeredByPipeline', () => { + it('opens the given pipeline', () => { + store.openTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]); + + expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(true); + }); + }); + + describe('closeTriggeredByPipeline', () => { + it('closes the given pipeline', () => { + // open it first + store.openTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]); + + store.closeTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]); + + expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false); + }); + }); + + describe('resetTriggeredPipelines', () => { + it('closes the pipeline & nested ones', () => { + store.state.pipeline.triggered[0].isExpanded = true; + store.state.pipeline.triggered[0].triggered[0].isExpanded = true; + + store.resetTriggeredPipelines(store.state.pipeline, store.state.pipeline.triggered[0]); + + expect(store.state.pipeline.triggered[0].isExpanded).toEqual(false); + expect(store.state.pipeline.triggered[0].triggered[0].isExpanded).toEqual(false); + }); + }); + + describe('openTriggeredPipeline', () => { + it('opens the given pipeline', () => { + store.openTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]); + + expect(store.state.pipeline.triggered[0].isExpanded).toEqual(true); + }); + }); + + describe('closeTriggeredPipeline', () => { + it('closes the given pipeline', () => { + // open it first + store.openTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]); + + store.closeTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]); + + expect(store.state.pipeline.triggered[0].isExpanded).toEqual(false); + }); + }); + + describe('toggleLoading', () => { + it('toggles the isLoading property for the given pipeline', () => { + store.toggleLoading(store.state.pipeline.triggered[0]); + + expect(store.state.pipeline.triggered[0].isLoading).toEqual(true); + }); + }); + + describe('addExpandedPipelineToRequestData', () => { + it('pushes the given id to expandedPipelines array', () => { + store.addExpandedPipelineToRequestData('213231'); + + expect(store.state.expandedPipelines).toEqual(['213231']); + }); + }); + + describe('removeExpandedPipelineToRequestData', () => { + it('pushes the given id to expandedPipelines array', () => { + store.removeExpandedPipelineToRequestData('213231'); + + expect(store.state.expandedPipelines).toEqual([]); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js index 9eaa563025d..a0eb93c4e6b 100644 --- a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js @@ -20,7 +20,7 @@ describe('Mutations TestReports Store', () => { describe('set endpoint', () => { it('should set endpoint', () => { - const expectedState = Object.assign({}, mockState, { endpoint: 'foo' }); + const expectedState = { ...mockState, endpoint: 'foo' }; mutations[types.SET_ENDPOINT](mockState, 'foo'); expect(mockState.endpoint).toEqual(expectedState.endpoint); @@ -47,14 +47,14 @@ describe('Mutations TestReports Store', () => { describe('toggle loading', () => { it('should set to true', () => { - const expectedState = Object.assign({}, mockState, { isLoading: true }); + const expectedState = { ...mockState, isLoading: true }; mutations[types.TOGGLE_LOADING](mockState); expect(mockState.isLoading).toEqual(expectedState.isLoading); }); it('should toggle back to false', () => { - const expectedState = Object.assign({}, mockState, { isLoading: false }); + const expectedState = { ...mockState, isLoading: false }; mockState.isLoading = true; mutations[types.TOGGLE_LOADING](mockState); diff --git a/spec/frontend/pipelines/test_reports/test_summary_spec.js b/spec/frontend/pipelines/test_reports/test_summary_spec.js index 160d93d2e6b..8f041e46472 100644 --- a/spec/frontend/pipelines/test_reports/test_summary_spec.js +++ b/spec/frontend/pipelines/test_reports/test_summary_spec.js @@ -82,17 +82,19 @@ describe('Test reports summary', () => { describe('success percentage calculation', () => { it.each` - name | successCount | totalCount | result - ${'displays 0 when there are no tests'} | ${0} | ${0} | ${'0'} - ${'displays whole number when possible'} | ${10} | ${50} | ${'20'} - ${'rounds to 0.01'} | ${1} | ${16604} | ${'0.01'} - ${'correctly rounds to 50'} | ${8302} | ${16604} | ${'50'} - ${'rounds down for large close numbers'} | ${16603} | ${16604} | ${'99.99'} - ${'correctly displays 100'} | ${16604} | ${16604} | ${'100'} - `('$name', ({ successCount, totalCount, result }) => { + name | successCount | totalCount | skippedCount | result + ${'displays 0 when there are no tests'} | ${0} | ${0} | ${0} | ${'0'} + ${'displays whole number when possible'} | ${10} | ${50} | ${0} | ${'20'} + ${'excludes skipped tests from total'} | ${10} | ${50} | ${5} | ${'22.22'} + ${'rounds to 0.01'} | ${1} | ${16604} | ${0} | ${'0.01'} + ${'correctly rounds to 50'} | ${8302} | ${16604} | ${0} | ${'50'} + ${'rounds down for large close numbers'} | ${16603} | ${16604} | ${0} | ${'99.99'} + ${'correctly displays 100'} | ${16604} | ${16604} | ${0} | ${'100'} + `('$name', ({ successCount, totalCount, skippedCount, result }) => { createComponent({ report: { success_count: successCount, + skipped_count: skippedCount, total_count: totalCount, }, }); diff --git a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js index 9146f301f66..b585536ae09 100644 --- a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js +++ b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js @@ -37,11 +37,47 @@ describe('Test reports summary table', () => { describe('when test reports are supplied', () => { beforeEach(() => createComponent()); + const findErrorIcon = () => wrapper.find({ ref: 'suiteErrorIcon' }); it('renders the correct number of rows', () => { expect(noSuitesToShow().exists()).toBe(false); expect(allSuitesRows().length).toBe(testReports.test_suites.length); }); + + describe('when there is a suite error', () => { + beforeEach(() => { + createComponent({ + test_suites: [ + { + ...testReports.test_suites[0], + suite_error: 'Suite Error', + }, + ], + }); + }); + + it('renders error icon', () => { + expect(findErrorIcon().exists()).toBe(true); + expect(findErrorIcon().attributes('title')).toEqual('Suite Error'); + }); + }); + + describe('when there is not a suite error', () => { + beforeEach(() => { + createComponent({ + test_suites: [ + { + ...testReports.test_suites[0], + suite_error: null, + }, + ], + }); + }); + + it('does not render error icon', () => { + expect(findErrorIcon().exists()).toBe(false); + }); + }); }); describe('when there are no test suites', () => { diff --git a/spec/frontend/pipelines/time_ago_spec.js b/spec/frontend/pipelines/time_ago_spec.js new file mode 100644 index 00000000000..1bd16182d47 --- /dev/null +++ b/spec/frontend/pipelines/time_ago_spec.js @@ -0,0 +1,67 @@ +import { shallowMount } from '@vue/test-utils'; +import TimeAgo from '~/pipelines/components/time_ago.vue'; + +describe('Timeago component', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(TimeAgo, { + propsData: { + ...props, + }, + data() { + return { + iconTimerSvg: `<svg></svg>`, + }; + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('with duration', () => { + beforeEach(() => { + createComponent({ duration: 10, finishedTime: '' }); + }); + + it('should render duration and timer svg', () => { + expect(wrapper.find('.duration').exists()).toBe(true); + expect(wrapper.find('.duration svg').exists()).toBe(true); + }); + }); + + describe('without duration', () => { + beforeEach(() => { + createComponent({ duration: 0, finishedTime: '' }); + }); + + it('should not render duration and timer svg', () => { + expect(wrapper.find('.duration').exists()).toBe(false); + }); + }); + + describe('with finishedTime', () => { + beforeEach(() => { + createComponent({ duration: 0, finishedTime: '2017-04-26T12:40:23.277Z' }); + }); + + it('should render time and calendar icon', () => { + expect(wrapper.find('.finished-at').exists()).toBe(true); + expect(wrapper.find('.finished-at i.fa-calendar').exists()).toBe(true); + expect(wrapper.find('.finished-at time').exists()).toBe(true); + }); + }); + + describe('without finishedTime', () => { + beforeEach(() => { + createComponent({ duration: 0, finishedTime: '' }); + }); + + it('should not render time and calendar icon', () => { + expect(wrapper.find('.finished-at').exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js new file mode 100644 index 00000000000..a6753600792 --- /dev/null +++ b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js @@ -0,0 +1,89 @@ +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 { branches } from '../mock_data'; + +describe('Pipeline Branch Name Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion); + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + + const stubs = { + GlFilteredSearchToken: { + template: `<div><slot name="suggestions"></slot></div>`, + }, + }; + + const defaultProps = { + config: { + type: 'ref', + icon: 'branch', + title: 'Branch name', + dataType: 'ref', + unique: true, + branches, + projectId: '21', + }, + value: { + data: '', + }, + }; + + const createComponent = (options, data) => { + wrapper = shallowMount(PipelineBranchNameToken, { + propsData: { + ...defaultProps, + }, + data() { + return { + ...data, + }; + }, + ...options, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + describe('displays loading icon correctly', () => { + it('shows loading icon', () => { + createComponent({ stubs }, { loading: true }); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not show loading icon', () => { + createComponent({ stubs }, { loading: false }); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('shows branches correctly', () => { + it('renders all trigger authors', () => { + createComponent({ stubs }, { branches, loading: false }); + + expect(findAllFilteredSearchSuggestions()).toHaveLength(branches.length); + }); + + it('renders only the branch searched for', () => { + const mockBranches = ['master']; + createComponent({ stubs }, { branches: mockBranches, loading: false }); + + expect(findAllFilteredSearchSuggestions()).toHaveLength(mockBranches.length); + }); + }); +}); diff --git a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js new file mode 100644 index 00000000000..00a9ff04e75 --- /dev/null +++ b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js @@ -0,0 +1,98 @@ +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 { users } from '../mock_data'; + +describe('Pipeline Trigger Author Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion); + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + + const stubs = { + GlFilteredSearchToken: { + template: `<div><slot name="suggestions"></slot></div>`, + }, + }; + + const defaultProps = { + config: { + type: 'username', + icon: 'user', + title: 'Trigger author', + dataType: 'username', + unique: true, + triggerAuthors: users, + }, + value: { + data: '', + }, + }; + + const createComponent = (options, data) => { + wrapper = shallowMount(PipelineTriggerAuthorToken, { + propsData: { + ...defaultProps, + }, + data() { + return { + ...data, + }; + }, + ...options, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + describe('displays loading icon correctly', () => { + it('shows loading icon', () => { + createComponent({ stubs }, { loading: true }); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not show loading icon', () => { + createComponent({ stubs }, { loading: false }); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('shows trigger authors correctly', () => { + beforeEach(() => {}); + + it('renders all trigger authors', () => { + createComponent({ stubs }, { users, loading: false }); + + // should have length of all users plus the static 'Any' option + expect(findAllFilteredSearchSuggestions()).toHaveLength(users.length + 1); + }); + + it('renders only the trigger author searched for', () => { + createComponent( + { stubs }, + { + users: [ + { name: 'Arnold', username: 'admin', state: 'active', avatar_url: 'avatar-link' }, + ], + loading: false, + }, + ); + + expect(findAllFilteredSearchSuggestions()).toHaveLength(2); + }); + }); +}); diff --git a/spec/frontend/pipelines_spec.js b/spec/frontend/pipelines_spec.js new file mode 100644 index 00000000000..6d4d634c575 --- /dev/null +++ b/spec/frontend/pipelines_spec.js @@ -0,0 +1,19 @@ +import Pipelines from '~/pipelines'; + +describe('Pipelines', () => { + preloadFixtures('static/pipeline_graph.html'); + + beforeEach(() => { + loadFixtures('static/pipeline_graph.html'); + }); + + it('should be defined', () => { + expect(Pipelines).toBeDefined(); + }); + + it('should create a `Pipelines` instance without options', () => { + expect(() => { + new Pipelines(); // eslint-disable-line no-new + }).not.toThrow(); + }); +}); diff --git a/spec/frontend/prometheus_metrics/custom_metrics_spec.js b/spec/frontend/prometheus_metrics/custom_metrics_spec.js index 97b8f7bd913..1244d7342ad 100644 --- a/spec/frontend/prometheus_metrics/custom_metrics_spec.js +++ b/spec/frontend/prometheus_metrics/custom_metrics_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import CustomMetrics from '~/prometheus_metrics/custom_metrics'; import axios from '~/lib/utils/axios_utils'; import PANEL_STATE from '~/prometheus_metrics/constants'; -import metrics from './mock_data'; +import { metrics1 as metrics } from './mock_data'; describe('PrometheusMetrics', () => { const FIXTURE = 'services/prometheus/prometheus_service.html'; diff --git a/spec/frontend/prometheus_metrics/mock_data.js b/spec/frontend/prometheus_metrics/mock_data.js index d5532537302..375447ac3be 100644 --- a/spec/frontend/prometheus_metrics/mock_data.js +++ b/spec/frontend/prometheus_metrics/mock_data.js @@ -1,4 +1,4 @@ -const metrics = [ +export const metrics1 = [ { edit_path: '/root/prometheus-test/prometheus/metrics/3/edit', id: 3, @@ -19,4 +19,44 @@ const metrics = [ }, ]; -export default metrics; +export const metrics2 = [ + { + group: 'Kubernetes', + priority: 1, + active_metrics: 4, + metrics_missing_requirements: 0, + }, + { + group: 'HAProxy', + priority: 2, + active_metrics: 3, + metrics_missing_requirements: 0, + }, + { + group: 'Apache', + priority: 3, + active_metrics: 5, + metrics_missing_requirements: 0, + }, +]; + +export const missingVarMetrics = [ + { + group: 'Kubernetes', + priority: 1, + active_metrics: 4, + metrics_missing_requirements: 0, + }, + { + group: 'HAProxy', + priority: 2, + active_metrics: 3, + metrics_missing_requirements: 1, + }, + { + group: 'Apache', + priority: 3, + active_metrics: 5, + metrics_missing_requirements: 3, + }, +]; diff --git a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js new file mode 100644 index 00000000000..437a2116f5c --- /dev/null +++ b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js @@ -0,0 +1,178 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics'; +import PANEL_STATE from '~/prometheus_metrics/constants'; +import { metrics2 as metrics, missingVarMetrics } from './mock_data'; + +describe('PrometheusMetrics', () => { + const FIXTURE = 'services/prometheus/prometheus_service.html'; + preloadFixtures(FIXTURE); + + beforeEach(() => { + loadFixtures(FIXTURE); + }); + + describe('constructor', () => { + let prometheusMetrics; + + beforeEach(() => { + prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring'); + }); + + it('should initialize wrapper element refs on class object', () => { + expect(prometheusMetrics.$wrapper).toBeDefined(); + expect(prometheusMetrics.$monitoredMetricsPanel).toBeDefined(); + expect(prometheusMetrics.$monitoredMetricsCount).toBeDefined(); + expect(prometheusMetrics.$monitoredMetricsLoading).toBeDefined(); + expect(prometheusMetrics.$monitoredMetricsEmpty).toBeDefined(); + expect(prometheusMetrics.$monitoredMetricsList).toBeDefined(); + expect(prometheusMetrics.$missingEnvVarPanel).toBeDefined(); + expect(prometheusMetrics.$panelToggle).toBeDefined(); + expect(prometheusMetrics.$missingEnvVarMetricCount).toBeDefined(); + expect(prometheusMetrics.$missingEnvVarMetricsList).toBeDefined(); + }); + + it('should initialize metadata on class object', () => { + expect(prometheusMetrics.backOffRequestCounter).toEqual(0); + expect(prometheusMetrics.activeMetricsEndpoint).toContain('/test'); + }); + }); + + describe('showMonitoringMetricsPanelState', () => { + let prometheusMetrics; + + beforeEach(() => { + prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring'); + }); + + it('should show loading state when called with `loading`', () => { + prometheusMetrics.showMonitoringMetricsPanelState(PANEL_STATE.LOADING); + + expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeFalsy(); + expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeTruthy(); + expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeTruthy(); + }); + + it('should show metrics list when called with `list`', () => { + prometheusMetrics.showMonitoringMetricsPanelState(PANEL_STATE.LIST); + + expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy(); + expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeTruthy(); + expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeFalsy(); + }); + + it('should show empty state when called with `empty`', () => { + prometheusMetrics.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY); + + expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy(); + expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeFalsy(); + expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeTruthy(); + }); + }); + + describe('populateActiveMetrics', () => { + let prometheusMetrics; + + beforeEach(() => { + prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring'); + }); + + it('should show monitored metrics list', () => { + prometheusMetrics.populateActiveMetrics(metrics); + + const $metricsListLi = prometheusMetrics.$monitoredMetricsList.find('li'); + + expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy(); + expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeFalsy(); + + expect(prometheusMetrics.$monitoredMetricsCount.text()).toEqual( + '3 exporters with 12 metrics were found', + ); + + expect($metricsListLi.length).toEqual(metrics.length); + expect( + $metricsListLi + .first() + .find('.badge') + .text(), + ).toEqual(`${metrics[0].active_metrics}`); + }); + + it('should show missing environment variables list', () => { + prometheusMetrics.populateActiveMetrics(missingVarMetrics); + + expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy(); + expect(prometheusMetrics.$missingEnvVarPanel.hasClass('hidden')).toBeFalsy(); + + expect(prometheusMetrics.$missingEnvVarMetricCount.text()).toEqual('2'); + expect(prometheusMetrics.$missingEnvVarPanel.find('li').length).toEqual(2); + expect(prometheusMetrics.$missingEnvVarPanel.find('.flash-container')).toBeDefined(); + }); + }); + + describe('loadActiveMetrics', () => { + let prometheusMetrics; + let mock; + + function mockSuccess() { + mock.onGet(prometheusMetrics.activeMetricsEndpoint).reply(200, { + data: metrics, + success: true, + }); + } + + function mockError() { + mock.onGet(prometheusMetrics.activeMetricsEndpoint).networkError(); + } + + beforeEach(() => { + jest.spyOn(axios, 'get'); + + prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring'); + + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + it('should show loader animation while response is being loaded and hide it when request is complete', done => { + mockSuccess(); + + prometheusMetrics.loadActiveMetrics(); + + expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeFalsy(); + expect(axios.get).toHaveBeenCalledWith(prometheusMetrics.activeMetricsEndpoint); + + setImmediate(() => { + expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy(); + done(); + }); + }); + + it('should show empty state if response failed to load', done => { + mockError(); + + prometheusMetrics.loadActiveMetrics(); + + setImmediate(() => { + expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy(); + expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeFalsy(); + done(); + }); + }); + + it('should populate metrics list once response is loaded', done => { + jest.spyOn(prometheusMetrics, 'populateActiveMetrics').mockImplementation(); + mockSuccess(); + + prometheusMetrics.loadActiveMetrics(); + + setImmediate(() => { + expect(prometheusMetrics.populateActiveMetrics).toHaveBeenCalledWith(metrics); + done(); + }); + }); + }); +}); diff --git a/spec/frontend/registry/explorer/components/image_list_spec.js b/spec/frontend/registry/explorer/components/image_list_spec.js new file mode 100644 index 00000000000..12f0fbe0c87 --- /dev/null +++ b/spec/frontend/registry/explorer/components/image_list_spec.js @@ -0,0 +1,74 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlPagination } from '@gitlab/ui'; +import Component from '~/registry/explorer/components/image_list.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { RouterLink } from '../stubs'; +import { imagesListResponse, imagePagination } from '../mock_data'; + +describe('Image List', () => { + let wrapper; + + const firstElement = imagesListResponse.data[0]; + + const findDeleteBtn = () => wrapper.find('[data-testid="deleteImageButton"]'); + const findRowItems = () => wrapper.findAll('[data-testid="rowItem"]'); + const findDetailsLink = () => wrapper.find('[data-testid="detailsLink"]'); + const findClipboardButton = () => wrapper.find(ClipboardButton); + const findPagination = () => wrapper.find(GlPagination); + + const mountComponent = () => { + wrapper = shallowMount(Component, { + stubs: { + RouterLink, + }, + propsData: { + images: imagesListResponse.data, + pagination: imagePagination, + }, + }); + }; + + beforeEach(() => { + mountComponent(); + }); + + it('contains one list element for each image', () => { + expect(findRowItems().length).toBe(imagesListResponse.data.length); + }); + + it('contains a link to the details page', () => { + const link = findDetailsLink(); + expect(link.html()).toContain(firstElement.path); + expect(link.props('to').name).toBe('details'); + }); + + it('contains a clipboard button', () => { + const button = findClipboardButton(); + expect(button.exists()).toBe(true); + expect(button.props('text')).toBe(firstElement.location); + expect(button.props('title')).toBe(firstElement.location); + }); + + it('should be possible to delete a repo', () => { + const deleteBtn = findDeleteBtn(); + expect(deleteBtn.exists()).toBe(true); + }); + + describe('pagination', () => { + it('exists', () => { + expect(findPagination().exists()).toBe(true); + }); + + it('is wired to the correct pagination props', () => { + const pagination = findPagination(); + expect(pagination.props('perPage')).toBe(imagePagination.perPage); + expect(pagination.props('totalItems')).toBe(imagePagination.total); + expect(pagination.props('value')).toBe(imagePagination.page); + }); + + it('emits a pageChange event when the page change', () => { + wrapper.setData({ currentPage: 2 }); + expect(wrapper.emitted('pageChange')).toEqual([[2]]); + }); + }); +}); diff --git a/spec/frontend/registry/explorer/mock_data.js b/spec/frontend/registry/explorer/mock_data.js index 2d8cd4e42bc..f6beccda9b1 100644 --- a/spec/frontend/registry/explorer/mock_data.js +++ b/spec/frontend/registry/explorer/mock_data.js @@ -87,3 +87,11 @@ export const tagsListResponse = { ], headers, }; + +export const imagePagination = { + perPage: 10, + page: 1, + total: 14, + totalPages: 2, + nextPage: 2, +}; diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js index 15aa5008413..93098403a28 100644 --- a/spec/frontend/registry/explorer/pages/details_spec.js +++ b/spec/frontend/registry/explorer/pages/details_spec.js @@ -1,15 +1,21 @@ import { mount } from '@vue/test-utils'; -import { GlTable, GlPagination, GlSkeletonLoader } from '@gitlab/ui'; +import { GlTable, GlPagination, GlSkeletonLoader, GlAlert, GlLink } from '@gitlab/ui'; import Tracking from '~/tracking'; import stubChildren from 'helpers/stub_children'; import component from '~/registry/explorer/pages/details.vue'; -import store from '~/registry/explorer/stores/'; -import { SET_MAIN_LOADING } from '~/registry/explorer/stores/mutation_types/'; +import { createStore } from '~/registry/explorer/stores/'; +import { + SET_MAIN_LOADING, + SET_INITIAL_STATE, + SET_TAGS_LIST_SUCCESS, + SET_TAGS_PAGINATION, +} from '~/registry/explorer/stores/mutation_types/'; import { DELETE_TAG_SUCCESS_MESSAGE, DELETE_TAG_ERROR_MESSAGE, DELETE_TAGS_SUCCESS_MESSAGE, DELETE_TAGS_ERROR_MESSAGE, + ADMIN_GARBAGE_COLLECTION_TIP, } from '~/registry/explorer/constants'; import { tagsListResponse } from '../mock_data'; import { GlModal } from '../stubs'; @@ -18,6 +24,7 @@ import { $toast } from '../../shared/mocks'; describe('Details Page', () => { let wrapper; let dispatchSpy; + let store; const findDeleteModal = () => wrapper.find(GlModal); const findPagination = () => wrapper.find(GlPagination); @@ -30,6 +37,8 @@ describe('Details Page', () => { const findAllCheckboxes = () => wrapper.findAll('.js-row-checkbox'); const findCheckedCheckboxes = () => findAllCheckboxes().filter(c => c.attributes('checked')); const findFirsTagColumn = () => wrapper.find('.js-tag-column'); + const findFirstTagNameText = () => wrapper.find('[data-testid="rowNameText"]'); + const findAlert = () => wrapper.find(GlAlert); const routeId = window.btoa(JSON.stringify({ name: 'foo', tags_path: 'bar' })); @@ -55,13 +64,17 @@ describe('Details Page', () => { }; beforeEach(() => { + store = createStore(); dispatchSpy = jest.spyOn(store, 'dispatch'); - store.dispatch('receiveTagsListSuccess', tagsListResponse); + dispatchSpy.mockResolvedValue(); + store.commit(SET_TAGS_LIST_SUCCESS, tagsListResponse.data); + store.commit(SET_TAGS_PAGINATION, tagsListResponse.headers); jest.spyOn(Tracking, 'event'); }); afterEach(() => { wrapper.destroy(); + wrapper = null; }); describe('when isLoading is true', () => { @@ -130,10 +143,6 @@ describe('Details Page', () => { }); describe('row checkbox', () => { - beforeEach(() => { - mountComponent(); - }); - it('if selected adds item to selectedItems', () => { findFirstRowItem('rowCheckbox').vm.$emit('change'); return wrapper.vm.$nextTick().then(() => { @@ -240,15 +249,24 @@ describe('Details Page', () => { }); }); - describe('tag cell', () => { + 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('has class w-25', () => { + 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', () => { @@ -260,9 +278,28 @@ describe('Details Page', () => { }); }); - it('does not has class w-25', () => { + 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(() => { + 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'); }); }); }); @@ -328,25 +365,9 @@ describe('Details Page', () => { }); // itemsToBeDeleted is not represented in the DOM, is used as parking variable between selected and deleted items expect(wrapper.vm.itemsToBeDeleted).toEqual([]); + expect(wrapper.vm.selectedItems).toEqual([]); expect(findCheckedCheckboxes()).toHaveLength(0); }); - - it('show success toast on successful delete', () => { - return wrapper.vm.handleSingleDelete(0).then(() => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(DELETE_TAG_SUCCESS_MESSAGE, { - type: 'success', - }); - }); - }); - - it('show error toast on erred delete', () => { - dispatchSpy.mockRejectedValue(); - return wrapper.vm.handleSingleDelete(0).then(() => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(DELETE_TAG_ERROR_MESSAGE, { - type: 'error', - }); - }); - }); }); describe('when multiple elements are selected', () => { @@ -365,23 +386,6 @@ describe('Details Page', () => { expect(wrapper.vm.itemsToBeDeleted).toEqual([]); expect(findCheckedCheckboxes()).toHaveLength(0); }); - - it('show success toast on successful delete', () => { - return wrapper.vm.handleMultipleDelete(0).then(() => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(DELETE_TAGS_SUCCESS_MESSAGE, { - type: 'success', - }); - }); - }); - - it('show error toast on erred delete', () => { - dispatchSpy.mockRejectedValue(); - return wrapper.vm.handleMultipleDelete(0).then(() => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(DELETE_TAGS_ERROR_MESSAGE, { - type: 'error', - }); - }); - }); }); }); @@ -395,4 +399,108 @@ describe('Details Page', () => { }); }); }); + + describe('Delete alert', () => { + const config = { + garbageCollectionHelpPagePath: 'foo', + }; + + describe('when the user is an admin', () => { + beforeEach(() => { + store.commit(SET_INITIAL_STATE, { ...config, isAdmin: true }); + }); + + afterEach(() => { + store.commit(SET_INITIAL_STATE, config); + }); + + describe.each` + deleteType | successTitle | errorTitle + ${'handleSingleDelete'} | ${DELETE_TAG_SUCCESS_MESSAGE} | ${DELETE_TAG_ERROR_MESSAGE} + ${'handleMultipleDelete'} | ${DELETE_TAGS_SUCCESS_MESSAGE} | ${DELETE_TAGS_ERROR_MESSAGE} + `('behaves correctly on $deleteType', ({ deleteType, successTitle, errorTitle }) => { + describe('when delete is successful', () => { + beforeEach(() => { + dispatchSpy.mockResolvedValue(); + mountComponent(); + return wrapper.vm[deleteType]('foo'); + }); + + it('alert exists', () => { + expect(findAlert().exists()).toBe(true); + }); + + it('alert body contains admin tip', () => { + expect( + findAlert() + .text() + .replace(/\s\s+/gm, ' '), + ).toBe(ADMIN_GARBAGE_COLLECTION_TIP.replace(/%{\w+}/gm, '')); + }); + + it('alert body contains link', () => { + const alertLink = findAlert().find(GlLink); + expect(alertLink.exists()).toBe(true); + expect(alertLink.attributes('href')).toBe(config.garbageCollectionHelpPagePath); + }); + + it('alert title is appropriate', () => { + expect(findAlert().attributes('title')).toBe(successTitle); + }); + }); + + describe('when delete is not successful', () => { + beforeEach(() => { + mountComponent(); + dispatchSpy.mockRejectedValue(); + return wrapper.vm[deleteType]('foo'); + }); + + it('alert exist and text is appropriate', () => { + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(errorTitle); + }); + }); + }); + }); + + describe.each` + deleteType | successTitle | errorTitle + ${'handleSingleDelete'} | ${DELETE_TAG_SUCCESS_MESSAGE} | ${DELETE_TAG_ERROR_MESSAGE} + ${'handleMultipleDelete'} | ${DELETE_TAGS_SUCCESS_MESSAGE} | ${DELETE_TAGS_ERROR_MESSAGE} + `( + 'when the user is not an admin alert behaves correctly on $deleteType', + ({ deleteType, successTitle, errorTitle }) => { + beforeEach(() => { + store.commit('SET_INITIAL_STATE', { ...config }); + }); + + describe('when delete is successful', () => { + beforeEach(() => { + dispatchSpy.mockResolvedValue(); + mountComponent(); + return wrapper.vm[deleteType]('foo'); + }); + + it('alert exist and text is appropriate', () => { + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(successTitle); + }); + }); + + describe('when delete is not successful', () => { + beforeEach(() => { + mountComponent(); + dispatchSpy.mockRejectedValue(); + return wrapper.vm[deleteType]('foo'); + }); + + it('alert exist and text is appropriate', () => { + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(errorTitle); + }); + }); + }, + ); + }); }); diff --git a/spec/frontend/registry/explorer/pages/index_spec.js b/spec/frontend/registry/explorer/pages/index_spec.js index f52e7d67866..b558727ed5e 100644 --- a/spec/frontend/registry/explorer/pages/index_spec.js +++ b/spec/frontend/registry/explorer/pages/index_spec.js @@ -1,62 +1,26 @@ import { shallowMount } from '@vue/test-utils'; -import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; import component from '~/registry/explorer/pages/index.vue'; import store from '~/registry/explorer/stores/'; describe('List Page', () => { let wrapper; - let dispatchSpy; const findRouterView = () => wrapper.find({ ref: 'router-view' }); - const findAlert = () => wrapper.find(GlAlert); - const findLink = () => wrapper.find(GlLink); const mountComponent = () => { wrapper = shallowMount(component, { store, stubs: { RouterView: true, - GlSprintf, }, }); }; beforeEach(() => { - dispatchSpy = jest.spyOn(store, 'dispatch'); mountComponent(); }); it('has a router view', () => { expect(findRouterView().exists()).toBe(true); }); - - describe('garbageCollectionTip alert', () => { - beforeEach(() => { - store.dispatch('setInitialState', { isAdmin: true, garbageCollectionHelpPagePath: 'foo' }); - store.dispatch('setShowGarbageCollectionTip', true); - }); - - afterEach(() => { - store.dispatch('setInitialState', {}); - store.dispatch('setShowGarbageCollectionTip', false); - }); - - it('is visible when the user is an admin and the user performed a delete action', () => { - expect(findAlert().exists()).toBe(true); - }); - - it('on dismiss disappears ', () => { - findAlert().vm.$emit('dismiss'); - expect(dispatchSpy).toHaveBeenCalledWith('setShowGarbageCollectionTip', false); - return wrapper.vm.$nextTick().then(() => { - expect(findAlert().exists()).toBe(false); - }); - }); - - it('contains a link to the docs', () => { - const link = findLink(); - expect(link.exists()).toBe(true); - expect(link.attributes('href')).toBe(store.state.config.garbageCollectionHelpPagePath); - }); - }); }); diff --git a/spec/frontend/registry/explorer/pages/list_spec.js b/spec/frontend/registry/explorer/pages/list_spec.js index f69b849521d..97742b9e9b3 100644 --- a/spec/frontend/registry/explorer/pages/list_spec.js +++ b/spec/frontend/registry/explorer/pages/list_spec.js @@ -1,47 +1,53 @@ -import VueRouter from 'vue-router'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlPagination, GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { GlSkeletonLoader, GlSprintf, GlAlert, GlSearchBoxByClick } from '@gitlab/ui'; import Tracking from '~/tracking'; +import waitForPromises from 'helpers/wait_for_promises'; import component from '~/registry/explorer/pages/list.vue'; import QuickstartDropdown from '~/registry/explorer/components/quickstart_dropdown.vue'; import GroupEmptyState from '~/registry/explorer/components/group_empty_state.vue'; import ProjectEmptyState from '~/registry/explorer/components/project_empty_state.vue'; import ProjectPolicyAlert from '~/registry/explorer/components/project_policy_alert.vue'; -import store from '~/registry/explorer/stores/'; -import { SET_MAIN_LOADING } from '~/registry/explorer/stores/mutation_types/'; +import ImageList from '~/registry/explorer/components/image_list.vue'; +import { createStore } from '~/registry/explorer/stores/'; +import { + SET_MAIN_LOADING, + SET_IMAGES_LIST_SUCCESS, + SET_PAGINATION, + SET_INITIAL_STATE, +} from '~/registry/explorer/stores/mutation_types/'; import { DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE, + IMAGE_REPOSITORY_LIST_LABEL, + SEARCH_PLACEHOLDER_TEXT, } from '~/registry/explorer/constants'; import { imagesListResponse } from '../mock_data'; import { GlModal, GlEmptyState } from '../stubs'; import { $toast } from '../../shared/mocks'; -const localVue = createLocalVue(); -localVue.use(VueRouter); - describe('List Page', () => { let wrapper; let dispatchSpy; + let store; - const findDeleteBtn = () => wrapper.find({ ref: 'deleteImageButton' }); const findDeleteModal = () => wrapper.find(GlModal); const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader); const findImagesList = () => wrapper.find({ ref: 'imagesList' }); - const findRowItems = () => wrapper.findAll({ ref: 'rowItem' }); + const findEmptyState = () => wrapper.find(GlEmptyState); - const findDetailsLink = () => wrapper.find({ ref: 'detailsLink' }); - const findClipboardButton = () => wrapper.find({ ref: 'clipboardButton' }); - const findPagination = () => wrapper.find(GlPagination); + const findQuickStartDropdown = () => wrapper.find(QuickstartDropdown); const findProjectEmptyState = () => wrapper.find(ProjectEmptyState); const findGroupEmptyState = () => wrapper.find(GroupEmptyState); const findProjectPolicyAlert = () => wrapper.find(ProjectPolicyAlert); const findDeleteAlert = () => wrapper.find(GlAlert); + const findImageList = () => wrapper.find(ImageList); + const findListHeader = () => wrapper.find('[data-testid="listHeader"]'); + const findSearchBox = () => wrapper.find(GlSearchBoxByClick); + const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]'); - beforeEach(() => { + const mountComponent = ({ mocks } = {}) => { wrapper = shallowMount(component, { - localVue, store, stubs: { GlModal, @@ -50,10 +56,20 @@ describe('List Page', () => { }, mocks: { $toast, + $route: { + name: 'foo', + }, + ...mocks, }, }); + }; + + beforeEach(() => { + store = createStore(); dispatchSpy = jest.spyOn(store, 'dispatch'); - store.dispatch('receiveImagesListSuccess', imagesListResponse); + dispatchSpy.mockResolvedValue(); + store.commit(SET_IMAGES_LIST_SUCCESS, imagesListResponse.data); + store.commit(SET_PAGINATION, imagesListResponse.headers); }); afterEach(() => { @@ -61,17 +77,38 @@ describe('List Page', () => { }); describe('Expiration policy notification', () => { + beforeEach(() => { + mountComponent(); + }); it('shows up on project page', () => { expect(findProjectPolicyAlert().exists()).toBe(true); }); it('does show up on group page', () => { - store.dispatch('setInitialState', { isGroupPage: true }); + store.commit(SET_INITIAL_STATE, { isGroupPage: true }); return wrapper.vm.$nextTick().then(() => { expect(findProjectPolicyAlert().exists()).toBe(false); }); }); }); + describe('API calls', () => { + it.each` + imageList | name | called + ${[]} | ${'foo'} | ${['requestImagesList']} + ${imagesListResponse.data} | ${undefined} | ${['requestImagesList']} + ${imagesListResponse.data} | ${'foo'} | ${undefined} + `( + 'with images equal $imageList and name $name dispatch calls $called', + ({ imageList, name, called }) => { + store.commit(SET_IMAGES_LIST_SUCCESS, imageList); + dispatchSpy.mockClear(); + mountComponent({ mocks: { $route: { name } } }); + + expect(dispatchSpy.mock.calls[0]).toEqual(called); + }, + ); + }); + describe('connection error', () => { const config = { characterError: true, @@ -79,12 +116,13 @@ describe('List Page', () => { helpPagePath: 'bar', }; - beforeAll(() => { - store.dispatch('setInitialState', config); + beforeEach(() => { + store.commit(SET_INITIAL_STATE, config); + mountComponent(); }); - afterAll(() => { - store.dispatch('setInitialState', {}); + afterEach(() => { + store.commit(SET_INITIAL_STATE, {}); }); it('should show an empty state', () => { @@ -106,9 +144,12 @@ describe('List Page', () => { }); describe('isLoading is true', () => { - beforeAll(() => store.commit(SET_MAIN_LOADING, true)); + beforeEach(() => { + store.commit(SET_MAIN_LOADING, true); + mountComponent(); + }); - afterAll(() => store.commit(SET_MAIN_LOADING, false)); + afterEach(() => store.commit(SET_MAIN_LOADING, false)); it('shows the skeleton loader', () => { expect(findSkeletonLoader().exists()).toBe(true); @@ -125,7 +166,9 @@ describe('List Page', () => { describe('list is empty', () => { beforeEach(() => { - store.dispatch('receiveImagesListSuccess', { data: [] }); + store.commit(SET_IMAGES_LIST_SUCCESS, []); + mountComponent(); + return waitForPromises(); }); it('quick start is not visible', () => { @@ -137,12 +180,13 @@ describe('List Page', () => { }); describe('is group page is true', () => { - beforeAll(() => { - store.dispatch('setInitialState', { isGroupPage: true }); + beforeEach(() => { + store.commit(SET_INITIAL_STATE, { isGroupPage: true }); + mountComponent(); }); - afterAll(() => { - store.dispatch('setInitialState', { isGroupPage: undefined }); + afterEach(() => { + store.commit(SET_INITIAL_STATE, { isGroupPage: undefined }); }); it('group empty state is visible', () => { @@ -152,50 +196,39 @@ describe('List Page', () => { it('quick start is not visible', () => { expect(findQuickStartDropdown().exists()).toBe(false); }); + + it('list header is not visible', () => { + expect(findListHeader().exists()).toBe(false); + }); }); }); describe('list is not empty', () => { - it('quick start is visible', () => { - expect(findQuickStartDropdown().exists()).toBe(true); - }); - - describe('listElement', () => { - let listElements; - let firstElement; - + describe('unfiltered state', () => { beforeEach(() => { - listElements = findRowItems(); - [firstElement] = store.state.images; + mountComponent(); }); - it('contains one list element for each image', () => { - expect(listElements.length).toBe(store.state.images.length); + it('quick start is visible', () => { + expect(findQuickStartDropdown().exists()).toBe(true); }); - it('contains a link to the details page', () => { - const link = findDetailsLink(); - expect(link.html()).toContain(firstElement.path); - expect(link.props('to').name).toBe('details'); + it('list component is visible', () => { + expect(findImageList().exists()).toBe(true); }); - it('contains a clipboard button', () => { - const button = findClipboardButton(); - expect(button.exists()).toBe(true); - expect(button.props('text')).toBe(firstElement.location); - expect(button.props('title')).toBe(firstElement.location); + it('list header is visible', () => { + const header = findListHeader(); + expect(header.exists()).toBe(true); + expect(header.text()).toBe(IMAGE_REPOSITORY_LIST_LABEL); }); describe('delete image', () => { - it('should be possible to delete a repo', () => { - const deleteBtn = findDeleteBtn(); - expect(deleteBtn.exists()).toBe(true); - }); - + const itemToDelete = { path: 'bar' }; it('should call deleteItem when confirming deletion', () => { dispatchSpy.mockResolvedValue(); - findDeleteBtn().vm.$emit('click'); - expect(wrapper.vm.itemToDelete).not.toEqual({}); + findImageList().vm.$emit('delete', itemToDelete); + expect(wrapper.vm.itemToDelete).toEqual(itemToDelete); findDeleteModal().vm.$emit('ok'); expect(store.dispatch).toHaveBeenCalledWith( 'requestDeleteImage', @@ -205,8 +238,8 @@ describe('List Page', () => { it('should show a success alert when delete request is successful', () => { dispatchSpy.mockResolvedValue(); - findDeleteBtn().vm.$emit('click'); - expect(wrapper.vm.itemToDelete).not.toEqual({}); + findImageList().vm.$emit('delete', itemToDelete); + expect(wrapper.vm.itemToDelete).toEqual(itemToDelete); return wrapper.vm.handleDeleteImage().then(() => { const alert = findDeleteAlert(); expect(alert.exists()).toBe(true); @@ -218,8 +251,8 @@ describe('List Page', () => { it('should show an error alert when delete request fails', () => { dispatchSpy.mockRejectedValue(); - findDeleteBtn().vm.$emit('click'); - expect(wrapper.vm.itemToDelete).not.toEqual({}); + findImageList().vm.$emit('delete', itemToDelete); + expect(wrapper.vm.itemToDelete).toEqual(itemToDelete); return wrapper.vm.handleDeleteImage().then(() => { const alert = findDeleteAlert(); expect(alert.exists()).toBe(true); @@ -229,71 +262,93 @@ describe('List Page', () => { }); }); }); + }); - describe('pagination', () => { - it('exists', () => { - expect(findPagination().exists()).toBe(true); - }); + describe('search', () => { + it('has a search box element', () => { + mountComponent(); + const searchBox = findSearchBox(); + expect(searchBox.exists()).toBe(true); + expect(searchBox.attributes('placeholder')).toBe(SEARCH_PLACEHOLDER_TEXT); + }); - it('is wired to the correct pagination props', () => { - const pagination = findPagination(); - expect(pagination.props('perPage')).toBe(store.state.pagination.perPage); - expect(pagination.props('totalItems')).toBe(store.state.pagination.total); - expect(pagination.props('value')).toBe(store.state.pagination.page); + it('performs a search', () => { + mountComponent(); + findSearchBox().vm.$emit('submit', 'foo'); + expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', { + name: 'foo', }); + }); - it('fetch the data from the API when the v-model changes', () => { - dispatchSpy.mockReturnValue(); - wrapper.setData({ currentPage: 2 }); - return wrapper.vm.$nextTick().then(() => { - expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', { page: 2 }); - }); + it('when search result is empty displays an empty search message', () => { + mountComponent(); + store.commit(SET_IMAGES_LIST_SUCCESS, []); + return wrapper.vm.$nextTick().then(() => { + expect(findEmptySearchMessage().exists()).toBe(true); }); }); }); - describe('modal', () => { - it('exists', () => { - expect(findDeleteModal().exists()).toBe(true); - }); - - it('contains a description with the path of the item to delete', () => { - wrapper.setData({ itemToDelete: { path: 'foo' } }); - return wrapper.vm.$nextTick().then(() => { - expect(findDeleteModal().html()).toContain('foo'); + describe('pagination', () => { + it('pageChange event triggers the appropriate store function', () => { + mountComponent(); + findImageList().vm.$emit('pageChange', 2); + expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', { + pagination: { page: 2 }, + name: wrapper.vm.search, }); }); }); + }); - describe('tracking', () => { - const testTrackingCall = action => { - expect(Tracking.event).toHaveBeenCalledWith(undefined, action, { - label: 'registry_repository_delete', - }); - }; + describe('modal', () => { + beforeEach(() => { + mountComponent(); + }); - beforeEach(() => { - jest.spyOn(Tracking, 'event'); - dispatchSpy.mockResolvedValue(); - }); + it('exists', () => { + expect(findDeleteModal().exists()).toBe(true); + }); - it('send an event when delete button is clicked', () => { - const deleteBtn = findDeleteBtn(); - deleteBtn.vm.$emit('click'); - testTrackingCall('click_button'); + it('contains a description with the path of the item to delete', () => { + wrapper.setData({ itemToDelete: { path: 'foo' } }); + return wrapper.vm.$nextTick().then(() => { + expect(findDeleteModal().html()).toContain('foo'); }); + }); + }); - it('send an event when cancel is pressed on modal', () => { - const deleteModal = findDeleteModal(); - deleteModal.vm.$emit('cancel'); - testTrackingCall('cancel_delete'); - }); + describe('tracking', () => { + beforeEach(() => { + mountComponent(); + }); - it('send an event when confirm is clicked on modal', () => { - const deleteModal = findDeleteModal(); - deleteModal.vm.$emit('ok'); - testTrackingCall('confirm_delete'); + const testTrackingCall = action => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, action, { + label: 'registry_repository_delete', }); + }; + + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + dispatchSpy.mockResolvedValue(); + }); + + it('send an event when delete button is clicked', () => { + findImageList().vm.$emit('delete', {}); + testTrackingCall('click_button'); + }); + + it('send an event when cancel is pressed on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('cancel'); + testTrackingCall('cancel_delete'); + }); + + it('send an event when confirm is clicked on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('ok'); + testTrackingCall('confirm_delete'); }); }); }); diff --git a/spec/frontend/registry/explorer/stores/actions_spec.js b/spec/frontend/registry/explorer/stores/actions_spec.js index 58f61a0e8c2..15f9db90910 100644 --- a/spec/frontend/registry/explorer/stores/actions_spec.js +++ b/spec/frontend/registry/explorer/stores/actions_spec.js @@ -191,7 +191,10 @@ describe('Actions RegistryExplorer Store', () => { { tagsPagination: {}, }, - [{ type: types.SET_MAIN_LOADING, payload: true }], + [ + { type: types.SET_MAIN_LOADING, payload: true }, + { type: types.SET_MAIN_LOADING, payload: false }, + ], [ { type: 'setShowGarbageCollectionTip', @@ -220,8 +223,7 @@ describe('Actions RegistryExplorer Store', () => { { type: types.SET_MAIN_LOADING, payload: false }, ], [], - done, - ); + ).catch(() => done()); }); }); @@ -241,7 +243,10 @@ describe('Actions RegistryExplorer Store', () => { { tagsPagination: {}, }, - [{ type: types.SET_MAIN_LOADING, payload: true }], + [ + { type: types.SET_MAIN_LOADING, payload: true }, + { type: types.SET_MAIN_LOADING, payload: false }, + ], [ { type: 'setShowGarbageCollectionTip', @@ -273,8 +278,7 @@ describe('Actions RegistryExplorer Store', () => { { type: types.SET_MAIN_LOADING, payload: false }, ], [], - done, - ); + ).catch(() => done()); }); }); @@ -311,9 +315,7 @@ describe('Actions RegistryExplorer Store', () => { { type: types.SET_MAIN_LOADING, payload: false }, ], [], - ).catch(() => { - done(); - }); + ).catch(() => done()); }); }); }); diff --git a/spec/frontend/registry/explorer/stubs.js b/spec/frontend/registry/explorer/stubs.js index 2c2c7587af9..0e178abfbed 100644 --- a/spec/frontend/registry/explorer/stubs.js +++ b/spec/frontend/registry/explorer/stubs.js @@ -9,3 +9,8 @@ export const GlEmptyState = { template: '<div><slot name="description"></slot></div>', name: 'GlEmptyStateSTub', }; + +export const RouterLink = { + template: `<div><slot></slot></div>`, + props: ['to'], +}; diff --git a/spec/frontend/registry/settings/store/getters_spec.js b/spec/frontend/registry/settings/store/getters_spec.js index 944057ebc9f..b781d09466c 100644 --- a/spec/frontend/registry/settings/store/getters_spec.js +++ b/spec/frontend/registry/settings/store/getters_spec.js @@ -4,9 +4,12 @@ import { formOptions } from '../../shared/mock_data'; describe('Getters registry settings store', () => { const settings = { + enabled: true, cadence: 'foo', keep_n: 'bar', older_than: 'baz', + name_regex: 'name-foo', + name_regex_keep: 'name-keep-bar', }; describe.each` @@ -29,6 +32,17 @@ describe('Getters registry settings store', () => { }); }); + describe('getSettings', () => { + it('returns the content of settings', () => { + const computedGetters = { + getCadence: settings.cadence, + getOlderThan: settings.older_than, + getKeepN: settings.keep_n, + }; + expect(getters.getSettings({ settings }, computedGetters)).toEqual(settings); + }); + }); + describe('getIsEdited', () => { it('returns false when original is equal to settings', () => { const same = { foo: 'bar' }; 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 6e7bc0491ce..a9034b81d2f 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 @@ -117,11 +117,11 @@ 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="Docker tags with names matching this regex pattern will expire:" label-align="right" label-cols="3" label-for="expiration-policy-name-matching" > + <gl-form-textarea-stub disabled="true" id="expiration-policy-name-matching" @@ -130,5 +130,21 @@ exports[`Expiration Policy Form renders 1`] = ` value="" /> </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" + > + + <gl-form-textarea-stub + disabled="true" + id="expiration-policy-keep-name" + placeholder="" + trim="" + value="" + /> + </gl-form-group-stub> </div> `; 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 3782bfeaac4..4825351a6d3 100644 --- a/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js +++ b/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js @@ -40,12 +40,13 @@ describe('Expiration Policy Form', () => { }); describe.each` - elementName | modelName | value | disabledByToggle - ${'toggle'} | ${'enabled'} | ${true} | ${'not disabled'} - ${'interval'} | ${'older_than'} | ${'foo'} | ${'disabled'} - ${'schedule'} | ${'cadence'} | ${'foo'} | ${'disabled'} - ${'latest'} | ${'keep_n'} | ${'foo'} | ${'disabled'} - ${'name-matching'} | ${'name_regex'} | ${'foo'} | ${'disabled'} + elementName | modelName | value | disabledByToggle + ${'toggle'} | ${'enabled'} | ${true} | ${'not disabled'} + ${'interval'} | ${'older_than'} | ${'foo'} | ${'disabled'} + ${'schedule'} | ${'cadence'} | ${'foo'} | ${'disabled'} + ${'latest'} | ${'keep_n'} | ${'foo'} | ${'disabled'} + ${'name-matching'} | ${'name_regex'} | ${'foo'} | ${'disabled'} + ${'keep-name'} | ${'name_regex_keep'} | ${'bar'} | ${'disabled'} `( `${FORM_ELEMENTS_ID_PREFIX}-$elementName form element`, ({ elementName, modelName, value, disabledByToggle }) => { @@ -118,21 +119,26 @@ describe('Expiration Policy Form', () => { ${'schedule'} ${'latest'} ${'name-matching'} + ${'keep-name'} `(`${FORM_ELEMENTS_ID_PREFIX}-$elementName is disabled`, ({ elementName }) => { expect(findFormElements(elementName).attributes('disabled')).toBe('true'); }); }); - describe('form validation', () => { + 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: { name_regex: invalidString } }); + mountComponent({ value: { [modelName]: invalidString } }); }); - it('nameRegexState is false', () => { - expect(wrapper.vm.nameRegexState).toBe(false); + it(`${stateVariable} is false`, () => { + expect(wrapper.vm.textAreaState[stateVariable]).toBe(false); }); it('emit the @invalidated event', () => { @@ -141,17 +147,20 @@ describe('Expiration Policy Form', () => { }); it('if the user did not type validation is null', () => { - mountComponent({ value: { name_regex: '' } }); + mountComponent({ value: { [modelName]: '' } }); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.nameRegexState).toBe(null); + expect(wrapper.vm.textAreaState[stateVariable]).toBe(null); expect(wrapper.emitted('validated')).toBeTruthy(); }); }); it(`if the user typed and is less than ${NAME_REGEX_LENGTH} state is true`, () => { - mountComponent({ value: { name_regex: 'foo' } }); + mountComponent({ value: { [modelName]: 'foo' } }); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.nameRegexState).toBe(true); + const formGroup = findFormGroup(elementName); + const formElement = findFormElements(elementName, formGroup); + expect(formGroup.attributes('state')).toBeTruthy(); + expect(formElement.attributes('state')).toBeTruthy(); }); }); }); diff --git a/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js b/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js new file mode 100644 index 00000000000..1b938c93df8 --- /dev/null +++ b/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js @@ -0,0 +1,94 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; +import RelatedMergeRequests from '~/related_merge_requests/components/related_merge_requests.vue'; +import createStore from '~/related_merge_requests/store/index'; + +const FIXTURE_PATH = 'issues/related_merge_requests.json'; +const API_ENDPOINT = '/api/v4/projects/2/issues/33/related_merge_requests'; +const localVue = createLocalVue(); + +describe('RelatedMergeRequests', () => { + let wrapper; + let mock; + let mockData; + + beforeEach(done => { + loadFixtures(FIXTURE_PATH); + mockData = getJSONFixture(FIXTURE_PATH); + + // put the fixture in DOM as the component expects + document.body.innerHTML = `<div id="js-issuable-app-initial-data">${JSON.stringify( + mockData, + )}</div>`; + + mock = new MockAdapter(axios); + mock.onGet(`${API_ENDPOINT}?per_page=100`).reply(200, mockData, { 'x-total': 2 }); + + wrapper = mount(localVue.extend(RelatedMergeRequests), { + localVue, + store: createStore(), + propsData: { + endpoint: API_ENDPOINT, + projectNamespace: 'gitlab-org', + projectPath: 'gitlab-ce', + }, + }); + + setImmediate(done); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + describe('methods', () => { + describe('getAssignees', () => { + const assignees = [{ name: 'foo' }, { name: 'bar' }]; + + describe('when there is assignees array', () => { + it('should return assignees array', () => { + const mr = { assignees }; + + expect(wrapper.vm.getAssignees(mr)).toEqual(assignees); + }); + }); + + it('should return an array with single assingee', () => { + const mr = { assignee: assignees[0] }; + + expect(wrapper.vm.getAssignees(mr)).toEqual([assignees[0]]); + }); + + it('should return empty array when assignee is not set', () => { + expect(wrapper.vm.getAssignees({})).toEqual([]); + expect(wrapper.vm.getAssignees({ assignee: null })).toEqual([]); + }); + }); + }); + + describe('template', () => { + it('should render related merge request items', () => { + expect(wrapper.find('.js-items-count').text()).toEqual('2'); + expect(wrapper.findAll(RelatedIssuableItem).length).toEqual(2); + + const props = wrapper + .findAll(RelatedIssuableItem) + .at(1) + .props(); + const data = mockData[1]; + + expect(props.idKey).toEqual(data.id); + expect(props.pathIdSeparator).toEqual('!'); + expect(props.pipelineStatus).toBe(data.head_pipeline.detailed_status); + expect(props.assignees).toEqual([data.assignee]); + expect(props.isMergeRequest).toBe(true); + expect(props.confidential).toEqual(false); + expect(props.title).toEqual(data.title); + expect(props.state).toEqual(data.state); + expect(props.createdAt).toEqual(data.created_at); + }); + }); +}); diff --git a/spec/frontend/related_merge_requests/store/actions_spec.js b/spec/frontend/related_merge_requests/store/actions_spec.js new file mode 100644 index 00000000000..26c5977cb5f --- /dev/null +++ b/spec/frontend/related_merge_requests/store/actions_spec.js @@ -0,0 +1,111 @@ +import MockAdapter from 'axios-mock-adapter'; +import createFlash from '~/flash'; +import testAction from 'helpers/vuex_action_helper'; +import axios from '~/lib/utils/axios_utils'; +import * as types from '~/related_merge_requests/store/mutation_types'; +import * as actions from '~/related_merge_requests/store/actions'; + +jest.mock('~/flash'); + +describe('RelatedMergeRequest store actions', () => { + let state; + let mock; + + beforeEach(() => { + state = { + apiEndpoint: '/api/related_merge_requests', + }; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('setInitialState', () => { + it('commits types.SET_INITIAL_STATE with given props', done => { + const props = { a: 1, b: 2 }; + + testAction( + actions.setInitialState, + props, + {}, + [{ type: types.SET_INITIAL_STATE, payload: props }], + [], + done, + ); + }); + }); + + describe('requestData', () => { + it('commits types.REQUEST_DATA', done => { + testAction(actions.requestData, null, {}, [{ type: types.REQUEST_DATA }], [], done); + }); + }); + + describe('receiveDataSuccess', () => { + it('commits types.RECEIVE_DATA_SUCCESS with data', done => { + const data = { a: 1, b: 2 }; + + testAction( + actions.receiveDataSuccess, + data, + {}, + [{ type: types.RECEIVE_DATA_SUCCESS, payload: data }], + [], + done, + ); + }); + }); + + describe('receiveDataError', () => { + it('commits types.RECEIVE_DATA_ERROR', done => { + testAction( + actions.receiveDataError, + null, + {}, + [{ type: types.RECEIVE_DATA_ERROR }], + [], + done, + ); + }); + }); + + describe('fetchMergeRequests', () => { + describe('for a successful request', () => { + it('should dispatch success action', done => { + const data = { a: 1 }; + mock.onGet(`${state.apiEndpoint}?per_page=100`).replyOnce(200, data, { 'x-total': 2 }); + + testAction( + actions.fetchMergeRequests, + null, + state, + [], + [{ type: 'requestData' }, { type: 'receiveDataSuccess', payload: { data, total: 2 } }], + done, + ); + }); + }); + + describe('for a failing request', () => { + it('should dispatch error action', done => { + mock.onGet(`${state.apiEndpoint}?per_page=100`).replyOnce(400); + + testAction( + actions.fetchMergeRequests, + null, + state, + [], + [{ type: 'requestData' }, { type: 'receiveDataError' }], + () => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith(expect.stringMatching('Something went wrong')); + + done(); + }, + ); + }); + }); + }); +}); diff --git a/spec/frontend/related_merge_requests/store/mutations_spec.js b/spec/frontend/related_merge_requests/store/mutations_spec.js new file mode 100644 index 00000000000..21b6e26376b --- /dev/null +++ b/spec/frontend/related_merge_requests/store/mutations_spec.js @@ -0,0 +1,49 @@ +import mutations from '~/related_merge_requests/store/mutations'; +import * as types from '~/related_merge_requests/store/mutation_types'; + +describe('RelatedMergeRequests Store Mutations', () => { + describe('SET_INITIAL_STATE', () => { + it('should set initial state according to given data', () => { + const apiEndpoint = '/api'; + const state = {}; + + mutations[types.SET_INITIAL_STATE](state, { apiEndpoint }); + + expect(state.apiEndpoint).toEqual(apiEndpoint); + }); + }); + + describe('REQUEST_DATA', () => { + it('should set loading flag', () => { + const state = {}; + + mutations[types.REQUEST_DATA](state); + + expect(state.isFetchingMergeRequests).toEqual(true); + }); + }); + + describe('RECEIVE_DATA_SUCCESS', () => { + it('should set loading flag and data', () => { + const state = {}; + const mrs = [1, 2, 3]; + + mutations[types.RECEIVE_DATA_SUCCESS](state, { data: mrs, total: mrs.length }); + + expect(state.isFetchingMergeRequests).toEqual(false); + expect(state.mergeRequests).toEqual(mrs); + expect(state.totalCount).toEqual(mrs.length); + }); + }); + + describe('RECEIVE_DATA_ERROR', () => { + it('should set loading and error flags', () => { + const state = {}; + + mutations[types.RECEIVE_DATA_ERROR](state); + + expect(state.isFetchingMergeRequests).toEqual(false); + expect(state.hasErrorFetchingMergeRequests).toEqual(true); + }); + }); +}); diff --git a/spec/frontend/releases/components/app_edit_spec.js b/spec/frontend/releases/components/app_edit_spec.js index 09bafe4aa9b..4450b047acd 100644 --- a/spec/frontend/releases/components/app_edit_spec.js +++ b/spec/frontend/releases/components/app_edit_spec.js @@ -1,11 +1,13 @@ import Vuex from 'vuex'; import { mount } from '@vue/test-utils'; import ReleaseEditApp from '~/releases/components/app_edit.vue'; -import { release as originalRelease } from '../mock_data'; +import { release as originalRelease, milestones as originalMilestones } from '../mock_data'; import * as commonUtils from '~/lib/utils/common_utils'; import { BACK_URL_PARAM } from '~/releases/constants'; import AssetLinksForm from '~/releases/components/asset_links_form.vue'; import { merge } from 'lodash'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; describe('Release edit component', () => { let wrapper; @@ -13,6 +15,7 @@ describe('Release edit component', () => { let actions; let getters; let state; + let mock; const factory = ({ featureFlags = {}, store: storeUpdates = {} } = {}) => { state = { @@ -20,6 +23,7 @@ describe('Release edit component', () => { markdownDocsPath: 'path/to/markdown/docs', updateReleaseApiDocsPath: 'path/to/update/release/api/docs', releasesPagePath: 'path/to/releases/page', + projectId: '8', }; actions = { @@ -62,8 +66,11 @@ describe('Release edit component', () => { }; beforeEach(() => { + mock = new MockAdapter(axios); gon.api_version = 'v4'; + mock.onGet('/api/v4/projects/8/milestones').reply(200, originalMilestones); + release = commonUtils.convertObjectPropsToCamelCase(originalRelease, { deep: true }); }); diff --git a/spec/frontend/releases/components/release_block_footer_spec.js b/spec/frontend/releases/components/release_block_footer_spec.js index c63637c4cae..b91cfb82b65 100644 --- a/spec/frontend/releases/components/release_block_footer_spec.js +++ b/spec/frontend/releases/components/release_block_footer_spec.js @@ -3,13 +3,17 @@ import { GlLink } from '@gitlab/ui'; import { trimText } from 'helpers/text_helper'; import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue'; import Icon from '~/vue_shared/components/icon.vue'; -import { release } from '../mock_data'; +import { release as originalRelease } from '../mock_data'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { cloneDeep } from 'lodash'; + +const mockFutureDate = new Date(9999, 0, 0).toISOString(); +let mockIsFutureRelease = false; jest.mock('~/vue_shared/mixins/timeago', () => ({ methods: { timeFormatted() { - return '7 fortnights ago'; + return mockIsFutureRelease ? 'in 1 month' : '7 fortnights ago'; }, tooltipTitle() { return 'February 30, 2401'; @@ -19,12 +23,12 @@ jest.mock('~/vue_shared/mixins/timeago', () => ({ describe('Release block footer', () => { let wrapper; - let releaseClone; + let release; const factory = (props = {}) => { wrapper = mount(ReleaseBlockFooter, { propsData: { - ...convertObjectPropsToCamelCase(releaseClone, { deep: true }), + ...convertObjectPropsToCamelCase(release, { deep: true }), ...props, }, }); @@ -33,11 +37,13 @@ describe('Release block footer', () => { }; beforeEach(() => { - releaseClone = JSON.parse(JSON.stringify(release)); + release = cloneDeep(originalRelease); }); afterEach(() => { wrapper.destroy(); + wrapper = null; + mockIsFutureRelease = false; }); const commitInfoSection = () => wrapper.find('.js-commit-info'); @@ -60,8 +66,8 @@ describe('Release block footer', () => { const commitLink = commitInfoSectionLink(); expect(commitLink.exists()).toBe(true); - expect(commitLink.text()).toBe(releaseClone.commit.short_id); - expect(commitLink.attributes('href')).toBe(releaseClone.commit_path); + expect(commitLink.text()).toBe(release.commit.short_id); + expect(commitLink.attributes('href')).toBe(release.commit_path); }); it('renders the tag icon', () => { @@ -75,28 +81,60 @@ describe('Release block footer', () => { const commitLink = tagInfoSection().find(GlLink); expect(commitLink.exists()).toBe(true); - expect(commitLink.text()).toBe(releaseClone.tag_name); - expect(commitLink.attributes('href')).toBe(releaseClone.tag_path); + expect(commitLink.text()).toBe(release.tag_name); + expect(commitLink.attributes('href')).toBe(release.tag_path); }); it('renders the author and creation time info', () => { expect(trimText(authorDateInfoSection().text())).toBe( - `Created 7 fortnights ago by ${releaseClone.author.username}`, + `Created 7 fortnights ago by ${release.author.username}`, ); }); + describe('when the release date is in the past', () => { + it('prefixes the creation info with "Created"', () => { + expect(trimText(authorDateInfoSection().text())).toEqual(expect.stringMatching(/^Created/)); + }); + }); + + describe('renders the author and creation time info with future release date', () => { + beforeEach(() => { + mockIsFutureRelease = true; + factory({ releasedAt: mockFutureDate }); + }); + + it('renders the release date without the author name', () => { + expect(trimText(authorDateInfoSection().text())).toBe( + `Will be created in 1 month by ${release.author.username}`, + ); + }); + }); + + describe('when the release date is in the future', () => { + beforeEach(() => { + mockIsFutureRelease = true; + factory({ releasedAt: mockFutureDate }); + }); + + it('prefixes the creation info with "Will be created"', () => { + expect(trimText(authorDateInfoSection().text())).toEqual( + expect.stringMatching(/^Will be created/), + ); + }); + }); + it("renders the author's avatar image", () => { const avatarImg = authorDateInfoSection().find('img'); expect(avatarImg.exists()).toBe(true); - expect(avatarImg.attributes('src')).toBe(releaseClone.author.avatar_url); + expect(avatarImg.attributes('src')).toBe(release.author.avatar_url); }); it("renders a link to the author's profile", () => { const authorLink = authorDateInfoSection().find(GlLink); expect(authorLink.exists()).toBe(true); - expect(authorLink.attributes('href')).toBe(releaseClone.author.web_url); + expect(authorLink.attributes('href')).toBe(release.author.web_url); }); }); @@ -113,7 +151,7 @@ describe('Release block footer', () => { it('renders the commit SHA as plain text (instead of a link)', () => { expect(commitInfoSectionLink().exists()).toBe(false); - expect(commitInfoSection().text()).toBe(releaseClone.commit.short_id); + expect(commitInfoSection().text()).toBe(release.commit.short_id); }); }); @@ -130,7 +168,7 @@ describe('Release block footer', () => { it('renders the tag name as plain text (instead of a link)', () => { expect(tagInfoSectionLink().exists()).toBe(false); - expect(tagInfoSection().text()).toBe(releaseClone.tag_name); + expect(tagInfoSection().text()).toBe(release.tag_name); }); }); @@ -138,7 +176,18 @@ describe('Release block footer', () => { beforeEach(() => factory({ author: undefined })); it('renders the release date without the author name', () => { - expect(trimText(authorDateInfoSection().text())).toBe('Created 7 fortnights ago'); + expect(trimText(authorDateInfoSection().text())).toBe(`Created 7 fortnights ago`); + }); + }); + + describe('future release without any author info', () => { + beforeEach(() => { + mockIsFutureRelease = true; + factory({ author: undefined, releasedAt: mockFutureDate }); + }); + + it('renders the release date without the author name', () => { + expect(trimText(authorDateInfoSection().text())).toBe(`Will be created in 1 month`); }); }); @@ -147,7 +196,7 @@ describe('Release block footer', () => { it('renders the author name without the release date', () => { expect(trimText(authorDateInfoSection().text())).toBe( - `Created by ${releaseClone.author.username}`, + `Created by ${release.author.username}`, ); }); }); diff --git a/spec/frontend/releases/components/release_block_metadata_spec.js b/spec/frontend/releases/components/release_block_metadata_spec.js new file mode 100644 index 00000000000..cbe478bfa1f --- /dev/null +++ b/spec/frontend/releases/components/release_block_metadata_spec.js @@ -0,0 +1,67 @@ +import { mount } from '@vue/test-utils'; +import { trimText } from 'helpers/text_helper'; +import ReleaseBlockMetadata from '~/releases/components/release_block_metadata.vue'; +import { release as originalRelease } from '../mock_data'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { cloneDeep } from 'lodash'; + +const mockFutureDate = new Date(9999, 0, 0).toISOString(); +let mockIsFutureRelease = false; + +jest.mock('~/vue_shared/mixins/timeago', () => ({ + methods: { + timeFormatted() { + return mockIsFutureRelease ? 'in 1 month' : '7 fortnights ago'; + }, + tooltipTitle() { + return 'February 30, 2401'; + }, + }, +})); + +describe('Release block metadata', () => { + let wrapper; + let release; + + const factory = (releaseUpdates = {}) => { + wrapper = mount(ReleaseBlockMetadata, { + propsData: { + release: { + ...convertObjectPropsToCamelCase(release, { deep: true }), + ...releaseUpdates, + }, + }, + }); + }; + + beforeEach(() => { + release = cloneDeep(originalRelease); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + mockIsFutureRelease = false; + }); + + const findReleaseDateInfo = () => wrapper.find('.js-release-date-info'); + + describe('with all props provided', () => { + beforeEach(() => factory()); + + it('renders the release time info', () => { + expect(trimText(findReleaseDateInfo().text())).toBe(`released 7 fortnights ago`); + }); + }); + + describe('with a future release date', () => { + beforeEach(() => { + mockIsFutureRelease = true; + factory({ releasedAt: mockFutureDate }); + }); + + it('renders the release date without the author name', () => { + expect(trimText(findReleaseDateInfo().text())).toBe(`will be released in 1 month`); + }); + }); +}); diff --git a/spec/frontend/releases/components/release_block_milestone_info_spec.js b/spec/frontend/releases/components/release_block_milestone_info_spec.js index 0b65b6cab96..0e79c45b337 100644 --- a/spec/frontend/releases/components/release_block_milestone_info_spec.js +++ b/spec/frontend/releases/components/release_block_milestone_info_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import { GlProgressBar, GlLink, GlBadge, GlDeprecatedButton } from '@gitlab/ui'; +import { GlProgressBar, GlLink, GlBadge, GlButton } from '@gitlab/ui'; import { trimText } from 'helpers/text_helper'; import ReleaseBlockMilestoneInfo from '~/releases/components/release_block_milestone_info.vue'; import { milestones as originalMilestones } from '../mock_data'; @@ -106,7 +106,7 @@ describe('Release block milestone info', () => { const clickShowMoreFewerButton = () => { milestoneListContainer() - .find(GlDeprecatedButton) + .find(GlButton) .trigger('click'); return wrapper.vm.$nextTick(); diff --git a/spec/frontend/releases/components/release_block_spec.js b/spec/frontend/releases/components/release_block_spec.js index 9846fcb65eb..19119d99f3c 100644 --- a/spec/frontend/releases/components/release_block_spec.js +++ b/spec/frontend/releases/components/release_block_spec.js @@ -1,6 +1,5 @@ import $ from 'jquery'; import { mount } from '@vue/test-utils'; -import { first } from 'underscore'; import EvidenceBlock from '~/releases/components/evidence_block.vue'; import ReleaseBlock from '~/releases/components/release_block.vue'; import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue'; @@ -80,11 +79,11 @@ describe('Release block', () => { ); expect(wrapper.find('.js-sources-dropdown li a').attributes().href).toEqual( - first(release.assets.sources).url, + release.assets.sources[0].url, ); expect(wrapper.find('.js-sources-dropdown li a').text()).toContain( - first(release.assets.sources).format, + release.assets.sources[0].format, ); }); @@ -92,12 +91,10 @@ describe('Release block', () => { expect(wrapper.findAll('.js-assets-list li').length).toEqual(release.assets.links.length); expect(wrapper.find('.js-assets-list li a').attributes().href).toEqual( - first(release.assets.links).directAssetUrl, + release.assets.links[0].directAssetUrl, ); - expect(wrapper.find('.js-assets-list li a').text()).toContain( - first(release.assets.links).name, - ); + expect(wrapper.find('.js-assets-list li a').text()).toContain(release.assets.links[0].name); }); it('renders author avatar', () => { @@ -264,7 +261,7 @@ describe('Release block', () => { }); it('renders a link to the milestone with a tooltip', () => { - const milestone = first(release.milestones); + const milestone = release.milestones[0]; const milestoneLink = wrapper.find('.js-milestone-link'); expect(milestoneLink.exists()).toBe(true); diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js index 4a1790adb09..854f06821be 100644 --- a/spec/frontend/releases/stores/modules/detail/actions_spec.js +++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js @@ -130,6 +130,15 @@ describe('Release detail actions', () => { }); }); + describe('updateReleaseMilestones', () => { + it(`commits ${types.UPDATE_RELEASE_MILESTONES} with the updated release milestones`, () => { + const newReleaseMilestones = ['v0.0', 'v0.1']; + return testAction(actions.updateReleaseMilestones, newReleaseMilestones, state, [ + { type: types.UPDATE_RELEASE_MILESTONES, payload: newReleaseMilestones }, + ]); + }); + }); + describe('requestUpdateRelease', () => { it(`commits ${types.REQUEST_UPDATE_RELEASE}`, () => testAction(actions.requestUpdateRelease, undefined, state, [ @@ -143,7 +152,7 @@ describe('Release detail actions', () => { { type: types.RECEIVE_UPDATE_RELEASE_SUCCESS }, ])); - describe('when the releaseShowPage feature flag is enabled', () => { + it('redirects to the releases page if releaseShowPage feature flag is enabled', () => { const rootState = { featureFlags: { releaseShowPage: true } }; const updatedState = merge({}, state, { releasesPagePath: 'path/to/releases/page', @@ -248,6 +257,7 @@ describe('Release detail actions', () => { { name: state.release.name, description: state.release.description, + milestones: state.release.milestones.map(milestone => milestone.title), }, ], ]); diff --git a/spec/frontend/releases/stores/modules/detail/mutations_spec.js b/spec/frontend/releases/stores/modules/detail/mutations_spec.js index cb5a1880b0c..f3f7ca797b4 100644 --- a/spec/frontend/releases/stores/modules/detail/mutations_spec.js +++ b/spec/frontend/releases/stores/modules/detail/mutations_spec.js @@ -1,10 +1,3 @@ -/* eslint-disable jest/valid-describe */ -/* - * ESLint disable directive ↑ can be removed once - * https://github.com/jest-community/eslint-plugin-jest/issues/203 - * is resolved - */ - import createState from '~/releases/stores/modules/detail/state'; import mutations from '~/releases/stores/modules/detail/mutations'; import * as types from '~/releases/stores/modules/detail/mutation_types'; @@ -27,7 +20,7 @@ describe('Release detail mutations', () => { release = convertObjectPropsToCamelCase(originalRelease); }); - describe(types.REQUEST_RELEASE, () => { + describe(`${types.REQUEST_RELEASE}`, () => { it('set state.isFetchingRelease to true', () => { mutations[types.REQUEST_RELEASE](state); @@ -35,7 +28,7 @@ describe('Release detail mutations', () => { }); }); - describe(types.RECEIVE_RELEASE_SUCCESS, () => { + describe(`${types.RECEIVE_RELEASE_SUCCESS}`, () => { it('handles a successful response from the server', () => { mutations[types.RECEIVE_RELEASE_SUCCESS](state, release); @@ -49,7 +42,7 @@ describe('Release detail mutations', () => { }); }); - describe(types.RECEIVE_RELEASE_ERROR, () => { + describe(`${types.RECEIVE_RELEASE_ERROR}`, () => { it('handles an unsuccessful response from the server', () => { const error = { message: 'An error occurred!' }; mutations[types.RECEIVE_RELEASE_ERROR](state, error); @@ -62,7 +55,7 @@ describe('Release detail mutations', () => { }); }); - describe(types.UPDATE_RELEASE_TITLE, () => { + describe(`${types.UPDATE_RELEASE_TITLE}`, () => { it("updates the release's title", () => { state.release = release; const newTitle = 'The new release title'; @@ -72,7 +65,7 @@ describe('Release detail mutations', () => { }); }); - describe(types.UPDATE_RELEASE_NOTES, () => { + describe(`${types.UPDATE_RELEASE_NOTES}`, () => { it("updates the release's notes", () => { state.release = release; const newNotes = 'The new release notes'; @@ -82,7 +75,7 @@ describe('Release detail mutations', () => { }); }); - describe(types.REQUEST_UPDATE_RELEASE, () => { + describe(`${types.REQUEST_UPDATE_RELEASE}`, () => { it('set state.isUpdatingRelease to true', () => { mutations[types.REQUEST_UPDATE_RELEASE](state); @@ -90,7 +83,7 @@ describe('Release detail mutations', () => { }); }); - describe(types.RECEIVE_UPDATE_RELEASE_SUCCESS, () => { + describe(`${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () => { it('handles a successful response from the server', () => { mutations[types.RECEIVE_UPDATE_RELEASE_SUCCESS](state, release); @@ -100,7 +93,7 @@ describe('Release detail mutations', () => { }); }); - describe(types.RECEIVE_UPDATE_RELEASE_ERROR, () => { + describe(`${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () => { it('handles an unsuccessful response from the server', () => { const error = { message: 'An error occurred!' }; mutations[types.RECEIVE_UPDATE_RELEASE_ERROR](state, error); @@ -111,7 +104,7 @@ describe('Release detail mutations', () => { }); }); - describe(types.ADD_EMPTY_ASSET_LINK, () => { + describe(`${types.ADD_EMPTY_ASSET_LINK}`, () => { it('adds a new, empty link object to the release', () => { state.release = release; @@ -130,7 +123,7 @@ describe('Release detail mutations', () => { }); }); - describe(types.UPDATE_ASSET_LINK_URL, () => { + describe(`${types.UPDATE_ASSET_LINK_URL}`, () => { it('updates an asset link with a new URL', () => { state.release = release; @@ -145,7 +138,7 @@ describe('Release detail mutations', () => { }); }); - describe(types.UPDATE_ASSET_LINK_NAME, () => { + describe(`${types.UPDATE_ASSET_LINK_NAME}`, () => { it('updates an asset link with a new name', () => { state.release = release; @@ -160,7 +153,7 @@ describe('Release detail mutations', () => { }); }); - describe(types.REMOVE_ASSET_LINK, () => { + describe(`${types.REMOVE_ASSET_LINK}`, () => { it('removes an asset link from the release', () => { state.release = release; diff --git a/spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js b/spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js new file mode 100644 index 00000000000..a036588596a --- /dev/null +++ b/spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js @@ -0,0 +1,126 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import GroupedAccessibilityReportsApp from '~/reports/accessibility_report/grouped_accessibility_reports_app.vue'; +import AccessibilityIssueBody from '~/reports/accessibility_report/components/accessibility_issue_body.vue'; +import store from '~/reports/accessibility_report/store'; +import { mockReport } from './mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Grouped accessibility reports app', () => { + const Component = localVue.extend(GroupedAccessibilityReportsApp); + let wrapper; + let mockStore; + + const mountComponent = () => { + wrapper = mount(Component, { + store: mockStore, + localVue, + propsData: { + endpoint: 'endpoint.json', + }, + methods: { + fetchReport: () => {}, + }, + }); + }; + + const findHeader = () => wrapper.find('[data-testid="report-section-code-text"]'); + + beforeEach(() => { + mockStore = store(); + mountComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('while loading', () => { + beforeEach(() => { + mockStore.state.isLoading = true; + mountComponent(); + }); + + it('renders loading state', () => { + expect(findHeader().text()).toEqual('Accessibility scanning results are being parsed'); + }); + }); + + describe('with error', () => { + beforeEach(() => { + mockStore.state.isLoading = false; + mockStore.state.hasError = true; + mountComponent(); + }); + + it('renders error state', () => { + expect(findHeader().text()).toEqual('Accessibility scanning failed loading results'); + }); + }); + + describe('with a report', () => { + describe('with no issues', () => { + beforeEach(() => { + mockStore.state.report = { + summary: { + errored: 0, + }, + }; + }); + + it('renders no issues header', () => { + expect(findHeader().text()).toContain( + 'Accessibility scanning detected no issues for the source branch only', + ); + }); + }); + + describe('with one issue', () => { + beforeEach(() => { + mockStore.state.report = { + summary: { + errored: 1, + }, + }; + }); + + it('renders one issue header', () => { + expect(findHeader().text()).toContain( + 'Accessibility scanning detected 1 issue for the source branch only', + ); + }); + }); + + describe('with multiple issues', () => { + beforeEach(() => { + mockStore.state.report = { + summary: { + errored: 2, + }, + }; + }); + + it('renders multiple issues header', () => { + expect(findHeader().text()).toContain( + 'Accessibility scanning detected 2 issues for the source branch only', + ); + }); + }); + + describe('with issues to show', () => { + beforeEach(() => { + mockStore.state.report = mockReport; + }); + + it('renders custom accessibility issue body', () => { + const issueBody = wrapper.find(AccessibilityIssueBody); + + expect(issueBody.props('issue').code).toBe(mockReport.new_errors[0].code); + expect(issueBody.props('issue').message).toBe(mockReport.new_errors[0].message); + expect(issueBody.props('isNew')).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/reports/accessibility_report/mock_data.js b/spec/frontend/reports/accessibility_report/mock_data.js new file mode 100644 index 00000000000..f8e832c1ce5 --- /dev/null +++ b/spec/frontend/reports/accessibility_report/mock_data.js @@ -0,0 +1,55 @@ +export const mockReport = { + status: 'failed', + summary: { + total: 2, + resolved: 0, + errored: 2, + }, + new_errors: [ + { + code: 'WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail', + type: 'error', + typeCode: 1, + message: + 'This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 3.84:1. Recommendation: change text colour to #767676.', + context: '<a href="/stages-devops-lifecycle/" class="main-nav-link">Product</a>', + selector: '#main-nav > div:nth-child(2) > ul > li:nth-child(1) > a', + runner: 'htmlcs', + runnerExtras: {}, + }, + ], + new_notes: [], + new_warnings: [], + resolved_errors: [ + { + code: 'WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent', + type: 'error', + typeCode: 1, + message: + 'Anchor element found with a valid href attribute, but no link content has been supplied.', + context: '<a href="/" class="navbar-brand animated"><svg height="36" viewBox="0 0 1...</a>', + selector: '#main-nav > div:nth-child(1) > a', + runner: 'htmlcs', + runnerExtras: {}, + }, + ], + resolved_notes: [], + resolved_warnings: [], + existing_errors: [ + { + code: 'WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent', + type: 'error', + typeCode: 1, + message: + 'Anchor element found with a valid href attribute, but no link content has been supplied.', + context: '<a href="/" class="navbar-brand animated"><svg height="36" viewBox="0 0 1...</a>', + selector: '#main-nav > div:nth-child(1) > a', + runner: 'htmlcs', + runnerExtras: {}, + }, + ], + existing_notes: [], + existing_warnings: [], +}; + +export default () => {}; diff --git a/spec/frontend/reports/accessibility_report/store/actions_spec.js b/spec/frontend/reports/accessibility_report/store/actions_spec.js new file mode 100644 index 00000000000..129a5bade86 --- /dev/null +++ b/spec/frontend/reports/accessibility_report/store/actions_spec.js @@ -0,0 +1,121 @@ +import axios from '~/lib/utils/axios_utils'; +import MockAdapter from 'axios-mock-adapter'; +import * as actions from '~/reports/accessibility_report/store/actions'; +import * as types from '~/reports/accessibility_report/store/mutation_types'; +import createStore from '~/reports/accessibility_report/store'; +import { TEST_HOST } from 'spec/test_constants'; +import testAction from 'helpers/vuex_action_helper'; +import { mockReport } from '../mock_data'; + +describe('Accessibility Reports actions', () => { + let localState; + let localStore; + + beforeEach(() => { + localStore = createStore(); + localState = localStore.state; + }); + + describe('setEndpoints', () => { + it('should commit SET_ENDPOINTS mutation', done => { + const endpoint = 'endpoint.json'; + + testAction( + actions.setEndpoint, + endpoint, + localState, + [{ type: types.SET_ENDPOINT, payload: endpoint }], + [], + done, + ); + }); + }); + + describe('fetchReport', () => { + let mock; + + beforeEach(() => { + localState.endpoint = `${TEST_HOST}/endpoint.json`; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + actions.stopPolling(); + actions.clearEtagPoll(); + }); + + describe('success', () => { + it('should commit REQUEST_REPORT mutation and dispatch receiveReportSuccess', done => { + const data = { report: { summary: {} } }; + mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, data); + + testAction( + actions.fetchReport, + null, + localState, + [{ type: types.REQUEST_REPORT }], + [ + { + payload: { status: 200, data }, + type: 'receiveReportSuccess', + }, + ], + done, + ); + }); + }); + + describe('error', () => { + it('should commit REQUEST_REPORT and RECEIVE_REPORT_ERROR mutations', done => { + mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500); + + testAction( + actions.fetchReport, + null, + localState, + [{ type: types.REQUEST_REPORT }], + [{ type: 'receiveReportError' }], + done, + ); + }); + }); + }); + + describe('receiveReportSuccess', () => { + it('should commit RECEIVE_REPORT_SUCCESS mutation with 200', done => { + testAction( + actions.receiveReportSuccess, + { status: 200, data: mockReport }, + localState, + [{ type: types.RECEIVE_REPORT_SUCCESS, payload: mockReport }], + [{ type: 'stopPolling' }], + done, + ); + }); + + it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', done => { + testAction( + actions.receiveReportSuccess, + { status: 204, data: mockReport }, + localState, + [], + [], + done, + ); + }); + }); + + describe('receiveReportError', () => { + it('should commit RECEIVE_REPORT_ERROR mutation', done => { + testAction( + actions.receiveReportError, + null, + localState, + [{ type: types.RECEIVE_REPORT_ERROR }], + [{ type: 'stopPolling' }], + done, + ); + }); + }); +}); diff --git a/spec/frontend/reports/accessibility_report/store/getters_spec.js b/spec/frontend/reports/accessibility_report/store/getters_spec.js new file mode 100644 index 00000000000..d74c71cfa09 --- /dev/null +++ b/spec/frontend/reports/accessibility_report/store/getters_spec.js @@ -0,0 +1,149 @@ +import * as getters from '~/reports/accessibility_report/store/getters'; +import createStore from '~/reports/accessibility_report/store'; +import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '~/reports/constants'; + +describe('Accessibility reports store getters', () => { + let localState; + let localStore; + + beforeEach(() => { + localStore = createStore(); + localState = localStore.state; + }); + + describe('summaryStatus', () => { + describe('when summary is loading', () => { + it('returns loading status', () => { + localState.isLoading = true; + + expect(getters.summaryStatus(localState)).toEqual(LOADING); + }); + }); + + describe('when summary has error', () => { + it('returns error status', () => { + localState.hasError = true; + + expect(getters.summaryStatus(localState)).toEqual(ERROR); + }); + }); + + describe('when summary has failed status', () => { + it('returns loading status', () => { + localState.status = STATUS_FAILED; + + expect(getters.summaryStatus(localState)).toEqual(ERROR); + }); + }); + + describe('when summary has successfully loaded', () => { + it('returns loading status', () => { + expect(getters.summaryStatus(localState)).toEqual(SUCCESS); + }); + }); + }); + + describe('groupedSummaryText', () => { + describe('when state is loading', () => { + it('returns the loading summary message', () => { + localState.isLoading = true; + const result = 'Accessibility scanning results are being parsed'; + + expect(getters.groupedSummaryText(localState)).toEqual(result); + }); + }); + + describe('when state has error', () => { + it('returns the error summary message', () => { + localState.hasError = true; + const result = 'Accessibility scanning failed loading results'; + + expect(getters.groupedSummaryText(localState)).toEqual(result); + }); + }); + + describe('when state has successfully loaded', () => { + describe('when report has errors', () => { + it('returns summary message containing number of errors', () => { + localState.report = { + summary: { + errored: 2, + }, + }; + const result = 'Accessibility scanning detected 2 issues for the source branch only'; + + expect(getters.groupedSummaryText(localState)).toEqual(result); + }); + }); + + describe('when report has no errors', () => { + it('returns summary message containing no errors', () => { + localState.report = { + summary: { + errored: 0, + }, + }; + const result = 'Accessibility scanning detected no issues for the source branch only'; + + expect(getters.groupedSummaryText(localState)).toEqual(result); + }); + }); + }); + }); + + describe('shouldRenderIssuesList', () => { + describe('when has issues to render', () => { + it('returns true', () => { + localState.report = { + existing_errors: [{ name: 'Issue' }], + }; + + expect(getters.shouldRenderIssuesList(localState)).toEqual(true); + }); + }); + + describe('when does not have issues to render', () => { + it('returns false', () => { + localState.report = { + status: 'success', + summary: { errored: 0 }, + }; + + expect(getters.shouldRenderIssuesList(localState)).toEqual(false); + }); + }); + }); + + describe('unresolvedIssues', () => { + it('returns the array unresolved errors', () => { + localState.report = { + existing_errors: [1], + }; + const result = [1]; + + expect(getters.unresolvedIssues(localState)).toEqual(result); + }); + }); + + describe('resolvedIssues', () => { + it('returns array of resolved errors', () => { + localState.report = { + resolved_errors: [1], + }; + const result = [1]; + + expect(getters.resolvedIssues(localState)).toEqual(result); + }); + }); + + describe('newIssues', () => { + it('returns array of new errors', () => { + localState.report = { + new_errors: [1], + }; + const result = [1]; + + expect(getters.newIssues(localState)).toEqual(result); + }); + }); +}); diff --git a/spec/frontend/reports/accessibility_report/store/mutations_spec.js b/spec/frontend/reports/accessibility_report/store/mutations_spec.js new file mode 100644 index 00000000000..a4e9571b721 --- /dev/null +++ b/spec/frontend/reports/accessibility_report/store/mutations_spec.js @@ -0,0 +1,64 @@ +import mutations from '~/reports/accessibility_report/store/mutations'; +import createStore from '~/reports/accessibility_report/store'; + +describe('Accessibility Reports mutations', () => { + let localState; + let localStore; + + beforeEach(() => { + localStore = createStore(); + localState = localStore.state; + }); + + describe('SET_ENDPOINT', () => { + it('sets endpoint to given value', () => { + const endpoint = 'endpoint.json'; + mutations.SET_ENDPOINT(localState, endpoint); + + expect(localState.endpoint).toEqual(endpoint); + }); + }); + + describe('REQUEST_REPORT', () => { + it('sets isLoading to true', () => { + mutations.REQUEST_REPORT(localState); + + expect(localState.isLoading).toEqual(true); + }); + }); + + describe('RECEIVE_REPORT_SUCCESS', () => { + it('sets isLoading to false', () => { + mutations.RECEIVE_REPORT_SUCCESS(localState, {}); + + expect(localState.isLoading).toEqual(false); + }); + + it('sets hasError to false', () => { + mutations.RECEIVE_REPORT_SUCCESS(localState, {}); + + expect(localState.hasError).toEqual(false); + }); + + it('sets report to response report', () => { + const report = { data: 'testing' }; + mutations.RECEIVE_REPORT_SUCCESS(localState, report); + + expect(localState.report).toEqual(report); + }); + }); + + describe('RECEIVE_REPORT_ERROR', () => { + it('sets isLoading to false', () => { + mutations.RECEIVE_REPORT_ERROR(localState); + + expect(localState.isLoading).toEqual(false); + }); + + it('sets hasError to true', () => { + mutations.RECEIVE_REPORT_ERROR(localState); + + expect(localState.hasError).toEqual(true); + }); + }); +}); diff --git a/spec/frontend/reports/components/__snapshots__/grouped_issues_list_spec.js.snap b/spec/frontend/reports/components/__snapshots__/grouped_issues_list_spec.js.snap new file mode 100644 index 00000000000..c932379a253 --- /dev/null +++ b/spec/frontend/reports/components/__snapshots__/grouped_issues_list_spec.js.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Grouped Issues List renders a smart virtual list with the correct props 1`] = ` +Object { + "length": 4, + "remain": 20, + "rtag": "div", + "size": 32, + "wclass": "report-block-list", + "wtag": "ul", +} +`; + +exports[`Grouped Issues List with data renders a report item with the correct props 1`] = ` +Object { + "component": "TestIssueBody", + "isNew": false, + "issue": Object { + "name": "foo", + }, + "showReportSectionStatusIcon": false, + "status": "none", + "statusIconSize": 24, +} +`; diff --git a/spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap b/spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap new file mode 100644 index 00000000000..70e1ff01323 --- /dev/null +++ b/spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`IssueStatusIcon renders "failed" state correctly 1`] = ` +<div + class="report-block-list-icon failed" +> + <icon-stub + data-qa-selector="status_failed_icon" + name="status_failed_borderless" + size="24" + /> +</div> +`; + +exports[`IssueStatusIcon renders "neutral" state correctly 1`] = ` +<div + class="report-block-list-icon neutral" +> + <icon-stub + data-qa-selector="status_neutral_icon" + name="dash" + size="24" + /> +</div> +`; + +exports[`IssueStatusIcon renders "success" state correctly 1`] = ` +<div + class="report-block-list-icon success" +> + <icon-stub + data-qa-selector="status_success_icon" + name="status_success_borderless" + size="24" + /> +</div> +`; diff --git a/spec/frontend/reports/components/grouped_issues_list_spec.js b/spec/frontend/reports/components/grouped_issues_list_spec.js new file mode 100644 index 00000000000..1f8f4a0e4c1 --- /dev/null +++ b/spec/frontend/reports/components/grouped_issues_list_spec.js @@ -0,0 +1,86 @@ +import { shallowMount } from '@vue/test-utils'; +import GroupedIssuesList from '~/reports/components/grouped_issues_list.vue'; +import ReportItem from '~/reports/components/report_item.vue'; +import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; + +describe('Grouped Issues List', () => { + let wrapper; + + const createComponent = ({ propsData = {}, stubs = {} } = {}) => { + wrapper = shallowMount(GroupedIssuesList, { + propsData, + stubs, + }); + }; + + const findHeading = groupName => wrapper.find(`[data-testid="${groupName}Heading"`); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders a smart virtual list with the correct props', () => { + createComponent({ + propsData: { + resolvedIssues: [{ name: 'foo' }], + unresolvedIssues: [{ name: 'bar' }], + }, + stubs: { + SmartVirtualList, + }, + }); + + expect(wrapper.find(SmartVirtualList).props()).toMatchSnapshot(); + }); + + describe('without data', () => { + beforeEach(createComponent); + + it.each(['unresolved', 'resolved'])('does not a render a header for %s issues', issueName => { + expect(findHeading(issueName).exists()).toBe(false); + }); + + it.each('resolved', 'unresolved')('does not render report items for %s issues', () => { + expect(wrapper.contains(ReportItem)).toBe(false); + }); + }); + + describe('with data', () => { + it.each` + givenIssues | givenHeading | groupName + ${[{ name: 'foo issue' }]} | ${'Foo Heading'} | ${'resolved'} + ${[{ name: 'bar issue' }]} | ${'Bar Heading'} | ${'unresolved'} + `('renders the heading for $groupName issues', ({ givenIssues, givenHeading, groupName }) => { + createComponent({ + propsData: { [`${groupName}Issues`]: givenIssues, [`${groupName}Heading`]: givenHeading }, + }); + + expect(findHeading(groupName).text()).toBe(givenHeading); + }); + + it.each(['resolved', 'unresolved'])('renders all %s issues', issueName => { + const issues = [{ name: 'foo' }, { name: 'bar' }]; + + createComponent({ + propsData: { [`${issueName}Issues`]: issues }, + }); + + expect(wrapper.findAll(ReportItem)).toHaveLength(issues.length); + }); + + it('renders a report item with the correct props', () => { + createComponent({ + propsData: { + resolvedIssues: [{ name: 'foo' }], + component: 'TestIssueBody', + }, + stubs: { + ReportItem, + }, + }); + + expect(wrapper.find(ReportItem).props()).toMatchSnapshot(); + }); + }); +}); diff --git a/spec/frontend/reports/components/grouped_test_reports_app_spec.js b/spec/frontend/reports/components/grouped_test_reports_app_spec.js new file mode 100644 index 00000000000..1a01db391da --- /dev/null +++ b/spec/frontend/reports/components/grouped_test_reports_app_spec.js @@ -0,0 +1,260 @@ +import Vue from 'vue'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import state from '~/reports/store/state'; +import component from '~/reports/components/grouped_test_reports_app.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; +import { failedReport } from '../mock_data/mock_data'; +import newFailedTestReports from '../mock_data/new_failures_report.json'; +import newErrorsTestReports from '../mock_data/new_errors_report.json'; +import successTestReports from '../mock_data/no_failures_report.json'; +import mixedResultsTestReports from '../mock_data/new_and_fixed_failures_report.json'; +import resolvedFailures from '../mock_data/resolved_failures.json'; + +describe('Grouped Test Reports App', () => { + let vm; + let mock; + const Component = Vue.extend(component); + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + vm.$store.replaceState(state()); + vm.$destroy(); + mock.restore(); + }); + + describe('with success result', () => { + beforeEach(() => { + mock.onGet('test_results.json').reply(200, successTestReports, {}); + vm = mountComponent(Component, { + endpoint: 'test_results.json', + }); + }); + + it('renders success summary text', done => { + setImmediate(() => { + expect(vm.$el.querySelector('.gl-spinner')).toBeNull(); + expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual( + 'Test summary contained no changed test results out of 11 total tests', + ); + + expect(vm.$el.textContent).toContain( + 'rspec:pg found no changed test results out of 8 total tests', + ); + + expect(vm.$el.textContent).toContain( + 'java ant found no changed test results out of 3 total tests', + ); + done(); + }); + }); + }); + + describe('with 204 result', () => { + beforeEach(() => { + mock.onGet('test_results.json').reply(204, {}, {}); + vm = mountComponent(Component, { + endpoint: 'test_results.json', + }); + }); + + it('renders success summary text', done => { + setImmediate(() => { + expect(vm.$el.querySelector('.gl-spinner')).not.toBeNull(); + expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual( + 'Test summary results are being parsed', + ); + + done(); + }); + }); + }); + + describe('with new failed result', () => { + beforeEach(() => { + mock.onGet('test_results.json').reply(200, newFailedTestReports, {}); + vm = mountComponent(Component, { + endpoint: 'test_results.json', + }); + }); + + it('renders failed summary text + new badge', done => { + setImmediate(() => { + expect(vm.$el.querySelector('.gl-spinner')).toBeNull(); + expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual( + 'Test summary contained 2 failed out of 11 total tests', + ); + + expect(vm.$el.textContent).toContain('rspec:pg found 2 failed out of 8 total tests'); + + expect(vm.$el.textContent).toContain('New'); + expect(vm.$el.textContent).toContain( + 'java ant found no changed test results out of 3 total tests', + ); + done(); + }); + }); + }); + + describe('with new error result', () => { + beforeEach(() => { + mock.onGet('test_results.json').reply(200, newErrorsTestReports, {}); + vm = mountComponent(Component, { + endpoint: 'test_results.json', + }); + }); + + it('renders error summary text + new badge', done => { + setImmediate(() => { + expect(vm.$el.querySelector('.gl-spinner')).toBeNull(); + expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual( + 'Test summary contained 2 errors out of 11 total tests', + ); + + expect(vm.$el.textContent).toContain('karma found 2 errors out of 3 total tests'); + + expect(vm.$el.textContent).toContain('New'); + expect(vm.$el.textContent).toContain( + 'rspec:pg found no changed test results out of 8 total tests', + ); + done(); + }); + }); + }); + + describe('with mixed results', () => { + beforeEach(() => { + mock.onGet('test_results.json').reply(200, mixedResultsTestReports, {}); + vm = mountComponent(Component, { + endpoint: 'test_results.json', + }); + }); + + it('renders summary text', done => { + setImmediate(() => { + expect(vm.$el.querySelector('.gl-spinner')).toBeNull(); + expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual( + 'Test summary contained 2 failed and 2 fixed test results out of 11 total tests', + ); + + expect(vm.$el.textContent).toContain( + 'rspec:pg found 1 failed and 2 fixed test results out of 8 total tests', + ); + + expect(vm.$el.textContent).toContain('New'); + expect(vm.$el.textContent).toContain(' java ant found 1 failed out of 3 total tests'); + done(); + }); + }); + }); + + describe('with resolved failures and resolved errors', () => { + beforeEach(() => { + mock.onGet('test_results.json').reply(200, resolvedFailures, {}); + vm = mountComponent(Component, { + endpoint: 'test_results.json', + }); + }); + + it('renders summary text', done => { + setImmediate(() => { + expect(vm.$el.querySelector('.gl-spinner')).toBeNull(); + expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual( + 'Test summary contained 4 fixed test results out of 11 total tests', + ); + + expect(vm.$el.textContent).toContain( + 'rspec:pg found 4 fixed test results out of 8 total tests', + ); + done(); + }); + }); + + it('renders resolved failures', done => { + setImmediate(() => { + expect(vm.$el.querySelector('.report-block-container').textContent).toContain( + resolvedFailures.suites[0].resolved_failures[0].name, + ); + + expect(vm.$el.querySelector('.report-block-container').textContent).toContain( + resolvedFailures.suites[0].resolved_failures[1].name, + ); + done(); + }); + }); + + it('renders resolved errors', done => { + setImmediate(() => { + expect(vm.$el.querySelector('.report-block-container').textContent).toContain( + resolvedFailures.suites[0].resolved_errors[0].name, + ); + + expect(vm.$el.querySelector('.report-block-container').textContent).toContain( + resolvedFailures.suites[0].resolved_errors[1].name, + ); + done(); + }); + }); + }); + + describe('with a report that failed to load', () => { + beforeEach(() => { + mock.onGet('test_results.json').reply(200, failedReport, {}); + vm = mountComponent(Component, { + endpoint: 'test_results.json', + }); + }); + + it('renders an error status for the report', done => { + setImmediate(() => { + const { name } = failedReport.suites[0]; + + expect(vm.$el.querySelector('.report-block-list-issue').textContent).toContain( + `An error occurred while loading ${name} results`, + ); + done(); + }); + }); + }); + + describe('with error', () => { + beforeEach(() => { + mock.onGet('test_results.json').reply(500, {}, {}); + vm = mountComponent(Component, { + endpoint: 'test_results.json', + }); + }); + + it('renders loading summary text with loading icon', done => { + setImmediate(() => { + expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual( + 'Test summary failed loading results', + ); + done(); + }); + }); + }); + + describe('while loading', () => { + beforeEach(() => { + mock.onGet('test_results.json').reply(200, {}, {}); + vm = mountComponent(Component, { + endpoint: 'test_results.json', + }); + }); + + it('renders loading summary text with loading icon', done => { + expect(vm.$el.querySelector('.gl-spinner')).not.toBeNull(); + expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual( + 'Test summary results are being parsed', + ); + + setImmediate(() => { + done(); + }); + }); + }); +}); diff --git a/spec/frontend/reports/components/issue_status_icon_spec.js b/spec/frontend/reports/components/issue_status_icon_spec.js new file mode 100644 index 00000000000..3a55ff0a9e3 --- /dev/null +++ b/spec/frontend/reports/components/issue_status_icon_spec.js @@ -0,0 +1,29 @@ +import { shallowMount } from '@vue/test-utils'; +import ReportItem from '~/reports/components/issue_status_icon.vue'; +import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/reports/constants'; + +describe('IssueStatusIcon', () => { + let wrapper; + + const createComponent = ({ status }) => { + wrapper = shallowMount(ReportItem, { + propsData: { + status, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it.each([STATUS_SUCCESS, STATUS_NEUTRAL, STATUS_FAILED])( + 'renders "%s" state correctly', + status => { + createComponent({ status }); + + expect(wrapper.element).toMatchSnapshot(); + }, + ); +}); diff --git a/spec/frontend/reports/components/modal_open_name_spec.js b/spec/frontend/reports/components/modal_open_name_spec.js new file mode 100644 index 00000000000..d59f3571c4b --- /dev/null +++ b/spec/frontend/reports/components/modal_open_name_spec.js @@ -0,0 +1,47 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import component from '~/reports/components/modal_open_name.vue'; + +Vue.use(Vuex); + +describe('Modal open name', () => { + const Component = Vue.extend(component); + let vm; + + const store = new Vuex.Store({ + actions: { + openModal: () => {}, + }, + state: {}, + mutations: {}, + }); + + beforeEach(() => { + vm = mountComponentWithStore(Component, { + store, + props: { + issue: { + title: 'Issue', + }, + status: 'failed', + }, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders the issue name', () => { + expect(vm.$el.textContent.trim()).toEqual('Issue'); + }); + + it('calls openModal actions when button is clicked', () => { + jest.spyOn(vm, 'openModal').mockImplementation(() => {}); + + vm.$el.click(); + + expect(vm.openModal).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/reports/components/modal_spec.js b/spec/frontend/reports/components/modal_spec.js new file mode 100644 index 00000000000..ff046e64b6e --- /dev/null +++ b/spec/frontend/reports/components/modal_spec.js @@ -0,0 +1,54 @@ +import Vue from 'vue'; +import component from '~/reports/components/modal.vue'; +import state from '~/reports/store/state'; +import mountComponent from '../../helpers/vue_mount_component_helper'; +import { trimText } from '../../helpers/text_helper'; + +describe('Grouped Test Reports Modal', () => { + const Component = Vue.extend(component); + const modalDataStructure = state().modal.data; + + // populate data + modalDataStructure.execution_time.value = 0.009411; + modalDataStructure.system_output.value = 'Failure/Error: is_expected.to eq(3)\n\n'; + modalDataStructure.class.value = 'link'; + + let vm; + + beforeEach(() => { + vm = mountComponent(Component, { + title: 'Test#sum when a is 1 and b is 2 returns summary', + modalData: modalDataStructure, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders code block', () => { + expect(vm.$el.querySelector('code').textContent).toEqual( + modalDataStructure.system_output.value, + ); + }); + + it('renders link', () => { + expect(vm.$el.querySelector('.js-modal-link').getAttribute('href')).toEqual( + modalDataStructure.class.value, + ); + + expect(trimText(vm.$el.querySelector('.js-modal-link').textContent)).toEqual( + modalDataStructure.class.value, + ); + }); + + it('renders seconds', () => { + expect(vm.$el.textContent).toContain(`${modalDataStructure.execution_time.value} s`); + }); + + it('render title', () => { + expect(trimText(vm.$el.querySelector('.modal-title').textContent)).toEqual( + 'Test#sum when a is 1 and b is 2 returns summary', + ); + }); +}); diff --git a/spec/frontend/reports/components/summary_row_spec.js b/spec/frontend/reports/components/summary_row_spec.js new file mode 100644 index 00000000000..cb0cc025e80 --- /dev/null +++ b/spec/frontend/reports/components/summary_row_spec.js @@ -0,0 +1,37 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import component from '~/reports/components/summary_row.vue'; + +describe('Summary row', () => { + const Component = Vue.extend(component); + let vm; + + const props = { + summary: 'SAST detected 1 new vulnerability and 1 fixed vulnerability', + popoverOptions: { + title: 'Static Application Security Testing (SAST)', + content: '<a>Learn more about SAST</a>', + }, + statusIcon: 'warning', + }; + + beforeEach(() => { + vm = mountComponent(Component, props); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders provided summary', () => { + expect( + vm.$el.querySelector('.report-block-list-issue-description-text').textContent.trim(), + ).toEqual(props.summary); + }); + + it('renders provided icon', () => { + expect(vm.$el.querySelector('.report-block-list-icon span').classList).toContain( + 'js-ci-status-icon-warning', + ); + }); +}); diff --git a/spec/frontend/reports/components/test_issue_body_spec.js b/spec/frontend/reports/components/test_issue_body_spec.js new file mode 100644 index 00000000000..ff81020a4eb --- /dev/null +++ b/spec/frontend/reports/components/test_issue_body_spec.js @@ -0,0 +1,72 @@ +import Vue from 'vue'; +import component from '~/reports/components/test_issue_body.vue'; +import createStore from '~/reports/store'; +import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { trimText } from '../../helpers/text_helper'; +import { issue } from '../mock_data/mock_data'; + +describe('Test Issue body', () => { + let vm; + const Component = Vue.extend(component); + const store = createStore(); + + const commonProps = { + issue, + status: 'failed', + }; + + afterEach(() => { + vm.$destroy(); + }); + + describe('on click', () => { + it('calls openModal action', () => { + vm = mountComponentWithStore(Component, { + store, + props: commonProps, + }); + + jest.spyOn(vm, 'openModal').mockImplementation(() => {}); + + vm.$el.querySelector('button').click(); + + expect(vm.openModal).toHaveBeenCalledWith({ + issue: commonProps.issue, + }); + }); + }); + + describe('is new', () => { + beforeEach(() => { + vm = mountComponentWithStore(Component, { + store, + props: { ...commonProps, isNew: true }, + }); + }); + + it('renders issue name', () => { + expect(vm.$el.textContent).toContain(commonProps.issue.name); + }); + + it('renders new badge', () => { + expect(trimText(vm.$el.querySelector('.badge').textContent)).toEqual('New'); + }); + }); + + describe('not new', () => { + beforeEach(() => { + vm = mountComponentWithStore(Component, { + store, + props: commonProps, + }); + }); + + it('renders issue name', () => { + expect(vm.$el.textContent).toContain(commonProps.issue.name); + }); + + it('does not renders new badge', () => { + expect(vm.$el.querySelector('.badge')).toEqual(null); + }); + }); +}); diff --git a/spec/frontend/reports/mock_data/mock_data.js b/spec/frontend/reports/mock_data/mock_data.js new file mode 100644 index 00000000000..3caaab2fd79 --- /dev/null +++ b/spec/frontend/reports/mock_data/mock_data.js @@ -0,0 +1,24 @@ +export const issue = { + result: 'failure', + name: 'Test#sum when a is 1 and b is 2 returns summary', + execution_time: 0.009411, + system_output: + "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in \u003ctop (required)\u003e'", +}; + +export const failedReport = { + summary: { total: 11, resolved: 0, errored: 2, failed: 0 }, + suites: [ + { + name: 'rspec:pg', + status: 'error', + summary: { total: 0, resolved: 0, errored: 0, failed: 0 }, + new_failures: [], + resolved_failures: [], + existing_failures: [], + new_errors: [], + resolved_errors: [], + existing_errors: [], + }, + ], +}; diff --git a/spec/frontend/reports/mock_data/new_and_fixed_failures_report.json b/spec/frontend/reports/mock_data/new_and_fixed_failures_report.json new file mode 100644 index 00000000000..6141e5433a6 --- /dev/null +++ b/spec/frontend/reports/mock_data/new_and_fixed_failures_report.json @@ -0,0 +1,55 @@ +{ + "status": "failed", + "summary": { "total": 11, "resolved": 2, "errored": 0, "failed": 2 }, + "suites": [ + { + "name": "rspec:pg", + "status": "failed", + "summary": { "total": 8, "resolved": 2, "errored": 0, "failed": 1 }, + "new_failures": [ + { + "status": "failed", + "name": "Test#subtract when a is 2 and b is 1 returns correct result", + "execution_time": 0.00908, + "system_output": "Failure/Error: is_expected.to eq(1)\n\n expected: 1\n got: 3\n\n (compared using ==)\n./spec/test_spec.rb:43:in `block (4 levels) in <top (required)>'" + } + ], + "resolved_failures": [ + { + "status": "success", + "name": "Test#sum when a is 1 and b is 2 returns summary", + "execution_time": 0.000318, + "system_output": null + }, + { + "status": "success", + "name": "Test#sum when a is 100 and b is 200 returns summary", + "execution_time": 0.000074, + "system_output": null + } + ], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + }, + { + "name": "java ant", + "status": "failed", + "summary": { "total": 3, "resolved": 0, "errored": 0, "failed": 1 }, + "new_failures": [], + "resolved_failures": [], + "existing_failures": [ + { + "status": "failed", + "name": "sumTest", + "execution_time": 0.004, + "system_output": "junit.framework.AssertionFailedError: expected:<3> but was:<-1>\n\tat CalculatorTest.sumTest(Unknown Source)\n\tat java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\n\tat java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)\n\tat java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)\n" + } + ], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + } + ] +} diff --git a/spec/frontend/reports/mock_data/new_errors_report.json b/spec/frontend/reports/mock_data/new_errors_report.json new file mode 100644 index 00000000000..cebf98fdb63 --- /dev/null +++ b/spec/frontend/reports/mock_data/new_errors_report.json @@ -0,0 +1,38 @@ +{ + "summary": { "total": 11, "resolved": 0, "errored": 2, "failed": 0 }, + "suites": [ + { + "name": "rspec:pg", + "summary": { "total": 8, "resolved": 0, "errored": 0, "failed": 0 }, + "new_failures": [], + "resolved_failures": [], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + }, + { + "name": "karma", + "summary": { "total": 3, "resolved": 0, "errored": 2, "failed": 0 }, + "new_failures": [], + "resolved_failures": [], + "existing_failures": [], + "new_errors": [ + { + "result": "error", + "name": "Test#sum when a is 1 and b is 2 returns summary", + "execution_time": 0.009411, + "system_output": "Failed: Error in render: 'TypeError: Cannot read property 'status' of undefined'" + }, + { + "result": "error", + "name": "Test#sum when a is 100 and b is 200 returns summary", + "execution_time": 0.000162, + "system_output": "Failed: Error in render: 'TypeError: Cannot read property 'length' of undefined'" + } + ], + "resolved_errors": [], + "existing_errors": [] + } + ] +} diff --git a/spec/frontend/reports/mock_data/new_failures_report.json b/spec/frontend/reports/mock_data/new_failures_report.json new file mode 100644 index 00000000000..8b9c12c6271 --- /dev/null +++ b/spec/frontend/reports/mock_data/new_failures_report.json @@ -0,0 +1,38 @@ +{ + "summary": { "total": 11, "resolved": 0, "errored": 0, "failed": 2 }, + "suites": [ + { + "name": "rspec:pg", + "summary": { "total": 8, "resolved": 0, "errored": 0, "failed": 2 }, + "new_failures": [ + { + "result": "failure", + "name": "Test#sum when a is 1 and b is 2 returns summary", + "execution_time": 0.009411, + "system_output": "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in <top (required)>'" + }, + { + "result": "failure", + "name": "Test#sum when a is 100 and b is 200 returns summary", + "execution_time": 0.000162, + "system_output": "Failure/Error: is_expected.to eq(300)\n\n expected: 300\n got: -100\n\n (compared using ==)\n./spec/test_spec.rb:21:in `block (4 levels) in <top (required)>'" + } + ], + "resolved_failures": [], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + }, + { + "name": "java ant", + "summary": { "total": 3, "resolved": 0, "errored": 0, "failed": 0 }, + "new_failures": [], + "resolved_failures": [], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + } + ] +} diff --git a/spec/frontend/reports/mock_data/no_failures_report.json b/spec/frontend/reports/mock_data/no_failures_report.json new file mode 100644 index 00000000000..7da9e0c6211 --- /dev/null +++ b/spec/frontend/reports/mock_data/no_failures_report.json @@ -0,0 +1,28 @@ +{ + "status": "success", + "summary": { "total": 11, "resolved": 0, "errored": 0, "failed": 0 }, + "suites": [ + { + "name": "rspec:pg", + "status": "success", + "summary": { "total": 8, "resolved": 0, "errored": 0, "failed": 0 }, + "new_failures": [], + "resolved_failures": [], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + }, + { + "name": "java ant", + "status": "success", + "summary": { "total": 3, "resolved": 0, "errored": 0, "failed": 0 }, + "new_failures": [], + "resolved_failures": [], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + } + ] +} diff --git a/spec/frontend/reports/mock_data/resolved_failures.json b/spec/frontend/reports/mock_data/resolved_failures.json new file mode 100644 index 00000000000..49de6aa840b --- /dev/null +++ b/spec/frontend/reports/mock_data/resolved_failures.json @@ -0,0 +1,58 @@ +{ + "status": "success", + "summary": { "total": 11, "resolved": 4, "errored": 0, "failed": 0 }, + "suites": [ + { + "name": "rspec:pg", + "status": "success", + "summary": { "total": 8, "resolved": 4, "errored": 0, "failed": 0 }, + "new_failures": [], + "resolved_failures": [ + { + "status": "success", + "name": "Test#sum when a is 1 and b is 2 returns summary", + "execution_time": 0.000411, + "system_output": null, + "stack_trace": null + }, + { + "status": "success", + "name": "Test#sum when a is 100 and b is 200 returns summary", + "execution_time": 7.6e-5, + "system_output": null, + "stack_trace": null + } + ], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [ + { + "status": "success", + "name": "Test#sum when a is 4 and b is 4 returns summary", + "execution_time": 0.00342, + "system_output": null, + "stack_trace": null + }, + { + "status": "success", + "name": "Test#sum when a is 40 and b is 400 returns summary", + "execution_time": 0.0000231, + "system_output": null, + "stack_trace": null + } + ], + "existing_errors": [] + }, + { + "name": "java ant", + "status": "success", + "summary": { "total": 3, "resolved": 0, "errored": 0, "failed": 0 }, + "new_failures": [], + "resolved_failures": [], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + } + ] +} diff --git a/spec/frontend/reports/store/actions_spec.js b/spec/frontend/reports/store/actions_spec.js new file mode 100644 index 00000000000..3f189736922 --- /dev/null +++ b/spec/frontend/reports/store/actions_spec.js @@ -0,0 +1,171 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { TEST_HOST } from 'helpers/test_constants'; +import axios from '~/lib/utils/axios_utils'; +import { + setEndpoint, + requestReports, + fetchReports, + stopPolling, + clearEtagPoll, + receiveReportsSuccess, + receiveReportsError, + openModal, + setModalData, +} from '~/reports/store/actions'; +import state from '~/reports/store/state'; +import * as types from '~/reports/store/mutation_types'; + +describe('Reports Store Actions', () => { + let mockedState; + + beforeEach(() => { + mockedState = state(); + }); + + describe('setEndpoint', () => { + it('should commit SET_ENDPOINT mutation', done => { + testAction( + setEndpoint, + 'endpoint.json', + mockedState, + [{ type: types.SET_ENDPOINT, payload: 'endpoint.json' }], + [], + done, + ); + }); + }); + + describe('requestReports', () => { + it('should commit REQUEST_REPORTS mutation', done => { + testAction(requestReports, null, mockedState, [{ type: types.REQUEST_REPORTS }], [], done); + }); + }); + + describe('fetchReports', () => { + let mock; + + beforeEach(() => { + mockedState.endpoint = `${TEST_HOST}/endpoint.json`; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + stopPolling(); + clearEtagPoll(); + }); + + describe('success', () => { + it('dispatches requestReports and receiveReportsSuccess ', done => { + mock + .onGet(`${TEST_HOST}/endpoint.json`) + .replyOnce(200, { summary: {}, suites: [{ name: 'rspec' }] }); + + testAction( + fetchReports, + null, + mockedState, + [], + [ + { + type: 'requestReports', + }, + { + payload: { data: { summary: {}, suites: [{ name: 'rspec' }] }, status: 200 }, + type: 'receiveReportsSuccess', + }, + ], + done, + ); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500); + }); + + it('dispatches requestReports and receiveReportsError ', done => { + testAction( + fetchReports, + null, + mockedState, + [], + [ + { + type: 'requestReports', + }, + { + type: 'receiveReportsError', + }, + ], + done, + ); + }); + }); + }); + + describe('receiveReportsSuccess', () => { + it('should commit RECEIVE_REPORTS_SUCCESS mutation with 200', done => { + testAction( + receiveReportsSuccess, + { data: { summary: {} }, status: 200 }, + mockedState, + [{ type: types.RECEIVE_REPORTS_SUCCESS, payload: { summary: {} } }], + [], + done, + ); + }); + + it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', done => { + testAction( + receiveReportsSuccess, + { data: { summary: {} }, status: 204 }, + mockedState, + [], + [], + done, + ); + }); + }); + + describe('receiveReportsError', () => { + it('should commit RECEIVE_REPORTS_ERROR mutation', done => { + testAction( + receiveReportsError, + null, + mockedState, + [{ type: types.RECEIVE_REPORTS_ERROR }], + [], + done, + ); + }); + }); + + describe('openModal', () => { + it('should dispatch setModalData', done => { + testAction( + openModal, + { name: 'foo' }, + mockedState, + [], + [{ type: 'setModalData', payload: { name: 'foo' } }], + done, + ); + }); + }); + + describe('setModalData', () => { + it('should commit SET_ISSUE_MODAL_DATA', done => { + testAction( + setModalData, + { name: 'foo' }, + mockedState, + [{ type: types.SET_ISSUE_MODAL_DATA, payload: { name: 'foo' } }], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/reports/store/mutations_spec.js b/spec/frontend/reports/store/mutations_spec.js new file mode 100644 index 00000000000..9446cd454ab --- /dev/null +++ b/spec/frontend/reports/store/mutations_spec.js @@ -0,0 +1,126 @@ +import state from '~/reports/store/state'; +import mutations from '~/reports/store/mutations'; +import * as types from '~/reports/store/mutation_types'; +import { issue } from '../mock_data/mock_data'; + +describe('Reports Store Mutations', () => { + let stateCopy; + + beforeEach(() => { + stateCopy = state(); + }); + + describe('SET_ENDPOINT', () => { + it('should set endpoint', () => { + mutations[types.SET_ENDPOINT](stateCopy, 'endpoint.json'); + + expect(stateCopy.endpoint).toEqual('endpoint.json'); + }); + }); + + describe('REQUEST_REPORTS', () => { + it('should set isLoading to true', () => { + mutations[types.REQUEST_REPORTS](stateCopy); + + expect(stateCopy.isLoading).toEqual(true); + }); + }); + + describe('RECEIVE_REPORTS_SUCCESS', () => { + const mockedResponse = { + summary: { + total: 14, + resolved: 0, + failed: 7, + }, + suites: [ + { + name: 'build:linux', + summary: { + total: 2, + resolved: 0, + failed: 1, + }, + new_failures: [ + { + name: 'StringHelper#concatenate when a is git and b is lab returns summary', + execution_time: 0.0092435, + system_output: "Failure/Error: is_expected.to eq('gitlab')", + }, + ], + resolved_failures: [ + { + name: 'StringHelper#concatenate when a is git and b is lab returns summary', + execution_time: 0.009235, + system_output: "Failure/Error: is_expected.to eq('gitlab')", + }, + ], + existing_failures: [ + { + name: 'StringHelper#concatenate when a is git and b is lab returns summary', + execution_time: 1232.08, + system_output: "Failure/Error: is_expected.to eq('gitlab')", + }, + ], + }, + ], + }; + + beforeEach(() => { + mutations[types.RECEIVE_REPORTS_SUCCESS](stateCopy, mockedResponse); + }); + + it('should reset isLoading', () => { + expect(stateCopy.isLoading).toEqual(false); + }); + + it('should reset hasError', () => { + expect(stateCopy.hasError).toEqual(false); + }); + + it('should set summary counts', () => { + expect(stateCopy.summary.total).toEqual(mockedResponse.summary.total); + expect(stateCopy.summary.resolved).toEqual(mockedResponse.summary.resolved); + expect(stateCopy.summary.failed).toEqual(mockedResponse.summary.failed); + }); + + it('should set reports', () => { + expect(stateCopy.reports).toEqual(mockedResponse.suites); + }); + }); + + describe('RECEIVE_REPORTS_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_REPORTS_ERROR](stateCopy); + }); + + it('should reset isLoading', () => { + expect(stateCopy.isLoading).toEqual(false); + }); + + it('should set hasError to true', () => { + expect(stateCopy.hasError).toEqual(true); + }); + + it('should reset reports', () => { + expect(stateCopy.reports).toEqual([]); + }); + }); + + describe('SET_ISSUE_MODAL_DATA', () => { + beforeEach(() => { + mutations[types.SET_ISSUE_MODAL_DATA](stateCopy, { + issue, + }); + }); + + it('should set modal title', () => { + expect(stateCopy.modal.title).toEqual(issue.name); + }); + + it('should set modal data', () => { + expect(stateCopy.modal.data.execution_time.value).toEqual(issue.execution_time); + expect(stateCopy.modal.data.system_output.value).toEqual(issue.system_output); + }); + }); +}); diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap index 491fc20c40e..1dca65dd862 100644 --- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap @@ -26,9 +26,7 @@ exports[`Repository last commit component renders commit widget 1`] = ` class="commit-row-message item-title" href="https://test.com/commit/123" > - - Commit title - + Commit title </gl-link-stub> <!----> @@ -128,9 +126,7 @@ exports[`Repository last commit component renders the signature HTML as returned class="commit-row-message item-title" href="https://test.com/commit/123" > - - Commit title - + Commit title </gl-link-stub> <!----> diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js index d2576ec26b7..a5bfeb08fe4 100644 --- a/spec/frontend/repository/components/last_commit_spec.js +++ b/spec/frontend/repository/components/last_commit_spec.js @@ -9,6 +9,7 @@ function createCommitData(data = {}) { const defaultData = { sha: '123456789', title: 'Commit title', + titleHtml: 'Commit title', message: 'Commit message', webUrl: 'https://test.com/commit/123', authoredDate: '2019-01-01', diff --git a/spec/frontend/repository/utils/commit_spec.js b/spec/frontend/repository/utils/commit_spec.js index e7cc28178bf..aaaa39f739f 100644 --- a/spec/frontend/repository/utils/commit_spec.js +++ b/spec/frontend/repository/utils/commit_spec.js @@ -8,6 +8,7 @@ const mockData = [ committed_date: '2019-01-01', }, commit_path: `https://test.com`, + commit_title_html: 'testing message', file_name: 'index.js', type: 'blob', }, @@ -24,6 +25,7 @@ describe('normalizeData', () => { fileName: 'index.js', filePath: '/index.js', type: 'blob', + titleHtml: 'testing message', __typename: 'LogTreeCommit', }, ]); diff --git a/spec/frontend/settings_panels_spec.js b/spec/frontend/settings_panels_spec.js new file mode 100644 index 00000000000..2c5d91a45bc --- /dev/null +++ b/spec/frontend/settings_panels_spec.js @@ -0,0 +1,45 @@ +import $ from 'jquery'; +import initSettingsPanels from '~/settings_panels'; + +describe('Settings Panels', () => { + preloadFixtures('groups/edit.html'); + + beforeEach(() => { + loadFixtures('groups/edit.html'); + }); + + describe('initSettingsPane', () => { + afterEach(() => { + window.location.hash = ''; + }); + + it('should expand linked hash fragment panel', () => { + window.location.hash = '#js-general-settings'; + + const panel = document.querySelector('#js-general-settings'); + // Our test environment automatically expands everything so we need to clear that out first + panel.classList.remove('expanded'); + + expect(panel.classList.contains('expanded')).toBe(false); + + initSettingsPanels(); + + expect(panel.classList.contains('expanded')).toBe(true); + }); + }); + + it('does not change the text content of triggers', () => { + const panel = document.querySelector('#js-general-settings'); + const trigger = panel.querySelector('.js-settings-toggle-trigger-only'); + const originalText = trigger.textContent; + + initSettingsPanels(); + + expect(panel.classList.contains('expanded')).toBe(true); + + $(trigger).click(); + + expect(panel.classList.contains('expanded')).toBe(false); + expect(trigger.textContent).toEqual(originalText); + }); +}); 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 1f93336e755..cf7832f3948 100644 --- a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap +++ b/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Confidential Issue Sidebar Block renders for isConfidential = false and isEditable = false 1`] = ` +exports[`Confidential Issue Sidebar Block renders for confidential = false and isEditable = false 1`] = ` <div class="block issuable-sidebar-item confidentiality" > @@ -52,7 +52,7 @@ exports[`Confidential Issue Sidebar Block renders for isConfidential = false and </div> `; -exports[`Confidential Issue Sidebar Block renders for isConfidential = false and isEditable = true 1`] = ` +exports[`Confidential Issue Sidebar Block renders for confidential = false and isEditable = true 1`] = ` <div class="block issuable-sidebar-item confidentiality" > @@ -84,9 +84,7 @@ exports[`Confidential Issue Sidebar Block renders for isConfidential = false and data-track-property="confidentiality" href="#" > - Edit - </a> </div> @@ -114,7 +112,7 @@ exports[`Confidential Issue Sidebar Block renders for isConfidential = false and </div> `; -exports[`Confidential Issue Sidebar Block renders for isConfidential = true and isEditable = false 1`] = ` +exports[`Confidential Issue Sidebar Block renders for confidential = true and isEditable = false 1`] = ` <div class="block issuable-sidebar-item confidentiality" > @@ -166,7 +164,7 @@ exports[`Confidential Issue Sidebar Block renders for isConfidential = true and </div> `; -exports[`Confidential Issue Sidebar Block renders for isConfidential = true and isEditable = true 1`] = ` +exports[`Confidential Issue Sidebar Block renders for confidential = true and isEditable = true 1`] = ` <div class="block issuable-sidebar-item confidentiality" > @@ -198,9 +196,7 @@ exports[`Confidential Issue Sidebar Block renders for isConfidential = true and data-track-property="confidentiality" href="#" > - Edit - </a> </div> diff --git a/spec/frontend/sidebar/assignees_realtime_spec.js b/spec/frontend/sidebar/assignees_realtime_spec.js new file mode 100644 index 00000000000..1c62c52dc67 --- /dev/null +++ b/spec/frontend/sidebar/assignees_realtime_spec.js @@ -0,0 +1,102 @@ +import { shallowMount } from '@vue/test-utils'; +import ActionCable from '@rails/actioncable'; +import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import Mock from './mock_data'; +import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql'; + +jest.mock('@rails/actioncable', () => { + const mockConsumer = { + subscriptions: { create: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }) }, + }; + return { + createConsumer: jest.fn().mockReturnValue(mockConsumer), + }; +}); + +describe('Assignees Realtime', () => { + let wrapper; + let mediator; + + const createComponent = () => { + wrapper = shallowMount(AssigneesRealtime, { + propsData: { + issuableIid: '1', + mediator, + projectPath: 'path/to/project', + }, + mocks: { + $apollo: { + query, + queries: { + project: { + refetch: jest.fn(), + }, + }, + }, + }, + }); + }; + + beforeEach(() => { + mediator = new SidebarMediator(Mock.mediator); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + SidebarMediator.singleton = null; + }); + + describe('when handleFetchResult is called from smart query', () => { + it('sets assignees to the store', () => { + const data = { + project: { + issue: { + assignees: { + nodes: [{ id: 'gid://gitlab/Environments/123', avatarUrl: 'url' }], + }, + }, + }, + }; + const expected = [{ id: 123, avatar_url: 'url', avatarUrl: 'url' }]; + createComponent(); + + wrapper.vm.handleFetchResult({ data }); + + expect(mediator.store.assignees).toEqual(expected); + }); + }); + + describe('when mounted', () => { + it('calls create subscription', () => { + const cable = ActionCable.createConsumer(); + + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + expect(cable.subscriptions.create).toHaveBeenCalledTimes(1); + expect(cable.subscriptions.create).toHaveBeenCalledWith( + { + channel: 'IssuesChannel', + iid: wrapper.props('issuableIid'), + project_path: wrapper.props('projectPath'), + }, + { received: wrapper.vm.received }, + ); + }); + }); + }); + + describe('when subscription is recieved', () => { + it('refetches the GraphQL project query', () => { + createComponent(); + + wrapper.vm.received({ event: 'updated' }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.$apollo.queries.project.refetch).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js new file mode 100644 index 00000000000..1f028f74423 --- /dev/null +++ b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js @@ -0,0 +1,279 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; + +describe('Issuable Time Tracker', () => { + let initialData; + let vm; + + const initTimeTrackingComponent = ({ + timeEstimate, + timeSpent, + timeEstimateHumanReadable, + timeSpentHumanReadable, + limitToHours, + }) => { + setFixtures(` + <div> + <div id="mock-container"></div> + </div> + `); + + initialData = { + timeEstimate, + timeSpent, + humanTimeEstimate: timeEstimateHumanReadable, + humanTimeSpent: timeSpentHumanReadable, + limitToHours: Boolean(limitToHours), + rootPath: '/', + }; + + const TimeTrackingComponent = Vue.extend({ + ...TimeTracker, + components: { + ...TimeTracker.components, + transition: { + // disable animations + render(h) { + return h('div', this.$slots.default); + }, + }, + }, + }); + vm = mountComponent(TimeTrackingComponent, initialData, '#mock-container'); + }; + + afterEach(() => { + vm.$destroy(); + }); + + describe('Initialization', () => { + beforeEach(() => { + initTimeTrackingComponent({ + timeEstimate: 10000, // 2h 46m + timeSpent: 5000, // 1h 23m + timeEstimateHumanReadable: '2h 46m', + timeSpentHumanReadable: '1h 23m', + }); + }); + + it('should return something defined', () => { + expect(vm).toBeDefined(); + }); + + it('should correctly set timeEstimate', done => { + Vue.nextTick(() => { + expect(vm.timeEstimate).toBe(initialData.timeEstimate); + done(); + }); + }); + + it('should correctly set time_spent', done => { + Vue.nextTick(() => { + expect(vm.timeSpent).toBe(initialData.timeSpent); + done(); + }); + }); + }); + + describe('Content Display', () => { + describe('Panes', () => { + describe('Comparison pane', () => { + beforeEach(() => { + initTimeTrackingComponent({ + timeEstimate: 100000, // 1d 3h + timeSpent: 5000, // 1h 23m + timeEstimateHumanReadable: '1d 3h', + timeSpentHumanReadable: '1h 23m', + }); + }); + + it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', done => { + Vue.nextTick(() => { + expect(vm.showComparisonState).toBe(true); + const $comparisonPane = vm.$el.querySelector('.time-tracking-comparison-pane'); + + expect($comparisonPane).toBeVisible(); + done(); + }); + }); + + it('should show full times when the sidebar is collapsed', done => { + Vue.nextTick(() => { + const timeTrackingText = vm.$el.querySelector('.time-tracking-collapsed-summary span') + .textContent; + + expect(timeTrackingText.trim()).toBe('1h 23m / 1d 3h'); + done(); + }); + }); + + describe('Remaining meter', () => { + it('should display the remaining meter with the correct width', done => { + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.time-tracking-comparison-pane .progress[value="5"]'), + ).not.toBeNull(); + done(); + }); + }); + + it('should display the remaining meter with the correct background color when within estimate', done => { + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.time-tracking-comparison-pane .progress[variant="primary"]'), + ).not.toBeNull(); + done(); + }); + }); + + it('should display the remaining meter with the correct background color when over estimate', done => { + vm.timeEstimate = 10000; // 2h 46m + vm.timeSpent = 20000000; // 231 days + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.time-tracking-comparison-pane .progress[variant="danger"]'), + ).not.toBeNull(); + done(); + }); + }); + }); + }); + + describe('Comparison pane when limitToHours is true', () => { + beforeEach(() => { + initTimeTrackingComponent({ + timeEstimate: 100000, // 1d 3h + timeSpent: 5000, // 1h 23m + timeEstimateHumanReadable: '', + timeSpentHumanReadable: '', + limitToHours: true, + }); + }); + + it('should show the correct tooltip text', done => { + Vue.nextTick(() => { + expect(vm.showComparisonState).toBe(true); + const $title = vm.$el.querySelector('.time-tracking-content .compare-meter').dataset + .originalTitle; + + expect($title).toBe('Time remaining: 26h 23m'); + done(); + }); + }); + }); + + describe('Estimate only pane', () => { + beforeEach(() => { + initTimeTrackingComponent({ + timeEstimate: 10000, // 2h 46m + timeSpent: 0, + timeEstimateHumanReadable: '2h 46m', + timeSpentHumanReadable: '', + }); + }); + + it('should display the human readable version of time estimated', done => { + Vue.nextTick(() => { + const estimateText = vm.$el.querySelector('.time-tracking-estimate-only-pane') + .textContent; + const correctText = 'Estimated: 2h 46m'; + + expect(estimateText.trim()).toBe(correctText); + done(); + }); + }); + }); + + describe('Spent only pane', () => { + beforeEach(() => { + initTimeTrackingComponent({ + timeEstimate: 0, + timeSpent: 5000, // 1h 23m + timeEstimateHumanReadable: '2h 46m', + timeSpentHumanReadable: '1h 23m', + }); + }); + + it('should display the human readable version of time spent', done => { + Vue.nextTick(() => { + const spentText = vm.$el.querySelector('.time-tracking-spend-only-pane').textContent; + const correctText = 'Spent: 1h 23m'; + + expect(spentText).toBe(correctText); + done(); + }); + }); + }); + + describe('No time tracking pane', () => { + beforeEach(() => { + initTimeTrackingComponent({ + timeEstimate: 0, + timeSpent: 0, + timeEstimateHumanReadable: '', + timeSpentHumanReadable: '', + }); + }); + + it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', done => { + Vue.nextTick(() => { + const $noTrackingPane = vm.$el.querySelector('.time-tracking-no-tracking-pane'); + const noTrackingText = $noTrackingPane.textContent; + const correctText = 'No estimate or time spent'; + + expect(vm.showNoTimeTrackingState).toBe(true); + expect($noTrackingPane).toBeVisible(); + expect(noTrackingText.trim()).toBe(correctText); + done(); + }); + }); + }); + + describe('Help pane', () => { + const helpButton = () => vm.$el.querySelector('.help-button'); + const closeHelpButton = () => vm.$el.querySelector('.close-help-button'); + const helpPane = () => vm.$el.querySelector('.time-tracking-help-state'); + + beforeEach(() => { + initTimeTrackingComponent({ timeEstimate: 0, timeSpent: 0 }); + + return vm.$nextTick(); + }); + + it('should not show the "Help" pane by default', () => { + expect(vm.showHelpState).toBe(false); + expect(helpPane()).toBeNull(); + }); + + it('should show the "Help" pane when help button is clicked', () => { + helpButton().click(); + + return vm.$nextTick().then(() => { + expect(vm.showHelpState).toBe(true); + + // let animations run + jest.advanceTimersByTime(500); + + expect(helpPane()).toBeVisible(); + }); + }); + + it('should not show the "Help" pane when help button is clicked and then closed', done => { + helpButton().click(); + + Vue.nextTick() + .then(() => closeHelpButton().click()) + .then(() => Vue.nextTick()) + .then(() => { + expect(vm.showHelpState).toBe(false); + expect(helpPane()).toBeNull(); + }) + .then(done) + .catch(done.fail); + }); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js new file mode 100644 index 00000000000..acdfb5139bf --- /dev/null +++ b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js @@ -0,0 +1,41 @@ +import { shallowMount } from '@vue/test-utils'; +import EditFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue'; + +describe('Edit Form Buttons', () => { + let wrapper; + const findConfidentialToggle = () => wrapper.find('[data-testid="confidential-toggle"]'); + + const createComponent = props => { + wrapper = shallowMount(EditFormButtons, { + propsData: { + updateConfidentialAttribute: () => {}, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when not confidential', () => { + it('renders Turn On in the ', () => { + createComponent({ + isConfidential: false, + }); + + expect(findConfidentialToggle().text()).toBe('Turn On'); + }); + }); + + describe('when confidential', () => { + it('renders on or off text based on confidentiality', () => { + createComponent({ + isConfidential: true, + }); + + expect(findConfidentialToggle().text()).toBe('Turn Off'); + }); + }); +}); diff --git a/spec/frontend/sidebar/confidential/edit_form_spec.js b/spec/frontend/sidebar/confidential/edit_form_spec.js new file mode 100644 index 00000000000..137019a1e1b --- /dev/null +++ b/spec/frontend/sidebar/confidential/edit_form_spec.js @@ -0,0 +1,45 @@ +import { shallowMount } from '@vue/test-utils'; +import EditForm from '~/sidebar/components/confidential/edit_form.vue'; + +describe('Edit Form Dropdown', () => { + let wrapper; + const toggleForm = () => {}; + const updateConfidentialAttribute = () => {}; + + const createComponent = props => { + wrapper = shallowMount(EditForm, { + propsData: { + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when not confidential', () => { + it('renders "You are going to turn off the confidentiality." in the ', () => { + createComponent({ + isConfidential: false, + toggleForm, + updateConfidentialAttribute, + }); + + expect(wrapper.find('p').text()).toContain('You are going to turn on the confidentiality.'); + }); + }); + + describe('when confidential', () => { + it('renders on or off text based on confidentiality', () => { + createComponent({ + isConfidential: true, + toggleForm, + updateConfidentialAttribute, + }); + + expect(wrapper.find('p').text()).toContain('You are going to turn off the confidentiality.'); + }); + }); +}); diff --git a/spec/frontend/sidebar/confidential_edit_buttons_spec.js b/spec/frontend/sidebar/confidential_edit_buttons_spec.js deleted file mode 100644 index 32da9f83112..00000000000 --- a/spec/frontend/sidebar/confidential_edit_buttons_spec.js +++ /dev/null @@ -1,35 +0,0 @@ -import Vue from 'vue'; -import editFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue'; - -describe('Edit Form Buttons', () => { - let vm1; - let vm2; - - beforeEach(() => { - const Component = Vue.extend(editFormButtons); - const toggleForm = () => {}; - const updateConfidentialAttribute = () => {}; - - vm1 = new Component({ - propsData: { - isConfidential: true, - toggleForm, - updateConfidentialAttribute, - }, - }).$mount(); - - vm2 = new Component({ - propsData: { - isConfidential: false, - toggleForm, - updateConfidentialAttribute, - }, - }).$mount(); - }); - - it('renders on or off text based on confidentiality', () => { - expect(vm1.$el.innerHTML.includes('Turn Off')).toBe(true); - - expect(vm2.$el.innerHTML.includes('Turn On')).toBe(true); - }); -}); diff --git a/spec/frontend/sidebar/confidential_edit_form_buttons_spec.js b/spec/frontend/sidebar/confidential_edit_form_buttons_spec.js deleted file mode 100644 index 369088cb258..00000000000 --- a/spec/frontend/sidebar/confidential_edit_form_buttons_spec.js +++ /dev/null @@ -1,35 +0,0 @@ -import Vue from 'vue'; -import editForm from '~/sidebar/components/confidential/edit_form.vue'; - -describe('Edit Form Dropdown', () => { - let vm1; - let vm2; - - beforeEach(() => { - const Component = Vue.extend(editForm); - const toggleForm = () => {}; - const updateConfidentialAttribute = () => {}; - - vm1 = new Component({ - propsData: { - isConfidential: true, - toggleForm, - updateConfidentialAttribute, - }, - }).$mount(); - - vm2 = new Component({ - propsData: { - isConfidential: false, - toggleForm, - updateConfidentialAttribute, - }, - }).$mount(); - }); - - it('renders on the appropriate warning text', () => { - expect(vm1.$el.innerHTML.includes('You are going to turn off the confidentiality.')).toBe(true); - - expect(vm2.$el.innerHTML.includes('You are going to turn on the confidentiality.')).toBe(true); - }); -}); diff --git a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js b/spec/frontend/sidebar/confidential_issue_sidebar_spec.js index 4853d9795b1..e7a64ec5ed9 100644 --- a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js +++ b/spec/frontend/sidebar/confidential_issue_sidebar_spec.js @@ -5,6 +5,7 @@ import EditForm from '~/sidebar/components/confidential/edit_form.vue'; import SidebarService from '~/sidebar/services/sidebar_service'; import createFlash from '~/flash'; import RecaptchaModal from '~/vue_shared/components/recaptcha_modal.vue'; +import createStore from '~/notes/stores'; jest.mock('~/flash'); jest.mock('~/sidebar/services/sidebar_service'); @@ -31,8 +32,10 @@ describe('Confidential Issue Sidebar Block', () => { }; const createComponent = propsData => { + const store = createStore(); const service = new SidebarService(); wrapper = shallowMount(ConfidentialIssueSidebar, { + store, propsData: { service, ...propsData, @@ -49,29 +52,31 @@ describe('Confidential Issue Sidebar Block', () => { }); it.each` - isConfidential | isEditable - ${false} | ${false} - ${false} | ${true} - ${true} | ${false} - ${true} | ${true} + confidential | isEditable + ${false} | ${false} + ${false} | ${true} + ${true} | ${false} + ${true} | ${true} `( - 'renders for isConfidential = $isConfidential and isEditable = $isEditable', - ({ isConfidential, isEditable }) => { + 'renders for confidential = $confidential and isEditable = $isEditable', + ({ confidential, isEditable }) => { createComponent({ - isConfidential, isEditable, }); + wrapper.vm.$store.state.noteableData.confidential = confidential; - expect(wrapper.element).toMatchSnapshot(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); }, ); describe('if editable', () => { beforeEach(() => { createComponent({ - isConfidential: true, isEditable: true, }); + wrapper.vm.$store.state.noteableData.confidential = true; }); it('displays the edit form when editable', () => { diff --git a/spec/frontend/sidebar/lock/edit_form_buttons_spec.js b/spec/frontend/sidebar/lock/edit_form_buttons_spec.js new file mode 100644 index 00000000000..66f9237ce97 --- /dev/null +++ b/spec/frontend/sidebar/lock/edit_form_buttons_spec.js @@ -0,0 +1,31 @@ +import { shallowMount } from '@vue/test-utils'; +import EditFormButtons from '~/sidebar/components/lock/edit_form_buttons.vue'; + +describe('EditFormButtons', () => { + let wrapper; + + const mountComponent = propsData => shallowMount(EditFormButtons, { propsData }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('displays "Unlock" when locked', () => { + wrapper = mountComponent({ + isLocked: true, + updateLockedAttribute: () => {}, + }); + + expect(wrapper.text()).toContain('Unlock'); + }); + + it('displays "Lock" when unlocked', () => { + wrapper = mountComponent({ + isLocked: false, + updateLockedAttribute: () => {}, + }); + + expect(wrapper.text()).toContain('Lock'); + }); +}); diff --git a/spec/frontend/sidebar/lock/lock_issue_sidebar_spec.js b/spec/frontend/sidebar/lock/lock_issue_sidebar_spec.js new file mode 100644 index 00000000000..00997326d87 --- /dev/null +++ b/spec/frontend/sidebar/lock/lock_issue_sidebar_spec.js @@ -0,0 +1,99 @@ +import Vue from 'vue'; +import { mockTracking, triggerEvent } from 'helpers/tracking_helper'; +import lockIssueSidebar from '~/sidebar/components/lock/lock_issue_sidebar.vue'; + +describe('LockIssueSidebar', () => { + let vm1; + let vm2; + + beforeEach(() => { + const Component = Vue.extend(lockIssueSidebar); + + const mediator = { + service: { + update: Promise.resolve(true), + }, + + store: { + isLockDialogOpen: false, + }, + }; + + vm1 = new Component({ + propsData: { + isLocked: true, + isEditable: true, + mediator, + issuableType: 'issue', + }, + }).$mount(); + + vm2 = new Component({ + propsData: { + isLocked: false, + isEditable: false, + mediator, + issuableType: 'merge_request', + }, + }).$mount(); + }); + + it('shows if locked and/or editable', () => { + expect(vm1.$el.innerHTML.includes('Edit')).toBe(true); + + expect(vm1.$el.innerHTML.includes('Locked')).toBe(true); + + expect(vm2.$el.innerHTML.includes('Unlocked')).toBe(true); + }); + + it('displays the edit form when editable', done => { + expect(vm1.isLockDialogOpen).toBe(false); + + vm1.$el.querySelector('.lock-edit').click(); + + expect(vm1.isLockDialogOpen).toBe(true); + + vm1.$nextTick(() => { + expect(vm1.$el.innerHTML.includes('Unlock this issue?')).toBe(true); + + done(); + }); + }); + + it('tracks an event when "Edit" is clicked', () => { + const spy = mockTracking('_category_', vm1.$el, jest.spyOn); + triggerEvent('.lock-edit'); + + expect(spy).toHaveBeenCalledWith('_category_', 'click_edit_button', { + label: 'right_sidebar', + property: 'lock_issue', + }); + }); + + it('displays the edit form when opened from collapsed state', done => { + expect(vm1.isLockDialogOpen).toBe(false); + + vm1.$el.querySelector('.sidebar-collapsed-icon').click(); + + expect(vm1.isLockDialogOpen).toBe(true); + + setImmediate(() => { + expect(vm1.$el.innerHTML.includes('Unlock this issue?')).toBe(true); + + done(); + }); + }); + + it('does not display the edit form when opened from collapsed state if not editable', done => { + expect(vm2.isLockDialogOpen).toBe(false); + + vm2.$el.querySelector('.sidebar-collapsed-icon').click(); + + Vue.nextTick() + .then(() => { + expect(vm2.isLockDialogOpen).toBe(false); + }) + .then(done) + .catch(done.fail); + }); +}); diff --git a/spec/frontend/sidebar/participants_spec.js b/spec/frontend/sidebar/participants_spec.js new file mode 100644 index 00000000000..ebe94582588 --- /dev/null +++ b/spec/frontend/sidebar/participants_spec.js @@ -0,0 +1,206 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Participants from '~/sidebar/components/participants/participants.vue'; + +const PARTICIPANT = { + id: 1, + state: 'active', + username: 'marcene', + name: 'Allie Will', + web_url: 'foo.com', + avatar_url: 'gravatar.com/avatar/xxx', +}; + +const PARTICIPANT_LIST = [PARTICIPANT, { ...PARTICIPANT, id: 2 }, { ...PARTICIPANT, id: 3 }]; + +describe('Participants', () => { + let wrapper; + + const getMoreParticipantsButton = () => wrapper.find('button'); + + const getCollapsedParticipantsCount = () => wrapper.find('[data-testid="collapsed-count"]'); + + const mountComponent = propsData => + shallowMount(Participants, { + propsData, + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('collapsed sidebar state', () => { + it('shows loading spinner when loading', () => { + wrapper = mountComponent({ + loading: true, + }); + + expect(wrapper.contains(GlLoadingIcon)).toBe(true); + }); + + it('does not show loading spinner not loading', () => { + wrapper = mountComponent({ + loading: false, + }); + + expect(wrapper.contains(GlLoadingIcon)).toBe(false); + }); + + it('shows participant count when given', () => { + wrapper = mountComponent({ + loading: false, + participants: PARTICIPANT_LIST, + }); + + expect(getCollapsedParticipantsCount().text()).toBe(`${PARTICIPANT_LIST.length}`); + }); + + it('shows full participant count when there are hidden participants', () => { + wrapper = mountComponent({ + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 1, + }); + + expect(getCollapsedParticipantsCount().text()).toBe(`${PARTICIPANT_LIST.length}`); + }); + }); + + describe('expanded sidebar state', () => { + it('shows loading spinner when loading', () => { + wrapper = mountComponent({ + loading: true, + }); + + expect(wrapper.contains(GlLoadingIcon)).toBe(true); + }); + + it('when only showing visible participants, shows an avatar only for each participant under the limit', () => { + const numberOfLessParticipants = 2; + wrapper = mountComponent({ + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants, + }); + + wrapper.setData({ + isShowingMoreParticipants: false, + }); + + return Vue.nextTick().then(() => { + expect(wrapper.findAll('.participants-author')).toHaveLength(numberOfLessParticipants); + }); + }); + + it('when only showing all participants, each has an avatar', () => { + wrapper = mountComponent({ + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + + wrapper.setData({ + isShowingMoreParticipants: true, + }); + + return Vue.nextTick().then(() => { + expect(wrapper.findAll('.participants-author')).toHaveLength(PARTICIPANT_LIST.length); + }); + }); + + it('does not have more participants link when they can all be shown', () => { + const numberOfLessParticipants = 100; + wrapper = mountComponent({ + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants, + }); + + expect(PARTICIPANT_LIST.length).toBeLessThan(numberOfLessParticipants); + expect(getMoreParticipantsButton().exists()).toBe(false); + }); + + it('when too many participants, has more participants link to show more', () => { + wrapper = mountComponent({ + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + + wrapper.setData({ + isShowingMoreParticipants: false, + }); + + return Vue.nextTick().then(() => { + expect(getMoreParticipantsButton().text()).toBe('+ 1 more'); + }); + }); + + it('when too many participants and already showing them, has more participants link to show less', () => { + wrapper = mountComponent({ + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + + wrapper.setData({ + isShowingMoreParticipants: true, + }); + + return Vue.nextTick().then(() => { + expect(getMoreParticipantsButton().text()).toBe('- show less'); + }); + }); + + it('clicking more participants link emits event', () => { + wrapper = mountComponent({ + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + + expect(wrapper.vm.isShowingMoreParticipants).toBe(false); + + getMoreParticipantsButton().trigger('click'); + + expect(wrapper.vm.isShowingMoreParticipants).toBe(true); + }); + + it('clicking on participants icon emits `toggleSidebar` event', () => { + wrapper = mountComponent({ + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + + const spy = jest.spyOn(wrapper.vm, '$emit'); + + wrapper.find('.sidebar-collapsed-icon').trigger('click'); + + return Vue.nextTick(() => { + expect(spy).toHaveBeenCalledWith('toggleSidebar'); + + spy.mockRestore(); + }); + }); + }); + + describe('when not showing participants label', () => { + beforeEach(() => { + wrapper = mountComponent({ + participants: PARTICIPANT_LIST, + showParticipantLabel: false, + }); + }); + + it('does not show sidebar collapsed icon', () => { + expect(wrapper.contains('.sidebar-collapsed-icon')).toBe(false); + }); + + it('does not show participants label title', () => { + expect(wrapper.contains('.title')).toBe(false); + }); + }); +}); diff --git a/spec/frontend/sidebar/sidebar_assignees_spec.js b/spec/frontend/sidebar/sidebar_assignees_spec.js index c1876066a21..88e2d2c9514 100644 --- a/spec/frontend/sidebar/sidebar_assignees_spec.js +++ b/spec/frontend/sidebar/sidebar_assignees_spec.js @@ -3,6 +3,7 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import axios from 'axios'; import SidebarAssignees from '~/sidebar/components/assignees/sidebar_assignees.vue'; import Assigness from '~/sidebar/components/assignees/assignees.vue'; +import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarService from '~/sidebar/services/sidebar_service'; import SidebarStore from '~/sidebar/stores/sidebar_store'; @@ -12,12 +13,19 @@ describe('sidebar assignees', () => { let wrapper; let mediator; let axiosMock; - - const createComponent = () => { + const createComponent = (realTimeIssueSidebar = false, props) => { wrapper = shallowMount(SidebarAssignees, { propsData: { + issuableIid: '1', mediator, field: '', + projectPath: 'projectPath', + ...props, + }, + provide: { + glFeatures: { + realTimeIssueSidebar, + }, }, // Attaching to document is required because this component emits something from the parent element :/ attachToDocument: true, @@ -30,8 +38,6 @@ describe('sidebar assignees', () => { jest.spyOn(mediator, 'saveAssignees'); jest.spyOn(mediator, 'assignYourself'); - - createComponent(); }); afterEach(() => { @@ -45,6 +51,8 @@ describe('sidebar assignees', () => { }); it('calls the mediator when saves the assignees', () => { + createComponent(); + expect(mediator.saveAssignees).not.toHaveBeenCalled(); wrapper.vm.saveAssignees(); @@ -53,6 +61,8 @@ describe('sidebar assignees', () => { }); it('calls the mediator when "assignSelf" method is called', () => { + createComponent(); + expect(mediator.assignYourself).not.toHaveBeenCalled(); expect(mediator.store.assignees.length).toBe(0); @@ -63,6 +73,8 @@ describe('sidebar assignees', () => { }); it('hides assignees until fetched', () => { + createComponent(); + expect(wrapper.find(Assigness).exists()).toBe(false); wrapper.vm.store.isFetching.assignees = false; @@ -71,4 +83,30 @@ describe('sidebar assignees', () => { expect(wrapper.find(Assigness).exists()).toBe(true); }); }); + + describe('when realTimeIssueSidebar is turned on', () => { + describe('when issuableType is issue', () => { + it('finds AssigneesRealtime componeont', () => { + createComponent(true); + + expect(wrapper.find(AssigneesRealtime).exists()).toBe(true); + }); + }); + + describe('when issuableType is MR', () => { + it('does not find AssigneesRealtime componeont', () => { + createComponent(true, { issuableType: 'MR' }); + + expect(wrapper.find(AssigneesRealtime).exists()).toBe(false); + }); + }); + }); + + describe('when realTimeIssueSidebar is turned off', () => { + it('does not find AssigneesRealtime', () => { + createComponent(false, { issuableType: 'issue' }); + + expect(wrapper.find(AssigneesRealtime).exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/sidebar/sidebar_mediator_spec.js b/spec/frontend/sidebar/sidebar_mediator_spec.js new file mode 100644 index 00000000000..0892d452966 --- /dev/null +++ b/spec/frontend/sidebar/sidebar_mediator_spec.js @@ -0,0 +1,135 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import * as urlUtility from '~/lib/utils/url_utility'; +import SidebarService, { gqClient } from '~/sidebar/services/sidebar_service'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import Mock from './mock_data'; + +describe('Sidebar mediator', () => { + const { mediator: mediatorMockData } = Mock; + let mock; + let mediator; + + beforeEach(() => { + mock = new MockAdapter(axios); + mediator = new SidebarMediator(mediatorMockData); + }); + + afterEach(() => { + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + mock.restore(); + }); + + it('assigns yourself ', () => { + mediator.assignYourself(); + + expect(mediator.store.currentUser).toEqual(mediatorMockData.currentUser); + expect(mediator.store.assignees[0]).toEqual(mediatorMockData.currentUser); + }); + + it('saves assignees', () => { + mock.onPut(mediatorMockData.endpoint).reply(200, {}); + + return mediator.saveAssignees('issue[assignee_ids]').then(resp => { + expect(resp.status).toEqual(200); + }); + }); + + it('fetches the data', () => { + const mockData = Mock.responseMap.GET[mediatorMockData.endpoint]; + mock.onGet(mediatorMockData.endpoint).reply(200, mockData); + + const mockGraphQlData = Mock.graphQlResponseData; + const graphQlSpy = jest.spyOn(gqClient, 'query').mockReturnValue({ + data: mockGraphQlData, + }); + const spy = jest.spyOn(mediator, 'processFetchedData').mockReturnValue(Promise.resolve()); + + return mediator.fetch().then(() => { + expect(spy).toHaveBeenCalledWith(mockData, mockGraphQlData); + + spy.mockRestore(); + graphQlSpy.mockRestore(); + }); + }); + + it('processes fetched data', () => { + const mockData = Mock.responseMap.GET[mediatorMockData.endpoint]; + mediator.processFetchedData(mockData); + + expect(mediator.store.assignees).toEqual(mockData.assignees); + expect(mediator.store.humanTimeEstimate).toEqual(mockData.human_time_estimate); + expect(mediator.store.humanTotalTimeSpent).toEqual(mockData.human_total_time_spent); + expect(mediator.store.participants).toEqual(mockData.participants); + expect(mediator.store.subscribed).toEqual(mockData.subscribed); + expect(mediator.store.timeEstimate).toEqual(mockData.time_estimate); + expect(mediator.store.totalTimeSpent).toEqual(mockData.total_time_spent); + }); + + it('sets moveToProjectId', () => { + const projectId = 7; + const spy = jest.spyOn(mediator.store, 'setMoveToProjectId').mockReturnValue(Promise.resolve()); + + mediator.setMoveToProjectId(projectId); + + expect(spy).toHaveBeenCalledWith(projectId); + + spy.mockRestore(); + }); + + it('fetches autocomplete projects', () => { + const searchTerm = 'foo'; + mock.onGet(mediatorMockData.projectsAutocompleteEndpoint).reply(200, {}); + const getterSpy = jest + .spyOn(mediator.service, 'getProjectsAutocomplete') + .mockReturnValue(Promise.resolve({ data: {} })); + const setterSpy = jest + .spyOn(mediator.store, 'setAutocompleteProjects') + .mockReturnValue(Promise.resolve()); + + return mediator.fetchAutocompleteProjects(searchTerm).then(() => { + expect(getterSpy).toHaveBeenCalledWith(searchTerm); + expect(setterSpy).toHaveBeenCalled(); + + getterSpy.mockRestore(); + setterSpy.mockRestore(); + }); + }); + + it('moves issue', () => { + const mockData = Mock.responseMap.POST[mediatorMockData.moveIssueEndpoint]; + const moveToProjectId = 7; + mock.onPost(mediatorMockData.moveIssueEndpoint).reply(200, mockData); + mediator.store.setMoveToProjectId(moveToProjectId); + const moveIssueSpy = jest + .spyOn(mediator.service, 'moveIssue') + .mockReturnValue(Promise.resolve({ data: { web_url: mockData.web_url } })); + const urlSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({}); + + return mediator.moveIssue().then(() => { + expect(moveIssueSpy).toHaveBeenCalledWith(moveToProjectId); + expect(urlSpy).toHaveBeenCalledWith(mockData.web_url); + + moveIssueSpy.mockRestore(); + urlSpy.mockRestore(); + }); + }); + + it('toggle subscription', () => { + mediator.store.setSubscribedState(false); + mock.onPost(mediatorMockData.toggleSubscriptionEndpoint).reply(200, {}); + const spy = jest + .spyOn(mediator.service, 'toggleSubscription') + .mockReturnValue(Promise.resolve()); + + return mediator.toggleSubscription().then(() => { + expect(spy).toHaveBeenCalled(); + expect(mediator.store.subscribed).toEqual(true); + + spy.mockRestore(); + }); + }); +}); diff --git a/spec/frontend/sidebar/sidebar_move_issue_spec.js b/spec/frontend/sidebar/sidebar_move_issue_spec.js new file mode 100644 index 00000000000..db0d3e06272 --- /dev/null +++ b/spec/frontend/sidebar/sidebar_move_issue_spec.js @@ -0,0 +1,167 @@ +import $ from 'jquery'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import SidebarMoveIssue from '~/sidebar/lib/sidebar_move_issue'; +import Mock from './mock_data'; + +describe('SidebarMoveIssue', () => { + let mock; + const test = {}; + + beforeEach(() => { + mock = new MockAdapter(axios); + const mockData = Mock.responseMap.GET['/autocomplete/projects?project_id=15']; + mock.onGet('/autocomplete/projects?project_id=15').reply(200, mockData); + test.mediator = new SidebarMediator(Mock.mediator); + test.$content = $(` + <div class="dropdown"> + <div class="js-toggle"></div> + <div class="dropdown-menu"> + <div class="dropdown-content"></div> + </div> + <div class="js-confirm-button"></div> + </div> + `); + test.$toggleButton = test.$content.find('.js-toggle'); + test.$confirmButton = test.$content.find('.js-confirm-button'); + + test.sidebarMoveIssue = new SidebarMoveIssue( + test.mediator, + test.$toggleButton, + test.$confirmButton, + ); + test.sidebarMoveIssue.init(); + }); + + afterEach(() => { + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + + test.sidebarMoveIssue.destroy(); + mock.restore(); + }); + + describe('init', () => { + it('should initialize the dropdown and listeners', () => { + jest.spyOn(test.sidebarMoveIssue, 'initDropdown').mockImplementation(() => {}); + jest.spyOn(test.sidebarMoveIssue, 'addEventListeners').mockImplementation(() => {}); + + test.sidebarMoveIssue.init(); + + expect(test.sidebarMoveIssue.initDropdown).toHaveBeenCalled(); + expect(test.sidebarMoveIssue.addEventListeners).toHaveBeenCalled(); + }); + }); + + describe('destroy', () => { + it('should remove the listeners', () => { + jest.spyOn(test.sidebarMoveIssue, 'removeEventListeners').mockImplementation(() => {}); + + test.sidebarMoveIssue.destroy(); + + expect(test.sidebarMoveIssue.removeEventListeners).toHaveBeenCalled(); + }); + }); + + describe('initDropdown', () => { + it('should initialize the gl_dropdown', () => { + jest.spyOn($.fn, 'glDropdown').mockImplementation(() => {}); + + test.sidebarMoveIssue.initDropdown(); + + expect($.fn.glDropdown).toHaveBeenCalled(); + }); + + it('escapes html from project name', done => { + test.$toggleButton.dropdown('toggle'); + + setImmediate(() => { + expect(test.$content.find('.js-move-issue-dropdown-item')[1].innerHTML.trim()).toEqual( + '<img src=x onerror=alert(document.domain)> foo / bar', + ); + done(); + }); + }); + }); + + describe('onConfirmClicked', () => { + it('should move the issue with valid project ID', () => { + jest.spyOn(test.mediator, 'moveIssue').mockReturnValue(Promise.resolve()); + test.mediator.setMoveToProjectId(7); + + test.sidebarMoveIssue.onConfirmClicked(); + + expect(test.mediator.moveIssue).toHaveBeenCalled(); + expect(test.$confirmButton.prop('disabled')).toBeTruthy(); + expect(test.$confirmButton.hasClass('is-loading')).toBe(true); + }); + + it('should remove loading state from confirm button on failure', done => { + jest.spyOn(window, 'Flash').mockImplementation(() => {}); + jest.spyOn(test.mediator, 'moveIssue').mockReturnValue(Promise.reject()); + test.mediator.setMoveToProjectId(7); + + test.sidebarMoveIssue.onConfirmClicked(); + + expect(test.mediator.moveIssue).toHaveBeenCalled(); + // Wait for the move issue request to fail + setImmediate(() => { + expect(window.Flash).toHaveBeenCalled(); + expect(test.$confirmButton.prop('disabled')).toBeFalsy(); + expect(test.$confirmButton.hasClass('is-loading')).toBe(false); + done(); + }); + }); + + it('should not move the issue with id=0', () => { + jest.spyOn(test.mediator, 'moveIssue').mockImplementation(() => {}); + test.mediator.setMoveToProjectId(0); + + test.sidebarMoveIssue.onConfirmClicked(); + + expect(test.mediator.moveIssue).not.toHaveBeenCalled(); + }); + }); + + it('should set moveToProjectId on dropdown item "No project" click', done => { + jest.spyOn(test.mediator, 'setMoveToProjectId').mockImplementation(() => {}); + + // Open the dropdown + test.$toggleButton.dropdown('toggle'); + + // Wait for the autocomplete request to finish + setImmediate(() => { + test.$content + .find('.js-move-issue-dropdown-item') + .eq(0) + .trigger('click'); + + expect(test.mediator.setMoveToProjectId).toHaveBeenCalledWith(0); + expect(test.$confirmButton.prop('disabled')).toBeTruthy(); + done(); + }); + }); + + it('should set moveToProjectId on dropdown item click', done => { + jest.spyOn(test.mediator, 'setMoveToProjectId').mockImplementation(() => {}); + + // Open the dropdown + test.$toggleButton.dropdown('toggle'); + + // Wait for the autocomplete request to finish + setImmediate(() => { + test.$content + .find('.js-move-issue-dropdown-item') + .eq(1) + .trigger('click'); + + expect(test.mediator.setMoveToProjectId).toHaveBeenCalledWith(20); + expect(test.$confirmButton.attr('disabled')).toBe(undefined); + done(); + }); + }); +}); diff --git a/spec/frontend/sidebar/sidebar_subscriptions_spec.js b/spec/frontend/sidebar/sidebar_subscriptions_spec.js new file mode 100644 index 00000000000..18aaeabe3dd --- /dev/null +++ b/spec/frontend/sidebar/sidebar_subscriptions_spec.js @@ -0,0 +1,36 @@ +import { shallowMount } from '@vue/test-utils'; +import SidebarSubscriptions from '~/sidebar/components/subscriptions/sidebar_subscriptions.vue'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import Mock from './mock_data'; + +describe('Sidebar Subscriptions', () => { + let wrapper; + let mediator; + + beforeEach(() => { + mediator = new SidebarMediator(Mock.mediator); + wrapper = shallowMount(SidebarSubscriptions, { + propsData: { + mediator, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + }); + + it('calls the mediator toggleSubscription on event', () => { + const spy = jest.spyOn(mediator, 'toggleSubscription').mockReturnValue(Promise.resolve()); + + wrapper.vm.onToggleSubscription(); + + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); diff --git a/spec/frontend/sidebar/subscriptions_spec.js b/spec/frontend/sidebar/subscriptions_spec.js new file mode 100644 index 00000000000..cce35666985 --- /dev/null +++ b/spec/frontend/sidebar/subscriptions_spec.js @@ -0,0 +1,106 @@ +import { shallowMount } from '@vue/test-utils'; +import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue'; +import eventHub from '~/sidebar/event_hub'; +import ToggleButton from '~/vue_shared/components/toggle_button.vue'; + +describe('Subscriptions', () => { + let wrapper; + + const findToggleButton = () => wrapper.find(ToggleButton); + + const mountComponent = propsData => + shallowMount(Subscriptions, { + propsData, + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('shows loading spinner when loading', () => { + wrapper = mountComponent({ + loading: true, + subscribed: undefined, + }); + + expect(findToggleButton().attributes('isloading')).toBe('true'); + }); + + it('is toggled "off" when currently not subscribed', () => { + wrapper = mountComponent({ + subscribed: false, + }); + + expect(findToggleButton().attributes('value')).toBeFalsy(); + }); + + it('is toggled "on" when currently subscribed', () => { + wrapper = mountComponent({ + subscribed: true, + }); + + expect(findToggleButton().attributes('value')).toBe('true'); + }); + + it('toggleSubscription method emits `toggleSubscription` event on eventHub and Component', () => { + const id = 42; + wrapper = mountComponent({ subscribed: true, id }); + const eventHubSpy = jest.spyOn(eventHub, '$emit'); + const wrapperEmitSpy = jest.spyOn(wrapper.vm, '$emit'); + + wrapper.vm.toggleSubscription(); + + expect(eventHubSpy).toHaveBeenCalledWith('toggleSubscription', id); + expect(wrapperEmitSpy).toHaveBeenCalledWith('toggleSubscription', id); + eventHubSpy.mockRestore(); + wrapperEmitSpy.mockRestore(); + }); + + it('tracks the event when toggled', () => { + wrapper = mountComponent({ subscribed: true }); + + const wrapperTrackSpy = jest.spyOn(wrapper.vm, 'track'); + + wrapper.vm.toggleSubscription(); + + expect(wrapperTrackSpy).toHaveBeenCalledWith('toggle_button', { + property: 'notifications', + value: 0, + }); + wrapperTrackSpy.mockRestore(); + }); + + it('onClickCollapsedIcon method emits `toggleSidebar` event on component', () => { + wrapper = mountComponent({ subscribed: true }); + const spy = jest.spyOn(wrapper.vm, '$emit'); + + wrapper.vm.onClickCollapsedIcon(); + + expect(spy).toHaveBeenCalledWith('toggleSidebar'); + spy.mockRestore(); + }); + + describe('given project emails are disabled', () => { + const subscribeDisabledDescription = 'Notifications have been disabled'; + + beforeEach(() => { + wrapper = mountComponent({ + subscribed: false, + projectEmailsDisabled: true, + subscribeDisabledDescription, + }); + }); + + it('sets the correct display text', () => { + expect(wrapper.find('.issuable-header-text').text()).toContain(subscribeDisabledDescription); + expect(wrapper.find({ ref: 'tooltip' }).attributes('data-original-title')).toBe( + subscribeDisabledDescription, + ); + }); + + it('does not render the toggle button', () => { + expect(wrapper.contains('.js-issuable-subscribe-button')).toBe(false); + }); + }); +}); diff --git a/spec/frontend/smart_interval_spec.js b/spec/frontend/smart_interval_spec.js index b32ac99e4e4..1a2fd7ff8f1 100644 --- a/spec/frontend/smart_interval_spec.js +++ b/spec/frontend/smart_interval_spec.js @@ -3,8 +3,6 @@ import { assignIn } from 'lodash'; import waitForPromises from 'helpers/wait_for_promises'; import SmartInterval from '~/smart_interval'; -jest.useFakeTimers(); - let interval; describe('SmartInterval', () => { diff --git a/spec/frontend/snippet/snippet_bundle_spec.js b/spec/frontend/snippet/snippet_bundle_spec.js index 12d20d5cd85..38d05243c65 100644 --- a/spec/frontend/snippet/snippet_bundle_spec.js +++ b/spec/frontend/snippet/snippet_bundle_spec.js @@ -1,94 +1,85 @@ import Editor from '~/editor/editor_lite'; -import { initEditor } from '~/snippet/snippet_bundle'; +import initEditor from '~/snippet/snippet_bundle'; import { setHTMLFixture } from 'helpers/fixtures'; jest.mock('~/editor/editor_lite', () => jest.fn()); describe('Snippet editor', () => { - describe('Monaco editor for Snippets', () => { - let oldGon; - let editorEl; - let contentEl; - let fileNameEl; - let form; - - const mockName = 'foo.bar'; - const mockContent = 'Foo Bar'; - const updatedMockContent = 'New Foo Bar'; - - const mockEditor = { - createInstance: jest.fn(), - updateModelLanguage: jest.fn(), - getValue: jest.fn().mockReturnValueOnce(updatedMockContent), - }; - Editor.mockImplementation(() => mockEditor); - - function setUpFixture(name, content) { - setHTMLFixture(` - <div class="snippet-form-holder"> - <form> - <input class="js-snippet-file-name" type="text" value="${name}"> - <input class="snippet-file-content" type="hidden" value="${content}"> - <pre id="editor"></pre> - </form> - </div> - `); - } - - function bootstrap(name = '', content = '') { - setUpFixture(name, content); - editorEl = document.getElementById('editor'); - contentEl = document.querySelector('.snippet-file-content'); - fileNameEl = document.querySelector('.js-snippet-file-name'); - form = document.querySelector('.snippet-form-holder form'); - - initEditor(); - } - - function createEvent(name) { - return new Event(name, { - view: window, - bubbles: true, - cancelable: true, - }); - } - - beforeEach(() => { - oldGon = window.gon; - window.gon = { features: { monacoSnippets: true } }; - bootstrap(mockName, mockContent); + let editorEl; + let contentEl; + let fileNameEl; + let form; + + const mockName = 'foo.bar'; + const mockContent = 'Foo Bar'; + const updatedMockContent = 'New Foo Bar'; + + const mockEditor = { + createInstance: jest.fn(), + updateModelLanguage: jest.fn(), + getValue: jest.fn().mockReturnValueOnce(updatedMockContent), + }; + Editor.mockImplementation(() => mockEditor); + + function setUpFixture(name, content) { + setHTMLFixture(` + <div class="snippet-form-holder"> + <form> + <input class="js-snippet-file-name" type="text" value="${name}"> + <input class="snippet-file-content" type="hidden" value="${content}"> + <pre id="editor"></pre> + </form> + </div> + `); + } + + function bootstrap(name = '', content = '') { + setUpFixture(name, content); + editorEl = document.getElementById('editor'); + contentEl = document.querySelector('.snippet-file-content'); + fileNameEl = document.querySelector('.js-snippet-file-name'); + form = document.querySelector('.snippet-form-holder form'); + + initEditor(); + } + + function createEvent(name) { + return new Event(name, { + view: window, + bubbles: true, + cancelable: true, }); + } - afterEach(() => { - window.gon = oldGon; - }); + beforeEach(() => { + bootstrap(mockName, mockContent); + }); - it('correctly initializes Editor', () => { - expect(mockEditor.createInstance).toHaveBeenCalledWith({ - el: editorEl, - blobPath: mockName, - blobContent: mockContent, - }); + it('correctly initializes Editor', () => { + expect(mockEditor.createInstance).toHaveBeenCalledWith({ + el: editorEl, + blobPath: mockName, + blobContent: mockContent, }); + }); - it('listens to file name changes and updates syntax highlighting of code', () => { - expect(mockEditor.updateModelLanguage).not.toHaveBeenCalled(); + it('listens to file name changes and updates syntax highlighting of code', () => { + expect(mockEditor.updateModelLanguage).not.toHaveBeenCalled(); - const event = createEvent('change'); + const event = createEvent('change'); - fileNameEl.value = updatedMockContent; - fileNameEl.dispatchEvent(event); + fileNameEl.value = updatedMockContent; + fileNameEl.dispatchEvent(event); - expect(mockEditor.updateModelLanguage).toHaveBeenCalledWith(updatedMockContent); - }); + expect(mockEditor.updateModelLanguage).toHaveBeenCalledWith(updatedMockContent); + }); - it('listens to form submit event and populates the hidden field with most recent version of the content', () => { - expect(contentEl.value).toBe(mockContent); + it('listens to form submit event and populates the hidden field with most recent version of the content', () => { + expect(contentEl.value).toBe(mockContent); - const event = createEvent('submit'); + const event = createEvent('submit'); - form.dispatchEvent(event); - expect(contentEl.value).toBe(updatedMockContent); - }); + form.dispatchEvent(event); + expect(contentEl.value).toBe(updatedMockContent); }); }); diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap index b1bbe2a9710..301ec5652a9 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap @@ -12,6 +12,7 @@ exports[`Snippet Blob Edit component rendering matches the snapshot 1`] = ` class="file-holder snippet" > <blob-header-edit-stub + data-qa-selector="snippet_file_name" value="lorem.txt" /> diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap index 334ceaa064f..9fd4cba5b87 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap @@ -35,8 +35,8 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = > <textarea aria-label="Description" - class="note-textarea js-gfm-input js-autosize markdown-area - qa-description-textarea" + class="note-textarea js-gfm-input js-autosize markdown-area" + data-qa-selector="snippet_description_field" data-supports-quick-actions="false" dir="auto" placeholder="Write a comment or drag your files here…" diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_view_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_view_spec.js.snap new file mode 100644 index 00000000000..9ebc4e81baf --- /dev/null +++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_view_spec.js.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Snippet Description component matches the snapshot 1`] = ` +<markdown-field-view-stub + class="snippet-description" + data-qa-selector="snippet_description_field" +> + <div + class="md js-snippet-description" + > + <h2> + The property of Thor + </h2> + </div> +</markdown-field-view-stub> +`; diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index 21a4ccf5a74..ba62a0a92ca 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -100,6 +100,7 @@ describe('Snippet Edit app', () => { }); const findSubmitButton = () => wrapper.find('[type=submit]'); + const findCancellButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]'); describe('rendering', () => { it('renders loader while the query is in flight', () => { @@ -148,6 +149,21 @@ describe('Snippet Edit app', () => { expect(isBtnDisabled).toBe(expectation); }, ); + + it.each` + isNew | status | expectation + ${true} | ${`new`} | ${`/snippets`} + ${false} | ${`existing`} | ${newlyEditedSnippetUrl} + `('sets correct href for the cancel button on a $status snippet', ({ isNew, expectation }) => { + createComponent({ + data: { + snippet: { webUrl: newlyEditedSnippetUrl }, + newSnippet: isNew, + }, + }); + + expect(findCancellButton().attributes('href')).toBe(expectation); + }); }); describe('functionality', () => { diff --git a/spec/frontend/snippets/components/snippet_blob_view_spec.js b/spec/frontend/snippets/components/snippet_blob_view_spec.js index 1f6038bc7f0..d06489cffa9 100644 --- a/spec/frontend/snippets/components/snippet_blob_view_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_view_spec.js @@ -3,6 +3,7 @@ import SnippetBlobView from '~/snippets/components/snippet_blob_view.vue'; import BlobHeader from '~/blob/components/blob_header.vue'; import BlobEmbeddable from '~/blob/components/blob_embeddable.vue'; import BlobContent from '~/blob/components/blob_content.vue'; +import { BLOB_RENDER_EVENT_LOAD, BLOB_RENDER_EVENT_SHOW_SOURCE } from '~/blob/components/constants'; import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers'; import { SNIPPET_VISIBILITY_PRIVATE, @@ -29,6 +30,8 @@ describe('Blob Embeddable', () => { queries: { blobContent: { loading: contentLoading, + refetch: jest.fn(), + skip: true, }, }, }; @@ -84,9 +87,7 @@ describe('Blob Embeddable', () => { }); it('sets rich viewer correctly', () => { - const data = Object.assign({}, dataMock, { - activeViewerType: RichViewerMock.type, - }); + const data = { ...dataMock, activeViewerType: RichViewerMock.type }; createComponent({}, data); expect(wrapper.find(RichViewer).exists()).toBe(true); }); @@ -145,4 +146,35 @@ describe('Blob Embeddable', () => { }); }); }); + + describe('functionality', () => { + describe('render error', () => { + const findContentEl = () => wrapper.find(BlobContent); + + it('correctly sets blob on the blob-content-error component', () => { + createComponent(); + expect(findContentEl().props('blob')).toEqual(BlobMock); + }); + + it(`refetches blob content on ${BLOB_RENDER_EVENT_LOAD} event`, () => { + createComponent(); + + expect(wrapper.vm.$apollo.queries.blobContent.refetch).not.toHaveBeenCalled(); + findContentEl().vm.$emit(BLOB_RENDER_EVENT_LOAD); + expect(wrapper.vm.$apollo.queries.blobContent.refetch).toHaveBeenCalledTimes(1); + }); + + it(`sets '${SimpleViewerMock.type}' as active on ${BLOB_RENDER_EVENT_SHOW_SOURCE} event`, () => { + createComponent( + {}, + { + 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_description_view_spec.js b/spec/frontend/snippets/components/snippet_description_view_spec.js new file mode 100644 index 00000000000..46467ef311e --- /dev/null +++ b/spec/frontend/snippets/components/snippet_description_view_spec.js @@ -0,0 +1,27 @@ +import SnippetDescription from '~/snippets/components/snippet_description_view.vue'; +import { shallowMount } from '@vue/test-utils'; + +describe('Snippet Description component', () => { + let wrapper; + const description = '<h2>The property of Thor</h2>'; + + function createComponent() { + wrapper = shallowMount(SnippetDescription, { + propsData: { + description, + }, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('matches the snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js index 16a66c70d6a..5230910b6f5 100644 --- a/spec/frontend/snippets/components/snippet_header_spec.js +++ b/spec/frontend/snippets/components/snippet_header_spec.js @@ -7,26 +7,27 @@ import { shallowMount } from '@vue/test-utils'; describe('Snippet header component', () => { let wrapper; const snippet = { - snippet: { - id: 'gid://gitlab/PersonalSnippet/50', - title: 'The property of Thor', - visibilityLevel: 'private', - webUrl: 'http://personal.dev.null/42', - userPermissions: { - adminSnippet: true, - updateSnippet: true, - reportSnippet: false, - }, - project: null, - author: { - name: 'Thor Odinson', - }, + id: 'gid://gitlab/PersonalSnippet/50', + title: 'The property of Thor', + visibilityLevel: 'private', + webUrl: 'http://personal.dev.null/42', + userPermissions: { + adminSnippet: true, + updateSnippet: true, + reportSnippet: false, + }, + project: null, + author: { + name: 'Thor Odinson', + }, + blob: { + binary: false, }, }; const mutationVariables = { mutation: DeleteSnippetMutation, variables: { - id: snippet.snippet.id, + id: snippet.id, }, }; const errorMsg = 'Foo bar'; @@ -46,10 +47,12 @@ describe('Snippet header component', () => { loading = false, permissions = {}, mutationRes = mutationTypes.RESOLVE, + snippetProps = {}, } = {}) { - const defaultProps = Object.assign({}, snippet); + // const defaultProps = Object.assign({}, snippet, snippetProps); + const defaultProps = Object.assign(snippet, snippetProps); if (permissions) { - Object.assign(defaultProps.snippet.userPermissions, { + Object.assign(defaultProps.userPermissions, { ...permissions, }); } @@ -65,7 +68,9 @@ describe('Snippet header component', () => { wrapper = shallowMount(SnippetHeader, { mocks: { $apollo }, propsData: { - ...defaultProps, + snippet: { + ...defaultProps, + }, }, stubs: { ApolloMutation, @@ -126,6 +131,17 @@ describe('Snippet header component', () => { expect(wrapper.find(GlModal).exists()).toBe(true); }); + it('renders Edit button as disabled for binary snippets', () => { + createComponent({ + snippetProps: { + blob: { + binary: true, + }, + }, + }); + expect(wrapper.find('[href*="edit"]').props('disabled')).toBe(true); + }); + describe('Delete mutation', () => { const { location } = window; @@ -156,14 +172,34 @@ describe('Snippet header component', () => { }); }); - it('closes modal and redirects to snippets listing in case of successful mutation', () => { - createComponent(); - wrapper.vm.closeDeleteModal = jest.fn(); + describe('in case of successful mutation, closes modal and redirects to correct listing', () => { + const createDeleteSnippet = (snippetProps = {}) => { + createComponent({ + snippetProps, + }); + wrapper.vm.closeDeleteModal = jest.fn(); - wrapper.vm.deleteSnippet(); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.closeDeleteModal).toHaveBeenCalled(); - expect(window.location.pathname).toEqual('dashboard/snippets'); + wrapper.vm.deleteSnippet(); + return wrapper.vm.$nextTick(); + }; + + it('redirects to dashboard/snippets for personal snippet', () => { + return createDeleteSnippet().then(() => { + expect(wrapper.vm.closeDeleteModal).toHaveBeenCalled(); + expect(window.location.pathname).toBe('dashboard/snippets'); + }); + }); + + it('redirects to project snippets for project snippet', () => { + const fullPath = 'foo/bar'; + return createDeleteSnippet({ + project: { + fullPath, + }, + }).then(() => { + expect(wrapper.vm.closeDeleteModal).toHaveBeenCalled(); + expect(window.location.pathname).toBe(`${fullPath}/snippets`); + }); }); }); }); diff --git a/spec/frontend/snippets/components/snippet_title_spec.js b/spec/frontend/snippets/components/snippet_title_spec.js index b49b2008610..88261a75f6c 100644 --- a/spec/frontend/snippets/components/snippet_title_spec.js +++ b/spec/frontend/snippets/components/snippet_title_spec.js @@ -1,4 +1,5 @@ import SnippetTitle from '~/snippets/components/snippet_title.vue'; +import SnippetDescription from '~/snippets/components/snippet_description_view.vue'; import { GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; @@ -16,7 +17,7 @@ describe('Snippet header component', () => { }; function createComponent({ props = snippet } = {}) { - const defaultProps = Object.assign({}, props); + const defaultProps = { ...props }; wrapper = shallowMount(SnippetTitle, { propsData: { @@ -36,8 +37,9 @@ describe('Snippet header component', () => { it('renders snippets title and description', () => { createComponent(); + expect(wrapper.text().trim()).toContain(title); - expect(wrapper.find('.js-snippet-description').element.innerHTML).toBe(descriptionHtml); + expect(wrapper.find(SnippetDescription).props('description')).toBe(descriptionHtml); }); it('does not render recent changes time stamp if there were no updates', () => { diff --git a/spec/frontend/static_site_editor/components/edit_area_spec.js b/spec/frontend/static_site_editor/components/edit_area_spec.js new file mode 100644 index 00000000000..bfe41f65d6e --- /dev/null +++ b/spec/frontend/static_site_editor/components/edit_area_spec.js @@ -0,0 +1,76 @@ +import { shallowMount } from '@vue/test-utils'; + +import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; + +import EditArea from '~/static_site_editor/components/edit_area.vue'; +import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue'; +import EditHeader from '~/static_site_editor/components/edit_header.vue'; + +import { sourceContentTitle as title, sourceContent as content, returnUrl } from '../mock_data'; + +describe('~/static_site_editor/components/edit_area.vue', () => { + let wrapper; + const savingChanges = true; + const newContent = `new ${content}`; + + const buildWrapper = (propsData = {}) => { + wrapper = shallowMount(EditArea, { + propsData: { + title, + content, + returnUrl, + savingChanges, + ...propsData, + }, + }); + }; + + const findEditHeader = () => wrapper.find(EditHeader); + const findRichContentEditor = () => wrapper.find(RichContentEditor); + const findPublishToolbar = () => wrapper.find(PublishToolbar); + + beforeEach(() => { + buildWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders edit header', () => { + expect(findEditHeader().exists()).toBe(true); + expect(findEditHeader().props('title')).toBe(title); + }); + + it('renders rich content editor', () => { + expect(findRichContentEditor().exists()).toBe(true); + expect(findRichContentEditor().props('value')).toBe(content); + }); + + it('renders publish toolbar', () => { + expect(findPublishToolbar().exists()).toBe(true); + expect(findPublishToolbar().props('returnUrl')).toBe(returnUrl); + expect(findPublishToolbar().props('savingChanges')).toBe(savingChanges); + expect(findPublishToolbar().props('saveable')).toBe(false); + }); + + describe('when content changes', () => { + beforeEach(() => { + findRichContentEditor().vm.$emit('input', newContent); + + return wrapper.vm.$nextTick(); + }); + + it('sets publish toolbar as saveable when content changes', () => { + expect(findPublishToolbar().props('saveable')).toBe(true); + }); + + it('sets publish toolbar as not saveable when content changes are rollback', () => { + findRichContentEditor().vm.$emit('input', content); + + return wrapper.vm.$nextTick().then(() => { + expect(findPublishToolbar().props('saveable')).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/components/publish_toolbar_spec.js b/spec/frontend/static_site_editor/components/publish_toolbar_spec.js index 82eb12d4c4d..5428ed23266 100644 --- a/spec/frontend/static_site_editor/components/publish_toolbar_spec.js +++ b/spec/frontend/static_site_editor/components/publish_toolbar_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue'; @@ -19,7 +19,6 @@ describe('Static Site Editor Toolbar', () => { const findReturnUrlLink = () => wrapper.find({ ref: 'returnUrlLink' }); const findSaveChangesButton = () => wrapper.find(GlButton); - const findLoadingIndicator = () => wrapper.find(GlLoadingIcon); beforeEach(() => { buildWrapper(); @@ -37,8 +36,8 @@ describe('Static Site Editor Toolbar', () => { expect(findSaveChangesButton().attributes('disabled')).toBe('true'); }); - it('does not display saving changes indicator', () => { - expect(findLoadingIndicator().classes()).toContain('invisible'); + it('does not render the Submit Changes button with a loader', () => { + expect(findSaveChangesButton().props('loading')).toBe(false); }); it('does not render returnUrl link', () => { @@ -62,15 +61,11 @@ describe('Static Site Editor Toolbar', () => { describe('when saving changes', () => { beforeEach(() => { - buildWrapper({ saveable: true, savingChanges: true }); + buildWrapper({ savingChanges: true }); }); - it('disables Submit Changes button', () => { - expect(findSaveChangesButton().attributes('disabled')).toBe('true'); - }); - - it('displays saving changes indicator', () => { - expect(findLoadingIndicator().classes()).not.toContain('invisible'); + it('renders the Submit Changes button with a loading indicator', () => { + expect(findSaveChangesButton().props('loading')).toBe(true); }); }); diff --git a/spec/frontend/static_site_editor/components/saved_changes_message_spec.js b/spec/frontend/static_site_editor/components/saved_changes_message_spec.js index 659e9be59d2..a63c3a83395 100644 --- a/spec/frontend/static_site_editor/components/saved_changes_message_spec.js +++ b/spec/frontend/static_site_editor/components/saved_changes_message_spec.js @@ -46,14 +46,11 @@ describe('~/static_site_editor/components/saved_changes_message.vue', () => { ${'branch'} | ${findBranchLink} | ${props.branch} ${'commit'} | ${findCommitLink} | ${props.commit} ${'merge request'} | ${findMergeRequestLink} | ${props.mergeRequest} - `('renders $desc link', ({ desc, findEl, prop }) => { + `('renders $desc link', ({ findEl, prop }) => { const el = findEl(); expect(el.exists()).toBe(true); expect(el.text()).toBe(prop.label); - - if (desc !== 'branch') { - expect(el.attributes('href')).toBe(prop.url); - } + expect(el.attributes('href')).toBe(prop.url); }); }); diff --git a/spec/frontend/static_site_editor/components/static_site_editor_spec.js b/spec/frontend/static_site_editor/components/static_site_editor_spec.js deleted file mode 100644 index 5d4e3758557..00000000000 --- a/spec/frontend/static_site_editor/components/static_site_editor_spec.js +++ /dev/null @@ -1,247 +0,0 @@ -import Vuex from 'vuex'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlSkeletonLoader } from '@gitlab/ui'; - -import createState from '~/static_site_editor/store/state'; - -import StaticSiteEditor from '~/static_site_editor/components/static_site_editor.vue'; -import EditArea from '~/static_site_editor/components/edit_area.vue'; -import EditHeader from '~/static_site_editor/components/edit_header.vue'; -import InvalidContentMessage from '~/static_site_editor/components/invalid_content_message.vue'; -import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue'; -import SubmitChangesError from '~/static_site_editor/components/submit_changes_error.vue'; -import SavedChangesMessage from '~/static_site_editor/components/saved_changes_message.vue'; - -import { - returnUrl, - sourceContent, - sourceContentTitle, - savedContentMeta, - submitChangesError, -} from '../mock_data'; - -const localVue = createLocalVue(); - -localVue.use(Vuex); - -describe('StaticSiteEditor', () => { - let wrapper; - let store; - let loadContentActionMock; - let setContentActionMock; - let submitChangesActionMock; - let dismissSubmitChangesErrorActionMock; - - const buildStore = ({ initialState, getters } = {}) => { - loadContentActionMock = jest.fn(); - setContentActionMock = jest.fn(); - submitChangesActionMock = jest.fn(); - dismissSubmitChangesErrorActionMock = jest.fn(); - - store = new Vuex.Store({ - state: createState({ - isSupportedContent: true, - ...initialState, - }), - getters: { - contentChanged: () => false, - ...getters, - }, - actions: { - loadContent: loadContentActionMock, - setContent: setContentActionMock, - submitChanges: submitChangesActionMock, - dismissSubmitChangesError: dismissSubmitChangesErrorActionMock, - }, - }); - }; - const buildContentLoadedStore = ({ initialState, getters } = {}) => { - buildStore({ - initialState: { - isContentLoaded: true, - ...initialState, - }, - getters: { - ...getters, - }, - }); - }; - - const buildWrapper = () => { - wrapper = shallowMount(StaticSiteEditor, { - localVue, - store, - }); - }; - - const findEditArea = () => wrapper.find(EditArea); - const findEditHeader = () => wrapper.find(EditHeader); - const findInvalidContentMessage = () => wrapper.find(InvalidContentMessage); - const findPublishToolbar = () => wrapper.find(PublishToolbar); - const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader); - const findSubmitChangesError = () => wrapper.find(SubmitChangesError); - const findSavedChangesMessage = () => wrapper.find(SavedChangesMessage); - - beforeEach(() => { - buildStore(); - buildWrapper(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders the saved changes message when changes are submitted successfully', () => { - buildStore({ initialState: { returnUrl, savedContentMeta } }); - buildWrapper(); - - expect(findSavedChangesMessage().exists()).toBe(true); - expect(findSavedChangesMessage().props()).toEqual({ - returnUrl, - ...savedContentMeta, - }); - }); - - describe('when content is not loaded', () => { - it('does not render edit area', () => { - expect(findEditArea().exists()).toBe(false); - }); - - it('does not render edit header', () => { - expect(findEditHeader().exists()).toBe(false); - }); - - it('does not render toolbar', () => { - expect(findPublishToolbar().exists()).toBe(false); - }); - - it('does not render saved changes message', () => { - expect(findSavedChangesMessage().exists()).toBe(false); - }); - }); - - describe('when content is loaded', () => { - const content = sourceContent; - const title = sourceContentTitle; - - beforeEach(() => { - buildContentLoadedStore({ initialState: { content, title } }); - buildWrapper(); - }); - - it('renders the edit area', () => { - expect(findEditArea().exists()).toBe(true); - }); - - it('renders the edit header', () => { - expect(findEditHeader().exists()).toBe(true); - }); - - it('does not render skeleton loader', () => { - expect(findSkeletonLoader().exists()).toBe(false); - }); - - it('passes page content to edit area', () => { - expect(findEditArea().props('value')).toBe(content); - }); - - it('passes page title to edit header', () => { - expect(findEditHeader().props('title')).toBe(title); - }); - - it('renders toolbar', () => { - expect(findPublishToolbar().exists()).toBe(true); - }); - }); - - it('sets toolbar as saveable when content changes', () => { - buildContentLoadedStore({ - getters: { - contentChanged: () => true, - }, - }); - buildWrapper(); - - expect(findPublishToolbar().props('saveable')).toBe(true); - }); - - it('displays skeleton loader when loading content', () => { - buildStore({ initialState: { isLoadingContent: true } }); - buildWrapper(); - - expect(findSkeletonLoader().exists()).toBe(true); - }); - - it('does not display submit changes error when an error does not exist', () => { - buildContentLoadedStore(); - buildWrapper(); - - expect(findSubmitChangesError().exists()).toBe(false); - }); - - it('sets toolbar as saving when saving changes', () => { - buildContentLoadedStore({ - initialState: { - isSavingChanges: true, - }, - }); - buildWrapper(); - - expect(findPublishToolbar().props('savingChanges')).toBe(true); - }); - - it('displays invalid content message when content is not supported', () => { - buildStore({ initialState: { isSupportedContent: false } }); - buildWrapper(); - - expect(findInvalidContentMessage().exists()).toBe(true); - }); - - describe('when submitting changes fail', () => { - beforeEach(() => { - buildContentLoadedStore({ - initialState: { - submitChangesError, - }, - }); - buildWrapper(); - }); - - it('displays submit changes error message', () => { - expect(findSubmitChangesError().exists()).toBe(true); - }); - - it('dispatches submitChanges action when error message emits retry event', () => { - findSubmitChangesError().vm.$emit('retry'); - - expect(submitChangesActionMock).toHaveBeenCalled(); - }); - - it('dispatches dismissSubmitChangesError action when error message emits dismiss event', () => { - findSubmitChangesError().vm.$emit('dismiss'); - - expect(dismissSubmitChangesErrorActionMock).toHaveBeenCalled(); - }); - }); - - it('dispatches load content action', () => { - expect(loadContentActionMock).toHaveBeenCalled(); - }); - - it('dispatches setContent action when edit area emits input event', () => { - buildContentLoadedStore(); - buildWrapper(); - - findEditArea().vm.$emit('input', sourceContent); - - expect(setContentActionMock).toHaveBeenCalledWith(expect.anything(), sourceContent, undefined); - }); - - it('dispatches submitChanges action when toolbar emits submit event', () => { - buildContentLoadedStore(); - buildWrapper(); - findPublishToolbar().vm.$emit('submit'); - - expect(submitChangesActionMock).toHaveBeenCalled(); - }); -}); diff --git a/spec/frontend/static_site_editor/graphql/resolvers/file_spec.js b/spec/frontend/static_site_editor/graphql/resolvers/file_spec.js new file mode 100644 index 00000000000..8504d09e0f1 --- /dev/null +++ b/spec/frontend/static_site_editor/graphql/resolvers/file_spec.js @@ -0,0 +1,25 @@ +import fileResolver from '~/static_site_editor/graphql/resolvers/file'; +import loadSourceContent from '~/static_site_editor/services/load_source_content'; + +import { + projectId, + sourcePath, + sourceContentTitle as title, + sourceContent as content, +} from '../../mock_data'; + +jest.mock('~/static_site_editor/services/load_source_content', () => jest.fn()); + +describe('static_site_editor/graphql/resolvers/file', () => { + it('returns file content and title when fetching file successfully', () => { + loadSourceContent.mockResolvedValueOnce({ title, content }); + + return fileResolver({ fullPath: projectId }, { path: sourcePath }).then(file => { + expect(file).toEqual({ + __typename: 'File', + title, + content, + }); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js b/spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js new file mode 100644 index 00000000000..515b5394594 --- /dev/null +++ b/spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js @@ -0,0 +1,37 @@ +import savedContentMetaQuery from '~/static_site_editor/graphql/queries/saved_content_meta.query.graphql'; +import submitContentChanges from '~/static_site_editor/services/submit_content_changes'; +import submitContentChangesResolver from '~/static_site_editor/graphql/resolvers/submit_content_changes'; + +import { + projectId as project, + sourcePath, + username, + sourceContent as content, + savedContentMeta, +} from '../../mock_data'; + +jest.mock('~/static_site_editor/services/submit_content_changes', () => jest.fn()); + +describe('static_site_editor/graphql/resolvers/submit_content_changes', () => { + it('writes savedContentMeta query with the data returned by the submitContentChanges service', () => { + const cache = { writeQuery: jest.fn() }; + + submitContentChanges.mockResolvedValueOnce(savedContentMeta); + + return submitContentChangesResolver( + {}, + { input: { path: sourcePath, project, sourcePath, content, username } }, + { cache }, + ).then(() => { + expect(cache.writeQuery).toHaveBeenCalledWith({ + query: savedContentMetaQuery, + data: { + savedContentMeta: { + __typename: 'SavedContentMeta', + ...savedContentMeta, + }, + }, + }); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/mock_data.js b/spec/frontend/static_site_editor/mock_data.js index 962047e6dd2..371695e913e 100644 --- a/spec/frontend/static_site_editor/mock_data.js +++ b/spec/frontend/static_site_editor/mock_data.js @@ -34,6 +34,9 @@ export const savedContentMeta = { }; export const submitChangesError = 'Could not save changes'; +export const commitBranchResponse = { + web_url: '/tree/root-master-patch-88195', +}; export const commitMultipleResponse = { short_id: 'ed899a2f4b5', web_url: '/commit/ed899a2f4b5', @@ -42,3 +45,5 @@ export const createMergeRequestResponse = { iid: '123', web_url: '/merge_requests/123', }; + +export const trackingCategory = 'projects:static_site_editor:show'; diff --git a/spec/frontend/static_site_editor/pages/home_spec.js b/spec/frontend/static_site_editor/pages/home_spec.js new file mode 100644 index 00000000000..8c9c54f593e --- /dev/null +++ b/spec/frontend/static_site_editor/pages/home_spec.js @@ -0,0 +1,211 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Home from '~/static_site_editor/pages/home.vue'; +import SkeletonLoader from '~/static_site_editor/components/skeleton_loader.vue'; +import EditArea from '~/static_site_editor/components/edit_area.vue'; +import InvalidContentMessage from '~/static_site_editor/components/invalid_content_message.vue'; +import SubmitChangesError from '~/static_site_editor/components/submit_changes_error.vue'; +import submitContentChangesMutation from '~/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql'; +import { SUCCESS_ROUTE } from '~/static_site_editor/router/constants'; + +import { + projectId as project, + returnUrl, + sourceContent as content, + sourceContentTitle as title, + sourcePath, + username, + savedContentMeta, + submitChangesError, +} from '../mock_data'; + +const localVue = createLocalVue(); + +localVue.use(Vuex); + +describe('static_site_editor/pages/home', () => { + let wrapper; + let store; + let $apollo; + let $router; + let mutateMock; + + const buildApollo = (queries = {}) => { + mutateMock = jest.fn(); + + $apollo = { + queries: { + sourceContent: { + loading: false, + }, + ...queries, + }, + mutate: mutateMock, + }; + }; + + const buildRouter = () => { + $router = { + push: jest.fn(), + }; + }; + + const buildWrapper = (data = {}) => { + wrapper = shallowMount(Home, { + localVue, + store, + mocks: { + $apollo, + $router, + }, + data() { + return { + appData: { isSupportedContent: true, returnUrl, project, username, sourcePath }, + sourceContent: { title, content }, + ...data, + }; + }, + }); + }; + + const findEditArea = () => wrapper.find(EditArea); + const findInvalidContentMessage = () => wrapper.find(InvalidContentMessage); + const findSkeletonLoader = () => wrapper.find(SkeletonLoader); + const findSubmitChangesError = () => wrapper.find(SubmitChangesError); + + beforeEach(() => { + buildApollo(); + buildRouter(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + $apollo = null; + }); + + describe('when content is loaded', () => { + beforeEach(() => { + buildWrapper(); + }); + + it('renders edit area', () => { + expect(findEditArea().exists()).toBe(true); + }); + + it('provides source content, returnUrl, and isSavingChanges to the edit area', () => { + expect(findEditArea().props()).toMatchObject({ + title, + content, + returnUrl, + savingChanges: false, + }); + }); + }); + + it('does not render edit area when content is not loaded', () => { + buildWrapper({ sourceContent: null }); + + expect(findEditArea().exists()).toBe(false); + }); + + it('renders skeleton loader when content is not loading', () => { + buildApollo({ + sourceContent: { + loading: true, + }, + }); + buildWrapper(); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('does not render skeleton loader when content is not loading', () => { + buildApollo({ + sourceContent: { + loading: false, + }, + }); + buildWrapper(); + + expect(findSkeletonLoader().exists()).toBe(false); + }); + + it('displays invalid content message when content is not supported', () => { + buildWrapper({ appData: { isSupportedContent: false } }); + + expect(findInvalidContentMessage().exists()).toBe(true); + }); + + it('does not display invalid content message when content is supported', () => { + buildWrapper({ appData: { isSupportedContent: true } }); + + expect(findInvalidContentMessage().exists()).toBe(false); + }); + + describe('when submitting changes fails', () => { + beforeEach(() => { + mutateMock.mockRejectedValue(new Error(submitChangesError)); + + buildWrapper(); + findEditArea().vm.$emit('submit', { content }); + + return wrapper.vm.$nextTick(); + }); + + it('displays submit changes error message', () => { + expect(findSubmitChangesError().exists()).toBe(true); + }); + + it('retries submitting changes when retry button is clicked', () => { + findSubmitChangesError().vm.$emit('retry'); + + expect(mutateMock).toHaveBeenCalled(); + }); + + it('hides submit changes error message when dismiss button is clicked', () => { + findSubmitChangesError().vm.$emit('dismiss'); + + return wrapper.vm.$nextTick().then(() => { + expect(findSubmitChangesError().exists()).toBe(false); + }); + }); + }); + + it('does not display submit changes error when an error does not exist', () => { + buildWrapper(); + + expect(findSubmitChangesError().exists()).toBe(false); + }); + + describe('when submitting changes succeeds', () => { + const newContent = `new ${content}`; + + beforeEach(() => { + mutateMock.mockResolvedValueOnce({ data: { submitContentChanges: savedContentMeta } }); + + buildWrapper(); + findEditArea().vm.$emit('submit', { content: newContent }); + + return wrapper.vm.$nextTick(); + }); + + it('dispatches submitContentChanges mutation', () => { + expect(mutateMock).toHaveBeenCalledWith({ + mutation: submitContentChangesMutation, + variables: { + input: { + content: newContent, + project, + sourcePath, + username, + }, + }, + }); + }); + + it('transitions to the SUCCESS route', () => { + expect($router.push).toHaveBeenCalledWith(SUCCESS_ROUTE); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/pages/success_spec.js b/spec/frontend/static_site_editor/pages/success_spec.js new file mode 100644 index 00000000000..d62b67bfa83 --- /dev/null +++ b/spec/frontend/static_site_editor/pages/success_spec.js @@ -0,0 +1,78 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Success from '~/static_site_editor/pages/success.vue'; +import SavedChangesMessage from '~/static_site_editor/components/saved_changes_message.vue'; +import { savedContentMeta, returnUrl } from '../mock_data'; +import { HOME_ROUTE } from '~/static_site_editor/router/constants'; + +const localVue = createLocalVue(); + +localVue.use(Vuex); + +describe('static_site_editor/pages/success', () => { + let wrapper; + let store; + let router; + + const buildRouter = () => { + router = { + push: jest.fn(), + }; + }; + + const buildWrapper = (data = {}) => { + wrapper = shallowMount(Success, { + localVue, + store, + mocks: { + $router: router, + }, + data() { + return { + savedContentMeta, + appData: { + returnUrl, + }, + ...data, + }; + }, + }); + }; + + const findSavedChangesMessage = () => wrapper.find(SavedChangesMessage); + + beforeEach(() => { + buildRouter(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders saved changes message', () => { + buildWrapper(); + + expect(findSavedChangesMessage().exists()).toBe(true); + }); + + it('passes returnUrl to the saved changes message', () => { + buildWrapper(); + + expect(findSavedChangesMessage().props('returnUrl')).toBe(returnUrl); + }); + + it('passes saved content metadata to the saved changes message', () => { + buildWrapper(); + + expect(findSavedChangesMessage().props('branch')).toBe(savedContentMeta.branch); + expect(findSavedChangesMessage().props('commit')).toBe(savedContentMeta.commit); + expect(findSavedChangesMessage().props('mergeRequest')).toBe(savedContentMeta.mergeRequest); + }); + + it('redirects to the HOME route when content has not been submitted', () => { + buildWrapper({ savedContentMeta: null }); + + expect(router.push).toHaveBeenCalledWith(HOME_ROUTE); + }); +}); 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 9a0bd88b57d..a1e9ff4ec4c 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 @@ -1,11 +1,13 @@ import Api from '~/api'; import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { DEFAULT_TARGET_BRANCH, SUBMIT_CHANGES_BRANCH_ERROR, SUBMIT_CHANGES_COMMIT_ERROR, SUBMIT_CHANGES_MERGE_REQUEST_ERROR, + TRACKING_ACTION_CREATE_COMMIT, } from '~/static_site_editor/constants'; import generateBranchName from '~/static_site_editor/services/generate_branch_name'; import submitContentChanges from '~/static_site_editor/services/submit_content_changes'; @@ -13,10 +15,12 @@ import submitContentChanges from '~/static_site_editor/services/submit_content_c import { username, projectId, + commitBranchResponse, commitMultipleResponse, createMergeRequestResponse, sourcePath, sourceContent as content, + trackingCategory, } from '../mock_data'; jest.mock('~/static_site_editor/services/generate_branch_name'); @@ -24,15 +28,26 @@ jest.mock('~/static_site_editor/services/generate_branch_name'); describe('submitContentChanges', () => { const mergeRequestTitle = `Update ${sourcePath} file`; const branch = 'branch-name'; + let trackingSpy; + let origPage; beforeEach(() => { - jest.spyOn(Api, 'createBranch').mockResolvedValue(); + jest.spyOn(Api, 'createBranch').mockResolvedValue({ data: commitBranchResponse }); jest.spyOn(Api, 'commitMultiple').mockResolvedValue({ data: commitMultipleResponse }); jest .spyOn(Api, 'createProjectMergeRequest') .mockResolvedValue({ data: createMergeRequestResponse }); generateBranchName.mockReturnValue(branch); + + origPage = document.body.dataset.page; + document.body.dataset.page = trackingCategory; + trackingSpy = mockTracking(document.body.dataset.page, undefined, jest.spyOn); + }); + + afterEach(() => { + document.body.dataset.page = origPage; + unmockTracking(); }); it('creates a branch named after the username and target branch', () => { @@ -47,7 +62,7 @@ describe('submitContentChanges', () => { it('notifies error when branch could not be created', () => { Api.createBranch.mockRejectedValueOnce(); - expect(submitContentChanges({ username, projectId })).rejects.toThrow( + return expect(submitContentChanges({ username, projectId })).rejects.toThrow( SUBMIT_CHANGES_BRANCH_ERROR, ); }); @@ -68,10 +83,19 @@ describe('submitContentChanges', () => { }); }); + it('sends the correct tracking event when committing content changes', () => { + return submitContentChanges({ username, projectId, sourcePath, content }).then(() => { + expect(trackingSpy).toHaveBeenCalledWith( + document.body.dataset.page, + TRACKING_ACTION_CREATE_COMMIT, + ); + }); + }); + it('notifies error when content could not be committed', () => { Api.commitMultiple.mockRejectedValueOnce(); - expect(submitContentChanges({ username, projectId })).rejects.toThrow( + return expect(submitContentChanges({ username, projectId })).rejects.toThrow( SUBMIT_CHANGES_COMMIT_ERROR, ); }); @@ -92,7 +116,7 @@ describe('submitContentChanges', () => { it('notifies error when merge request could not be created', () => { Api.createProjectMergeRequest.mockRejectedValueOnce(); - expect(submitContentChanges({ username, projectId })).rejects.toThrow( + return expect(submitContentChanges({ username, projectId })).rejects.toThrow( SUBMIT_CHANGES_MERGE_REQUEST_ERROR, ); }); diff --git a/spec/frontend/static_site_editor/store/actions_spec.js b/spec/frontend/static_site_editor/store/actions_spec.js deleted file mode 100644 index 6b0b77f59b7..00000000000 --- a/spec/frontend/static_site_editor/store/actions_spec.js +++ /dev/null @@ -1,152 +0,0 @@ -import testAction from 'helpers/vuex_action_helper'; -import createState from '~/static_site_editor/store/state'; -import * as actions from '~/static_site_editor/store/actions'; -import * as mutationTypes from '~/static_site_editor/store/mutation_types'; -import loadSourceContent from '~/static_site_editor/services/load_source_content'; -import submitContentChanges from '~/static_site_editor/services/submit_content_changes'; - -import createFlash from '~/flash'; - -import { - username, - projectId, - sourcePath, - sourceContentTitle as title, - sourceContent as content, - savedContentMeta, - submitChangesError, -} from '../mock_data'; - -jest.mock('~/flash'); -jest.mock('~/static_site_editor/services/load_source_content', () => jest.fn()); -jest.mock('~/static_site_editor/services/submit_content_changes', () => jest.fn()); - -describe('Static Site Editor Store actions', () => { - let state; - - beforeEach(() => { - state = createState({ - projectId, - sourcePath, - }); - }); - - describe('loadContent', () => { - describe('on success', () => { - const payload = { title, content }; - - beforeEach(() => { - loadSourceContent.mockResolvedValueOnce(payload); - }); - - it('commits receiveContentSuccess', () => { - testAction( - actions.loadContent, - null, - state, - [ - { type: mutationTypes.LOAD_CONTENT }, - { type: mutationTypes.RECEIVE_CONTENT_SUCCESS, payload }, - ], - [], - ); - - expect(loadSourceContent).toHaveBeenCalledWith({ projectId, sourcePath }); - }); - }); - - describe('on error', () => { - const expectedMutations = [ - { type: mutationTypes.LOAD_CONTENT }, - { type: mutationTypes.RECEIVE_CONTENT_ERROR }, - ]; - - beforeEach(() => { - loadSourceContent.mockRejectedValueOnce(); - }); - - it('commits receiveContentError', () => { - testAction(actions.loadContent, null, state, expectedMutations); - }); - - it('displays flash communicating error', () => { - return testAction(actions.loadContent, null, state, expectedMutations).then(() => { - expect(createFlash).toHaveBeenCalledWith( - 'An error ocurred while loading your content. Please try again.', - ); - }); - }); - }); - }); - - describe('setContent', () => { - it('commits setContent mutation', () => { - testAction(actions.setContent, content, state, [ - { - type: mutationTypes.SET_CONTENT, - payload: content, - }, - ]); - }); - }); - - describe('submitChanges', () => { - describe('on success', () => { - beforeEach(() => { - state = createState({ - projectId, - content, - username, - sourcePath, - }); - submitContentChanges.mockResolvedValueOnce(savedContentMeta); - }); - - it('commits submitChangesSuccess mutation', () => { - testAction( - actions.submitChanges, - null, - state, - [ - { type: mutationTypes.SUBMIT_CHANGES }, - { type: mutationTypes.SUBMIT_CHANGES_SUCCESS, payload: savedContentMeta }, - ], - [], - ); - - expect(submitContentChanges).toHaveBeenCalledWith({ - username, - projectId, - content, - sourcePath, - }); - }); - }); - - describe('on error', () => { - const error = new Error(submitChangesError); - const expectedMutations = [ - { type: mutationTypes.SUBMIT_CHANGES }, - { type: mutationTypes.SUBMIT_CHANGES_ERROR, payload: error.message }, - ]; - - beforeEach(() => { - submitContentChanges.mockRejectedValueOnce(error); - }); - - it('dispatches receiveContentError', () => { - testAction(actions.submitChanges, null, state, expectedMutations); - }); - }); - }); - - describe('dismissSubmitChangesError', () => { - it('commits dismissSubmitChangesError', () => { - testAction(actions.dismissSubmitChangesError, null, state, [ - { - type: mutationTypes.DISMISS_SUBMIT_CHANGES_ERROR, - }, - ]); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/store/getters_spec.js b/spec/frontend/static_site_editor/store/getters_spec.js deleted file mode 100644 index 5793e344784..00000000000 --- a/spec/frontend/static_site_editor/store/getters_spec.js +++ /dev/null @@ -1,19 +0,0 @@ -import createState from '~/static_site_editor/store/state'; -import { contentChanged } from '~/static_site_editor/store/getters'; -import { sourceContent as content } from '../mock_data'; - -describe('Static Site Editor Store getters', () => { - describe('contentChanged', () => { - it('returns true when content and originalContent are different', () => { - const state = createState({ content, originalContent: 'something else' }); - - expect(contentChanged(state)).toBe(true); - }); - - it('returns false when content and originalContent are the same', () => { - const state = createState({ content, originalContent: content }); - - expect(contentChanged(state)).toBe(false); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/store/mutations_spec.js b/spec/frontend/static_site_editor/store/mutations_spec.js deleted file mode 100644 index 2441f317d90..00000000000 --- a/spec/frontend/static_site_editor/store/mutations_spec.js +++ /dev/null @@ -1,54 +0,0 @@ -import createState from '~/static_site_editor/store/state'; -import mutations from '~/static_site_editor/store/mutations'; -import * as types from '~/static_site_editor/store/mutation_types'; -import { - sourceContentTitle as title, - sourceContent as content, - savedContentMeta, - submitChangesError, -} from '../mock_data'; - -describe('Static Site Editor Store mutations', () => { - let state; - const contentLoadedPayload = { title, content }; - - beforeEach(() => { - state = createState(); - }); - - it.each` - mutation | stateProperty | payload | expectedValue - ${types.LOAD_CONTENT} | ${'isLoadingContent'} | ${undefined} | ${true} - ${types.RECEIVE_CONTENT_SUCCESS} | ${'isLoadingContent'} | ${contentLoadedPayload} | ${false} - ${types.RECEIVE_CONTENT_SUCCESS} | ${'isContentLoaded'} | ${contentLoadedPayload} | ${true} - ${types.RECEIVE_CONTENT_SUCCESS} | ${'title'} | ${contentLoadedPayload} | ${title} - ${types.RECEIVE_CONTENT_SUCCESS} | ${'content'} | ${contentLoadedPayload} | ${content} - ${types.RECEIVE_CONTENT_SUCCESS} | ${'originalContent'} | ${contentLoadedPayload} | ${content} - ${types.RECEIVE_CONTENT_ERROR} | ${'isLoadingContent'} | ${undefined} | ${false} - ${types.SET_CONTENT} | ${'content'} | ${content} | ${content} - ${types.SUBMIT_CHANGES} | ${'isSavingChanges'} | ${undefined} | ${true} - ${types.SUBMIT_CHANGES_SUCCESS} | ${'savedContentMeta'} | ${savedContentMeta} | ${savedContentMeta} - ${types.SUBMIT_CHANGES_SUCCESS} | ${'isSavingChanges'} | ${savedContentMeta} | ${false} - ${types.SUBMIT_CHANGES_ERROR} | ${'isSavingChanges'} | ${undefined} | ${false} - ${types.SUBMIT_CHANGES_ERROR} | ${'submitChangesError'} | ${submitChangesError} | ${submitChangesError} - ${types.DISMISS_SUBMIT_CHANGES_ERROR} | ${'submitChangesError'} | ${undefined} | ${''} - `( - '$mutation sets $stateProperty to $expectedValue', - ({ mutation, stateProperty, payload, expectedValue }) => { - mutations[mutation](state, payload); - expect(state[stateProperty]).toBe(expectedValue); - }, - ); - - it(`${types.SUBMIT_CHANGES_SUCCESS} sets originalContent to content current value`, () => { - const editedContent = `${content} plus something else`; - - state = createState({ - originalContent: content, - content: editedContent, - }); - mutations[types.SUBMIT_CHANGES_SUCCESS](state); - - expect(state.originalContent).toBe(state.content); - }); -}); diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js index 30a8e138df2..08a26d46618 100644 --- a/spec/frontend/tracking_spec.js +++ b/spec/frontend/tracking_spec.js @@ -4,6 +4,7 @@ import Tracking, { initUserTracking } from '~/tracking'; describe('Tracking', () => { let snowplowSpy; let bindDocumentSpy; + let trackLoadEventsSpy; beforeEach(() => { window.snowplow = window.snowplow || (() => {}); @@ -18,6 +19,7 @@ describe('Tracking', () => { describe('initUserTracking', () => { beforeEach(() => { bindDocumentSpy = jest.spyOn(Tracking, 'bindDocument').mockImplementation(() => null); + trackLoadEventsSpy = jest.spyOn(Tracking, 'trackLoadEvents').mockImplementation(() => null); }); it('calls through to get a new tracker with the expected options', () => { @@ -44,10 +46,11 @@ describe('Tracking', () => { expect(snowplowSpy).not.toHaveBeenCalledWith('enableFormTracking'); expect(snowplowSpy).not.toHaveBeenCalledWith('enableLinkClickTracking'); - window.snowplowOptions = Object.assign({}, window.snowplowOptions, { + window.snowplowOptions = { + ...window.snowplowOptions, formTracking: true, linkClickTracking: true, - }); + }; initUserTracking(); expect(snowplowSpy).toHaveBeenCalledWith('enableFormTracking'); @@ -58,6 +61,11 @@ describe('Tracking', () => { initUserTracking(); expect(bindDocumentSpy).toHaveBeenCalled(); }); + + it('tracks page loaded events', () => { + initUserTracking(); + expect(trackLoadEventsSpy).toHaveBeenCalled(); + }); }); describe('.event', () => { @@ -127,6 +135,7 @@ describe('Tracking', () => { <input type="checkbox" data-track-event="toggle_checkbox" value="_value_" checked/> <input class="dropdown" data-track-event="toggle_dropdown"/> <div data-track-event="nested_event"><span class="nested"></span></div> + <input data-track-eventbogus="click_bogusinput" data-track-label="_label_" value="_value_"/> `); }); @@ -139,6 +148,12 @@ describe('Tracking', () => { }); }); + it('does not bind to clicks on elements without [data-track-event]', () => { + trigger('[data-track-eventbogus="click_bogusinput"]'); + + expect(eventSpy).not.toHaveBeenCalled(); + }); + it('allows value override with the data-track-value attribute', () => { trigger('[data-track-event="click_input2"]'); @@ -178,6 +193,44 @@ describe('Tracking', () => { }); }); + describe('tracking page loaded events', () => { + let eventSpy; + + beforeEach(() => { + eventSpy = jest.spyOn(Tracking, 'event'); + setHTMLFixture(` + <input data-track-event="render" data-track-label="label1" value="_value_" data-track-property="_property_"/> + <span data-track-event="render" data-track-label="label2" data-track-value="_value_"> + Something + </span> + <input data-track-event="_render_bogus_" data-track-label="label3" value="_value_" data-track-property="_property_"/> + `); + Tracking.trackLoadEvents('_category_'); // only happens once + }); + + it('sends tracking events when [data-track-event="render"] is on an element', () => { + expect(eventSpy.mock.calls).toEqual([ + [ + '_category_', + 'render', + { + label: 'label1', + value: '_value_', + property: '_property_', + }, + ], + [ + '_category_', + 'render', + { + label: 'label2', + value: '_value_', + }, + ], + ]); + }); + }); + describe('tracking mixin', () => { describe('trackingOptions', () => { it('return the options defined on initialisation', () => { diff --git a/spec/frontend/users_select/utils_spec.js b/spec/frontend/users_select/utils_spec.js new file mode 100644 index 00000000000..a09935d8a04 --- /dev/null +++ b/spec/frontend/users_select/utils_spec.js @@ -0,0 +1,33 @@ +import $ from 'jquery'; +import { getAjaxUsersSelectOptions, getAjaxUsersSelectParams } from '~/users_select/utils'; + +const options = { + fooBar: 'baz', + activeUserId: 1, +}; + +describe('getAjaxUsersSelectOptions', () => { + it('returns options built from select data attributes', () => { + const $select = $('<select />', { 'data-foo-bar': 'baz', 'data-user-id': 1 }); + + expect( + getAjaxUsersSelectOptions($select, { fooBar: 'fooBar', activeUserId: 'user-id' }), + ).toEqual(options); + }); +}); + +describe('getAjaxUsersSelectParams', () => { + it('returns query parameters built from provided options', () => { + expect( + getAjaxUsersSelectParams(options, { + foo_bar: 'fooBar', + active_user_id: 'activeUserId', + non_existent_key: 'nonExistentKey', + }), + ).toEqual({ + foo_bar: 'baz', + active_user_id: 1, + non_existent_key: null, + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js b/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js index a7ecb863cfb..8a604355625 100644 --- a/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js @@ -61,7 +61,7 @@ describe('Merge Request Collapsible Extension', () => { describe('while loading', () => { beforeEach(() => { - mountComponent(Object.assign({}, data, { isLoading: true })); + mountComponent({ ...data, isLoading: true }); }); it('renders the buttons disabled', () => { @@ -86,7 +86,7 @@ describe('Merge Request Collapsible Extension', () => { describe('with error', () => { beforeEach(() => { - mountComponent(Object.assign({}, data, { hasError: true })); + mountComponent({ ...data, hasError: true }); }); it('does not render the buttons', () => { diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js new file mode 100644 index 00000000000..5f3a8654990 --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js @@ -0,0 +1,100 @@ +import { mount } from '@vue/test-utils'; +import MrWidgetPipelineContainer from '~/vue_merge_request_widget/components/mr_widget_pipeline_container.vue'; +import MrWidgetPipeline from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; +import ArtifactsApp from '~/vue_merge_request_widget/components/artifacts_list_app.vue'; +import { mockStore } from '../mock_data'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; + +describe('MrWidgetPipelineContainer', () => { + let wrapper; + let mock; + + const factory = (props = {}) => { + wrapper = mount(MrWidgetPipelineContainer, { + propsData: { + mr: { ...mockStore }, + ...props, + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet().reply(200, {}); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when pre merge', () => { + beforeEach(() => { + factory(); + }); + + it('renders pipeline', () => { + expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true); + expect(wrapper.find(MrWidgetPipeline).props()).toMatchObject({ + pipeline: mockStore.pipeline, + pipelineCoverageDelta: mockStore.pipelineCoverageDelta, + ciStatus: mockStore.ciStatus, + hasCi: mockStore.hasCI, + sourceBranch: mockStore.sourceBranch, + sourceBranchLink: mockStore.sourceBranchLink, + }); + }); + + it('renders deployments', () => { + const expectedProps = mockStore.deployments.map(dep => + expect.objectContaining({ + deployment: dep, + showMetrics: false, + }), + ); + + const deployments = wrapper.findAll('.mr-widget-extension .js-pre-deployment'); + + expect(deployments.wrappers.map(x => x.props())).toEqual(expectedProps); + }); + }); + + describe('when post merge', () => { + beforeEach(() => { + factory({ + isPostMerge: true, + }); + }); + + it('renders pipeline', () => { + expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true); + expect(wrapper.find(MrWidgetPipeline).props()).toMatchObject({ + pipeline: mockStore.mergePipeline, + pipelineCoverageDelta: mockStore.pipelineCoverageDelta, + ciStatus: mockStore.ciStatus, + hasCi: mockStore.hasCI, + sourceBranch: mockStore.targetBranch, + sourceBranchLink: mockStore.targetBranch, + }); + }); + + it('renders deployments', () => { + const expectedProps = mockStore.postMergeDeployments.map(dep => + expect.objectContaining({ + deployment: dep, + showMetrics: true, + }), + ); + + const deployments = wrapper.findAll('.mr-widget-extension .js-post-deployment'); + + expect(deployments.wrappers.map(x => x.props())).toEqual(expectedProps); + }); + }); + + describe('with artifacts path', () => { + it('renders the artifacts app', () => { + expect(wrapper.find(ArtifactsApp).isVisible()).toBe(true); + }); + }); +}); 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 index 1951b56587a..91e95b2bdb1 100644 --- 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 @@ -3,6 +3,7 @@ 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, @@ -57,11 +58,23 @@ describe('MrWidgetTerraformPlan', () => { }); describe('successful poll', () => { + let pollRequest; + let pollStop; + beforeEach(() => { + pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); + pollStop = jest.spyOn(Poll.prototype, 'stop'); + mockPollingApi(200, { 'tfplan.json': plan }, {}); + return mountWrapper(); }); + afterEach(() => { + pollRequest.mockRestore(); + pollStop.mockRestore(); + }); + it('content change text', () => { expect(wrapper.find(GlSprintf).exists()).toBe(true); }); @@ -69,6 +82,11 @@ describe('MrWidgetTerraformPlan', () => { it('renders button when url is found', () => { expect(wrapper.find('a').text()).toContain('View full log'); }); + + it('does not make additional requests after poll is successful', () => { + expect(pollRequest).toHaveBeenCalledTimes(1); + expect(pollStop).toHaveBeenCalledTimes(1); + }); }); describe('polling fails', () => { diff --git a/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js b/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js new file mode 100644 index 00000000000..026ea0e4d0a --- /dev/null +++ b/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js @@ -0,0 +1,165 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { TEST_HOST } from 'helpers/test_constants'; +import axios from '~/lib/utils/axios_utils'; +import { + setEndpoint, + requestArtifacts, + clearEtagPoll, + stopPolling, + fetchArtifacts, + receiveArtifactsSuccess, + receiveArtifactsError, +} from '~/vue_merge_request_widget/stores/artifacts_list/actions'; +import state from '~/vue_merge_request_widget/stores/artifacts_list/state'; +import * as types from '~/vue_merge_request_widget/stores/artifacts_list/mutation_types'; + +describe('Artifacts App Store Actions', () => { + let mockedState; + + beforeEach(() => { + mockedState = state(); + }); + + describe('setEndpoint', () => { + it('should commit SET_ENDPOINT mutation', done => { + testAction( + setEndpoint, + 'endpoint.json', + mockedState, + [{ type: types.SET_ENDPOINT, payload: 'endpoint.json' }], + [], + done, + ); + }); + }); + + describe('requestArtifacts', () => { + it('should commit REQUEST_ARTIFACTS mutation', done => { + testAction( + requestArtifacts, + null, + mockedState, + [{ type: types.REQUEST_ARTIFACTS }], + [], + done, + ); + }); + }); + + describe('fetchArtifacts', () => { + let mock; + + beforeEach(() => { + mockedState.endpoint = `${TEST_HOST}/endpoint.json`; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + stopPolling(); + clearEtagPoll(); + }); + + describe('success', () => { + it('dispatches requestArtifacts and receiveArtifactsSuccess ', done => { + mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, [ + { + text: 'result.txt', + url: 'asda', + job_name: 'generate-artifact', + job_path: 'asda', + }, + ]); + + testAction( + fetchArtifacts, + null, + mockedState, + [], + [ + { + type: 'requestArtifacts', + }, + { + payload: { + data: [ + { + text: 'result.txt', + url: 'asda', + job_name: 'generate-artifact', + job_path: 'asda', + }, + ], + status: 200, + }, + type: 'receiveArtifactsSuccess', + }, + ], + done, + ); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500); + }); + + it('dispatches requestArtifacts and receiveArtifactsError ', done => { + testAction( + fetchArtifacts, + null, + mockedState, + [], + [ + { + type: 'requestArtifacts', + }, + { + type: 'receiveArtifactsError', + }, + ], + done, + ); + }); + }); + }); + + describe('receiveArtifactsSuccess', () => { + it('should commit RECEIVE_ARTIFACTS_SUCCESS mutation with 200', done => { + testAction( + receiveArtifactsSuccess, + { data: { summary: {} }, status: 200 }, + mockedState, + [{ type: types.RECEIVE_ARTIFACTS_SUCCESS, payload: { summary: {} } }], + [], + done, + ); + }); + + it('should not commit RECEIVE_ARTIFACTS_SUCCESS mutation with 204', done => { + testAction( + receiveArtifactsSuccess, + { data: { summary: {} }, status: 204 }, + mockedState, + [], + [], + done, + ); + }); + }); + + describe('receiveArtifactsError', () => { + it('should commit RECEIVE_ARTIFACTS_ERROR mutation', done => { + testAction( + receiveArtifactsError, + null, + mockedState, + [{ type: types.RECEIVE_ARTIFACTS_ERROR }], + [], + 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 0f5d47b3bfe..e54cd345a37 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 @@ -35,10 +35,12 @@ describe('getStateKey', () => { expect(bound()).toEqual('autoMergeEnabled'); + context.canMerge = true; context.isSHAMismatch = true; expect(bound()).toEqual('shaMismatch'); + context.canMerge = false; context.isPipelineBlocked = true; expect(bound()).toEqual('pipelineBlocked'); @@ -100,4 +102,26 @@ describe('getStateKey', () => { expect(bound()).toEqual('rebase'); }); + + it.each` + canMerge | isSHAMismatch | stateKey + ${true} | ${true} | ${'shaMismatch'} + ${false} | ${true} | ${'notAllowedToMerge'} + ${false} | ${false} | ${'notAllowedToMerge'} + `( + 'returns $stateKey when canMerge is $canMerge and isSHAMismatch is $isSHAMismatch', + ({ canMerge, isSHAMismatch, stateKey }) => { + const bound = getStateKey.bind( + { + canMerge, + isSHAMismatch, + }, + { + commits_count: 2, + }, + ); + + expect(bound()).toEqual(stateKey); + }, + ); }); diff --git a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js new file mode 100644 index 00000000000..48326eda404 --- /dev/null +++ b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js @@ -0,0 +1,112 @@ +import MergeRequestStore from '~/vue_merge_request_widget/stores/mr_widget_store'; +import { stateKey } from '~/vue_merge_request_widget/stores/state_maps'; +import mockData from '../mock_data'; + +describe('MergeRequestStore', () => { + let store; + + beforeEach(() => { + store = new MergeRequestStore(mockData); + }); + + describe('setData', () => { + it('should set isSHAMismatch when the diff SHA changes', () => { + store.setData({ ...mockData, diff_head_sha: 'a-different-string' }); + + expect(store.isSHAMismatch).toBe(true); + }); + + it('should not set isSHAMismatch when other data changes', () => { + store.setData({ ...mockData, work_in_progress: !mockData.work_in_progress }); + + expect(store.isSHAMismatch).toBe(false); + }); + + it('should update cached sha after rebasing', () => { + store.setData({ ...mockData, diff_head_sha: 'abc123' }, true); + + expect(store.isSHAMismatch).toBe(false); + expect(store.sha).toBe('abc123'); + }); + + describe('isPipelinePassing', () => { + it('is true when the CI status is `success`', () => { + store.setData({ ...mockData, ci_status: 'success' }); + + expect(store.isPipelinePassing).toBe(true); + }); + + it('is true when the CI status is `success-with-warnings`', () => { + store.setData({ ...mockData, ci_status: 'success-with-warnings' }); + + expect(store.isPipelinePassing).toBe(true); + }); + + it('is false when the CI status is `failed`', () => { + store.setData({ ...mockData, ci_status: 'failed' }); + + expect(store.isPipelinePassing).toBe(false); + }); + + it('is false when the CI status is anything except `success`', () => { + store.setData({ ...mockData, ci_status: 'foobarbaz' }); + + expect(store.isPipelinePassing).toBe(false); + }); + }); + + describe('isPipelineSkipped', () => { + it('should set isPipelineSkipped=true when the CI status is `skipped`', () => { + store.setData({ ...mockData, ci_status: 'skipped' }); + + expect(store.isPipelineSkipped).toBe(true); + }); + + it('should set isPipelineSkipped=false when the CI status is anything except `skipped`', () => { + store.setData({ ...mockData, ci_status: 'foobarbaz' }); + + expect(store.isPipelineSkipped).toBe(false); + }); + }); + + describe('isNothingToMergeState', () => { + it('returns true when nothingToMerge', () => { + store.state = stateKey.nothingToMerge; + + expect(store.isNothingToMergeState).toBe(true); + }); + + it('returns false when not nothingToMerge', () => { + store.state = 'state'; + + expect(store.isNothingToMergeState).toBe(false); + }); + }); + }); + + describe('setPaths', () => { + it('should set the add ci config path', () => { + store.setData({ ...mockData }); + + expect(store.mergeRequestAddCiConfigPath).toBe('/group2/project2/new/pipeline'); + }); + + it('should set humanAccess=Maintainer when user has that role', () => { + store.setData({ ...mockData }); + + expect(store.humanAccess).toBe('Maintainer'); + }); + + it('should set pipelinesEmptySvgPath', () => { + store.setData({ ...mockData }); + + expect(store.pipelinesEmptySvgPath).toBe('/path/to/svg'); + }); + + it('should set newPipelinePath', () => { + store.setData({ ...mockData }); + + expect(store.newPipelinePath).toBe('/group2/project2/pipelines/new'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap index 4cd03a690e9..408f9d57147 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap @@ -24,12 +24,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` <b-input-group-stub tag="div" > - <b-input-group-prepend-stub - tag="div" - > - - <!----> - </b-input-group-prepend-stub> + <!----> <b-form-input-stub class="gl-form-input" @@ -44,18 +39,14 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` > <gl-button-stub category="tertiary" + class="d-inline-flex" data-clipboard-text="ssh://foo.bar" - icon="" + data-qa-selector="copy_ssh_url_button" + icon="copy-to-clipboard" size="medium" title="Copy URL" variant="default" - > - <gl-icon-stub - name="copy-to-clipboard" - size="16" - title="Copy URL" - /> - </gl-button-stub> + /> </b-input-group-append-stub> </b-input-group-stub> </div> @@ -74,12 +65,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` <b-input-group-stub tag="div" > - <b-input-group-prepend-stub - tag="div" - > - - <!----> - </b-input-group-prepend-stub> + <!----> <b-form-input-stub class="gl-form-input" @@ -94,18 +80,14 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` > <gl-button-stub category="tertiary" + class="d-inline-flex" data-clipboard-text="http://foo.bar" - icon="" + data-qa-selector="copy_http_url_button" + icon="copy-to-clipboard" size="medium" title="Copy URL" variant="default" - > - <gl-icon-stub - name="copy-to-clipboard" - size="16" - title="Copy URL" - /> - </gl-button-stub> + /> </b-input-group-append-stub> </b-input-group-stub> </div> diff --git a/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap index 5347d1efc48..db174346729 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap @@ -1,16 +1,26 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Code Block matches snapshot 1`] = ` +exports[`Code Block with default props renders correctly 1`] = ` <pre class="code-block rounded" > - <code class="d-block" > test-code </code> - +</pre> +`; +exports[`Code Block with maxHeight set to "200px" renders correctly 1`] = ` +<pre + class="code-block rounded" + style="max-height: 200px; overflow-y: auto;" +> + <code + class="d-block" + > + test-code + </code> </pre> `; diff --git a/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap index 72370cb5b52..1d8e04b83a3 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap @@ -1,6 +1,16 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Identicon matches snapshot 1`] = ` +exports[`Identicon entity id is a GraphQL id matches snapshot 1`] = ` +<div + class="avatar identicon s40 bg2" +> + + E + +</div> +`; + +exports[`Identicon entity id is a number matches snapshot 1`] = ` <div class="avatar identicon s40 bg2" > diff --git a/spec/frontend/vue_shared/components/awards_list_spec.js b/spec/frontend/vue_shared/components/awards_list_spec.js index bb3e60ab9e2..0abb72ace2e 100644 --- a/spec/frontend/vue_shared/components/awards_list_spec.js +++ b/spec/frontend/vue_shared/components/awards_list_spec.js @@ -210,4 +210,46 @@ describe('vue_shared/components/awards_list', () => { expect(buttons.wrappers.every(x => x.classes('disabled'))).toBe(true); }); }); + + describe('with default awards', () => { + beforeEach(() => { + createComponent({ + awards: [createAward(EMOJI_SMILE, USERS.marie), createAward(EMOJI_100, USERS.marie)], + canAwardEmoji: true, + currentUserId: USERS.root.id, + // Let's assert that it puts thumbsup and thumbsdown in the right order still + defaultAwards: [EMOJI_THUMBSDOWN, EMOJI_100, EMOJI_THUMBSUP], + }); + }); + + it('shows awards in correct order', () => { + expect(findAwardsData()).toEqual([ + { + classes: ['btn', 'award-control'], + count: 0, + html: matchingEmojiTag(EMOJI_THUMBSUP), + title: '', + }, + { + classes: ['btn', 'award-control'], + count: 0, + html: matchingEmojiTag(EMOJI_THUMBSDOWN), + title: '', + }, + // We expect the EMOJI_100 before the EMOJI_SMILE because it was given as a defaultAward + { + classes: ['btn', 'award-control'], + count: 1, + html: matchingEmojiTag(EMOJI_100), + title: 'Marie', + }, + { + classes: ['btn', 'award-control'], + count: 1, + html: matchingEmojiTag(EMOJI_SMILE), + title: 'Marie', + }, + ]); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap b/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap index 87f2a8f9eff..4909d2d4226 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap +++ b/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap @@ -2,7 +2,8 @@ exports[`Blob Simple Viewer component rendering matches the snapshot 1`] = ` <div - class="file-content code js-syntax-highlight qa-file-content" + class="file-content code js-syntax-highlight" + data-qa-selector="file_content" > <div class="line-numbers" diff --git a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js index ce3f289eb6e..5cf42ecdc1d 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js +++ b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js @@ -1,5 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import RichViewer from '~/vue_shared/components/blob_viewers/rich_viewer.vue'; +import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue'; import { handleBlobRichViewer } from '~/blob/viewer'; jest.mock('~/blob/viewer'); @@ -33,4 +34,8 @@ describe('Blob Rich Viewer component', () => { it('queries for advanced viewer', () => { expect(handleBlobRichViewer).toHaveBeenCalledWith(expect.anything(), defaultType); }); + + it('is using Markdown View Field', () => { + expect(wrapper.contains(MarkdownFieldView)).toBe(true); + }); }); diff --git a/spec/frontend/vue_shared/components/ci_badge_link_spec.js b/spec/frontend/vue_shared/components/ci_badge_link_spec.js new file mode 100644 index 00000000000..f656bb0b60d --- /dev/null +++ b/spec/frontend/vue_shared/components/ci_badge_link_spec.js @@ -0,0 +1,100 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import ciBadge from '~/vue_shared/components/ci_badge_link.vue'; + +describe('CI Badge Link Component', () => { + let CIBadge; + let vm; + + const statuses = { + canceled: { + text: 'canceled', + label: 'canceled', + group: 'canceled', + icon: 'status_canceled', + details_path: 'status/canceled', + }, + created: { + text: 'created', + label: 'created', + group: 'created', + icon: 'status_created', + details_path: 'status/created', + }, + failed: { + text: 'failed', + label: 'failed', + group: 'failed', + icon: 'status_failed', + details_path: 'status/failed', + }, + manual: { + text: 'manual', + label: 'manual action', + group: 'manual', + icon: 'status_manual', + details_path: 'status/manual', + }, + pending: { + text: 'pending', + label: 'pending', + group: 'pending', + icon: 'status_pending', + details_path: 'status/pending', + }, + running: { + text: 'running', + label: 'running', + group: 'running', + icon: 'status_running', + details_path: 'status/running', + }, + skipped: { + text: 'skipped', + label: 'skipped', + group: 'skipped', + icon: 'status_skipped', + details_path: 'status/skipped', + }, + success_warining: { + text: 'passed', + label: 'passed', + group: 'success-with-warnings', + icon: 'status_warning', + details_path: 'status/warning', + }, + success: { + text: 'passed', + label: 'passed', + group: 'passed', + icon: 'status_success', + details_path: 'status/passed', + }, + }; + + beforeEach(() => { + CIBadge = Vue.extend(ciBadge); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render each status badge', () => { + Object.keys(statuses).map(status => { + vm = mountComponent(CIBadge, { status: statuses[status] }); + + expect(vm.$el.getAttribute('href')).toEqual(statuses[status].details_path); + expect(vm.$el.textContent.trim()).toEqual(statuses[status].text); + expect(vm.$el.getAttribute('class')).toContain(`ci-status ci-${statuses[status].group}`); + expect(vm.$el.querySelector('svg')).toBeDefined(); + return vm; + }); + }); + + it('should not render label', () => { + vm = mountComponent(CIBadge, { status: statuses.canceled, showText: false }); + + expect(vm.$el.textContent.trim()).toEqual(''); + }); +}); diff --git a/spec/frontend/vue_shared/components/ci_icon_spec.js b/spec/frontend/vue_shared/components/ci_icon_spec.js new file mode 100644 index 00000000000..63afe631063 --- /dev/null +++ b/spec/frontend/vue_shared/components/ci_icon_spec.js @@ -0,0 +1,122 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import ciIcon from '~/vue_shared/components/ci_icon.vue'; + +describe('CI Icon component', () => { + const Component = Vue.extend(ciIcon); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + it('should render a span element with an svg', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_success', + }, + }); + + expect(vm.$el.tagName).toEqual('SPAN'); + expect(vm.$el.querySelector('span > svg')).toBeDefined(); + }); + + it('should render a success status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_success', + group: 'success', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-success')).toEqual(true); + }); + + it('should render a failed status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_failed', + group: 'failed', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-failed')).toEqual(true); + }); + + it('should render success with warnings status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_warning', + group: 'warning', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-warning')).toEqual(true); + }); + + it('should render pending status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_pending', + group: 'pending', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-pending')).toEqual(true); + }); + + it('should render running status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_running', + group: 'running', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-running')).toEqual(true); + }); + + it('should render created status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_created', + group: 'created', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-created')).toEqual(true); + }); + + it('should render skipped status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_skipped', + group: 'skipped', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-skipped')).toEqual(true); + }); + + it('should render canceled status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_canceled', + group: 'canceled', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-canceled')).toEqual(true); + }); + + it('should render status for manual action', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_manual', + group: 'manual', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-manual')).toEqual(true); + }); +}); diff --git a/spec/frontend/vue_shared/components/code_block_spec.js b/spec/frontend/vue_shared/components/code_block_spec.js index 0d21dd94f7c..60b0b0b566b 100644 --- a/spec/frontend/vue_shared/components/code_block_spec.js +++ b/spec/frontend/vue_shared/components/code_block_spec.js @@ -4,10 +4,15 @@ import CodeBlock from '~/vue_shared/components/code_block.vue'; describe('Code Block', () => { let wrapper; - const createComponent = () => { + const defaultProps = { + code: 'test-code', + }; + + const createComponent = (props = {}) => { wrapper = shallowMount(CodeBlock, { propsData: { - code: 'test-code', + ...defaultProps, + ...props, }, }); }; @@ -17,9 +22,23 @@ describe('Code Block', () => { wrapper = null; }); - it('matches snapshot', () => { - createComponent(); + describe('with default props', () => { + beforeEach(() => { + createComponent(); + }); - expect(wrapper.element).toMatchSnapshot(); + it('renders correctly', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('with maxHeight set to "200px"', () => { + beforeEach(() => { + createComponent({ maxHeight: '200px' }); + }); + + it('renders correctly', () => { + expect(wrapper.element).toMatchSnapshot(); + }); }); }); diff --git a/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js new file mode 100644 index 00000000000..16e7e4dd5cc --- /dev/null +++ b/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js @@ -0,0 +1,21 @@ +import { mount } from '@vue/test-utils'; +import { GREEN_BOX_IMAGE_URL } from 'spec/test_constants'; +import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; +import '~/behaviors/markdown/render_gfm'; + +describe('ContentViewer', () => { + let wrapper; + + it.each` + path | type | selector | viewer + ${GREEN_BOX_IMAGE_URL} | ${'image'} | ${'img'} | ${'<image-viewer>'} + ${'myfile.md'} | ${'markdown'} | ${'.md-previewer'} | ${'<markdown-viewer>'} + ${'myfile.abc'} | ${undefined} | ${'[download]'} | ${'<download-viewer>'} + `('renders $viewer when file type="$type"', ({ path, type, selector }) => { + wrapper = mount(ContentViewer, { + propsData: { path, fileSize: 1024, type }, + }); + + expect(wrapper.find(selector).element).toExist(); + }); +}); diff --git a/spec/frontend/vue_shared/components/content_viewer/lib/viewer_utils_spec.js b/spec/frontend/vue_shared/components/content_viewer/lib/viewer_utils_spec.js new file mode 100644 index 00000000000..facdaa86f84 --- /dev/null +++ b/spec/frontend/vue_shared/components/content_viewer/lib/viewer_utils_spec.js @@ -0,0 +1,20 @@ +import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; + +describe('viewerInformationForPath', () => { + it.each` + path | type + ${'p/somefile.jpg'} | ${'image'} + ${'p/somefile.jpeg'} | ${'image'} + ${'p/somefile.bmp'} | ${'image'} + ${'p/somefile.ico'} | ${'image'} + ${'p/somefile.png'} | ${'image'} + ${'p/somefile.gif'} | ${'image'} + ${'p/somefile.md'} | ${'markdown'} + ${'p/md'} | ${undefined} + ${'p/png'} | ${undefined} + ${'p/md.png/a'} | ${undefined} + ${'p/some-file.php'} | ${undefined} + `('when path=$path, type=$type', ({ path, type }) => { + expect(viewerInformationForPath(path)?.id).toBe(type); + }); +}); diff --git a/spec/frontend/vue_shared/components/content_viewer/viewers/download_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/viewers/download_viewer_spec.js new file mode 100644 index 00000000000..b83602e7bfc --- /dev/null +++ b/spec/frontend/vue_shared/components/content_viewer/viewers/download_viewer_spec.js @@ -0,0 +1,28 @@ +import { mount } from '@vue/test-utils'; +import DownloadViewer from '~/vue_shared/components/content_viewer/viewers/download_viewer.vue'; + +describe('DownloadViewer', () => { + let wrapper; + + it.each` + path | filePath | fileSize | renderedName | renderedSize + ${'somepath/test.abc'} | ${undefined} | ${1024} | ${'test.abc'} | ${'1.00 KiB'} + ${'somepath/test.abc'} | ${undefined} | ${null} | ${'test.abc'} | ${''} + ${'data:application/unknown;base64,U0VMRUNU'} | ${'somepath/test.abc'} | ${2048} | ${'test.abc'} | ${'2.00 KiB'} + `( + 'renders the file name as "$renderedName" and shows size as "$renderedSize"', + ({ path, filePath, fileSize, renderedName, renderedSize }) => { + wrapper = mount(DownloadViewer, { + propsData: { path, filePath, fileSize }, + }); + + const renderedFileInfo = wrapper.find('.file-info').text(); + + expect(renderedFileInfo).toContain(renderedName); + expect(renderedFileInfo).toContain(renderedSize); + + expect(wrapper.find('.btn.btn-default').text()).toContain('Download'); + expect(wrapper.find('.btn.btn-default').element).toHaveAttr('download', 'test.abc'); + }, + ); +}); diff --git a/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js index ef785b9f0f5..31e843297fa 100644 --- a/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js +++ b/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js @@ -1,45 +1,36 @@ -import { shallowMount } from '@vue/test-utils'; - +import { mount } from '@vue/test-utils'; import { GREEN_BOX_IMAGE_URL } from 'spec/test_constants'; import ImageViewer from '~/vue_shared/components/content_viewer/viewers/image_viewer.vue'; describe('Image Viewer', () => { - const requiredProps = { - path: GREEN_BOX_IMAGE_URL, - renderInfo: true, - }; let wrapper; - let imageInfo; - - function createElement({ props, includeRequired = true } = {}) { - const data = includeRequired ? { ...requiredProps, ...props } : { ...props }; - wrapper = shallowMount(ImageViewer, { - propsData: data, + it('renders image preview', () => { + wrapper = mount(ImageViewer, { + propsData: { path: GREEN_BOX_IMAGE_URL, fileSize: 1024 }, }); - imageInfo = wrapper.find('.image-info'); - } - - describe('file sizes', () => { - it('should show the humanized file size when `renderInfo` is true and there is size info', () => { - createElement({ props: { fileSize: 1024 } }); - - expect(imageInfo.text()).toContain('1.00 KiB'); - }); - - it('should not show the humanized file size when `renderInfo` is true and there is no size', () => { - const FILESIZE_RE = /\d+(\.\d+)?\s*([KMGTP]i)*B/; - createElement({ props: { fileSize: 0 } }); - - // It shouldn't show any filesize info - expect(imageInfo.text()).not.toMatch(FILESIZE_RE); - }); - - it('should not show any image information when `renderInfo` is false', () => { - createElement({ props: { renderInfo: false } }); + expect(wrapper.find('img').element).toHaveAttr('src', GREEN_BOX_IMAGE_URL); + }); - expect(imageInfo.exists()).toBe(false); - }); + describe('file sizes', () => { + it.each` + fileSize | renderInfo | elementExists | humanizedFileSize + ${1024} | ${true} | ${true} | ${'1.00 KiB'} + ${0} | ${true} | ${true} | ${''} + ${1024} | ${false} | ${false} | ${undefined} + `( + 'shows file size as "$humanizedFileSize", if fileSize=$fileSize and renderInfo=$renderInfo', + ({ fileSize, renderInfo, elementExists, humanizedFileSize }) => { + wrapper = mount(ImageViewer, { + propsData: { path: GREEN_BOX_IMAGE_URL, fileSize, renderInfo }, + }); + + const imageInfo = wrapper.find('.image-info'); + + expect(imageInfo.exists()).toBe(elementExists); + expect(imageInfo.element?.textContent.trim()).toBe(humanizedFileSize); + }, + ); }); }); diff --git a/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js new file mode 100644 index 00000000000..8d3fcdd48d2 --- /dev/null +++ b/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js @@ -0,0 +1,114 @@ +import $ from 'jquery'; +import axios from '~/lib/utils/axios_utils'; +import MockAdapter from 'axios-mock-adapter'; +import { mount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import MarkdownViewer from '~/vue_shared/components/content_viewer/viewers/markdown_viewer.vue'; + +describe('MarkdownViewer', () => { + let wrapper; + let mock; + + const createComponent = props => { + wrapper = mount(MarkdownViewer, { + propsData: { + ...props, + path: 'test.md', + content: '* Test', + projectPath: 'testproject', + type: 'markdown', + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + + jest.spyOn(axios, 'post'); + jest.spyOn($.fn, 'renderGFM'); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('success', () => { + beforeEach(() => { + mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).replyOnce(200, { + body: '<b>testing</b> {{gl_md_img_1}}', + }); + }); + + it('renders an animation container while the markdown is loading', () => { + createComponent(); + + expect(wrapper.find('.animation-container')).toExist(); + }); + + it('renders markdown preview preview renders and loads rendered markdown from server', () => { + createComponent(); + + return waitForPromises().then(() => { + expect(wrapper.find('.md-previewer').text()).toContain('testing'); + }); + }); + + it('receives the filePath and commitSha as a parameters and passes them on to the server', () => { + createComponent({ filePath: 'foo/test.md', commitSha: 'abcdef' }); + + expect(axios.post).toHaveBeenCalledWith( + `${gon.relative_url_root}/testproject/preview_markdown`, + { path: 'foo/test.md', text: '* Test', ref: 'abcdef' }, + expect.any(Object), + ); + }); + + it.each` + imgSrc | imgAlt + ${'data:image/jpeg;base64,AAAAAA+/'} | ${'my image title'} + ${'data:image/jpeg;base64,AAAAAA+/'} | ${'"somebody\'s image" &'} + ${'hack" onclick=alert(0)'} | ${'hack" onclick=alert(0)'} + ${'hack\\" onclick=alert(0)'} | ${'hack\\" onclick=alert(0)'} + ${"hack' onclick=alert(0)"} | ${"hack' onclick=alert(0)"} + ${"hack'><script>alert(0)</script>"} | ${"hack'><script>alert(0)</script>"} + `( + 'transforms template tags with base64 encoded images available locally', + ({ imgSrc, imgAlt }) => { + createComponent({ + images: { + '{{gl_md_img_1}}': { + src: imgSrc, + alt: imgAlt, + title: imgAlt, + }, + }, + }); + + return waitForPromises().then(() => { + const img = wrapper.find('.md-previewer img').element; + + // if the values are the same as the input, it means + // they were escaped correctly + expect(img).toHaveAttr('src', imgSrc); + expect(img).toHaveAttr('alt', imgAlt); + expect(img).toHaveAttr('title', imgAlt); + }); + }, + ); + }); + + describe('error', () => { + beforeEach(() => { + mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).replyOnce(500, { + body: 'Internal Server Error', + }); + }); + it('renders an error message if loading the markdown preview fails', () => { + createComponent(); + + return waitForPromises().then(() => { + expect(wrapper.find('.md-previewer').text()).toContain('error'); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js index 3a75ab2d127..98962918b49 100644 --- a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js +++ b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js @@ -56,13 +56,8 @@ describe('date time picker lib', () => { describe('stringToISODate', () => { ['', 'null', undefined, 'abc'].forEach(input => { - it(`throws error for invalid input like ${input}`, done => { - try { - dateTimePickerLib.stringToISODate(input); - } catch (e) { - expect(e).toBeDefined(); - done(); - } + it(`throws error for invalid input like ${input}`, () => { + expect(() => dateTimePickerLib.stringToISODate(input)).toThrow(); }); }); [ diff --git a/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js b/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js new file mode 100644 index 00000000000..636508be6b6 --- /dev/null +++ b/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js @@ -0,0 +1,98 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants'; +import diffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; + +describe('DiffViewer', () => { + const requiredProps = { + diffMode: 'replaced', + diffViewerMode: 'image', + newPath: GREEN_BOX_IMAGE_URL, + newSha: 'ABC', + oldPath: RED_BOX_IMAGE_URL, + oldSha: 'DEF', + }; + let vm; + + function createComponent(props) { + const DiffViewer = Vue.extend(diffViewer); + + vm = mountComponent(DiffViewer, props); + } + + afterEach(() => { + vm.$destroy(); + }); + + it('renders image diff', done => { + window.gon = { + relative_url_root: '', + }; + + createComponent({ ...requiredProps, projectPath: '' }); + + setImmediate(() => { + expect(vm.$el.querySelector('.deleted img').getAttribute('src')).toBe( + `//-/raw/DEF/${RED_BOX_IMAGE_URL}`, + ); + + expect(vm.$el.querySelector('.added img').getAttribute('src')).toBe( + `//-/raw/ABC/${GREEN_BOX_IMAGE_URL}`, + ); + + done(); + }); + }); + + it('renders fallback download diff display', done => { + createComponent({ + ...requiredProps, + diffViewerMode: 'added', + newPath: 'test.abc', + oldPath: 'testold.abc', + }); + + setImmediate(() => { + expect(vm.$el.querySelector('.deleted .file-info').textContent.trim()).toContain( + 'testold.abc', + ); + + expect(vm.$el.querySelector('.deleted .btn.btn-default').textContent.trim()).toContain( + 'Download', + ); + + expect(vm.$el.querySelector('.added .file-info').textContent.trim()).toContain('test.abc'); + expect(vm.$el.querySelector('.added .btn.btn-default').textContent.trim()).toContain( + 'Download', + ); + + done(); + }); + }); + + it('renders renamed component', () => { + createComponent({ + ...requiredProps, + diffMode: 'renamed', + diffViewerMode: 'renamed', + newPath: 'test.abc', + oldPath: 'testold.abc', + }); + + expect(vm.$el.textContent).toContain('File moved'); + }); + + it('renders mode changed component', () => { + createComponent({ + ...requiredProps, + diffMode: 'mode_changed', + newPath: 'test.abc', + oldPath: 'testold.abc', + aMode: '123', + bMode: '321', + }); + + expect(vm.$el.textContent).toContain('File mode changed from 123 to 321'); + }); +}); diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js new file mode 100644 index 00000000000..892a96b76fd --- /dev/null +++ b/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js @@ -0,0 +1,81 @@ +import Vue from 'vue'; + +import { mountComponentWithSlots } from 'helpers/vue_mount_component_helper'; +import dropdownButtonComponent from '~/vue_shared/components/dropdown/dropdown_button.vue'; + +const defaultLabel = 'Select'; +const customLabel = 'Select project'; + +const createComponent = (props, slots = {}) => { + const Component = Vue.extend(dropdownButtonComponent); + + return mountComponentWithSlots(Component, { props, slots }); +}; + +describe('DropdownButtonComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('dropdownToggleText', () => { + it('returns default toggle text', () => { + expect(vm.toggleText).toBe(defaultLabel); + }); + + it('returns custom toggle text when provided via props', () => { + const vmEmptyLabels = createComponent({ toggleText: customLabel }); + + expect(vmEmptyLabels.toggleText).toBe(customLabel); + vmEmptyLabels.$destroy(); + }); + }); + }); + + describe('template', () => { + it('renders component container element of type `button`', () => { + expect(vm.$el.nodeName).toBe('BUTTON'); + }); + + it('renders component container element with required data attributes', () => { + expect(vm.$el.dataset.abilityName).toBe(vm.abilityName); + expect(vm.$el.dataset.fieldName).toBe(vm.fieldName); + expect(vm.$el.dataset.issueUpdate).toBe(vm.updatePath); + expect(vm.$el.dataset.labels).toBe(vm.labelsPath); + expect(vm.$el.dataset.namespacePath).toBe(vm.namespace); + expect(vm.$el.dataset.showAny).not.toBeDefined(); + }); + + it('renders dropdown toggle text element', () => { + const dropdownToggleTextEl = vm.$el.querySelector('.dropdown-toggle-text'); + + expect(dropdownToggleTextEl).not.toBeNull(); + expect(dropdownToggleTextEl.innerText.trim()).toBe(defaultLabel); + }); + + it('renders dropdown button icon', () => { + const dropdownIconEl = vm.$el.querySelector('.dropdown-toggle-icon i.fa'); + + expect(dropdownIconEl).not.toBeNull(); + expect(dropdownIconEl.classList.contains('fa-chevron-down')).toBe(true); + }); + + it('renders slot, if default slot exists', () => { + vm = createComponent( + {}, + { + default: ['Lorem Ipsum Dolar'], + }, + ); + + expect(vm.$el.querySelector('.dropdown-toggle-text')).toBeNull(); + expect(vm.$el).toHaveText('Lorem Ipsum Dolar'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js new file mode 100644 index 00000000000..30b8e869aab --- /dev/null +++ b/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import dropdownHiddenInputComponent from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; + +import { mockLabels } from './mock_data'; + +const createComponent = (name = 'label_id[]', value = mockLabels[0].id) => { + const Component = Vue.extend(dropdownHiddenInputComponent); + + return mountComponent(Component, { + name, + value, + }); +}; + +describe('DropdownHiddenInputComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('template', () => { + it('renders input element of type `hidden`', () => { + expect(vm.$el.nodeName).toBe('INPUT'); + expect(vm.$el.getAttribute('type')).toBe('hidden'); + expect(vm.$el.getAttribute('name')).toBe(vm.name); + expect(vm.$el.getAttribute('value')).toBe(`${vm.value}`); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/dropdown/mock_data.js b/spec/frontend/vue_shared/components/dropdown/mock_data.js new file mode 100644 index 00000000000..b09d42da401 --- /dev/null +++ b/spec/frontend/vue_shared/components/dropdown/mock_data.js @@ -0,0 +1,11 @@ +export const mockLabels = [ + { + id: 26, + title: 'Foo Label', + description: 'Foobar', + color: '#BADA55', + text_color: '#FFFFFF', + }, +]; + +export default mockLabels; diff --git a/spec/frontend/vue_shared/components/file_finder/item_spec.js b/spec/frontend/vue_shared/components/file_finder/item_spec.js new file mode 100644 index 00000000000..63f2614106d --- /dev/null +++ b/spec/frontend/vue_shared/components/file_finder/item_spec.js @@ -0,0 +1,140 @@ +import Vue from 'vue'; +import { file } from 'jest/ide/helpers'; +import ItemComponent from '~/vue_shared/components/file_finder/item.vue'; +import createComponent from 'helpers/vue_mount_component_helper'; + +describe('File finder item spec', () => { + const Component = Vue.extend(ItemComponent); + let vm; + let localFile; + + beforeEach(() => { + localFile = { + ...file(), + name: 'test file', + path: 'test/file', + }; + + vm = createComponent(Component, { + file: localFile, + focused: true, + searchText: '', + index: 0, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders file name & path', () => { + expect(vm.$el.textContent).toContain('test file'); + expect(vm.$el.textContent).toContain('test/file'); + }); + + describe('focused', () => { + it('adds is-focused class', () => { + expect(vm.$el.classList).toContain('is-focused'); + }); + + it('does not have is-focused class when not focused', done => { + vm.focused = false; + + vm.$nextTick(() => { + expect(vm.$el.classList).not.toContain('is-focused'); + + done(); + }); + }); + }); + + describe('changed file icon', () => { + it('does not render when not a changed or temp file', () => { + expect(vm.$el.querySelector('.diff-changed-stats')).toBe(null); + }); + + it('renders when a changed file', done => { + vm.file.changed = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.diff-changed-stats')).not.toBe(null); + + done(); + }); + }); + + it('renders when a temp file', done => { + vm.file.tempFile = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.diff-changed-stats')).not.toBe(null); + + done(); + }); + }); + }); + + it('emits event when clicked', () => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + + vm.$el.click(); + + expect(vm.$emit).toHaveBeenCalledWith('click', vm.file); + }); + + describe('path', () => { + let el; + + beforeEach(done => { + vm.searchText = 'file'; + + el = vm.$el.querySelector('.diff-changed-file-path'); + + vm.$nextTick(done); + }); + + it('highlights text', () => { + expect(el.querySelectorAll('.highlighted').length).toBe(4); + }); + + it('adds ellipsis to long text', done => { + vm.file.path = new Array(70) + .fill() + .map((_, i) => `${i}-`) + .join(''); + + vm.$nextTick(() => { + expect(el.textContent).toBe(`...${vm.file.path.substr(vm.file.path.length - 60)}`); + done(); + }); + }); + }); + + describe('name', () => { + let el; + + beforeEach(done => { + vm.searchText = 'file'; + + el = vm.$el.querySelector('.diff-changed-file-name'); + + vm.$nextTick(done); + }); + + it('highlights text', () => { + expect(el.querySelectorAll('.highlighted').length).toBe(4); + }); + + it('does not add ellipsis to long text', done => { + vm.file.name = new Array(70) + .fill() + .map((_, i) => `${i}-`) + .join(''); + + vm.$nextTick(() => { + expect(el.textContent).not.toBe(`...${vm.file.name.substr(vm.file.name.length - 60)}`); + done(); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/file_row_spec.js b/spec/frontend/vue_shared/components/file_row_spec.js index 732491378fa..46df2d2aaf1 100644 --- a/spec/frontend/vue_shared/components/file_row_spec.js +++ b/spec/frontend/vue_shared/components/file_row_spec.js @@ -91,9 +91,7 @@ describe('File row component', () => { jest.spyOn(wrapper.vm, 'scrollIntoView'); wrapper.setProps({ - file: Object.assign({}, wrapper.props('file'), { - active: true, - }), + file: { ...wrapper.props('file'), active: true }, }); return nextTick().then(() => { @@ -125,9 +123,7 @@ describe('File row component', () => { it('matches the current route against encoded file URL', () => { const fileName = 'with space'; - const rowFile = Object.assign({}, file(fileName), { - url: `/${fileName}`, - }); + const rowFile = { ...file(fileName), url: `/${fileName}` }; const routerPath = `/project/${escapeFileUrl(fileName)}`; createComponent( { diff --git a/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js b/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js new file mode 100644 index 00000000000..87cafa0bb8c --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js @@ -0,0 +1,190 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import component from '~/vue_shared/components/filtered_search_dropdown.vue'; + +describe('Filtered search dropdown', () => { + const Component = Vue.extend(component); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + describe('with an empty array of items', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [], + filterKey: '', + }); + }); + + it('renders empty list', () => { + expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(0); + }); + + it('renders filter input', () => { + expect(vm.$el.querySelector('.js-filtered-dropdown-input')).not.toBeNull(); + }); + }); + + describe('when visible numbers is less than the items length', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [{ title: 'One' }, { title: 'Two' }, { title: 'Three' }], + visibleItems: 2, + filterKey: 'title', + }); + }); + + it('it renders only the maximum number provided', () => { + expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(2); + }); + }); + + describe('when visible number is bigger than the items length', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [{ title: 'One' }, { title: 'Two' }, { title: 'Three' }], + filterKey: 'title', + }); + }); + + it('it renders the full list of items the maximum number provided', () => { + expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(3); + }); + }); + + describe('while filtering', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [ + { title: 'One' }, + { title: 'Two/three' }, + { title: 'Three four' }, + { title: 'Five' }, + ], + filterKey: 'title', + }); + }); + + it('updates the results to match the typed value', done => { + vm.$el.querySelector('.js-filtered-dropdown-input').value = 'three'; + vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); + vm.$nextTick(() => { + expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(2); + done(); + }); + }); + + describe('when no value matches the typed one', () => { + it('does not render any result', done => { + vm.$el.querySelector('.js-filtered-dropdown-input').value = 'six'; + vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); + + vm.$nextTick(() => { + expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(0); + done(); + }); + }); + }); + }); + + describe('with create mode enabled', () => { + describe('when there are no matches', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [ + { title: 'One' }, + { title: 'Two/three' }, + { title: 'Three four' }, + { title: 'Five' }, + ], + filterKey: 'title', + showCreateMode: true, + }); + + vm.$el.querySelector('.js-filtered-dropdown-input').value = 'eleven'; + vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); + }); + + it('renders a create button', done => { + vm.$nextTick(() => { + expect(vm.$el.querySelector('.js-dropdown-create-button')).not.toBeNull(); + done(); + }); + }); + + it('renders computed button text', done => { + vm.$nextTick(() => { + expect(vm.$el.querySelector('.js-dropdown-create-button').textContent.trim()).toEqual( + 'Create eleven', + ); + done(); + }); + }); + + describe('on click create button', () => { + it('emits createItem event with the filter', done => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + vm.$nextTick(() => { + vm.$el.querySelector('.js-dropdown-create-button').click(); + + expect(vm.$emit).toHaveBeenCalledWith('createItem', 'eleven'); + done(); + }); + }); + }); + }); + + describe('when there are matches', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [ + { title: 'One' }, + { title: 'Two/three' }, + { title: 'Three four' }, + { title: 'Five' }, + ], + filterKey: 'title', + showCreateMode: true, + }); + + vm.$el.querySelector('.js-filtered-dropdown-input').value = 'one'; + vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); + }); + + it('does not render a create button', done => { + vm.$nextTick(() => { + expect(vm.$el.querySelector('.js-dropdown-create-button')).toBeNull(); + done(); + }); + }); + }); + }); + + describe('with create mode disabled', () => { + describe('when there are no matches', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [ + { title: 'One' }, + { title: 'Two/three' }, + { title: 'Three four' }, + { title: 'Five' }, + ], + filterKey: 'title', + }); + + vm.$el.querySelector('.js-filtered-dropdown-input').value = 'eleven'; + vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); + }); + + it('does not render a create button', done => { + vm.$nextTick(() => { + expect(vm.$el.querySelector('.js-dropdown-create-button')).toBeNull(); + done(); + }); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/gl_countdown_spec.js b/spec/frontend/vue_shared/components/gl_countdown_spec.js new file mode 100644 index 00000000000..365c9fad478 --- /dev/null +++ b/spec/frontend/vue_shared/components/gl_countdown_spec.js @@ -0,0 +1,83 @@ +import mountComponent from 'helpers/vue_mount_component_helper'; +import Vue from 'vue'; +import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; + +describe('GlCountdown', () => { + const Component = Vue.extend(GlCountdown); + let vm; + let now = '2000-01-01T00:00:00Z'; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => new Date(now).getTime()); + }); + + afterEach(() => { + vm.$destroy(); + jest.clearAllTimers(); + }); + + describe('when there is time remaining', () => { + beforeEach(done => { + vm = mountComponent(Component, { + endDateString: '2000-01-01T01:02:03Z', + }); + + Vue.nextTick() + .then(done) + .catch(done.fail); + }); + + it('displays remaining time', () => { + expect(vm.$el.textContent).toContain('01:02:03'); + }); + + it('updates remaining time', done => { + now = '2000-01-01T00:00:01Z'; + jest.advanceTimersByTime(1000); + + Vue.nextTick() + .then(() => { + expect(vm.$el.textContent).toContain('01:02:02'); + done(); + }) + .catch(done.fail); + }); + }); + + describe('when there is no time remaining', () => { + beforeEach(done => { + vm = mountComponent(Component, { + endDateString: '1900-01-01T00:00:00Z', + }); + + Vue.nextTick() + .then(done) + .catch(done.fail); + }); + + it('displays 00:00:00', () => { + expect(vm.$el.textContent).toContain('00:00:00'); + }); + }); + + describe('when an invalid date is passed', () => { + beforeEach(() => { + Vue.config.warnHandler = jest.fn(); + }); + + afterEach(() => { + Vue.config.warnHandler = null; + }); + + it('throws a validation error', () => { + vm = mountComponent(Component, { + endDateString: 'this is invalid', + }); + + expect(Vue.config.warnHandler).toHaveBeenCalledTimes(1); + const [errorMessage] = Vue.config.warnHandler.mock.calls[0]; + + expect(errorMessage).toMatch(/^Invalid prop: .* "endDateString"/); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/header_ci_component_spec.js b/spec/frontend/vue_shared/components/header_ci_component_spec.js new file mode 100644 index 00000000000..216563165d6 --- /dev/null +++ b/spec/frontend/vue_shared/components/header_ci_component_spec.js @@ -0,0 +1,93 @@ +import Vue from 'vue'; +import mountComponent, { mountComponentWithSlots } from 'helpers/vue_mount_component_helper'; +import headerCi from '~/vue_shared/components/header_ci_component.vue'; + +describe('Header CI Component', () => { + let HeaderCi; + let vm; + let props; + + beforeEach(() => { + HeaderCi = Vue.extend(headerCi); + props = { + status: { + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + details_path: 'path', + }, + itemName: 'job', + itemId: 123, + time: '2017-05-08T14:57:39.781Z', + user: { + web_url: 'path', + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatar_url: 'link', + }, + hasSidebarButton: true, + }; + }); + + afterEach(() => { + vm.$destroy(); + }); + + const findActionButtons = () => vm.$el.querySelector('.header-action-buttons'); + + describe('render', () => { + beforeEach(() => { + vm = mountComponent(HeaderCi, props); + }); + + it('should render status badge', () => { + expect(vm.$el.querySelector('.ci-failed')).toBeDefined(); + expect(vm.$el.querySelector('.ci-status-icon-failed svg')).toBeDefined(); + expect(vm.$el.querySelector('.ci-failed').getAttribute('href')).toEqual( + props.status.details_path, + ); + }); + + it('should render item name and id', () => { + expect(vm.$el.querySelector('strong').textContent.trim()).toEqual('job #123'); + }); + + it('should render timeago date', () => { + expect(vm.$el.querySelector('time')).toBeDefined(); + }); + + it('should render user icon and name', () => { + expect(vm.$el.querySelector('.js-user-link').innerText.trim()).toContain(props.user.name); + }); + + it('should render sidebar toggle button', () => { + expect(vm.$el.querySelector('.js-sidebar-build-toggle')).not.toBeNull(); + }); + + it('should not render header action buttons when empty', () => { + expect(findActionButtons()).toBeNull(); + }); + }); + + describe('slot', () => { + it('should render header action buttons', () => { + vm = mountComponentWithSlots(HeaderCi, { props, slots: { default: 'Test Actions' } }); + + const buttons = findActionButtons(); + + expect(buttons).not.toBeNull(); + expect(buttons.textContent).toEqual('Test Actions'); + }); + }); + + describe('shouldRenderTriggeredLabel', () => { + it('should rendered created keyword when the shouldRenderTriggeredLabel is false', () => { + vm = mountComponent(HeaderCi, { ...props, shouldRenderTriggeredLabel: false }); + + expect(vm.$el.textContent).toContain('created'); + expect(vm.$el.textContent).not.toContain('triggered'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/identicon_spec.js b/spec/frontend/vue_shared/components/identicon_spec.js index 5e8b013d480..53a55dcd6bd 100644 --- a/spec/frontend/vue_shared/components/identicon_spec.js +++ b/spec/frontend/vue_shared/components/identicon_spec.js @@ -4,12 +4,17 @@ import IdenticonComponent from '~/vue_shared/components/identicon.vue'; describe('Identicon', () => { let wrapper; - const createComponent = () => { + const defaultProps = { + entityId: 1, + entityName: 'entity-name', + sizeClass: 's40', + }; + + const createComponent = (props = {}) => { wrapper = shallowMount(IdenticonComponent, { propsData: { - entityId: 1, - entityName: 'entity-name', - sizeClass: 's40', + ...defaultProps, + ...props, }, }); }; @@ -19,15 +24,27 @@ describe('Identicon', () => { wrapper = null; }); - it('matches snapshot', () => { - createComponent(); + describe('entity id is a number', () => { + beforeEach(createComponent); + + it('matches snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); - expect(wrapper.element).toMatchSnapshot(); + it('adds a correct class to identicon', () => { + expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2'); + }); }); - it('adds a correct class to identicon', () => { - createComponent(); + describe('entity id is a GraphQL id', () => { + beforeEach(() => createComponent({ entityId: 'gid://gitlab/Project/8' })); + + it('matches snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); - expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2'); + it('adds a correct class to identicon', () => { + expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2'); + }); }); }); 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 4c654e01f74..90c3fe54901 100644 --- a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js @@ -36,9 +36,7 @@ describe('IssueMilestoneComponent', () => { describe('isMilestoneStarted', () => { it('should return `false` when milestoneStart prop is not defined', () => { wrapper.setProps({ - milestone: Object.assign({}, mockMilestone, { - start_date: '', - }), + milestone: { ...mockMilestone, start_date: '' }, }); expect(wrapper.vm.isMilestoneStarted).toBe(false); @@ -46,9 +44,7 @@ describe('IssueMilestoneComponent', () => { it('should return `true` when milestone start date is past current date', () => { wrapper.setProps({ - milestone: Object.assign({}, mockMilestone, { - start_date: '1990-07-22', - }), + milestone: { ...mockMilestone, start_date: '1990-07-22' }, }); expect(wrapper.vm.isMilestoneStarted).toBe(true); @@ -58,9 +54,7 @@ describe('IssueMilestoneComponent', () => { describe('isMilestonePastDue', () => { it('should return `false` when milestoneDue prop is not defined', () => { wrapper.setProps({ - milestone: Object.assign({}, mockMilestone, { - due_date: '', - }), + milestone: { ...mockMilestone, due_date: '' }, }); expect(wrapper.vm.isMilestonePastDue).toBe(false); @@ -68,9 +62,7 @@ describe('IssueMilestoneComponent', () => { it('should return `true` when milestone due is past current date', () => { wrapper.setProps({ - milestone: Object.assign({}, mockMilestone, { - due_date: '1990-07-22', - }), + milestone: { ...mockMilestone, due_date: '1990-07-22' }, }); expect(wrapper.vm.isMilestonePastDue).toBe(true); @@ -84,9 +76,7 @@ describe('IssueMilestoneComponent', () => { it('returns string containing absolute milestone start date when due date is not present', () => { wrapper.setProps({ - milestone: Object.assign({}, mockMilestone, { - due_date: '', - }), + milestone: { ...mockMilestone, due_date: '' }, }); expect(wrapper.vm.milestoneDatesAbsolute).toBe('(January 1, 2018)'); @@ -94,10 +84,7 @@ describe('IssueMilestoneComponent', () => { it('returns empty string when both milestone start and due dates are not present', () => { wrapper.setProps({ - milestone: Object.assign({}, mockMilestone, { - start_date: '', - due_date: '', - }), + milestone: { ...mockMilestone, start_date: '', due_date: '' }, }); expect(wrapper.vm.milestoneDatesAbsolute).toBe(''); @@ -107,9 +94,7 @@ describe('IssueMilestoneComponent', () => { describe('milestoneDatesHuman', () => { it('returns string containing milestone due date when date is yet to be due', () => { wrapper.setProps({ - milestone: Object.assign({}, mockMilestone, { - due_date: `${new Date().getFullYear() + 10}-01-01`, - }), + milestone: { ...mockMilestone, due_date: `${new Date().getFullYear() + 10}-01-01` }, }); expect(wrapper.vm.milestoneDatesHuman).toContain('years remaining'); @@ -117,10 +102,7 @@ describe('IssueMilestoneComponent', () => { it('returns string containing milestone start date when date has already started and due date is not present', () => { wrapper.setProps({ - milestone: Object.assign({}, mockMilestone, { - start_date: '1990-07-22', - due_date: '', - }), + milestone: { ...mockMilestone, start_date: '1990-07-22', due_date: '' }, }); expect(wrapper.vm.milestoneDatesHuman).toContain('Started'); @@ -128,10 +110,11 @@ describe('IssueMilestoneComponent', () => { it('returns string containing milestone start date when date is yet to start and due date is not present', () => { wrapper.setProps({ - milestone: Object.assign({}, mockMilestone, { + milestone: { + ...mockMilestone, start_date: `${new Date().getFullYear() + 10}-01-01`, due_date: '', - }), + }, }); expect(wrapper.vm.milestoneDatesHuman).toContain('Starts'); @@ -139,10 +122,7 @@ describe('IssueMilestoneComponent', () => { it('returns empty string when milestone start and due dates are not present', () => { wrapper.setProps({ - milestone: Object.assign({}, mockMilestone, { - start_date: '', - due_date: '', - }), + milestone: { ...mockMilestone, start_date: '', due_date: '' }, }); expect(wrapper.vm.milestoneDatesHuman).toBe(''); 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 f7b1f041ef2..dd24ecf707d 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 @@ -2,10 +2,7 @@ import Vue from 'vue'; 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 '../../../../javascripts/vue_shared/components/issue/related_issuable_mock_data'; +import { defaultAssignees, defaultMilestone } from './related_issuable_mock_data'; describe('RelatedIssuableItem', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index 46e269e5071..54ce1f47e28 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -9,9 +9,9 @@ const markdownPreviewPath = `${TEST_HOST}/preview`; const markdownDocsPath = `${TEST_HOST}/docs`; function assertMarkdownTabs(isWrite, writeLink, previewLink, wrapper) { - expect(writeLink.element.parentNode.classList.contains('active')).toEqual(isWrite); - expect(previewLink.element.parentNode.classList.contains('active')).toEqual(!isWrite); - expect(wrapper.find('.md-preview-holder').element.style.display).toEqual(isWrite ? 'none' : ''); + expect(writeLink.element.parentNode.classList.contains('active')).toBe(isWrite); + expect(previewLink.element.parentNode.classList.contains('active')).toBe(!isWrite); + expect(wrapper.find('.md-preview-holder').element.style.display).toBe(isWrite ? 'none' : ''); } function createComponent() { @@ -67,6 +67,10 @@ describe('Markdown field component', () => { let previewLink; let writeLink; + afterEach(() => { + wrapper.destroy(); + }); + it('renders textarea inside backdrop', () => { wrapper = createComponent(); expect(wrapper.find('.zen-backdrop textarea').element).not.toBeNull(); @@ -92,32 +96,24 @@ describe('Markdown field component', () => { previewLink = getPreviewLink(wrapper); previewLink.trigger('click'); - wrapper.vm.$nextTick(() => { + return wrapper.vm.$nextTick(() => { expect(wrapper.find('.md-preview-holder').element.textContent.trim()).toContain( 'Loading…', ); }); }); - it('renders markdown preview', () => { + it('renders markdown preview and GFM', () => { wrapper = createComponent(); - previewLink = getPreviewLink(wrapper); - previewLink.trigger('click'); + const renderGFMSpy = jest.spyOn($.fn, 'renderGFM'); - setTimeout(() => { - expect(wrapper.find('.md-preview-holder').element.innerHTML).toContain(previewHTML); - }); - }); - - it('renders GFM with jQuery', () => { - wrapper = createComponent(); previewLink = getPreviewLink(wrapper); - jest.spyOn($.fn, 'renderGFM'); previewLink.trigger('click'); return axios.waitFor(markdownPreviewPath).then(() => { expect(wrapper.find('.md-preview-holder').element.innerHTML).toContain(previewHTML); + expect(renderGFMSpy).toHaveBeenCalled(); }); }); @@ -176,7 +172,7 @@ describe('Markdown field component', () => { const markdownButton = getMarkdownButton(wrapper); markdownButton.trigger('click'); - wrapper.vm.$nextTick(() => { + return wrapper.vm.$nextTick(() => { expect(textarea.value).toContain('**testing**'); }); }); @@ -188,8 +184,8 @@ describe('Markdown field component', () => { const markdownButton = getAllMarkdownButtons(wrapper).wrappers[5]; markdownButton.trigger('click'); - wrapper.vm.$nextTick(() => { - expect(textarea.value).toContain('* testing'); + return wrapper.vm.$nextTick(() => { + expect(textarea.value).toContain('* testing'); }); }); @@ -200,7 +196,7 @@ describe('Markdown field component', () => { const markdownButton = getAllMarkdownButtons(wrapper).wrappers[5]; markdownButton.trigger('click'); - wrapper.vm.$nextTick(() => { + return wrapper.vm.$nextTick(() => { expect(textarea.value).toContain('* testing\n* 123'); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/field_view_spec.js b/spec/frontend/vue_shared/components/markdown/field_view_spec.js new file mode 100644 index 00000000000..80cf1f655c6 --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/field_view_spec.js @@ -0,0 +1,26 @@ +import $ from 'jquery'; +import { shallowMount } from '@vue/test-utils'; + +import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue'; + +describe('Markdown Field View component', () => { + let renderGFMSpy; + let wrapper; + + function createComponent() { + wrapper = shallowMount(MarkdownFieldView); + } + + beforeEach(() => { + renderGFMSpy = jest.spyOn($.fn, 'renderGFM'); + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('processes rendering with GFM', () => { + expect(renderGFMSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/suggestions_spec.js b/spec/frontend/vue_shared/components/markdown/suggestions_spec.js new file mode 100644 index 00000000000..34ccdf38b00 --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/suggestions_spec.js @@ -0,0 +1,102 @@ +import Vue from 'vue'; +import SuggestionsComponent from '~/vue_shared/components/markdown/suggestions.vue'; + +const MOCK_DATA = { + suggestions: [ + { + id: 1, + appliable: true, + applied: false, + current_user: { + can_apply: true, + }, + diff_lines: [ + { + can_receive_suggestion: false, + line_code: null, + meta_data: null, + new_line: null, + old_line: 5, + rich_text: '-test', + text: '-test', + type: 'old', + }, + { + can_receive_suggestion: true, + line_code: null, + meta_data: null, + new_line: 5, + old_line: null, + rich_text: '+new test', + text: '+new test', + type: 'new', + }, + ], + }, + ], + noteHtml: ` + <div class="suggestion"> + <div class="line">-oldtest</div> + </div> + <div class="suggestion"> + <div class="line">+newtest</div> + </div> + `, + isApplied: false, + helpPagePath: 'path_to_docs', +}; + +describe('Suggestion component', () => { + let vm; + let diffTable; + + beforeEach(done => { + const Component = Vue.extend(SuggestionsComponent); + + vm = new Component({ + propsData: MOCK_DATA, + }).$mount(); + + diffTable = vm.generateDiff(0).$mount().$el; + + jest.spyOn(vm, 'renderSuggestions').mockImplementation(() => {}); + vm.renderSuggestions(); + Vue.nextTick(done); + }); + + describe('mounted', () => { + it('renders a flash container', () => { + expect(vm.$el.querySelector('.js-suggestions-flash')).not.toBeNull(); + }); + + it('renders a container for suggestions', () => { + expect(vm.$refs.container).not.toBeNull(); + }); + + it('renders suggestions', () => { + expect(vm.renderSuggestions).toHaveBeenCalled(); + expect(vm.$el.innerHTML.includes('oldtest')).toBe(true); + expect(vm.$el.innerHTML.includes('newtest')).toBe(true); + }); + }); + + describe('generateDiff', () => { + it('generates a diff table', () => { + expect(diffTable.querySelector('.md-suggestion-diff')).not.toBeNull(); + }); + + it('generates a diff table that contains contents the suggested lines', () => { + MOCK_DATA.suggestions[0].diff_lines.forEach(line => { + const text = line.text.substring(1); + + expect(diffTable.innerHTML.includes(text)).toBe(true); + }); + }); + + it('generates a diff table with the correct line number for each suggested line', () => { + const lines = diffTable.querySelectorAll('.old_line'); + + expect(parseInt([...lines][0].innerHTML, 10)).toBe(5); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js new file mode 100644 index 00000000000..e7c31014bfc --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import toolbar from '~/vue_shared/components/markdown/toolbar.vue'; + +describe('toolbar', () => { + let vm; + const Toolbar = Vue.extend(toolbar); + const props = { + markdownDocsPath: '', + }; + + afterEach(() => { + vm.$destroy(); + }); + + describe('user can attach file', () => { + beforeEach(() => { + vm = mountComponent(Toolbar, props); + }); + + it('should render uploading-container', () => { + expect(vm.$el.querySelector('.uploading-container')).not.toBeNull(); + }); + }); + + describe('user cannot attach file', () => { + beforeEach(() => { + vm = mountComponent(Toolbar, { ...props, canAttachFile: false }); + }); + + it('should not render uploading-container', () => { + expect(vm.$el.querySelector('.uploading-container')).toBeNull(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/navigation_tabs_spec.js b/spec/frontend/vue_shared/components/navigation_tabs_spec.js new file mode 100644 index 00000000000..561456d614e --- /dev/null +++ b/spec/frontend/vue_shared/components/navigation_tabs_spec.js @@ -0,0 +1,64 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import navigationTabs from '~/vue_shared/components/navigation_tabs.vue'; + +describe('navigation tabs component', () => { + let vm; + let Component; + let data; + + beforeEach(() => { + data = [ + { + name: 'All', + scope: 'all', + count: 1, + isActive: true, + }, + { + name: 'Pending', + scope: 'pending', + count: 0, + isActive: false, + }, + { + name: 'Running', + scope: 'running', + isActive: false, + }, + ]; + + Component = Vue.extend(navigationTabs); + vm = mountComponent(Component, { tabs: data, scope: 'pipelines' }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render tabs', () => { + expect(vm.$el.querySelectorAll('li').length).toEqual(data.length); + }); + + it('should render active tab', () => { + expect(vm.$el.querySelector('.active .js-pipelines-tab-all')).toBeDefined(); + }); + + it('should render badge', () => { + expect(vm.$el.querySelector('.js-pipelines-tab-all .badge').textContent.trim()).toEqual('1'); + expect(vm.$el.querySelector('.js-pipelines-tab-pending .badge').textContent.trim()).toEqual( + '0', + ); + }); + + it('should not render badge', () => { + expect(vm.$el.querySelector('.js-pipelines-tab-running .badge')).toEqual(null); + }); + + it('should trigger onTabClick', () => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + vm.$el.querySelector('.js-pipelines-tab-pending').click(); + + expect(vm.$emit).toHaveBeenCalledWith('onChangeTab', 'pending'); + }); +}); diff --git a/spec/frontend/vue_shared/components/pikaday_spec.js b/spec/frontend/vue_shared/components/pikaday_spec.js new file mode 100644 index 00000000000..867bf88ff50 --- /dev/null +++ b/spec/frontend/vue_shared/components/pikaday_spec.js @@ -0,0 +1,30 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import datePicker from '~/vue_shared/components/pikaday.vue'; + +describe('datePicker', () => { + let vm; + beforeEach(() => { + const DatePicker = Vue.extend(datePicker); + vm = mountComponent(DatePicker, { + label: 'label', + }); + }); + + it('should render label text', () => { + expect(vm.$el.querySelector('.dropdown-toggle-text').innerText.trim()).toEqual('label'); + }); + + it('should show calendar', () => { + expect(vm.$el.querySelector('.pika-single')).toBeDefined(); + }); + + it('should toggle when dropdown is clicked', () => { + const hidePicker = jest.fn(); + vm.$on('hidePicker', hidePicker); + + vm.$el.querySelector('.dropdown-menu-toggle').click(); + + expect(hidePicker).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/vue_shared/components/project_avatar/default_spec.js b/spec/frontend/vue_shared/components/project_avatar/default_spec.js new file mode 100644 index 00000000000..090f8b69213 --- /dev/null +++ b/spec/frontend/vue_shared/components/project_avatar/default_spec.js @@ -0,0 +1,58 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import { projectData } from 'jest/ide/mock_data'; +import { TEST_HOST } from 'spec/test_constants'; +import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility'; +import ProjectAvatarDefault from '~/vue_shared/components/project_avatar/default.vue'; + +describe('ProjectAvatarDefault component', () => { + const Component = Vue.extend(ProjectAvatarDefault); + let vm; + + beforeEach(() => { + vm = mountComponent(Component, { + project: projectData, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders identicon if project has no avatar_url', done => { + const expectedText = getFirstCharacterCapitalized(projectData.name); + + vm.project = { + ...vm.project, + avatar_url: null, + }; + + vm.$nextTick() + .then(() => { + const identiconEl = vm.$el.querySelector('.identicon'); + + expect(identiconEl).not.toBe(null); + expect(identiconEl.textContent.trim()).toEqual(expectedText); + }) + .then(done) + .catch(done.fail); + }); + + it('renders avatar image if project has avatar_url', done => { + const avatarUrl = `${TEST_HOST}/images/home/nasa.svg`; + + vm.project = { + ...vm.project, + avatar_url: avatarUrl, + }; + + vm.$nextTick() + .then(() => { + expect(vm.$el.querySelector('.avatar')).not.toBeNull(); + expect(vm.$el.querySelector('.identicon')).toBeNull(); + expect(vm.$el.querySelector('img')).toHaveAttr('src', avatarUrl); + }) + .then(done) + .catch(done.fail); + }); +}); 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 new file mode 100644 index 00000000000..eb1d9e93634 --- /dev/null +++ b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js @@ -0,0 +1,109 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { trimText } from 'helpers/text_helper'; +import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; + +const localVue = createLocalVue(); + +describe('ProjectListItem component', () => { + const Component = localVue.extend(ProjectListItem); + let wrapper; + let vm; + let options; + + const project = getJSONFixture('static/projects.json')[0]; + + beforeEach(() => { + options = { + propsData: { + project, + selected: false, + }, + localVue, + }; + }); + + afterEach(() => { + wrapper.vm.$destroy(); + }); + + it('does not render a check mark icon if selected === false', () => { + wrapper = shallowMount(Component, options); + + expect(wrapper.contains('.js-selected-icon.js-unselected')).toBe(true); + }); + + it('renders a check mark icon if selected === true', () => { + options.propsData.selected = true; + + wrapper = shallowMount(Component, options); + + expect(wrapper.contains('.js-selected-icon.js-selected')).toBe(true); + }); + + it(`emits a "clicked" event when clicked`, () => { + wrapper = shallowMount(Component, options); + ({ vm } = wrapper); + + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + wrapper.vm.onClick(); + + expect(wrapper.vm.$emit).toHaveBeenCalledWith('click'); + }); + + it(`renders the project avatar`, () => { + wrapper = shallowMount(Component, options); + + expect(wrapper.contains('.js-project-avatar')).toBe(true); + }); + + it(`renders a simple namespace name with a trailing slash`, () => { + options.propsData.project.name_with_namespace = 'a / b'; + + wrapper = shallowMount(Component, options); + const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text()); + + expect(renderedNamespace).toBe('a /'); + }); + + it(`renders a properly truncated namespace with a trailing slash`, () => { + options.propsData.project.name_with_namespace = 'a / b / c / d / e / f'; + + wrapper = shallowMount(Component, options); + const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text()); + + expect(renderedNamespace).toBe('a / ... / e /'); + }); + + it(`renders the project name`, () => { + options.propsData.project.name = 'my-test-project'; + + wrapper = shallowMount(Component, options); + const renderedName = trimText(wrapper.find('.js-project-name').text()); + + expect(renderedName).toBe('my-test-project'); + }); + + it(`renders the project name with highlighting in the case of a search query match`, () => { + options.propsData.project.name = 'my-test-project'; + options.propsData.matcher = 'pro'; + + wrapper = shallowMount(Component, options); + const renderedName = trimText(wrapper.find('.js-project-name').html()); + const expected = 'my-test-<b>p</b><b>r</b><b>o</b>ject'; + + expect(renderedName).toContain(expected); + }); + + it('prevents search query and project name XSS', () => { + const alertSpy = jest.spyOn(window, 'alert'); + options.propsData.project.name = "my-xss-pro<script>alert('XSS');</script>ject"; + options.propsData.matcher = "pro<script>alert('XSS');</script>"; + + wrapper = shallowMount(Component, options); + const renderedName = trimText(wrapper.find('.js-project-name').html()); + const expected = 'my-xss-project'; + + expect(renderedName).toContain(expected); + expect(alertSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js new file mode 100644 index 00000000000..29bced394dc --- /dev/null +++ b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js @@ -0,0 +1,112 @@ +import Vue from 'vue'; +import { head } from 'lodash'; + +import { GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui'; +import { mount, createLocalVue } from '@vue/test-utils'; +import { trimText } from 'helpers/text_helper'; +import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; +import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue'; + +const localVue = createLocalVue(); + +describe('ProjectSelector component', () => { + let wrapper; + let vm; + const allProjects = getJSONFixture('static/projects.json'); + const searchResults = allProjects.slice(0, 5); + let selected = []; + selected = selected.concat(allProjects.slice(0, 3)).concat(allProjects.slice(5, 8)); + + const findSearchInput = () => wrapper.find(GlSearchBoxByType).find('input'); + + beforeEach(() => { + wrapper = mount(Vue.extend(ProjectSelector), { + localVue, + propsData: { + projectSearchResults: searchResults, + selectedProjects: selected, + showNoResultsMessage: false, + showMinimumSearchQueryMessage: false, + showLoadingIndicator: false, + showSearchErrorMessage: false, + }, + attachToDocument: true, + }); + + ({ vm } = wrapper); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders the search results', () => { + expect(wrapper.findAll('.js-project-list-item').length).toBe(5); + }); + + it(`triggers a search when the search input value changes`, () => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + const query = 'my test query!'; + const searchInput = findSearchInput(); + + searchInput.setValue(query); + searchInput.trigger('input'); + + expect(vm.$emit).toHaveBeenCalledWith('searched', query); + }); + + it(`includes a placeholder in the search box`, () => { + const searchInput = findSearchInput(); + + expect(searchInput.attributes('placeholder')).toBe('Search your projects'); + }); + + it(`triggers a "bottomReached" event when user has scrolled to the bottom of the list`, () => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + wrapper.find(GlInfiniteScroll).vm.$emit('bottomReached'); + + expect(vm.$emit).toHaveBeenCalledWith('bottomReached'); + }); + + it(`triggers a "projectClicked" event when a project is clicked`, () => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + wrapper.find(ProjectListItem).vm.$emit('click', head(searchResults)); + + expect(vm.$emit).toHaveBeenCalledWith('projectClicked', head(searchResults)); + }); + + it(`shows a "no results" message if showNoResultsMessage === true`, () => { + wrapper.setProps({ showNoResultsMessage: true }); + + return vm.$nextTick().then(() => { + const noResultsEl = wrapper.find('.js-no-results-message'); + + expect(noResultsEl.exists()).toBe(true); + expect(trimText(noResultsEl.text())).toEqual('Sorry, no projects matched your search'); + }); + }); + + it(`shows a "minimum search query" message if showMinimumSearchQueryMessage === true`, () => { + wrapper.setProps({ showMinimumSearchQueryMessage: true }); + + return vm.$nextTick().then(() => { + const minimumSearchEl = wrapper.find('.js-minimum-search-query-message'); + + expect(minimumSearchEl.exists()).toBe(true); + expect(trimText(minimumSearchEl.text())).toEqual('Enter at least three characters to search'); + }); + }); + + it(`shows a error message if showSearchErrorMessage === true`, () => { + wrapper.setProps({ showSearchErrorMessage: true }); + + return vm.$nextTick().then(() => { + const errorMessageEl = wrapper.find('.js-search-error-message'); + + expect(errorMessageEl.exists()).toBe(true); + expect(trimText(errorMessageEl.text())).toEqual( + 'Something went wrong, unable to search projects', + ); + }); + }); +}); 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 new file mode 100644 index 00000000000..549d89171c6 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js @@ -0,0 +1,59 @@ +import { shallowMount } from '@vue/test-utils'; +import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; +import { + EDITOR_OPTIONS, + EDITOR_TYPES, + EDITOR_HEIGHT, + EDITOR_PREVIEW_STYLE, +} from '~/vue_shared/components/rich_content_editor/constants'; + +describe('Rich Content Editor', () => { + let wrapper; + + const value = '## Some Markdown'; + const findEditor = () => wrapper.find({ ref: 'editor' }); + + beforeEach(() => { + wrapper = shallowMount(RichContentEditor, { + propsData: { value }, + }); + }); + + describe('when content is loaded', () => { + it('renders an editor', () => { + expect(findEditor().exists()).toBe(true); + }); + + it('renders the correct content', () => { + expect(findEditor().props().initialValue).toBe(value); + }); + + it('provides the correct editor options', () => { + expect(findEditor().props().options).toEqual(EDITOR_OPTIONS); + }); + + it('has the correct preview style', () => { + expect(findEditor().props().previewStyle).toBe(EDITOR_PREVIEW_STYLE); + }); + + it('has the correct initial edit type', () => { + expect(findEditor().props().initialEditType).toBe(EDITOR_TYPES.wysiwyg); + }); + + it('has the correct height', () => { + expect(findEditor().props().height).toBe(EDITOR_HEIGHT); + }); + }); + + describe('when content is changed', () => { + it('emits an input event with the changed content', () => { + const changedMarkdown = '## Changed Markdown'; + const getMarkdownMock = jest.fn().mockReturnValueOnce(changedMarkdown); + + findEditor().setMethods({ invoke: getMarkdownMock }); + findEditor().vm.$emit('change'); + + expect(wrapper.emitted().input[0][0]).toBe(changedMarkdown); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js new file mode 100644 index 00000000000..8545c43dc1e --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js @@ -0,0 +1,44 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import ToolbarItem from '~/vue_shared/components/rich_content_editor/toolbar_item.vue'; + +describe('Toolbar Item', () => { + let wrapper; + + const findIcon = () => wrapper.find(GlIcon); + const findButton = () => wrapper.find('button'); + + const buildWrapper = propsData => { + wrapper = shallowMount(ToolbarItem, { propsData }); + }; + + describe.each` + icon + ${'heading'} + ${'bold'} + ${'italic'} + ${'strikethrough'} + ${'quote'} + ${'link'} + ${'doc-code'} + ${'list-bulleted'} + ${'list-numbered'} + ${'list-task'} + ${'list-indent'} + ${'list-outdent'} + ${'dash'} + ${'table'} + ${'code'} + `('toolbar item component', ({ icon }) => { + beforeEach(() => buildWrapper({ icon })); + + it('renders a toolbar button', () => { + expect(findButton().exists()).toBe(true); + }); + + it(`renders the ${icon} icon`, () => { + expect(findIcon().exists()).toBe(true); + expect(findIcon().props().name).toBe(icon); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js new file mode 100644 index 00000000000..7605cc6a22c --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js @@ -0,0 +1,29 @@ +import { generateToolbarItem } from '~/vue_shared/components/rich_content_editor/toolbar_service'; + +describe('Toolbar Service', () => { + const config = { + icon: 'bold', + command: 'some-command', + tooltip: 'Some Tooltip', + event: 'some-event', + }; + const generatedItem = generateToolbarItem(config); + + it('generates the correct command', () => { + expect(generatedItem.options.command).toBe(config.command); + }); + + it('generates the correct tooltip', () => { + expect(generatedItem.options.tooltip).toBe(config.tooltip); + }); + + it('generates the correct event', () => { + expect(generatedItem.options.event).toBe(config.event); + }); + + it('generates a divider when isDivider is set to true', () => { + const isDivider = true; + + expect(generateToolbarItem({ isDivider })).toBe('divider'); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js index d90fafb6bf7..9db86fa775f 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js @@ -4,10 +4,7 @@ import { shallowMount } from '@vue/test-utils'; import LabelsSelect from '~/labels_select'; import BaseComponent from '~/vue_shared/components/sidebar/labels_select/base.vue'; -import { - mockConfig, - mockLabels, -} from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data'; +import { mockConfig, mockLabels } from './mock_data'; const createComponent = (config = mockConfig) => shallowMount(BaseComponent, { 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 e2e11c94c0d..d02d924bd2b 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 @@ -3,16 +3,14 @@ import Vue from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; import dropdownButtonComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_button.vue'; -import { - mockConfig, - mockLabels, -} from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data'; +import { mockConfig, mockLabels } from './mock_data'; -const componentConfig = Object.assign({}, mockConfig, { +const componentConfig = { + ...mockConfig, fieldName: 'label_id[]', labels: mockLabels, showExtraOptions: false, -}); +}; const createComponent = (config = componentConfig) => { const Component = Vue.extend(dropdownButtonComponent); @@ -34,7 +32,7 @@ describe('DropdownButtonComponent', () => { describe('computed', () => { describe('dropdownToggleText', () => { it('returns text as `Label` when `labels` prop is empty array', () => { - const mockEmptyLabels = Object.assign({}, componentConfig, { labels: [] }); + const mockEmptyLabels = { ...componentConfig, labels: [] }; const vmEmptyLabels = createComponent(mockEmptyLabels); expect(vmEmptyLabels.dropdownToggleText).toBe('Label'); @@ -42,9 +40,7 @@ describe('DropdownButtonComponent', () => { }); it('returns first label name with remaining label count when `labels` prop has more than one item', () => { - const mockMoreLabels = Object.assign({}, componentConfig, { - labels: mockLabels.concat(mockLabels), - }); + const mockMoreLabels = { ...componentConfig, labels: mockLabels.concat(mockLabels) }; const vmMoreLabels = createComponent(mockMoreLabels); expect(vmMoreLabels.dropdownToggleText).toBe( @@ -54,9 +50,7 @@ describe('DropdownButtonComponent', () => { }); it('returns first label name when `labels` prop has only one item present', () => { - const singleLabel = Object.assign({}, componentConfig, { - labels: [mockLabels[0]], - }); + const singleLabel = { ...componentConfig, labels: [mockLabels[0]] }; const vmSingleLabel = createComponent(singleLabel); expect(vmSingleLabel.dropdownToggleText).toBe(mockLabels[0].title); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js index d0299523137..edec3b138b3 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; import dropdownCreateLabelComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue'; -import { mockSuggestedColors } from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data'; +import { mockSuggestedColors } from './mock_data'; const createComponent = headerTitle => { const Component = Vue.extend(dropdownCreateLabelComponent); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js index 784bbaf8e6a..7e9e242a4f5 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; import dropdownFooterComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_footer.vue'; -import { mockConfig } from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data'; +import { mockConfig } from './mock_data'; const createComponent = ( labelsWebUrl = mockConfig.labelsWebUrl, diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js index 887c04268d1..e09f0006359 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; import dropdownValueCollapsedComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue'; -import { mockLabels } from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data'; +import { mockLabels } from './mock_data'; const createComponent = (labels = mockLabels) => { const Component = Vue.extend(dropdownValueCollapsedComponent); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js index 06355c0dd65..c33cffb421d 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js @@ -2,10 +2,7 @@ import { mount } from '@vue/test-utils'; import DropdownValueComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value.vue'; import { GlLabel } from '@gitlab/ui'; -import { - mockConfig, - mockLabels, -} from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data'; +import { mockConfig, mockLabels } from './mock_data'; const createComponent = ( labels = mockLabels, diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js new file mode 100644 index 00000000000..6564c012e67 --- /dev/null +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js @@ -0,0 +1,57 @@ +export const mockLabels = [ + { + id: 26, + title: 'Foo Label', + description: 'Foobar', + color: '#BADA55', + text_color: '#FFFFFF', + }, + { + id: 27, + title: 'Foo::Bar', + description: 'Foobar', + color: '#0033CC', + text_color: '#FFFFFF', + }, +]; + +export const mockSuggestedColors = [ + '#0033CC', + '#428BCA', + '#44AD8E', + '#A8D695', + '#5CB85C', + '#69D100', + '#004E00', + '#34495E', + '#7F8C8D', + '#A295D6', + '#5843AD', + '#8E44AD', + '#FFECDB', + '#AD4363', + '#D10069', + '#CC0033', + '#FF0000', + '#D9534F', + '#D1D100', + '#F0AD4E', + '#AD8D43', +]; + +export const mockConfig = { + showCreate: true, + isProject: true, + abilityName: 'issue', + context: { + labels: mockLabels, + }, + namespace: 'gitlab-org', + updatePath: '/gitlab-org/my-project/issue/1', + labelsPath: '/gitlab-org/my-project/-/labels.json', + labelsWebUrl: '/gitlab-org/my-project/-/labels', + labelFilterBasePath: '/gitlab-org/my-project/issues', + canEdit: true, + suggestedColors: mockSuggestedColors, + emptyValueText: 'None', +}; 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 e2d31a41e82..214eb239432 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 @@ -33,9 +33,32 @@ describe('DropdownButton', () => { wrapper.destroy(); }); + 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(); + }); + }); + }); + describe('template', () => { it('renders component container element', () => { - expect(wrapper.is('gl-deprecated-button-stub')).toBe(true); + expect(wrapper.is('gl-button-stub')).toBe(true); }); it('renders button text element', () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js index d7ca7ce30a9..04320a72be6 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js @@ -1,7 +1,7 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlDeprecatedButton, GlIcon, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue'; import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; @@ -127,12 +127,12 @@ describe('DropdownContentsCreateView', () => { it('renders dropdown back button element', () => { const backBtnEl = wrapper .find('.dropdown-title') - .findAll(GlDeprecatedButton) + .findAll(GlButton) .at(0); expect(backBtnEl.exists()).toBe(true); expect(backBtnEl.attributes('aria-label')).toBe('Go back'); - expect(backBtnEl.find(GlIcon).props('name')).toBe('arrow-left'); + expect(backBtnEl.props('icon')).toBe('arrow-left'); }); it('renders dropdown title element', () => { @@ -145,12 +145,12 @@ describe('DropdownContentsCreateView', () => { it('renders dropdown close button element', () => { const closeBtnEl = wrapper .find('.dropdown-title') - .findAll(GlDeprecatedButton) + .findAll(GlButton) .at(1); expect(closeBtnEl.exists()).toBe(true); expect(closeBtnEl.attributes('aria-label')).toBe('Close'); - expect(closeBtnEl.find(GlIcon).props('name')).toBe('close'); + expect(closeBtnEl.props('icon')).toBe('close'); }); it('renders label title input element', () => { @@ -192,7 +192,7 @@ describe('DropdownContentsCreateView', () => { it('renders create button element', () => { const createBtnEl = wrapper .find('.dropdown-actions') - .findAll(GlDeprecatedButton) + .findAll(GlButton) .at(0); expect(createBtnEl.exists()).toBe(true); @@ -213,7 +213,7 @@ describe('DropdownContentsCreateView', () => { it('renders cancel button element', () => { const cancelBtnEl = wrapper .find('.dropdown-actions') - .findAll(GlDeprecatedButton) + .findAll(GlButton) .at(1); expect(cancelBtnEl.exists()).toBe(true); 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 3e6dbdb7ecb..74c769f86a3 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 @@ -1,9 +1,10 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlDeprecatedButton, GlLoadingIcon, GlIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue'; +import LabelItem from '~/vue_shared/components/sidebar/labels_select_vue/label_item.vue'; import defaultState from '~/vue_shared/components/sidebar/labels_select_vue/store/state'; import mutations from '~/vue_shared/components/sidebar/labels_select_vue/store/mutations'; @@ -41,13 +42,19 @@ const createComponent = (initialState = mockConfig) => { describe('DropdownContentsLabelsView', () => { let wrapper; + let wrapperStandalone; beforeEach(() => { wrapper = createComponent(); + wrapperStandalone = createComponent({ + ...mockConfig, + variant: 'standalone', + }); }); afterEach(() => { wrapper.destroy(); + wrapperStandalone.destroy(); }); describe('computed', () => { @@ -72,16 +79,6 @@ describe('DropdownContentsLabelsView', () => { }); describe('methods', () => { - describe('getDropdownLabelBoxStyle', () => { - it('returns an object containing `backgroundColor` based on provided `label` param', () => { - expect(wrapper.vm.getDropdownLabelBoxStyle(mockRegularLabel)).toEqual( - expect.objectContaining({ - backgroundColor: mockRegularLabel.color, - }), - ); - }); - }); - describe('isLabelSelected', () => { it('returns true when provided `label` param is one of the selected labels', () => { expect(wrapper.vm.isLabelSelected(mockRegularLabel)).toBe(true); @@ -165,13 +162,24 @@ describe('DropdownContentsLabelsView', () => { }); describe('handleLabelClick', () => { - it('calls action `updateSelectedLabels` with provided `label` param', () => { + beforeEach(() => { jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation(); + }); + it('calls action `updateSelectedLabels` with provided `label` param', () => { wrapper.vm.handleLabelClick(mockRegularLabel); expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([mockRegularLabel]); }); + + it('calls action `toggleDropdownContents` when `state.allowMultiselect` is false', () => { + jest.spyOn(wrapper.vm, 'toggleDropdownContents'); + wrapper.vm.$store.state.allowMultiselect = false; + + wrapper.vm.handleLabelClick(mockRegularLabel); + + expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled(); + }); }); }); @@ -198,12 +206,15 @@ describe('DropdownContentsLabelsView', () => { expect(titleEl.text()).toBe('Assign labels'); }); + it('does not render dropdown title element when `state.variant` is "standalone"', () => { + expect(wrapperStandalone.find('.dropdown-title').exists()).toBe(false); + }); + it('renders dropdown close button element', () => { - const closeButtonEl = wrapper.find('.dropdown-title').find(GlDeprecatedButton); + const closeButtonEl = wrapper.find('.dropdown-title').find(GlButton); expect(closeButtonEl.exists()).toBe(true); - expect(closeButtonEl.find(GlIcon).exists()).toBe(true); - expect(closeButtonEl.find(GlIcon).props('name')).toBe('close'); + expect(closeButtonEl.props('icon')).toBe('close'); }); it('renders label search input element', () => { @@ -214,16 +225,7 @@ describe('DropdownContentsLabelsView', () => { }); it('renders label elements for all labels', () => { - const labelsEl = wrapper.findAll('.dropdown-content li'); - const labelItemEl = labelsEl.at(0).find(GlLink); - - expect(labelsEl.length).toBe(mockLabels.length); - expect(labelItemEl.exists()).toBe(true); - expect(labelItemEl.find(GlIcon).props('name')).toBe('mobile-issue-close'); - expect(labelItemEl.find('.dropdown-label-box').attributes('style')).toBe( - 'background-color: rgb(186, 218, 85);', - ); - expect(labelItemEl.find(GlLink).text()).toContain(mockLabels[0].title); + expect(wrapper.findAll(LabelItem)).toHaveLength(mockLabels.length); }); it('renders label element with "is-focused" when value of `currentHighlightItem` is more than -1', () => { @@ -233,9 +235,9 @@ describe('DropdownContentsLabelsView', () => { return wrapper.vm.$nextTick(() => { const labelsEl = wrapper.findAll('.dropdown-content li'); - const labelItemEl = labelsEl.at(0).find(GlLink); + const labelItemEl = labelsEl.at(0).find(LabelItem); - expect(labelItemEl.attributes('class')).toContain('is-focused'); + expect(labelItemEl.props('highlight')).toBe(true); }); }); @@ -247,19 +249,42 @@ describe('DropdownContentsLabelsView', () => { return wrapper.vm.$nextTick(() => { const noMatchEl = wrapper.find('.dropdown-content li'); - expect(noMatchEl.exists()).toBe(true); + expect(noMatchEl.isVisible()).toBe(true); expect(noMatchEl.text()).toContain('No matching results'); }); }); it('renders footer list items', () => { - const createLabelBtn = wrapper.find('.dropdown-footer').find(GlDeprecatedButton); - const manageLabelsLink = wrapper.find('.dropdown-footer').find(GlLink); - - expect(createLabelBtn.exists()).toBe(true); - expect(createLabelBtn.text()).toBe('Create label'); + const createLabelLink = wrapper + .find('.dropdown-footer') + .findAll(GlLink) + .at(0); + const manageLabelsLink = wrapper + .find('.dropdown-footer') + .findAll(GlLink) + .at(1); + + expect(createLabelLink.exists()).toBe(true); + expect(createLabelLink.text()).toBe('Create label'); expect(manageLabelsLink.exists()).toBe(true); expect(manageLabelsLink.text()).toBe('Manage labels'); }); + + it('does not render "Create label" footer link when `state.allowLabelCreate` is `false`', () => { + wrapper.vm.$store.state.allowLabelCreate = false; + + return wrapper.vm.$nextTick(() => { + const createLabelLink = wrapper + .find('.dropdown-footer') + .findAll(GlLink) + .at(0); + + expect(createLabelLink.text()).not.toBe('Create label'); + }); + }); + + it('does not render footer list items when `state.variant` is "standalone"', () => { + expect(wrapperStandalone.find('.dropdown-footer').exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js new file mode 100644 index 00000000000..401d208da5c --- /dev/null +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js @@ -0,0 +1,111 @@ +import { shallowMount } from '@vue/test-utils'; + +import { GlIcon, GlLink } from '@gitlab/ui'; +import LabelItem from '~/vue_shared/components/sidebar/labels_select_vue/label_item.vue'; +import { mockRegularLabel } from './mock_data'; + +const createComponent = ({ label = mockRegularLabel, highlight = true } = {}) => + shallowMount(LabelItem, { + propsData: { + label, + highlight, + }, + }); + +describe('LabelItem', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('computed', () => { + describe('labelBoxStyle', () => { + it('returns an object containing `backgroundColor` based on `label` prop', () => { + expect(wrapper.vm.labelBoxStyle).toEqual( + expect.objectContaining({ + backgroundColor: mockRegularLabel.color, + }), + ); + }); + }); + }); + + describe('methods', () => { + describe('handleClick', () => { + it('sets value of `isSet` data prop to opposite of its current value', () => { + wrapper.setData({ + isSet: true, + }); + + wrapper.vm.handleClick(); + expect(wrapper.vm.isSet).toBe(false); + wrapper.vm.handleClick(); + expect(wrapper.vm.isSet).toBe(true); + }); + + it('emits event `clickLabel` on component with `label` prop as param', () => { + wrapper.vm.handleClick(); + + expect(wrapper.emitted('clickLabel')).toBeTruthy(); + expect(wrapper.emitted('clickLabel')[0]).toEqual([mockRegularLabel]); + }); + }); + }); + + describe('template', () => { + it('renders gl-link component', () => { + expect(wrapper.find(GlLink).exists()).toBe(true); + }); + + it('renders gl-link component with class `is-focused` when `highlight` prop is true', () => { + wrapper.setProps({ + highlight: true, + }); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.find(GlLink).classes()).toContain('is-focused'); + }); + }); + + it('renders visible gl-icon component when `isSet` prop is true', () => { + wrapper.setData({ + isSet: true, + }); + + return wrapper.vm.$nextTick(() => { + const iconEl = wrapper.find(GlIcon); + + expect(iconEl.isVisible()).toBe(true); + expect(iconEl.props('name')).toBe('mobile-issue-close'); + }); + }); + + it('renders visible span element as placeholder instead of gl-icon when `isSet` prop is false', () => { + wrapper.setData({ + isSet: false, + }); + + return wrapper.vm.$nextTick(() => { + const placeholderEl = wrapper.find('[data-testid="no-icon"]'); + + expect(placeholderEl.isVisible()).toBe(true); + }); + }); + + it('renders label color element', () => { + const colorEl = wrapper.find('[data-testid="label-color-box"]'); + + expect(colorEl.exists()).toBe(true); + expect(colorEl.attributes('style')).toBe('background-color: rgb(186, 218, 85);'); + }); + + it('renders label title', () => { + expect(wrapper.text()).toContain(mockRegularLabel.title); + }); + }); +}); 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 126fd5438c4..ee4e9090e5d 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,6 +89,19 @@ 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('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', () => { expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true); }); @@ -101,13 +114,16 @@ describe('LabelsSelectRoot', () => { const wrapperDropdownValue = createComponent(mockConfig, { default: 'None', }); + wrapperDropdownValue.vm.$store.state.showDropdownButton = false; - const valueComp = wrapperDropdownValue.find(DropdownValue); + return wrapperDropdownValue.vm.$nextTick(() => { + const valueComp = wrapperDropdownValue.find(DropdownValue); - expect(valueComp.exists()).toBe(true); - expect(valueComp.text()).toBe('None'); + expect(valueComp.exists()).toBe(true); + expect(valueComp.text()).toBe('None'); - wrapperDropdownValue.destroy(); + wrapperDropdownValue.destroy(); + }); }); it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js index a863cddbaee..e1008d13fc2 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js @@ -30,15 +30,16 @@ export const mockConfig = { allowLabelEdit: true, allowLabelCreate: true, allowScopedLabels: true, + allowMultiselect: true, labelsListTitle: 'Assign labels', labelsCreateTitle: 'Create label', + variant: 'sidebar', dropdownOnly: false, selectedLabels: [mockRegularLabel, mockScopedLabel], labelsSelectInProgress: false, labelsFetchPath: '/gitlab-org/my-project/-/labels.json', labelsManagePath: '/gitlab-org/my-project/-/labels', labelsFilterBasePath: '/gitlab-org/my-project/issues', - scopedLabelsDocumentationPath: '/help/user/project/labels.md#scoped-labels-premium', }; export const mockSuggestedColors = { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js index 6e2363ba96f..072d8fe2fe2 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js @@ -15,7 +15,7 @@ describe('LabelsSelect Actions', () => { }; beforeEach(() => { - state = Object.assign({}, defaultState()); + state = { ...defaultState() }; }); describe('setInitialState', () => { 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 bfceaa0828b..b866117efcf 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 @@ -5,19 +5,25 @@ describe('LabelsSelect Getters', () => { 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 })).toBe('Label'); + expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe( + 'Label', + ); }); it('returns label title when state.labels has only 1 label', () => { const labels = [{ id: 1, title: 'Foobar', set: true }]; - expect(getters.dropdownButtonText({ labels })).toBe('Foobar'); + expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe( + 'Foobar', + ); }); it('returns first label title and remaining labels count when state.labels has more than 1 label', () => { const labels = [{ id: 1, title: 'Foo', set: true }, { id: 2, title: 'Bar', set: true }]; - expect(getters.dropdownButtonText({ labels })).toBe('Foo +1 more'); + expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe( + 'Foo +1 more', + ); }); }); @@ -28,4 +34,16 @@ describe('LabelsSelect Getters', () => { expect(getters.selectedLabelsList({ selectedLabels })).toEqual([1, 2, 3, 4]); }); }); + + describe('isDropdownVariantSidebar', () => { + it('returns `true` when `state.variant` is "sidebar"', () => { + expect(getters.isDropdownVariantSidebar({ variant: 'sidebar' })).toBe(true); + }); + }); + + describe('isDropdownVariantStandalone', () => { + it('returns `true` when `state.variant` is "standalone"', () => { + expect(getters.isDropdownVariantStandalone({ variant: 'standalone' })).toBe(true); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js index f6ca98fcc71..8081806e314 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js @@ -29,6 +29,7 @@ describe('LabelsSelect Mutations', () => { const state = { dropdownOnly: false, showDropdownButton: false, + variant: 'sidebar', }; mutations[types.TOGGLE_DROPDOWN_CONTENTS](state); @@ -155,11 +156,11 @@ describe('LabelsSelect Mutations', () => { const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param', () => { - const updatedLabelIds = [2, 4]; + const updatedLabelIds = [2]; const state = { labels, }; - mutations[types.UPDATE_SELECTED_LABELS](state, { labels }); + mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: 2 }] }); state.labels.forEach(label => { if (updatedLabelIds.includes(label.id)) { diff --git a/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js new file mode 100644 index 00000000000..bc86ee5a0c6 --- /dev/null +++ b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js @@ -0,0 +1,104 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import stackedProgressBarComponent from '~/vue_shared/components/stacked_progress_bar.vue'; + +const createComponent = config => { + const Component = Vue.extend(stackedProgressBarComponent); + const defaultConfig = { + successLabel: 'Synced', + failureLabel: 'Failed', + neutralLabel: 'Out of sync', + successCount: 25, + failureCount: 10, + totalCount: 5000, + ...config, + }; + + return mountComponent(Component, defaultConfig); +}; + +describe('StackedProgressBarComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('neutralCount', () => { + it('returns neutralCount based on totalCount, successCount and failureCount', () => { + expect(vm.neutralCount).toBe(4965); // 5000 - 25 - 10 + }); + }); + }); + + describe('methods', () => { + describe('getPercent', () => { + it('returns percentage from provided count based on `totalCount`', () => { + expect(vm.getPercent(500)).toBe(10); + }); + + it('returns percentage with decimal place from provided count based on `totalCount`', () => { + expect(vm.getPercent(67)).toBe(1.3); + }); + + it('returns percentage as `< 1` from provided count based on `totalCount` when evaluated value is less than 1', () => { + expect(vm.getPercent(10)).toBe('< 1'); + }); + + it('returns 0 if totalCount is falsy', () => { + vm = createComponent({ totalCount: 0 }); + + expect(vm.getPercent(100)).toBe(0); + }); + }); + + describe('barStyle', () => { + it('returns style string based on percentage provided', () => { + expect(vm.barStyle(50)).toBe('width: 50%;'); + }); + }); + + describe('getTooltip', () => { + describe('when hideTooltips is false', () => { + it('returns label string based on label and count provided', () => { + expect(vm.getTooltip('Synced', 10)).toBe('Synced: 10'); + }); + }); + + describe('when hideTooltips is true', () => { + beforeEach(() => { + vm = createComponent({ hideTooltips: true }); + }); + + it('returns an empty string', () => { + expect(vm.getTooltip('Synced', 10)).toBe(''); + }); + }); + }); + }); + + describe('template', () => { + it('renders container element', () => { + expect(vm.$el.classList.contains('stacked-progress-bar')).toBeTruthy(); + }); + + it('renders empty state when count is unavailable', () => { + const vmX = createComponent({ totalCount: 0, successCount: 0, failureCount: 0 }); + + expect(vmX.$el.querySelectorAll('.status-unavailable').length).not.toBe(0); + vmX.$destroy(); + }); + + it('renders bar elements when count is available', () => { + expect(vm.$el.querySelectorAll('.status-green').length).not.toBe(0); + expect(vm.$el.querySelectorAll('.status-neutral').length).not.toBe(0); + expect(vm.$el.querySelectorAll('.status-red').length).not.toBe(0); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/tabs/tab_spec.js b/spec/frontend/vue_shared/components/tabs/tab_spec.js new file mode 100644 index 00000000000..8cf07a9177c --- /dev/null +++ b/spec/frontend/vue_shared/components/tabs/tab_spec.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import Tab from '~/vue_shared/components/tabs/tab.vue'; + +describe('Tab component', () => { + const Component = Vue.extend(Tab); + let vm; + + beforeEach(() => { + vm = mountComponent(Component); + }); + + it('sets localActive to equal active', done => { + vm.active = true; + + vm.$nextTick(() => { + expect(vm.localActive).toBe(true); + + done(); + }); + }); + + it('sets active class', done => { + vm.active = true; + + vm.$nextTick(() => { + expect(vm.$el.classList).toContain('active'); + + done(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/tabs/tabs_spec.js b/spec/frontend/vue_shared/components/tabs/tabs_spec.js new file mode 100644 index 00000000000..49d92094b34 --- /dev/null +++ b/spec/frontend/vue_shared/components/tabs/tabs_spec.js @@ -0,0 +1,61 @@ +import Vue from 'vue'; +import Tabs from '~/vue_shared/components/tabs/tabs'; +import Tab from '~/vue_shared/components/tabs/tab.vue'; + +describe('Tabs component', () => { + let vm; + + beforeEach(() => { + vm = new Vue({ + components: { + Tabs, + Tab, + }, + render(h) { + return h('div', [ + h('tabs', [ + h('tab', { attrs: { title: 'Testing', active: true } }, 'First tab'), + h('tab', [h('template', { slot: 'title' }, 'Test slot'), 'Second tab']), + ]), + ]); + }, + }).$mount(); + + return vm.$nextTick(); + }); + + describe('tab links', () => { + it('renders links for tabs', () => { + expect(vm.$el.querySelectorAll('a').length).toBe(2); + }); + + it('renders link titles from props', () => { + expect(vm.$el.querySelector('a').textContent).toContain('Testing'); + }); + + it('renders link titles from slot', () => { + expect(vm.$el.querySelectorAll('a')[1].textContent).toContain('Test slot'); + }); + + it('renders active class', () => { + expect(vm.$el.querySelector('a').classList).toContain('active'); + }); + + it('updates active class on click', () => { + vm.$el.querySelectorAll('a')[1].click(); + + return vm.$nextTick(() => { + expect(vm.$el.querySelector('a').classList).not.toContain('active'); + expect(vm.$el.querySelectorAll('a')[1].classList).toContain('active'); + }); + }); + }); + + describe('content', () => { + it('renders content panes', () => { + expect(vm.$el.querySelectorAll('.tab-pane').length).toBe(2); + expect(vm.$el.querySelectorAll('.tab-pane')[0].textContent).toContain('First tab'); + expect(vm.$el.querySelectorAll('.tab-pane')[1].textContent).toContain('Second tab'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/toggle_button_spec.js b/spec/frontend/vue_shared/components/toggle_button_spec.js new file mode 100644 index 00000000000..83bbb37a89a --- /dev/null +++ b/spec/frontend/vue_shared/components/toggle_button_spec.js @@ -0,0 +1,101 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import toggleButton from '~/vue_shared/components/toggle_button.vue'; + +describe('Toggle Button', () => { + let vm; + let Component; + + beforeEach(() => { + Component = Vue.extend(toggleButton); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('render output', () => { + beforeEach(() => { + vm = mountComponent(Component, { + value: true, + name: 'foo', + }); + }); + + it('renders input with provided name', () => { + expect(vm.$el.querySelector('input').getAttribute('name')).toEqual('foo'); + }); + + it('renders input with provided value', () => { + expect(vm.$el.querySelector('input').getAttribute('value')).toEqual('true'); + }); + + it('renders input status icon', () => { + expect(vm.$el.querySelectorAll('span.toggle-icon').length).toEqual(1); + expect(vm.$el.querySelectorAll('svg.s16.toggle-icon-svg').length).toEqual(1); + }); + }); + + describe('is-checked', () => { + beforeEach(() => { + vm = mountComponent(Component, { + value: true, + }); + + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + }); + + it('renders is checked class', () => { + expect(vm.$el.querySelector('button').classList.contains('is-checked')).toEqual(true); + }); + + it('sets aria-label representing toggle state', () => { + vm.value = true; + + expect(vm.ariaLabel).toEqual('Toggle Status: ON'); + + vm.value = false; + + expect(vm.ariaLabel).toEqual('Toggle Status: OFF'); + }); + + it('emits change event when clicked', () => { + vm.$el.querySelector('button').click(); + + expect(vm.$emit).toHaveBeenCalledWith('change', false); + }); + }); + + describe('is-disabled', () => { + beforeEach(() => { + vm = mountComponent(Component, { + value: true, + disabledInput: true, + }); + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + }); + + it('renders disabled button', () => { + expect(vm.$el.querySelector('button').classList.contains('is-disabled')).toEqual(true); + }); + + it('does not emit change event when clicked', () => { + vm.$el.querySelector('button').click(); + + expect(vm.$emit).not.toHaveBeenCalled(); + }); + }); + + describe('is-loading', () => { + beforeEach(() => { + vm = mountComponent(Component, { + value: true, + isLoading: true, + }); + }); + + it('renders loading class', () => { + expect(vm.$el.querySelector('button').classList.contains('is-loading')).toEqual(true); + }); + }); +}); diff --git a/spec/frontend/wikis_spec.js b/spec/frontend/wikis_spec.js index 1d17c8b0777..e5d869840aa 100644 --- a/spec/frontend/wikis_spec.js +++ b/spec/frontend/wikis_spec.js @@ -14,6 +14,7 @@ describe('Wikis', () => { <option value="asciidoc">AsciiDoc</option> <option value="org">Org</option> </select> + <textarea id="wiki_content"></textarea> <code class="js-markup-link-example">{Link title}[link:page-slug]</code> </form> `; @@ -24,6 +25,10 @@ describe('Wikis', () => { let changeFormatSelect; let linkExample; + const findBeforeUnloadWarning = () => window.onbeforeunload?.(); + const findContent = () => document.getElementById('wiki_content'); + const findForm = () => document.querySelector('.wiki-form'); + describe('when the wiki page is being created', () => { const formHtmlFixture = editFormHtmlFixture({ newPage: true }); @@ -94,6 +99,27 @@ describe('Wikis', () => { expect(linkExample.innerHTML).toBe(text); }); + + it('starts with no unload warning', () => { + expect(findBeforeUnloadWarning()).toBeUndefined(); + }); + + describe('when wiki content is updated', () => { + beforeEach(() => { + const content = findContent(); + content.value = 'Lorem ipsum dolar sit!'; + content.dispatchEvent(new Event('input')); + }); + + it('sets before unload warning', () => { + expect(findBeforeUnloadWarning()).toBe(''); + }); + + it('when form submitted, unsets before unload warning', () => { + findForm().dispatchEvent(new Event('submit')); + expect(findBeforeUnloadWarning()).toBeUndefined(); + }); + }); }); }); }); |