summaryrefslogtreecommitdiff
path: root/spec/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend')
-rw-r--r--spec/frontend/api_spec.js20
-rw-r--r--spec/frontend/boards/components/issue_time_estimate_spec.js81
-rw-r--r--spec/frontend/boards/issue_card_spec.js307
-rw-r--r--spec/frontend/boards/stores/getters_spec.js21
-rw-r--r--spec/frontend/clusters/clusters_bundle_spec.js26
-rw-r--r--spec/frontend/clusters/components/applications_spec.js157
-rw-r--r--spec/frontend/clusters/services/crossplane_provider_stack_spec.js78
-rw-r--r--spec/frontend/clusters/services/mock_data.js27
-rw-r--r--spec/frontend/clusters/stores/clusters_store_spec.js43
-rw-r--r--spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap4
-rw-r--r--spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap47
-rw-r--r--spec/frontend/contributors/component/contributors_spec.js69
-rw-r--r--spec/frontend/contributors/store/actions_spec.js60
-rw-r--r--spec/frontend/contributors/store/getters_spec.js73
-rw-r--r--spec/frontend/contributors/store/mutations_spec.js40
-rw-r--r--spec/frontend/contributors/utils_spec.js21
-rw-r--r--spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js44
-rw-r--r--spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js91
-rw-r--r--spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js181
-rw-r--r--spec/frontend/create_cluster/eks_cluster/components/region_dropdown_spec.js55
-rw-r--r--spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js117
-rw-r--r--spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js152
-rw-r--r--spec/frontend/create_cluster/eks_cluster/store/actions_spec.js248
-rw-r--r--spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js113
-rw-r--r--spec/frontend/create_cluster/gke_cluster_namespace/gke_cluster_namespace_spec.js (renamed from spec/frontend/projects/gke_cluster_namespace/gke_cluster_namespace_spec.js)4
-rw-r--r--spec/frontend/create_cluster/init_create_cluster_spec.js73
-rw-r--r--spec/frontend/cycle_analytics/stage_nav_item_spec.js44
-rw-r--r--spec/frontend/environment.js6
-rw-r--r--spec/frontend/error_tracking/components/error_details_spec.js105
-rw-r--r--spec/frontend/error_tracking/components/error_tracking_list_spec.js15
-rw-r--r--spec/frontend/error_tracking/components/stacktrace_entry_spec.js49
-rw-r--r--spec/frontend/error_tracking/components/stacktrace_spec.js45
-rw-r--r--spec/frontend/error_tracking/store/details/actions_spec.js94
-rw-r--r--spec/frontend/error_tracking/store/details/getters_spec.js13
-rw-r--r--spec/frontend/error_tracking/store/list/getters_spec.js33
-rw-r--r--spec/frontend/error_tracking/store/list/mutation_spec.js (renamed from spec/frontend/error_tracking/store/mutation_spec.js)4
-rw-r--r--spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js25
-rw-r--r--spec/frontend/error_tracking_settings/store/actions_spec.js16
-rw-r--r--spec/frontend/fixtures/merge_requests.rb18
-rw-r--r--spec/frontend/fixtures/static/environments_logs.html4
-rw-r--r--spec/frontend/fixtures/static/signin_tabs.html3
-rw-r--r--spec/frontend/fixtures/u2f.rb4
-rw-r--r--spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap101
-rw-r--r--spec/frontend/grafana_integration/components/grafana_integration_spec.js125
-rw-r--r--spec/frontend/grafana_integration/store/mutations_spec.js35
-rw-r--r--spec/frontend/helpers/monitor_helper_spec.js82
-rw-r--r--spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap61
-rw-r--r--spec/frontend/ide/components/jobs/stage_spec.js86
-rw-r--r--spec/frontend/ide/components/preview/clientside_spec.js20
-rw-r--r--spec/frontend/ide/services/index_spec.js83
-rw-r--r--spec/frontend/ide/stores/modules/clientside/actions_spec.js39
-rw-r--r--spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap15
-rw-r--r--spec/frontend/issuables_list/components/issuable_spec.js345
-rw-r--r--spec/frontend/issuables_list/components/issuables_list_app_spec.js410
-rw-r--r--spec/frontend/issuables_list/issuable_list_test_data.js72
-rw-r--r--spec/frontend/issue_show/helpers.js10
-rw-r--r--spec/frontend/jobs/components/log/log_spec.js4
-rw-r--r--spec/frontend/jobs/store/utils_spec.js20
-rw-r--r--spec/frontend/lib/utils/chart_utils_spec.js11
-rw-r--r--spec/frontend/lib/utils/datetime_utility_spec.js47
-rw-r--r--spec/frontend/lib/utils/number_utility_spec.js40
-rw-r--r--spec/frontend/lib/utils/text_utility_spec.js13
-rw-r--r--spec/frontend/monitoring/charts/time_series_spec.js397
-rw-r--r--spec/frontend/monitoring/components/charts/anomaly_spec.js303
-rw-r--r--spec/frontend/monitoring/components/date_time_picker/date_time_picker_spec.js10
-rw-r--r--spec/frontend/monitoring/embed/embed_spec.js4
-rw-r--r--spec/frontend/monitoring/embed/mock_data.js4
-rw-r--r--spec/frontend/monitoring/mock_data.js465
-rw-r--r--spec/frontend/monitoring/panel_type_spec.js166
-rw-r--r--spec/frontend/monitoring/store/actions_spec.js416
-rw-r--r--spec/frontend/monitoring/store/mutations_spec.js142
-rw-r--r--spec/frontend/monitoring/store/utils_spec.js74
-rw-r--r--spec/frontend/notes/components/comment_form_spec.js331
-rw-r--r--spec/frontend/notes/components/diff_discussion_header_spec.js141
-rw-r--r--spec/frontend/notes/components/discussion_actions_spec.js2
-rw-r--r--spec/frontend/notes/components/discussion_notes_spec.js6
-rw-r--r--spec/frontend/notes/components/note_app_spec.js26
-rw-r--r--spec/frontend/notes/mock_data.js1255
-rw-r--r--spec/frontend/performance_bar/components/add_request_spec.js62
-rw-r--r--spec/frontend/pipelines/graph/action_component_spec.js75
-rw-r--r--spec/frontend/pipelines/pipeline_triggerer_spec.js57
-rw-r--r--spec/frontend/pipelines/pipelines_table_row_spec.js228
-rw-r--r--spec/frontend/pipelines/test_reports/mock_data.js123
-rw-r--r--spec/frontend/pipelines/test_reports/stores/actions_spec.js109
-rw-r--r--spec/frontend/pipelines/test_reports/stores/getters_spec.js54
-rw-r--r--spec/frontend/pipelines/test_reports/stores/mutations_spec.js63
-rw-r--r--spec/frontend/pipelines/test_reports/test_reports_spec.js64
-rw-r--r--spec/frontend/pipelines/test_reports/test_suite_table_spec.js77
-rw-r--r--spec/frontend/pipelines/test_reports/test_summary_spec.js78
-rw-r--r--spec/frontend/pipelines/test_reports/test_summary_table_spec.js54
-rw-r--r--spec/frontend/project_find_file_spec.js37
-rw-r--r--spec/frontend/registry/components/collapsible_container_spec.js53
-rw-r--r--spec/frontend/registry/components/table_registry_spec.js119
-rw-r--r--spec/frontend/releases/detail/components/app_spec.js19
-rw-r--r--spec/frontend/releases/list/components/__snapshots__/release_block_spec.js.snap332
-rw-r--r--spec/frontend/releases/list/components/release_block_footer_spec.js163
-rw-r--r--spec/frontend/releases/list/components/release_block_spec.js33
-rw-r--r--spec/frontend/releases/mock_data.js4
-rw-r--r--spec/frontend/repository/components/__snapshots__/directory_download_links_spec.js.snap75
-rw-r--r--spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap56
-rw-r--r--spec/frontend/repository/components/directory_download_links_spec.js29
-rw-r--r--spec/frontend/repository/components/last_commit_spec.js4
-rw-r--r--spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap36
-rw-r--r--spec/frontend/repository/components/preview/index_spec.js49
-rw-r--r--spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap2
-rw-r--r--spec/frontend/repository/components/table/index_spec.js74
-rw-r--r--spec/frontend/repository/components/table/row_spec.js25
-rw-r--r--spec/frontend/repository/components/tree_content_spec.js71
-rw-r--r--spec/frontend/repository/log_tree_spec.js27
-rw-r--r--spec/frontend/repository/pages/index_spec.js42
-rw-r--r--spec/frontend/repository/pages/tree_spec.js60
-rw-r--r--spec/frontend/repository/utils/commit_spec.js30
-rw-r--r--spec/frontend/repository/utils/dom_spec.js20
-rw-r--r--spec/frontend/repository/utils/readme_spec.js33
-rw-r--r--spec/frontend/repository/utils/title_spec.js4
-rw-r--r--spec/frontend/sentry/index_spec.js44
-rw-r--r--spec/frontend/sentry/sentry_config_spec.js214
-rw-r--r--spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js1
-rw-r--r--spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js1
-rw-r--r--spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap37
-rw-r--r--spec/frontend/vue_shared/components/commit_spec.js227
-rw-r--r--spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js45
-rw-r--r--spec/frontend/vue_shared/components/issue/issue_assignees_spec.js189
-rw-r--r--spec/frontend/vue_shared/components/notes/placeholder_note_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/notes/system_note_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/slot_switch_spec.js56
-rw-r--r--spec/frontend/vue_shared/components/split_button_spec.js104
-rw-r--r--spec/frontend/vue_shared/components/table_pagination_spec.js335
-rw-r--r--spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js108
-rw-r--r--spec/frontend/vue_shared/components/user_popover/user_popover_spec.js186
131 files changed, 11253 insertions, 731 deletions
diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js
index 62ba0d36982..cef50bf553c 100644
--- a/spec/frontend/api_spec.js
+++ b/spec/frontend/api_spec.js
@@ -467,6 +467,26 @@ describe('Api', () => {
});
});
+ describe('user projects', () => {
+ it('fetches all projects that belong to a particular user', done => {
+ const query = 'dummy query';
+ const options = { unused: 'option' };
+ const userId = '123456';
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}/projects`;
+ mock.onGet(expectedUrl).reply(200, [
+ {
+ name: 'test',
+ },
+ ]);
+
+ Api.userProjects(userId, query, options, response => {
+ expect(response.length).toBe(1);
+ expect(response[0].name).toBe('test');
+ done();
+ });
+ });
+ });
+
describe('commitPipelines', () => {
it('fetches pipelines for a given commit', done => {
const projectId = 'example/foobar';
diff --git a/spec/frontend/boards/components/issue_time_estimate_spec.js b/spec/frontend/boards/components/issue_time_estimate_spec.js
new file mode 100644
index 00000000000..0a16dfbc009
--- /dev/null
+++ b/spec/frontend/boards/components/issue_time_estimate_spec.js
@@ -0,0 +1,81 @@
+import IssueTimeEstimate from '~/boards/components/issue_time_estimate.vue';
+import boardsStore from '~/boards/stores/boards_store';
+import { shallowMount } from '@vue/test-utils';
+
+describe('Issue Time Estimate component', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ boardsStore.create();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when limitToHours is false', () => {
+ beforeEach(() => {
+ boardsStore.timeTracking.limitToHours = false;
+ wrapper = shallowMount(IssueTimeEstimate, {
+ propsData: {
+ estimate: 374460,
+ },
+ sync: false,
+ });
+ });
+
+ it('renders the correct time estimate', () => {
+ expect(
+ wrapper
+ .find('time')
+ .text()
+ .trim(),
+ ).toEqual('2w 3d 1m');
+ });
+
+ it('renders expanded time estimate in tooltip', () => {
+ expect(wrapper.find('.js-issue-time-estimate').text()).toContain('2 weeks 3 days 1 minute');
+ });
+
+ it('prevents tooltip xss', done => {
+ const alertSpy = jest.spyOn(window, 'alert');
+ wrapper.setProps({ estimate: 'Foo <script>alert("XSS")</script>' });
+ wrapper.vm.$nextTick(() => {
+ expect(alertSpy).not.toHaveBeenCalled();
+ expect(
+ wrapper
+ .find('time')
+ .text()
+ .trim(),
+ ).toEqual('0m');
+ expect(wrapper.find('.js-issue-time-estimate').text()).toContain('0m');
+ done();
+ });
+ });
+ });
+
+ describe('when limitToHours is true', () => {
+ beforeEach(() => {
+ boardsStore.timeTracking.limitToHours = true;
+ wrapper = shallowMount(IssueTimeEstimate, {
+ propsData: {
+ estimate: 374460,
+ },
+ sync: false,
+ });
+ });
+
+ it('renders the correct time estimate', () => {
+ expect(
+ wrapper
+ .find('time')
+ .text()
+ .trim(),
+ ).toEqual('104h 1m');
+ });
+
+ it('renders expanded time estimate in tooltip', () => {
+ expect(wrapper.find('.js-issue-time-estimate').text()).toContain('104 hours 1 minute');
+ });
+ });
+});
diff --git a/spec/frontend/boards/issue_card_spec.js b/spec/frontend/boards/issue_card_spec.js
new file mode 100644
index 00000000000..ebe97769ab7
--- /dev/null
+++ b/spec/frontend/boards/issue_card_spec.js
@@ -0,0 +1,307 @@
+/* global ListAssignee, ListLabel, ListIssue */
+import { mount } from '@vue/test-utils';
+import _ from 'underscore';
+import '~/boards/models/label';
+import '~/boards/models/assignee';
+import '~/boards/models/issue';
+import '~/boards/models/list';
+import IssueCardInner from '~/boards/components/issue_card_inner.vue';
+import { listObj } from '../../javascripts/boards/mock_data';
+import store from '~/boards/stores';
+
+describe('Issue card component', () => {
+ const user = new ListAssignee({
+ id: 1,
+ name: 'testing 123',
+ username: 'test',
+ avatar: 'test_image',
+ });
+
+ const label1 = new ListLabel({
+ id: 3,
+ title: 'testing 123',
+ color: 'blue',
+ text_color: 'white',
+ description: 'test',
+ });
+
+ let wrapper;
+ let issue;
+ let list;
+
+ beforeEach(() => {
+ list = { ...listObj, type: 'label' };
+ issue = new ListIssue({
+ title: 'Testing',
+ id: 1,
+ iid: 1,
+ confidential: false,
+ labels: [list.label],
+ assignees: [],
+ reference_path: '#1',
+ real_path: '/test/1',
+ weight: 1,
+ });
+ wrapper = mount(IssueCardInner, {
+ propsData: {
+ list,
+ issue,
+ issueLinkBase: '/test',
+ rootPath: '/',
+ },
+ store,
+ sync: false,
+ });
+ });
+
+ it('renders issue title', () => {
+ expect(wrapper.find('.board-card-title').text()).toContain(issue.title);
+ });
+
+ it('includes issue base in link', () => {
+ expect(wrapper.find('.board-card-title a').attributes('href')).toContain('/test');
+ });
+
+ it('includes issue title on link', () => {
+ expect(wrapper.find('.board-card-title a').attributes('title')).toBe(issue.title);
+ });
+
+ it('does not render confidential icon', () => {
+ expect(wrapper.find('.fa-eye-flash').exists()).toBe(false);
+ });
+
+ it('renders confidential icon', done => {
+ wrapper.setProps({
+ issue: {
+ ...wrapper.props('issue'),
+ confidential: true,
+ },
+ });
+ wrapper.vm.$nextTick(() => {
+ expect(wrapper.find('.confidential-icon').exists()).toBe(true);
+ done();
+ });
+ });
+
+ it('renders issue ID with #', () => {
+ expect(wrapper.find('.board-card-number').text()).toContain(`#${issue.id}`);
+ });
+
+ describe('assignee', () => {
+ it('does not render assignee', () => {
+ expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(false);
+ });
+
+ describe('exists', () => {
+ beforeEach(done => {
+ wrapper.setProps({
+ issue: {
+ ...wrapper.props('issue'),
+ assignees: [user],
+ },
+ });
+
+ wrapper.vm.$nextTick(done);
+ });
+
+ it('renders assignee', () => {
+ expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(true);
+ });
+
+ it('sets title', () => {
+ expect(wrapper.find('.js-assignee-tooltip').text()).toContain(`${user.name}`);
+ });
+
+ it('sets users path', () => {
+ expect(wrapper.find('.board-card-assignee a').attributes('href')).toBe('/test');
+ });
+
+ it('renders avatar', () => {
+ expect(wrapper.find('.board-card-assignee img').exists()).toBe(true);
+ });
+ });
+
+ describe('assignee default avatar', () => {
+ beforeEach(done => {
+ wrapper.setProps({
+ issue: {
+ ...wrapper.props('issue'),
+ assignees: [
+ new ListAssignee(
+ {
+ id: 1,
+ name: 'testing 123',
+ username: 'test',
+ },
+ 'default_avatar',
+ ),
+ ],
+ },
+ });
+
+ wrapper.vm.$nextTick(done);
+ });
+
+ it('displays defaults avatar if users avatar is null', () => {
+ expect(wrapper.find('.board-card-assignee img').exists()).toBe(true);
+ expect(wrapper.find('.board-card-assignee img').attributes('src')).toBe(
+ 'default_avatar?width=24',
+ );
+ });
+ });
+ });
+
+ describe('multiple assignees', () => {
+ beforeEach(done => {
+ wrapper.setProps({
+ issue: {
+ ...wrapper.props('issue'),
+ assignees: [
+ new ListAssignee({
+ id: 2,
+ name: 'user2',
+ username: 'user2',
+ avatar: 'test_image',
+ }),
+ new ListAssignee({
+ id: 3,
+ name: 'user3',
+ username: 'user3',
+ avatar: 'test_image',
+ }),
+ new ListAssignee({
+ id: 4,
+ name: 'user4',
+ username: 'user4',
+ avatar: 'test_image',
+ }),
+ ],
+ },
+ });
+
+ wrapper.vm.$nextTick(done);
+ });
+
+ it('renders all three assignees', () => {
+ expect(wrapper.findAll('.board-card-assignee .avatar').length).toEqual(3);
+ });
+
+ describe('more than three assignees', () => {
+ beforeEach(done => {
+ const { assignees } = wrapper.props('issue');
+ assignees.push(
+ new ListAssignee({
+ id: 5,
+ name: 'user5',
+ username: 'user5',
+ avatar: 'test_image',
+ }),
+ );
+
+ wrapper.setProps({
+ issue: {
+ ...wrapper.props('issue'),
+ assignees,
+ },
+ });
+ wrapper.vm.$nextTick(done);
+ });
+
+ it('renders more avatar counter', () => {
+ expect(
+ wrapper
+ .find('.board-card-assignee .avatar-counter')
+ .text()
+ .trim(),
+ ).toEqual('+2');
+ });
+
+ it('renders two assignees', () => {
+ expect(wrapper.findAll('.board-card-assignee .avatar').length).toEqual(2);
+ });
+
+ it('renders 99+ avatar counter', done => {
+ const assignees = [
+ ...wrapper.props('issue').assignees,
+ ..._.range(5, 103).map(
+ i =>
+ new ListAssignee({
+ id: i,
+ name: 'name',
+ username: 'username',
+ avatar: 'test_image',
+ }),
+ ),
+ ];
+ wrapper.setProps({
+ issue: {
+ ...wrapper.props('issue'),
+ assignees,
+ },
+ });
+
+ wrapper.vm.$nextTick(() => {
+ expect(
+ wrapper
+ .find('.board-card-assignee .avatar-counter')
+ .text()
+ .trim(),
+ ).toEqual('99+');
+ done();
+ });
+ });
+ });
+ });
+
+ describe('labels', () => {
+ beforeEach(done => {
+ issue.addLabel(label1);
+ wrapper.setProps({ issue: { ...issue } });
+
+ wrapper.vm.$nextTick(done);
+ });
+
+ it('does not render list label but renders all other labels', () => {
+ expect(wrapper.findAll('.badge').length).toBe(1);
+ });
+
+ it('renders label', () => {
+ const nodes = wrapper
+ .findAll('.badge')
+ .wrappers.map(label => label.attributes('data-original-title'));
+
+ expect(nodes.includes(label1.description)).toBe(true);
+ });
+
+ it('sets label description as title', () => {
+ expect(wrapper.find('.badge').attributes('data-original-title')).toContain(
+ label1.description,
+ );
+ });
+
+ it('sets background color of button', () => {
+ const nodes = wrapper
+ .findAll('.badge')
+ .wrappers.map(label => label.element.style.backgroundColor);
+
+ expect(nodes.includes(label1.color)).toBe(true);
+ });
+
+ it('does not render label if label does not have an ID', done => {
+ issue.addLabel(
+ new ListLabel({
+ title: 'closed',
+ }),
+ );
+ wrapper.setProps({ issue: { ...issue } });
+ wrapper.vm
+ .$nextTick()
+ .then(() => {
+ expect(wrapper.findAll('.badge').length).toBe(1);
+ expect(wrapper.text()).not.toContain('closed');
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js
new file mode 100644
index 00000000000..38b2333e679
--- /dev/null
+++ b/spec/frontend/boards/stores/getters_spec.js
@@ -0,0 +1,21 @@
+import getters from '~/boards/stores/getters';
+
+describe('Boards - Getters', () => {
+ describe('getLabelToggleState', () => {
+ it('should return "on" when isShowingLabels is true', () => {
+ const state = {
+ isShowingLabels: true,
+ };
+
+ expect(getters.getLabelToggleState(state)).toBe('on');
+ });
+
+ it('should return "off" when isShowingLabels is false', () => {
+ const state = {
+ isShowingLabels: false,
+ };
+
+ expect(getters.getLabelToggleState(state)).toBe('off');
+ });
+ });
+});
diff --git a/spec/frontend/clusters/clusters_bundle_spec.js b/spec/frontend/clusters/clusters_bundle_spec.js
index 517d8781600..199e11401a9 100644
--- a/spec/frontend/clusters/clusters_bundle_spec.js
+++ b/spec/frontend/clusters/clusters_bundle_spec.js
@@ -10,8 +10,10 @@ import axios from '~/lib/utils/axios_utils';
import { loadHTMLFixture } from 'helpers/fixtures';
import { setTestTimeout } from 'helpers/timeout';
import $ from 'jquery';
+import initProjectSelectDropdown from '~/project_select';
jest.mock('~/lib/utils/poll');
+jest.mock('~/project_select');
const { INSTALLING, INSTALLABLE, INSTALLED, UNINSTALLING } = APPLICATION_STATUS;
@@ -44,6 +46,7 @@ describe('Clusters', () => {
afterEach(() => {
cluster.destroy();
mock.restore();
+ jest.clearAllMocks();
});
describe('class constructor', () => {
@@ -55,6 +58,10 @@ describe('Clusters', () => {
it('should call initPolling on construct', () => {
expect(cluster.initPolling).toHaveBeenCalled();
});
+
+ it('should call initProjectSelectDropdown on construct', () => {
+ expect(initProjectSelectDropdown).toHaveBeenCalled();
+ });
});
describe('toggle', () => {
@@ -279,16 +286,21 @@ describe('Clusters', () => {
});
describe('installApplication', () => {
- it.each(APPLICATIONS)('tries to install %s', applicationId => {
- jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce();
+ it.each(APPLICATIONS)('tries to install %s', (applicationId, done) => {
+ jest.spyOn(cluster.service, 'installApplication').mockResolvedValue();
cluster.store.state.applications[applicationId].status = INSTALLABLE;
- cluster.installApplication({ id: applicationId });
-
- expect(cluster.store.state.applications[applicationId].status).toEqual(INSTALLING);
- expect(cluster.store.state.applications[applicationId].requestReason).toEqual(null);
- expect(cluster.service.installApplication).toHaveBeenCalledWith(applicationId, undefined);
+ // eslint-disable-next-line promise/valid-params
+ cluster
+ .installApplication({ id: applicationId })
+ .then(() => {
+ expect(cluster.store.state.applications[applicationId].status).toEqual(INSTALLING);
+ expect(cluster.store.state.applications[applicationId].requestReason).toEqual(null);
+ expect(cluster.service.installApplication).toHaveBeenCalledWith(applicationId, undefined);
+ done();
+ })
+ .catch();
});
it('sets error request status when the request fails', () => {
diff --git a/spec/frontend/clusters/components/applications_spec.js b/spec/frontend/clusters/components/applications_spec.js
index fbcab078993..49bda9539fd 100644
--- a/spec/frontend/clusters/components/applications_spec.js
+++ b/spec/frontend/clusters/components/applications_spec.js
@@ -6,6 +6,7 @@ import { APPLICATIONS_MOCK_STATE } from '../services/mock_data';
import eventHub from '~/clusters/event_hub';
import { shallowMount } from '@vue/test-utils';
import KnativeDomainEditor from '~/clusters/components/knative_domain_editor.vue';
+import CrossplaneProviderStack from '~/clusters/components/crossplane_provider_stack.vue';
describe('Applications', () => {
let vm;
@@ -13,6 +14,10 @@ describe('Applications', () => {
beforeEach(() => {
Applications = Vue.extend(applications);
+
+ gon.features = gon.features || {};
+ gon.features.enableClusterApplicationElasticStack = true;
+ gon.features.enableClusterApplicationCrossplane = true;
});
afterEach(() => {
@@ -39,6 +44,10 @@ describe('Applications', () => {
expect(vm.$el.querySelector('.js-cluster-application-row-cert_manager')).not.toBeNull();
});
+ it('renders a row for Crossplane', () => {
+ expect(vm.$el.querySelector('.js-cluster-application-row-crossplane')).not.toBeNull();
+ });
+
it('renders a row for Prometheus', () => {
expect(vm.$el.querySelector('.js-cluster-application-row-prometheus')).not.toBeNull();
});
@@ -54,6 +63,10 @@ describe('Applications', () => {
it('renders a row for Knative', () => {
expect(vm.$el.querySelector('.js-cluster-application-row-knative')).not.toBeNull();
});
+
+ it('renders a row for Elastic Stack', () => {
+ expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull();
+ });
});
describe('Group cluster applications', () => {
@@ -76,6 +89,10 @@ describe('Applications', () => {
expect(vm.$el.querySelector('.js-cluster-application-row-cert_manager')).not.toBeNull();
});
+ it('renders a row for Crossplane', () => {
+ expect(vm.$el.querySelector('.js-cluster-application-row-crossplane')).not.toBeNull();
+ });
+
it('renders a row for Prometheus', () => {
expect(vm.$el.querySelector('.js-cluster-application-row-prometheus')).not.toBeNull();
});
@@ -91,6 +108,10 @@ describe('Applications', () => {
it('renders a row for Knative', () => {
expect(vm.$el.querySelector('.js-cluster-application-row-knative')).not.toBeNull();
});
+
+ it('renders a row for Elastic Stack', () => {
+ expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull();
+ });
});
describe('Instance cluster applications', () => {
@@ -113,6 +134,10 @@ describe('Applications', () => {
expect(vm.$el.querySelector('.js-cluster-application-row-cert_manager')).not.toBeNull();
});
+ it('renders a row for Crossplane', () => {
+ expect(vm.$el.querySelector('.js-cluster-application-row-crossplane')).not.toBeNull();
+ });
+
it('renders a row for Prometheus', () => {
expect(vm.$el.querySelector('.js-cluster-application-row-prometheus')).not.toBeNull();
});
@@ -128,6 +153,10 @@ describe('Applications', () => {
it('renders a row for Knative', () => {
expect(vm.$el.querySelector('.js-cluster-application-row-knative')).not.toBeNull();
});
+
+ it('renders a row for Elastic Stack', () => {
+ expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull();
+ });
});
describe('Ingress application', () => {
@@ -164,10 +193,12 @@ describe('Applications', () => {
},
helm: { title: 'Helm Tiller' },
cert_manager: { title: 'Cert-Manager' },
+ crossplane: { title: 'Crossplane', stack: '' },
runner: { title: 'GitLab Runner' },
prometheus: { title: 'Prometheus' },
jupyter: { title: 'JupyterHub', hostname: '' },
knative: { title: 'Knative', hostname: '' },
+ elastic_stack: { title: 'Elastic Stack', kibana_hostname: '' },
},
});
@@ -260,7 +291,11 @@ describe('Applications', () => {
},
});
- expect(vm.$el.querySelector('.js-hostname').getAttribute('readonly')).toEqual(null);
+ expect(
+ vm.$el
+ .querySelector('.js-cluster-application-row-jupyter .js-hostname')
+ .getAttribute('readonly'),
+ ).toEqual(null);
});
});
@@ -273,7 +308,9 @@ describe('Applications', () => {
},
});
- expect(vm.$el.querySelector('.js-hostname')).toBe(null);
+ expect(vm.$el.querySelector('.js-cluster-application-row-jupyter .js-hostname')).toBe(
+ null,
+ );
});
});
@@ -287,7 +324,11 @@ describe('Applications', () => {
},
});
- expect(vm.$el.querySelector('.js-hostname').getAttribute('readonly')).toEqual('readonly');
+ expect(
+ vm.$el
+ .querySelector('.js-cluster-application-row-jupyter .js-hostname')
+ .getAttribute('readonly'),
+ ).toEqual('readonly');
});
});
@@ -299,7 +340,9 @@ describe('Applications', () => {
});
it('does not render input', () => {
- expect(vm.$el.querySelector('.js-hostname')).toBe(null);
+ expect(vm.$el.querySelector('.js-cluster-application-row-jupyter .js-hostname')).toBe(
+ null,
+ );
});
it('renders disabled install button', () => {
@@ -361,4 +404,110 @@ describe('Applications', () => {
});
});
});
+
+ describe('Crossplane application', () => {
+ const propsData = {
+ applications: {
+ ...APPLICATIONS_MOCK_STATE,
+ crossplane: {
+ title: 'Crossplane',
+ stack: {
+ code: '',
+ },
+ },
+ },
+ };
+
+ let wrapper;
+ beforeEach(() => {
+ wrapper = shallowMount(Applications, { propsData });
+ });
+ afterEach(() => {
+ wrapper.destroy();
+ });
+ it('renders the correct Component', () => {
+ const crossplane = wrapper.find(CrossplaneProviderStack);
+ expect(crossplane.exists()).toBe(true);
+ });
+ });
+
+ describe('Elastic Stack application', () => {
+ describe('with ingress installed with ip & elastic stack installable', () => {
+ it('renders hostname active input', () => {
+ vm = mountComponent(Applications, {
+ applications: {
+ ...APPLICATIONS_MOCK_STATE,
+ ingress: {
+ title: 'Ingress',
+ status: 'installed',
+ externalIp: '1.1.1.1',
+ },
+ },
+ });
+
+ expect(
+ vm.$el
+ .querySelector('.js-cluster-application-row-elastic_stack .js-hostname')
+ .getAttribute('readonly'),
+ ).toEqual(null);
+ });
+ });
+
+ describe('with ingress installed without external ip', () => {
+ it('does not render hostname input', () => {
+ vm = mountComponent(Applications, {
+ applications: {
+ ...APPLICATIONS_MOCK_STATE,
+ ingress: { title: 'Ingress', status: 'installed' },
+ },
+ });
+
+ expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack .js-hostname')).toBe(
+ null,
+ );
+ });
+ });
+
+ describe('with ingress & elastic stack installed', () => {
+ it('renders readonly input', () => {
+ vm = mountComponent(Applications, {
+ applications: {
+ ...APPLICATIONS_MOCK_STATE,
+ ingress: { title: 'Ingress', status: 'installed', externalIp: '1.1.1.1' },
+ elastic_stack: { title: 'Elastic Stack', status: 'installed', kibana_hostname: '' },
+ },
+ });
+
+ expect(
+ vm.$el
+ .querySelector('.js-cluster-application-row-elastic_stack .js-hostname')
+ .getAttribute('readonly'),
+ ).toEqual('readonly');
+ });
+ });
+
+ describe('without ingress installed', () => {
+ beforeEach(() => {
+ vm = mountComponent(Applications, {
+ applications: APPLICATIONS_MOCK_STATE,
+ });
+ });
+
+ it('does not render input', () => {
+ expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack .js-hostname')).toBe(
+ null,
+ );
+ });
+
+ it('renders disabled install button', () => {
+ expect(
+ vm.$el
+ .querySelector(
+ '.js-cluster-application-row-elastic_stack .js-cluster-application-install-button',
+ )
+ .getAttribute('disabled'),
+ ).toEqual('disabled');
+ });
+ });
+ });
});
diff --git a/spec/frontend/clusters/services/crossplane_provider_stack_spec.js b/spec/frontend/clusters/services/crossplane_provider_stack_spec.js
new file mode 100644
index 00000000000..0d234822d7b
--- /dev/null
+++ b/spec/frontend/clusters/services/crossplane_provider_stack_spec.js
@@ -0,0 +1,78 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlDropdownItem } from '@gitlab/ui';
+import CrossplaneProviderStack from '~/clusters/components/crossplane_provider_stack.vue';
+
+describe('CrossplaneProviderStack component', () => {
+ let wrapper;
+
+ const defaultProps = {
+ stacks: [
+ {
+ name: 'Google Cloud Platform',
+ code: 'gcp',
+ },
+ {
+ name: 'Amazon Web Services',
+ code: 'aws',
+ },
+ ],
+ };
+
+ function createComponent(props = {}) {
+ const propsData = {
+ ...defaultProps,
+ ...props,
+ };
+
+ wrapper = shallowMount(CrossplaneProviderStack, {
+ propsData,
+ });
+ }
+
+ beforeEach(() => {
+ const crossplane = {
+ title: 'crossplane',
+ stack: '',
+ };
+ createComponent({ crossplane });
+ });
+
+ const findDropdownElements = () => wrapper.findAll(GlDropdownItem);
+ const findFirstDropdownElement = () => findDropdownElements().at(0);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders all of the available stacks in the dropdown', () => {
+ const dropdownElements = findDropdownElements();
+
+ expect(dropdownElements.length).toBe(defaultProps.stacks.length);
+
+ defaultProps.stacks.forEach((stack, index) =>
+ expect(dropdownElements.at(index).text()).toEqual(stack.name),
+ );
+ });
+
+ it('displays the correct label for the first dropdown item if a stack is selected', () => {
+ const crossplane = {
+ title: 'crossplane',
+ stack: 'gcp',
+ };
+ createComponent({ crossplane });
+ expect(wrapper.vm.dropdownText).toBe('Google Cloud Platform');
+ });
+
+ it('emits the "set" event with the selected stack value', () => {
+ const crossplane = {
+ title: 'crossplane',
+ stack: 'gcp',
+ };
+ createComponent({ crossplane });
+ findFirstDropdownElement().vm.$emit('click');
+ expect(wrapper.emitted().set[0][0].code).toEqual('gcp');
+ });
+ it('it renders the correct dropdown text when no stack is selected', () => {
+ expect(wrapper.vm.dropdownText).toBe('Select Stack');
+ });
+});
diff --git a/spec/frontend/clusters/services/mock_data.js b/spec/frontend/clusters/services/mock_data.js
index 41ad398e924..016f5a259b5 100644
--- a/spec/frontend/clusters/services/mock_data.js
+++ b/spec/frontend/clusters/services/mock_data.js
@@ -52,6 +52,18 @@ const CLUSTERS_MOCK_DATA = {
email: 'test@example.com',
can_uninstall: false,
},
+ {
+ name: 'crossplane',
+ status: APPLICATION_STATUS.ERROR,
+ status_reason: 'Cannot connect',
+ can_uninstall: false,
+ },
+ {
+ name: 'elastic_stack',
+ status: APPLICATION_STATUS.ERROR,
+ status_reason: 'Cannot connect',
+ can_uninstall: false,
+ },
],
},
},
@@ -98,6 +110,17 @@ const CLUSTERS_MOCK_DATA = {
status_reason: 'Cannot connect',
email: 'test@example.com',
},
+ {
+ name: 'crossplane',
+ status: APPLICATION_STATUS.ERROR,
+ status_reason: 'Cannot connect',
+ stack: 'gcp',
+ },
+ {
+ name: 'elastic_stack',
+ status: APPLICATION_STATUS.ERROR,
+ status_reason: 'Cannot connect',
+ },
],
},
},
@@ -105,11 +128,13 @@ const CLUSTERS_MOCK_DATA = {
POST: {
'/gitlab-org/gitlab-shell/clusters/1/applications/helm': {},
'/gitlab-org/gitlab-shell/clusters/1/applications/ingress': {},
+ '/gitlab-org/gitlab-shell/clusters/1/applications/crossplane': {},
'/gitlab-org/gitlab-shell/clusters/1/applications/cert_manager': {},
'/gitlab-org/gitlab-shell/clusters/1/applications/runner': {},
'/gitlab-org/gitlab-shell/clusters/1/applications/prometheus': {},
'/gitlab-org/gitlab-shell/clusters/1/applications/jupyter': {},
'/gitlab-org/gitlab-shell/clusters/1/applications/knative': {},
+ '/gitlab-org/gitlab-shell/clusters/1/applications/elastic_stack': {},
},
};
@@ -126,11 +151,13 @@ const DEFAULT_APPLICATION_STATE = {
const APPLICATIONS_MOCK_STATE = {
helm: { title: 'Helm Tiller', status: 'installable' },
ingress: { title: 'Ingress', status: 'installable' },
+ crossplane: { title: 'Crossplane', status: 'installable', stack: '' },
cert_manager: { title: 'Cert-Manager', status: 'installable' },
runner: { title: 'GitLab Runner' },
prometheus: { title: 'Prometheus' },
jupyter: { title: 'JupyterHub', status: 'installable', hostname: '' },
knative: { title: 'Knative ', status: 'installable', hostname: '' },
+ elastic_stack: { title: 'Elastic Stack', status: 'installable', kibana_hostname: '' },
};
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 5ee06eb44c9..71d4daceb75 100644
--- a/spec/frontend/clusters/stores/clusters_store_spec.js
+++ b/spec/frontend/clusters/stores/clusters_store_spec.js
@@ -71,6 +71,7 @@ describe('Clusters Store', () => {
uninstallable: false,
uninstallSuccessful: false,
uninstallFailed: false,
+ validationError: null,
},
ingress: {
title: 'Ingress',
@@ -84,6 +85,7 @@ describe('Clusters Store', () => {
uninstallable: false,
uninstallSuccessful: false,
uninstallFailed: false,
+ validationError: null,
},
runner: {
title: 'GitLab Runner',
@@ -100,6 +102,7 @@ describe('Clusters Store', () => {
uninstallable: false,
uninstallSuccessful: false,
uninstallFailed: false,
+ validationError: null,
},
prometheus: {
title: 'Prometheus',
@@ -111,6 +114,7 @@ describe('Clusters Store', () => {
uninstallable: false,
uninstallSuccessful: false,
uninstallFailed: false,
+ validationError: null,
},
jupyter: {
title: 'JupyterHub',
@@ -123,6 +127,7 @@ describe('Clusters Store', () => {
uninstallable: false,
uninstallSuccessful: false,
uninstallFailed: false,
+ validationError: null,
},
knative: {
title: 'Knative',
@@ -140,6 +145,7 @@ describe('Clusters Store', () => {
uninstallFailed: false,
updateSuccessful: false,
updateFailed: false,
+ validationError: null,
},
cert_manager: {
title: 'Cert-Manager',
@@ -152,6 +158,32 @@ describe('Clusters Store', () => {
uninstallable: false,
uninstallSuccessful: false,
uninstallFailed: false,
+ validationError: null,
+ },
+ elastic_stack: {
+ title: 'Elastic Stack',
+ status: APPLICATION_STATUS.INSTALLABLE,
+ installFailed: true,
+ statusReason: mockResponseData.applications[7].status_reason,
+ requestReason: null,
+ kibana_hostname: '',
+ installed: false,
+ uninstallable: false,
+ uninstallSuccessful: false,
+ uninstallFailed: false,
+ validationError: null,
+ },
+ crossplane: {
+ title: 'Crossplane',
+ status: APPLICATION_STATUS.INSTALLABLE,
+ installFailed: true,
+ statusReason: mockResponseData.applications[8].status_reason,
+ requestReason: null,
+ installed: false,
+ uninstallable: false,
+ uninstallSuccessful: false,
+ uninstallFailed: false,
+ validationError: null,
},
},
environments: [],
@@ -183,5 +215,16 @@ describe('Clusters Store', () => {
`jupyter.${store.state.applications.ingress.externalIp}.nip.io`,
);
});
+
+ it('sets default hostname for elastic stack when ingress has a ip address', () => {
+ const mockResponseData =
+ CLUSTERS_MOCK_DATA.GET['/gitlab-org/gitlab-shell/clusters/2/status.json'].data;
+
+ store.updateStateFromServer(mockResponseData);
+
+ expect(store.state.applications.elastic_stack.kibana_hostname).toEqual(
+ `kibana.${store.state.applications.ingress.externalIp}.nip.io`,
+ );
+ });
});
});
diff --git a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap
index 47bdc677068..3c603c7f573 100644
--- a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap
+++ b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap
@@ -26,7 +26,7 @@ exports[`Confidential merge request project form group component renders empty s
>
fork the project
</a>
- and set the forks visiblity to private.
+ and set the forks visibility to private.
</span>
<gllink-stub
@@ -76,7 +76,7 @@ exports[`Confidential merge request project form group component renders fork dr
>
fork the project
</a>
- and set the forks visiblity to private.
+ and set the forks visibility to private.
</span>
<gllink-stub
diff --git a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap
new file mode 100644
index 00000000000..b87afdd7eb4
--- /dev/null
+++ b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap
@@ -0,0 +1,47 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Contributors charts should render charts when loading completed and there is chart data 1`] = `
+<div>
+ <div
+ class="contributors-charts"
+ >
+ <h4>
+ Commits to master
+ </h4>
+
+ <span>
+ Excluding merge commits. Limited to 6,000 commits.
+ </span>
+
+ <div>
+ <glareachart-stub
+ data="[object Object]"
+ height="264"
+ option="[object Object]"
+ />
+ </div>
+
+ <div
+ class="row"
+ >
+ <div
+ class="col-6"
+ >
+ <h4>
+ John
+ </h4>
+
+ <p>
+ 2 commits (jawnnypoo@gmail.com)
+ </p>
+
+ <glareachart-stub
+ data="[object Object]"
+ height="216"
+ option="[object Object]"
+ />
+ </div>
+ </div>
+ </div>
+</div>
+`;
diff --git a/spec/frontend/contributors/component/contributors_spec.js b/spec/frontend/contributors/component/contributors_spec.js
new file mode 100644
index 00000000000..fdba09ed26c
--- /dev/null
+++ b/spec/frontend/contributors/component/contributors_spec.js
@@ -0,0 +1,69 @@
+import Vue from 'vue';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { createStore } from '~/contributors/stores';
+import axios from '~/lib/utils/axios_utils';
+import MockAdapter from 'axios-mock-adapter';
+import ContributorsCharts from '~/contributors/components/contributors.vue';
+
+const localVue = createLocalVue();
+let wrapper;
+let mock;
+let store;
+const Component = Vue.extend(ContributorsCharts);
+const endpoint = 'contributors';
+const branch = 'master';
+const chartData = [
+ { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-05-05' },
+ { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-03-03' },
+];
+
+function factory() {
+ mock = new MockAdapter(axios);
+ jest.spyOn(axios, 'get');
+ mock.onGet().reply(200, chartData);
+ store = createStore();
+
+ wrapper = shallowMount(Component, {
+ propsData: {
+ endpoint,
+ branch,
+ },
+ stubs: {
+ GlLoadingIcon: true,
+ GlAreaChart: true,
+ },
+ store,
+ });
+}
+
+describe('Contributors charts', () => {
+ beforeEach(() => {
+ factory();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ wrapper.destroy();
+ });
+
+ it('should fetch chart data when mounted', () => {
+ expect(axios.get).toHaveBeenCalledWith(endpoint);
+ });
+
+ it('should display loader whiled loading data', () => {
+ wrapper.vm.$store.state.loading = true;
+ return localVue.nextTick(() => {
+ expect(wrapper.find('.contributors-loader').exists()).toBe(true);
+ });
+ });
+
+ it('should render charts when loading completed and there is chart data', () => {
+ wrapper.vm.$store.state.loading = false;
+ wrapper.vm.$store.state.chartData = chartData;
+ return localVue.nextTick(() => {
+ expect(wrapper.find('.contributors-loader').exists()).toBe(false);
+ expect(wrapper.find('.contributors-charts').exists()).toBe(true);
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+});
diff --git a/spec/frontend/contributors/store/actions_spec.js b/spec/frontend/contributors/store/actions_spec.js
new file mode 100644
index 00000000000..bb017e0ac0f
--- /dev/null
+++ b/spec/frontend/contributors/store/actions_spec.js
@@ -0,0 +1,60 @@
+import axios from '~/lib/utils/axios_utils';
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import flashError from '~/flash';
+import * as actions from '~/contributors/stores/actions';
+import * as types from '~/contributors/stores/mutation_types';
+
+jest.mock('~/flash.js');
+
+describe('Contributors store actions', () => {
+ describe('fetchChartData', () => {
+ let mock;
+ const endpoint = '/contributors';
+ const chartData = { '2017-11': 0, '2017-12': 2 };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ it('should commit SET_CHART_DATA with received response', done => {
+ mock.onGet().reply(200, chartData);
+
+ testAction(
+ actions.fetchChartData,
+ { endpoint },
+ {},
+ [
+ { type: types.SET_LOADING_STATE, payload: true },
+ { type: types.SET_CHART_DATA, payload: chartData },
+ { type: types.SET_LOADING_STATE, payload: false },
+ ],
+ [],
+ () => {
+ mock.restore();
+ done();
+ },
+ );
+ });
+
+ it('should show flash on API error', done => {
+ mock.onGet().reply(400, 'Not Found');
+
+ testAction(
+ actions.fetchChartData,
+ { endpoint },
+ {},
+ [{ type: types.SET_LOADING_STATE, payload: true }],
+ [],
+ () => {
+ expect(flashError).toHaveBeenCalledWith(expect.stringMatching('error'));
+ mock.restore();
+ done();
+ },
+ );
+ });
+ });
+});
+
+// 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
new file mode 100644
index 00000000000..62ae9b36f87
--- /dev/null
+++ b/spec/frontend/contributors/store/getters_spec.js
@@ -0,0 +1,73 @@
+import * as getters from '~/contributors/stores/getters';
+
+describe('Contributors Store Getters', () => {
+ const state = {};
+
+ describe('showChart', () => {
+ it('should NOT show chart if loading', () => {
+ state.loading = true;
+
+ expect(getters.showChart(state)).toEqual(false);
+ });
+
+ it('should NOT show chart there is not data', () => {
+ state.loading = false;
+ state.chartData = null;
+
+ expect(getters.showChart(state)).toEqual(false);
+ });
+
+ it('should show the chart in case loading complated and there is data', () => {
+ state.loading = false;
+ state.chartData = true;
+
+ expect(getters.showChart(state)).toEqual(true);
+ });
+
+ describe('parsedData', () => {
+ let parsed;
+
+ beforeAll(() => {
+ state.chartData = [
+ { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-05-05' },
+ { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-05-05' },
+ { author_name: 'Carlson', author_email: 'jawnnypoo@gmail.com', date: '2019-03-03' },
+ { author_name: 'Carlson', author_email: 'jawnnypoo@gmail.com', date: '2019-05-05' },
+ { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-04-04' },
+ { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-04-04' },
+ { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-03-03' },
+ ];
+ parsed = getters.parsedData(state);
+ });
+
+ it('should group contributions by date ', () => {
+ expect(parsed.total).toMatchObject({ '2019-05-05': 3, '2019-03-03': 2, '2019-04-04': 2 });
+ });
+
+ it('should group contributions by author ', () => {
+ expect(parsed.byAuthor).toMatchObject({
+ Carlson: {
+ email: 'jawnnypoo@gmail.com',
+ commits: 2,
+ dates: {
+ '2019-03-03': 1,
+ '2019-05-05': 1,
+ },
+ },
+ John: {
+ email: 'jawnnypoo@gmail.com',
+ commits: 5,
+ dates: {
+ '2019-03-03': 1,
+ '2019-04-04': 2,
+ '2019-05-05': 2,
+ },
+ },
+ });
+ });
+ });
+ });
+});
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/spec/frontend/contributors/store/mutations_spec.js b/spec/frontend/contributors/store/mutations_spec.js
new file mode 100644
index 00000000000..e9e756d4a65
--- /dev/null
+++ b/spec/frontend/contributors/store/mutations_spec.js
@@ -0,0 +1,40 @@
+import state from '~/contributors/stores/state';
+import mutations from '~/contributors/stores/mutations';
+import * as types from '~/contributors/stores/mutation_types';
+
+describe('Contributors mutations', () => {
+ let stateCopy;
+
+ beforeEach(() => {
+ stateCopy = state();
+ });
+
+ describe('SET_LOADING_STATE', () => {
+ it('should set loading flag', () => {
+ const loading = true;
+ mutations[types.SET_LOADING_STATE](stateCopy, loading);
+
+ expect(stateCopy.loading).toEqual(loading);
+ });
+ });
+
+ describe('SET_CHART_DATA', () => {
+ const chartData = { '2017-11': 0, '2017-12': 2 };
+
+ it('should set chart data', () => {
+ mutations[types.SET_CHART_DATA](stateCopy, chartData);
+
+ expect(stateCopy.chartData).toEqual(chartData);
+ });
+ });
+
+ describe('SET_ACTIVE_BRANCH', () => {
+ it('should set search query', () => {
+ const branch = 'feature-branch';
+
+ mutations[types.SET_ACTIVE_BRANCH](stateCopy, branch);
+
+ expect(stateCopy.branch).toEqual(branch);
+ });
+ });
+});
diff --git a/spec/frontend/contributors/utils_spec.js b/spec/frontend/contributors/utils_spec.js
new file mode 100644
index 00000000000..a2b9154329b
--- /dev/null
+++ b/spec/frontend/contributors/utils_spec.js
@@ -0,0 +1,21 @@
+import * as utils from '~/contributors/utils';
+
+describe('Contributors Util Functions', () => {
+ describe('xAxisLabelFormatter', () => {
+ it('should return year if the date is in January', () => {
+ expect(utils.xAxisLabelFormatter(new Date('01-12-2019'))).toEqual('2019');
+ });
+
+ it('should return month name otherwise', () => {
+ expect(utils.xAxisLabelFormatter(new Date('12-02-2019'))).toEqual('Dec');
+ expect(utils.xAxisLabelFormatter(new Date('07-12-2019'))).toEqual('Jul');
+ });
+ });
+
+ describe('dateFormatter', () => {
+ it('should format provided date to YYYY-MM-DD format', () => {
+ expect(utils.dateFormatter(new Date('December 17, 1995 03:24:00'))).toEqual('1995-12-17');
+ expect(utils.dateFormatter(new Date(1565308800000))).toEqual('2019-08-09');
+ });
+ });
+});
diff --git a/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js b/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js
index 366c2fc7b26..efbe2635fcc 100644
--- a/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js
+++ b/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js
@@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import ClusterFormDropdown from '~/create_cluster/eks_cluster/components/cluster_form_dropdown.vue';
import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue';
-import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
+import { GlIcon } from '@gitlab/ui';
describe('ClusterFormDropdown', () => {
let vm;
@@ -41,24 +41,50 @@ describe('ClusterFormDropdown', () => {
.trigger('click');
});
- it('displays selected item label', () => {
- expect(vm.find(DropdownButton).props('toggleText')).toEqual(secondItem.name);
+ it('emits input event with selected item', () => {
+ expect(vm.emitted('input')[0]).toEqual([secondItem.value]);
+ });
+ });
+
+ describe('when multiple items are selected', () => {
+ const value = [1];
+
+ beforeEach(() => {
+ vm.setProps({ items, multiple: true, value });
+ vm.findAll('.js-dropdown-item')
+ .at(0)
+ .trigger('click');
+ vm.findAll('.js-dropdown-item')
+ .at(1)
+ .trigger('click');
+ });
+
+ it('emits input event with an array of selected items', () => {
+ expect(vm.emitted('input')[1]).toEqual([[firstItem.value, secondItem.value]]);
+ });
+ });
+
+ describe('when multiple items can be selected', () => {
+ beforeEach(() => {
+ vm.setProps({ items, multiple: true, value: firstItem.value });
});
- it('sets selected value to dropdown hidden input', () => {
- expect(vm.find(DropdownHiddenInput).props('value')).toEqual(secondItem.value);
+ it('displays a checked GlIcon next to the item', () => {
+ expect(vm.find(GlIcon).is('.invisible')).toBe(false);
+ expect(vm.find(GlIcon).props('name')).toBe('mobile-issue-close');
});
});
describe('when an item is selected and has a custom label property', () => {
it('displays selected item custom label', () => {
const labelProperty = 'customLabel';
- const selectedItem = { [labelProperty]: 'Name' };
+ const label = 'Name';
+ const currentValue = 1;
+ const customLabelItems = [{ [labelProperty]: label, value: currentValue }];
- vm.setProps({ labelProperty });
- vm.setData({ selectedItem });
+ vm.setProps({ labelProperty, items: customLabelItems, value: currentValue });
- expect(vm.find(DropdownButton).props('toggleText')).toEqual(selectedItem[labelProperty]);
+ expect(vm.find(DropdownButton).props('toggleText')).toEqual(label);
});
});
diff --git a/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js b/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js
new file mode 100644
index 00000000000..4bf3ac430f5
--- /dev/null
+++ b/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js
@@ -0,0 +1,91 @@
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+
+import CreateEksCluster from '~/create_cluster/eks_cluster/components/create_eks_cluster.vue';
+import EksClusterConfigurationForm from '~/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue';
+import ServiceCredentialsForm from '~/create_cluster/eks_cluster/components/service_credentials_form.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('CreateEksCluster', () => {
+ let vm;
+ let state;
+ const gitlabManagedClusterHelpPath = 'gitlab-managed-cluster-help-path';
+ const accountAndExternalIdsHelpPath = 'account-and-external-id-help-path';
+ const createRoleArnHelpPath = 'role-arn-help-path';
+ const kubernetesIntegrationHelpPath = 'kubernetes-integration';
+ const externalLinkIcon = 'external-link';
+
+ beforeEach(() => {
+ state = { hasCredentials: false };
+ const store = new Vuex.Store({
+ state,
+ });
+
+ vm = shallowMount(CreateEksCluster, {
+ propsData: {
+ gitlabManagedClusterHelpPath,
+ accountAndExternalIdsHelpPath,
+ createRoleArnHelpPath,
+ externalLinkIcon,
+ kubernetesIntegrationHelpPath,
+ },
+ localVue,
+ store,
+ });
+ });
+ afterEach(() => vm.destroy());
+
+ describe('when credentials are provided', () => {
+ beforeEach(() => {
+ state.hasCredentials = true;
+ });
+
+ it('displays eks cluster configuration form when credentials are valid', () => {
+ expect(vm.find(EksClusterConfigurationForm).exists()).toBe(true);
+ });
+
+ describe('passes to the cluster configuration form', () => {
+ it('help url for kubernetes integration documentation', () => {
+ expect(vm.find(EksClusterConfigurationForm).props('gitlabManagedClusterHelpPath')).toBe(
+ gitlabManagedClusterHelpPath,
+ );
+ });
+
+ it('help url for gitlab managed cluster documentation', () => {
+ expect(vm.find(EksClusterConfigurationForm).props('kubernetesIntegrationHelpPath')).toBe(
+ kubernetesIntegrationHelpPath,
+ );
+ });
+ });
+ });
+
+ describe('when credentials are invalid', () => {
+ beforeEach(() => {
+ state.hasCredentials = false;
+ });
+
+ it('displays service credentials form', () => {
+ expect(vm.find(ServiceCredentialsForm).exists()).toBe(true);
+ });
+
+ describe('passes to the service credentials form', () => {
+ it('help url for account and external ids', () => {
+ expect(vm.find(ServiceCredentialsForm).props('accountAndExternalIdsHelpPath')).toBe(
+ accountAndExternalIdsHelpPath,
+ );
+ });
+
+ it('external link icon', () => {
+ expect(vm.find(ServiceCredentialsForm).props('externalLinkIcon')).toBe(externalLinkIcon);
+ });
+
+ it('help url to create a role ARN', () => {
+ expect(vm.find(ServiceCredentialsForm).props('createRoleArnHelpPath')).toBe(
+ createRoleArnHelpPath,
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js
index 69290f6dfa9..25d613d64ed 100644
--- a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js
+++ b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js
@@ -4,7 +4,6 @@ import Vue from 'vue';
import { GlFormCheckbox } from '@gitlab/ui';
import EksClusterConfigurationForm from '~/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue';
-import RegionDropdown from '~/create_cluster/eks_cluster/components/region_dropdown.vue';
import eksClusterFormState from '~/create_cluster/eks_cluster/store/state';
import clusterDropdownStoreState from '~/create_cluster/eks_cluster/store/cluster_dropdown/state';
@@ -21,17 +20,21 @@ describe('EksClusterConfigurationForm', () => {
let subnetsState;
let keyPairsState;
let securityGroupsState;
+ let instanceTypesState;
let vpcsActions;
let rolesActions;
let regionsActions;
let subnetsActions;
let keyPairsActions;
let securityGroupsActions;
+ let instanceTypesActions;
let vm;
beforeEach(() => {
state = eksClusterFormState();
actions = {
+ signOut: jest.fn(),
+ createCluster: jest.fn(),
setClusterName: jest.fn(),
setEnvironmentScope: jest.fn(),
setKubernetesVersion: jest.fn(),
@@ -41,6 +44,8 @@ describe('EksClusterConfigurationForm', () => {
setRole: jest.fn(),
setKeyPair: jest.fn(),
setSecurityGroup: jest.fn(),
+ setInstanceType: jest.fn(),
+ setNodeCount: jest.fn(),
setGitlabManagedCluster: jest.fn(),
};
regionsActions = {
@@ -61,6 +66,9 @@ describe('EksClusterConfigurationForm', () => {
securityGroupsActions = {
fetchItems: jest.fn(),
};
+ instanceTypesActions = {
+ fetchItems: jest.fn(),
+ };
rolesState = {
...clusterDropdownStoreState(),
};
@@ -79,6 +87,9 @@ describe('EksClusterConfigurationForm', () => {
securityGroupsState = {
...clusterDropdownStoreState(),
};
+ instanceTypesState = {
+ ...clusterDropdownStoreState(),
+ };
store = new Vuex.Store({
state,
actions,
@@ -113,6 +124,11 @@ describe('EksClusterConfigurationForm', () => {
state: securityGroupsState,
actions: securityGroupsActions,
},
+ instanceTypes: {
+ namespaced: true,
+ state: instanceTypesState,
+ actions: instanceTypesActions,
+ },
},
});
});
@@ -124,6 +140,7 @@ describe('EksClusterConfigurationForm', () => {
propsData: {
gitlabManagedClusterHelpPath: '',
kubernetesIntegrationHelpPath: '',
+ externalLinkIcon: '',
},
});
});
@@ -132,15 +149,34 @@ describe('EksClusterConfigurationForm', () => {
vm.destroy();
});
+ const setAllConfigurationFields = () => {
+ store.replaceState({
+ ...state,
+ clusterName: 'cluster name',
+ environmentScope: '*',
+ selectedRegion: 'region',
+ selectedRole: 'role',
+ selectedKeyPair: 'key pair',
+ selectedVpc: 'vpc',
+ selectedSubnet: 'subnet',
+ selectedSecurityGroup: 'group',
+ selectedInstanceType: 'small-1',
+ });
+ };
+
+ const findSignOutButton = () => vm.find('.js-sign-out');
+ const findCreateClusterButton = () => vm.find('.js-create-cluster');
const findClusterNameInput = () => vm.find('[id=eks-cluster-name]');
const findEnvironmentScopeInput = () => vm.find('[id=eks-environment-scope]');
const findKubernetesVersionDropdown = () => vm.find('[field-id="eks-kubernetes-version"]');
- const findRegionDropdown = () => vm.find(RegionDropdown);
+ const findRegionDropdown = () => vm.find('[field-id="eks-region"]');
const findKeyPairDropdown = () => vm.find('[field-id="eks-key-pair"]');
const findVpcDropdown = () => vm.find('[field-id="eks-vpc"]');
const findSubnetDropdown = () => vm.find('[field-id="eks-subnet"]');
const findRoleDropdown = () => vm.find('[field-id="eks-role"]');
const findSecurityGroupDropdown = () => vm.find('[field-id="eks-security-group"]');
+ const findInstanceTypeDropdown = () => vm.find('[field-id="eks-instance-type"');
+ const findNodeCountInput = () => vm.find('[id="eks-node-count"]');
const findGitlabManagedClusterCheckbox = () => vm.find(GlFormCheckbox);
describe('when mounted', () => {
@@ -151,6 +187,15 @@ describe('EksClusterConfigurationForm', () => {
it('fetches available roles', () => {
expect(rolesActions.fetchItems).toHaveBeenCalled();
});
+
+ it('fetches available instance types', () => {
+ expect(instanceTypesActions.fetchItems).toHaveBeenCalled();
+ });
+ });
+
+ it('dispatches signOut action when sign out button is clicked', () => {
+ findSignOutButton().trigger('click');
+ expect(actions.signOut).toHaveBeenCalled();
});
it('sets isLoadingRoles to RoleDropdown loading property', () => {
@@ -180,11 +225,13 @@ describe('EksClusterConfigurationForm', () => {
});
it('sets regions to RegionDropdown regions property', () => {
- expect(findRegionDropdown().props('regions')).toBe(regionsState.items);
+ expect(findRegionDropdown().props('items')).toBe(regionsState.items);
});
it('sets loadingRegionsError to RegionDropdown error property', () => {
- expect(findRegionDropdown().props('error')).toBe(regionsState.loadingItemsError);
+ regionsState.loadingItemsError = new Error();
+
+ expect(findRegionDropdown().props('hasErrors')).toEqual(true);
});
it('disables KeyPairDropdown when no region is selected', () => {
@@ -329,6 +376,34 @@ describe('EksClusterConfigurationForm', () => {
undefined,
);
});
+
+ it('cleans selected vpc', () => {
+ expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc: null }, undefined);
+ });
+
+ it('cleans selected key pair', () => {
+ expect(actions.setKeyPair).toHaveBeenCalledWith(
+ expect.anything(),
+ { keyPair: null },
+ undefined,
+ );
+ });
+
+ it('cleans selected subnet', () => {
+ expect(actions.setSubnet).toHaveBeenCalledWith(
+ expect.anything(),
+ { subnet: null },
+ undefined,
+ );
+ });
+
+ it('cleans selected security group', () => {
+ expect(actions.setSecurityGroup).toHaveBeenCalledWith(
+ expect.anything(),
+ { securityGroup: null },
+ undefined,
+ );
+ });
});
it('dispatches setClusterName when cluster name input changes', () => {
@@ -381,8 +456,10 @@ describe('EksClusterConfigurationForm', () => {
describe('when vpc is selected', () => {
const vpc = { name: 'vpc-1' };
+ const region = 'east-1';
beforeEach(() => {
+ state.selectedRegion = region;
findVpcDropdown().vm.$emit('input', vpc);
});
@@ -390,14 +467,34 @@ describe('EksClusterConfigurationForm', () => {
expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc }, undefined);
});
+ it('cleans selected subnet', () => {
+ expect(actions.setSubnet).toHaveBeenCalledWith(
+ expect.anything(),
+ { subnet: null },
+ undefined,
+ );
+ });
+
+ it('cleans selected security group', () => {
+ expect(actions.setSecurityGroup).toHaveBeenCalledWith(
+ expect.anything(),
+ { securityGroup: null },
+ undefined,
+ );
+ });
+
it('dispatches fetchSubnets action', () => {
- expect(subnetsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { vpc }, undefined);
+ expect(subnetsActions.fetchItems).toHaveBeenCalledWith(
+ expect.anything(),
+ { vpc, region },
+ undefined,
+ );
});
it('dispatches fetchSecurityGroups action', () => {
expect(securityGroupsActions.fetchItems).toHaveBeenCalledWith(
expect.anything(),
- { vpc },
+ { vpc, region },
undefined,
);
});
@@ -454,4 +551,76 @@ describe('EksClusterConfigurationForm', () => {
);
});
});
+
+ describe('when instance type is selected', () => {
+ const instanceType = 'small-1';
+
+ beforeEach(() => {
+ findInstanceTypeDropdown().vm.$emit('input', instanceType);
+ });
+
+ it('dispatches setInstanceType action', () => {
+ expect(actions.setInstanceType).toHaveBeenCalledWith(
+ expect.anything(),
+ { instanceType },
+ undefined,
+ );
+ });
+ });
+
+ it('dispatches setNodeCount when node count input changes', () => {
+ const nodeCount = 5;
+
+ findNodeCountInput().vm.$emit('input', nodeCount);
+
+ expect(actions.setNodeCount).toHaveBeenCalledWith(expect.anything(), { nodeCount }, undefined);
+ });
+
+ describe('when all cluster configuration fields are set', () => {
+ beforeEach(() => {
+ setAllConfigurationFields();
+ });
+
+ it('enables create cluster button', () => {
+ expect(findCreateClusterButton().props('disabled')).toBe(false);
+ });
+ });
+
+ describe('when at least one cluster configuration field is not set', () => {
+ beforeEach(() => {
+ setAllConfigurationFields();
+ store.replaceState({
+ ...state,
+ clusterName: '',
+ });
+ });
+
+ it('disables create cluster button', () => {
+ expect(findCreateClusterButton().props('disabled')).toBe(true);
+ });
+ });
+
+ describe('when isCreatingCluster', () => {
+ beforeEach(() => {
+ setAllConfigurationFields();
+ store.replaceState({
+ ...state,
+ isCreatingCluster: true,
+ });
+ });
+
+ it('sets create cluster button as loading', () => {
+ expect(findCreateClusterButton().props('loading')).toBe(true);
+ });
+ });
+
+ describe('clicking create cluster button', () => {
+ beforeEach(() => {
+ findCreateClusterButton().vm.$emit('click');
+ });
+
+ it('dispatches createCluster action', () => {
+ expect(actions.createCluster).toHaveBeenCalled();
+ });
+ });
});
diff --git a/spec/frontend/create_cluster/eks_cluster/components/region_dropdown_spec.js b/spec/frontend/create_cluster/eks_cluster/components/region_dropdown_spec.js
deleted file mode 100644
index 0ebb5026a4b..00000000000
--- a/spec/frontend/create_cluster/eks_cluster/components/region_dropdown_spec.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-
-import ClusterFormDropdown from '~/create_cluster/eks_cluster/components/cluster_form_dropdown.vue';
-import RegionDropdown from '~/create_cluster/eks_cluster/components/region_dropdown.vue';
-
-describe('RegionDropdown', () => {
- let vm;
-
- const getClusterFormDropdown = () => vm.find(ClusterFormDropdown);
-
- beforeEach(() => {
- vm = shallowMount(RegionDropdown);
- });
- afterEach(() => vm.destroy());
-
- it('renders a cluster-form-dropdown', () => {
- expect(getClusterFormDropdown().exists()).toBe(true);
- });
-
- it('sets regions to cluster-form-dropdown items property', () => {
- const regions = [{ name: 'basic' }];
-
- vm.setProps({ regions });
-
- expect(getClusterFormDropdown().props('items')).toEqual(regions);
- });
-
- it('sets a loading text', () => {
- expect(getClusterFormDropdown().props('loadingText')).toEqual('Loading Regions');
- });
-
- it('sets a placeholder', () => {
- expect(getClusterFormDropdown().props('placeholder')).toEqual('Select a region');
- });
-
- it('sets an empty results text', () => {
- expect(getClusterFormDropdown().props('emptyText')).toEqual('No region found');
- });
-
- it('sets a search field placeholder', () => {
- expect(getClusterFormDropdown().props('searchFieldPlaceholder')).toEqual('Search regions');
- });
-
- it('sets hasErrors property', () => {
- vm.setProps({ error: {} });
-
- expect(getClusterFormDropdown().props('hasErrors')).toEqual(true);
- });
-
- it('sets an error message', () => {
- expect(getClusterFormDropdown().props('errorMessage')).toEqual(
- 'Could not load regions from your AWS account',
- );
- });
-});
diff --git a/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js
new file mode 100644
index 00000000000..0be723b48f0
--- /dev/null
+++ b/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js
@@ -0,0 +1,117 @@
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+
+import ServiceCredentialsForm from '~/create_cluster/eks_cluster/components/service_credentials_form.vue';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
+
+import eksClusterState from '~/create_cluster/eks_cluster/store/state';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('ServiceCredentialsForm', () => {
+ let vm;
+ let state;
+ let createRoleAction;
+ const accountId = 'accountId';
+ const externalId = 'externalId';
+
+ beforeEach(() => {
+ state = Object.assign(eksClusterState(), {
+ accountId,
+ externalId,
+ });
+ createRoleAction = jest.fn();
+
+ const store = new Vuex.Store({
+ state,
+ actions: {
+ createRole: createRoleAction,
+ },
+ });
+ vm = shallowMount(ServiceCredentialsForm, {
+ propsData: {
+ accountAndExternalIdsHelpPath: '',
+ createRoleArnHelpPath: '',
+ externalLinkIcon: '',
+ },
+ localVue,
+ store,
+ });
+ });
+ afterEach(() => vm.destroy());
+
+ const findAccountIdInput = () => vm.find('#gitlab-account-id');
+ const findCopyAccountIdButton = () => vm.find('.js-copy-account-id-button');
+ const findExternalIdInput = () => vm.find('#eks-external-id');
+ const findCopyExternalIdButton = () => vm.find('.js-copy-external-id-button');
+ const findInvalidCredentials = () => vm.find('.js-invalid-credentials');
+ const findSubmitButton = () => vm.find(LoadingButton);
+ const findForm = () => vm.find('form[name="service-credentials-form"]');
+
+ it('displays provided account id', () => {
+ expect(findAccountIdInput().attributes('value')).toBe(accountId);
+ });
+
+ it('allows to copy account id', () => {
+ expect(findCopyAccountIdButton().props('text')).toBe(accountId);
+ });
+
+ it('displays provided external id', () => {
+ expect(findExternalIdInput().attributes('value')).toBe(externalId);
+ });
+
+ it('allows to copy external id', () => {
+ expect(findCopyExternalIdButton().props('text')).toBe(externalId);
+ });
+
+ it('disables submit button when role ARN is not provided', () => {
+ expect(findSubmitButton().attributes('disabled')).toBeTruthy();
+ });
+
+ it('enables submit button when role ARN is not provided', () => {
+ vm.setData({ roleArn: '123' });
+
+ expect(findSubmitButton().attributes('disabled')).toBeFalsy();
+ });
+
+ it('dispatches createRole action when form is submitted', () => {
+ findForm().trigger('submit');
+
+ expect(createRoleAction).toHaveBeenCalled();
+ });
+
+ describe('when is creating role', () => {
+ beforeEach(() => {
+ vm.setData({ roleArn: '123' }); // set role ARN to enable button
+
+ state.isCreatingRole = true;
+ });
+
+ it('disables submit button', () => {
+ expect(findSubmitButton().props('disabled')).toBe(true);
+ });
+
+ it('sets submit button as loading', () => {
+ expect(findSubmitButton().props('loading')).toBe(true);
+ });
+
+ it('displays Authenticating label on submit button', () => {
+ expect(findSubmitButton().props('label')).toBe('Authenticating');
+ });
+ });
+
+ describe('when role can’t be created', () => {
+ beforeEach(() => {
+ state.createRoleError = 'Invalid credentials';
+ });
+
+ it('displays invalid role warning banner', () => {
+ expect(findInvalidCredentials().exists()).toBe(true);
+ });
+
+ it('displays invalid role error message', () => {
+ expect(findInvalidCredentials().text()).toContain(state.createRoleError);
+ });
+ });
+});
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
new file mode 100644
index 00000000000..25be858dcb3
--- /dev/null
+++ b/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js
@@ -0,0 +1,152 @@
+import awsServicesFacadeFactory from '~/create_cluster/eks_cluster/services/aws_services_facade';
+import axios from '~/lib/utils/axios_utils';
+import AxiosMockAdapter from 'axios-mock-adapter';
+
+describe('awsServicesFacade', () => {
+ let apiPaths;
+ let axiosMock;
+ let awsServices;
+ let region;
+ let vpc;
+
+ beforeEach(() => {
+ apiPaths = {
+ getKeyPairsPath: '/clusters/aws/api/key_pairs',
+ getRegionsPath: '/clusters/aws/api/regions',
+ getRolesPath: '/clusters/aws/api/roles',
+ getSecurityGroupsPath: '/clusters/aws/api/security_groups',
+ getSubnetsPath: '/clusters/aws/api/subnets',
+ getVpcsPath: '/clusters/aws/api/vpcs',
+ getInstanceTypesPath: '/clusters/aws/api/instance_types',
+ };
+ region = 'west-1';
+ vpc = 'vpc-2';
+ awsServices = awsServicesFacadeFactory(apiPaths);
+ axiosMock = new AxiosMockAdapter(axios);
+ });
+
+ describe('when fetchRegions succeeds', () => {
+ let regions;
+ let regionsOutput;
+
+ beforeEach(() => {
+ regions = [{ region_name: 'east-1' }, { region_name: 'west-2' }];
+ regionsOutput = regions.map(({ region_name: name }) => ({ name, value: name }));
+ axiosMock.onGet(apiPaths.getRegionsPath).reply(200, { regions });
+ });
+
+ it('return list of roles where each item has a name and value', () => {
+ expect(awsServices.fetchRegions()).resolves.toEqual(regionsOutput);
+ });
+ });
+
+ describe('when fetchRoles succeeds', () => {
+ let roles;
+ let rolesOutput;
+
+ beforeEach(() => {
+ roles = [
+ { role_name: 'admin', arn: 'aws::admin' },
+ { role_name: 'read-only', arn: 'aws::read-only' },
+ ];
+ rolesOutput = roles.map(({ role_name: name, arn: value }) => ({ name, value }));
+ axiosMock.onGet(apiPaths.getRolesPath).reply(200, { roles });
+ });
+
+ it('return list of regions where each item has a name and value', () => {
+ expect(awsServices.fetchRoles()).resolves.toEqual(rolesOutput);
+ });
+ });
+
+ describe('when fetchKeyPairs succeeds', () => {
+ let keyPairs;
+ let keyPairsOutput;
+
+ beforeEach(() => {
+ keyPairs = [{ key_pair: 'key-pair' }, { key_pair: 'key-pair-2' }];
+ keyPairsOutput = keyPairs.map(({ key_name: name }) => ({ name, value: name }));
+ axiosMock
+ .onGet(apiPaths.getKeyPairsPath, { params: { region } })
+ .reply(200, { key_pairs: keyPairs });
+ });
+
+ it('return list of key pairs where each item has a name and value', () => {
+ expect(awsServices.fetchKeyPairs({ region })).resolves.toEqual(keyPairsOutput);
+ });
+ });
+
+ describe('when fetchVpcs succeeds', () => {
+ let vpcs;
+ let vpcsOutput;
+
+ beforeEach(() => {
+ vpcs = [{ vpc_id: 'vpc-1' }, { vpc_id: 'vpc-2' }];
+ vpcsOutput = vpcs.map(({ vpc_id: name }) => ({ name, value: name }));
+ axiosMock.onGet(apiPaths.getVpcsPath, { params: { region } }).reply(200, { vpcs });
+ });
+
+ it('return list of vpcs where each item has a name and value', () => {
+ expect(awsServices.fetchVpcs({ region })).resolves.toEqual(vpcsOutput);
+ });
+ });
+
+ describe('when fetchSubnets succeeds', () => {
+ let subnets;
+ let subnetsOutput;
+
+ beforeEach(() => {
+ subnets = [{ subnet_id: 'vpc-1' }, { subnet_id: 'vpc-2' }];
+ subnetsOutput = subnets.map(({ subnet_id }) => ({ name: subnet_id, value: subnet_id }));
+ axiosMock
+ .onGet(apiPaths.getSubnetsPath, { params: { region, vpc_id: vpc } })
+ .reply(200, { subnets });
+ });
+
+ it('return list of subnets where each item has a name and value', () => {
+ expect(awsServices.fetchSubnets({ region, vpc })).resolves.toEqual(subnetsOutput);
+ });
+ });
+
+ describe('when fetchSecurityGroups succeeds', () => {
+ let securityGroups;
+ let securityGroupsOutput;
+
+ beforeEach(() => {
+ securityGroups = [
+ { group_name: 'admin group', group_id: 'group-1' },
+ { group_name: 'basic group', group_id: 'group-2' },
+ ];
+ securityGroupsOutput = securityGroups.map(({ group_id: value, group_name: name }) => ({
+ name,
+ value,
+ }));
+ axiosMock
+ .onGet(apiPaths.getSecurityGroupsPath, { params: { region, vpc_id: vpc } })
+ .reply(200, { security_groups: securityGroups });
+ });
+
+ it('return list of security groups where each item has a name and value', () => {
+ expect(awsServices.fetchSecurityGroups({ region, vpc })).resolves.toEqual(
+ securityGroupsOutput,
+ );
+ });
+ });
+
+ describe('when fetchInstanceTypes succeeds', () => {
+ let instanceTypes;
+ let instanceTypesOutput;
+
+ beforeEach(() => {
+ instanceTypes = [{ instance_type_name: 't2.small' }, { instance_type_name: 't2.medium' }];
+ instanceTypesOutput = instanceTypes.map(({ instance_type_name }) => ({
+ name: instance_type_name,
+ value: instance_type_name,
+ }));
+ axiosMock.onGet(apiPaths.getInstanceTypesPath).reply(200, { instance_types: instanceTypes });
+ });
+
+ it('return list of instance types where each item has a name and value', () => {
+ expect(awsServices.fetchInstanceTypes()).resolves.toEqual(instanceTypesOutput);
+ });
+ });
+});
diff --git a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js
index 1ed7f806804..cf6c317a2df 100644
--- a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js
+++ b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js
@@ -13,7 +13,20 @@ import {
SET_ROLE,
SET_SECURITY_GROUP,
SET_GITLAB_MANAGED_CLUSTER,
+ SET_INSTANCE_TYPE,
+ SET_NODE_COUNT,
+ REQUEST_CREATE_ROLE,
+ CREATE_ROLE_SUCCESS,
+ CREATE_ROLE_ERROR,
+ REQUEST_CREATE_CLUSTER,
+ CREATE_CLUSTER_ERROR,
+ SIGN_OUT,
} from '~/create_cluster/eks_cluster/store/mutation_types';
+import axios from '~/lib/utils/axios_utils';
+import MockAdapter from 'axios-mock-adapter';
+import createFlash from '~/flash';
+
+jest.mock('~/flash');
describe('EKS Cluster Store Actions', () => {
let clusterName;
@@ -25,19 +38,43 @@ describe('EKS Cluster Store Actions', () => {
let role;
let keyPair;
let securityGroup;
+ let instanceType;
+ let nodeCount;
let gitlabManagedCluster;
+ let mock;
+ let state;
+ let newClusterUrl;
beforeEach(() => {
clusterName = 'my cluster';
environmentScope = 'production';
kubernetesVersion = '11.1';
- region = { name: 'regions-1' };
- vpc = { name: 'vpc-1' };
- subnet = { name: 'subnet-1' };
- role = { name: 'role-1' };
- keyPair = { name: 'key-pair-1' };
- securityGroup = { name: 'default group' };
+ region = 'regions-1';
+ vpc = 'vpc-1';
+ subnet = 'subnet-1';
+ role = 'role-1';
+ keyPair = 'key-pair-1';
+ securityGroup = 'default group';
+ instanceType = 'small-1';
+ nodeCount = '5';
gitlabManagedCluster = true;
+
+ newClusterUrl = '/clusters/1';
+
+ state = {
+ ...createState(),
+ createRolePath: '/clusters/roles/',
+ signOutPath: '/aws/signout',
+ createClusterPath: '/clusters/',
+ };
+ });
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
});
it.each`
@@ -51,10 +88,207 @@ describe('EKS Cluster Store Actions', () => {
${'setVpc'} | ${SET_VPC} | ${{ vpc }} | ${'vpc'}
${'setSubnet'} | ${SET_SUBNET} | ${{ subnet }} | ${'subnet'}
${'setSecurityGroup'} | ${SET_SECURITY_GROUP} | ${{ securityGroup }} | ${'securityGroup'}
+ ${'setInstanceType'} | ${SET_INSTANCE_TYPE} | ${{ instanceType }} | ${'instance type'}
+ ${'setNodeCount'} | ${SET_NODE_COUNT} | ${{ nodeCount }} | ${'node count'}
${'setGitlabManagedCluster'} | ${SET_GITLAB_MANAGED_CLUSTER} | ${gitlabManagedCluster} | ${'gitlab managed cluster'}
`(`$action commits $mutation with $payloadDescription payload`, data => {
const { action, mutation, payload } = data;
- testAction(actions[action], payload, createState(), [{ type: mutation, payload }]);
+ testAction(actions[action], payload, state, [{ type: mutation, payload }]);
+ });
+
+ describe('createRole', () => {
+ const payload = {
+ roleArn: 'role_arn',
+ externalId: 'externalId',
+ };
+
+ describe('when request succeeds', () => {
+ beforeEach(() => {
+ mock
+ .onPost(state.createRolePath, {
+ role_arn: payload.roleArn,
+ role_external_id: payload.externalId,
+ })
+ .reply(201);
+ });
+
+ it('dispatches createRoleSuccess action', () =>
+ testAction(
+ actions.createRole,
+ payload,
+ state,
+ [],
+ [{ type: 'requestCreateRole' }, { type: 'createRoleSuccess' }],
+ ));
+ });
+
+ describe('when request fails', () => {
+ let error;
+
+ beforeEach(() => {
+ error = new Error('Request failed with status code 400');
+ mock
+ .onPost(state.createRolePath, {
+ role_arn: payload.roleArn,
+ role_external_id: payload.externalId,
+ })
+ .reply(400, error);
+ });
+
+ it('dispatches createRoleError action', () =>
+ testAction(
+ actions.createRole,
+ payload,
+ state,
+ [],
+ [{ type: 'requestCreateRole' }, { type: 'createRoleError', payload: { error } }],
+ ));
+ });
+ });
+
+ describe('requestCreateRole', () => {
+ it('commits requestCreaterole mutation', () => {
+ testAction(actions.requestCreateRole, null, state, [{ type: REQUEST_CREATE_ROLE }]);
+ });
+ });
+
+ describe('createRoleSuccess', () => {
+ it('commits createRoleSuccess mutation', () => {
+ testAction(actions.createRoleSuccess, null, state, [{ type: CREATE_ROLE_SUCCESS }]);
+ });
+ });
+
+ describe('createRoleError', () => {
+ it('commits createRoleError mutation', () => {
+ const payload = {
+ error: new Error(),
+ };
+
+ testAction(actions.createRoleError, payload, state, [{ type: CREATE_ROLE_ERROR, payload }]);
+ });
+ });
+
+ describe('createCluster', () => {
+ let requestPayload;
+
+ beforeEach(() => {
+ requestPayload = {
+ name: clusterName,
+ environment_scope: environmentScope,
+ managed: gitlabManagedCluster,
+ provider_aws_attributes: {
+ region,
+ vpc_id: vpc,
+ subnet_ids: subnet,
+ role_arn: role,
+ key_name: keyPair,
+ security_group_id: securityGroup,
+ instance_type: instanceType,
+ num_nodes: nodeCount,
+ },
+ };
+ state = Object.assign(createState(), {
+ clusterName,
+ environmentScope,
+ kubernetesVersion,
+ selectedRegion: region,
+ selectedVpc: vpc,
+ selectedSubnet: subnet,
+ selectedRole: role,
+ selectedKeyPair: keyPair,
+ selectedSecurityGroup: securityGroup,
+ selectedInstanceType: instanceType,
+ nodeCount,
+ gitlabManagedCluster,
+ });
+ });
+
+ describe('when request succeeds', () => {
+ beforeEach(() => {
+ mock.onPost(state.createClusterPath, requestPayload).reply(201, null, {
+ location: '/clusters/1',
+ });
+ });
+
+ it('dispatches createClusterSuccess action', () =>
+ testAction(
+ actions.createCluster,
+ null,
+ state,
+ [],
+ [
+ { type: 'requestCreateCluster' },
+ { type: 'createClusterSuccess', payload: newClusterUrl },
+ ],
+ ));
+ });
+
+ describe('when request fails', () => {
+ let response;
+
+ beforeEach(() => {
+ response = 'Request failed with status code 400';
+ mock.onPost(state.createClusterPath, requestPayload).reply(400, response);
+ });
+
+ it('dispatches createRoleError action', () =>
+ testAction(
+ actions.createCluster,
+ null,
+ state,
+ [],
+ [{ type: 'requestCreateCluster' }, { type: 'createClusterError', payload: response }],
+ ));
+ });
+ });
+
+ describe('requestCreateCluster', () => {
+ it('commits requestCreateCluster mutation', () => {
+ testAction(actions.requestCreateCluster, null, state, [{ type: REQUEST_CREATE_CLUSTER }]);
+ });
+ });
+
+ describe('createClusterSuccess', () => {
+ beforeEach(() => {
+ jest.spyOn(window.location, 'assign').mockImplementation(() => {});
+ });
+ afterEach(() => {
+ window.location.assign.mockRestore();
+ });
+
+ it('redirects to the new cluster URL', () => {
+ actions.createClusterSuccess(null, newClusterUrl);
+
+ expect(window.location.assign).toHaveBeenCalledWith(newClusterUrl);
+ });
+ });
+
+ describe('createClusterError', () => {
+ let payload;
+
+ beforeEach(() => {
+ payload = { name: ['Create cluster failed'] };
+ });
+
+ it('commits createClusterError mutation', () => {
+ testAction(actions.createClusterError, payload, state, [
+ { type: CREATE_CLUSTER_ERROR, payload },
+ ]);
+ });
+
+ it('creates a flash that displays the create cluster error', () => {
+ expect(createFlash).toHaveBeenCalledWith(payload.name[0]);
+ });
+ });
+
+ describe('signOut', () => {
+ beforeEach(() => {
+ mock.onDelete(state.signOutPath).reply(200, null);
+ });
+
+ it('commits signOut mutation', () => {
+ testAction(actions.signOut, null, state, [{ type: SIGN_OUT }]);
+ });
});
});
diff --git a/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js b/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js
index 81b65180fb5..0fb392f5eea 100644
--- a/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js
+++ b/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js
@@ -8,7 +8,15 @@ import {
SET_SUBNET,
SET_ROLE,
SET_SECURITY_GROUP,
+ SET_INSTANCE_TYPE,
+ SET_NODE_COUNT,
SET_GITLAB_MANAGED_CLUSTER,
+ REQUEST_CREATE_ROLE,
+ CREATE_ROLE_SUCCESS,
+ CREATE_ROLE_ERROR,
+ REQUEST_CREATE_CLUSTER,
+ CREATE_CLUSTER_ERROR,
+ SIGN_OUT,
} from '~/create_cluster/eks_cluster/store/mutation_types';
import createState from '~/create_cluster/eks_cluster/store/state';
import mutations from '~/create_cluster/eks_cluster/store/mutations';
@@ -24,6 +32,8 @@ describe('Create EKS cluster store mutations', () => {
let role;
let keyPair;
let securityGroup;
+ let instanceType;
+ let nodeCount;
let gitlabManagedCluster;
beforeEach(() => {
@@ -36,6 +46,8 @@ describe('Create EKS cluster store mutations', () => {
role = { name: 'role-1' };
keyPair = { name: 'key pair' };
securityGroup = { name: 'default group' };
+ instanceType = 'small-1';
+ nodeCount = '5';
gitlabManagedCluster = false;
state = createState();
@@ -50,8 +62,10 @@ describe('Create EKS cluster store mutations', () => {
${SET_REGION} | ${'selectedRegion'} | ${{ region }} | ${region} | ${'selected region payload'}
${SET_KEY_PAIR} | ${'selectedKeyPair'} | ${{ keyPair }} | ${keyPair} | ${'selected key pair payload'}
${SET_VPC} | ${'selectedVpc'} | ${{ vpc }} | ${vpc} | ${'selected vpc payload'}
- ${SET_SUBNET} | ${'selectedSubnet'} | ${{ subnet }} | ${subnet} | ${'selected sybnet payload'}
+ ${SET_SUBNET} | ${'selectedSubnet'} | ${{ subnet }} | ${subnet} | ${'selected subnet payload'}
${SET_SECURITY_GROUP} | ${'selectedSecurityGroup'} | ${{ securityGroup }} | ${securityGroup} | ${'selected security group payload'}
+ ${SET_INSTANCE_TYPE} | ${'selectedInstanceType'} | ${{ instanceType }} | ${instanceType} | ${'selected instance type payload'}
+ ${SET_NODE_COUNT} | ${'nodeCount'} | ${{ nodeCount }} | ${nodeCount} | ${'node count payload'}
${SET_GITLAB_MANAGED_CLUSTER} | ${'gitlabManagedCluster'} | ${{ gitlabManagedCluster }} | ${gitlabManagedCluster} | ${'gitlab managed cluster'}
`(`$mutation sets $mutatedProperty to $expectedValueDescription`, data => {
const { mutation, mutatedProperty, payload, expectedValue } = data;
@@ -59,4 +73,101 @@ describe('Create EKS cluster store mutations', () => {
mutations[mutation](state, payload);
expect(state[mutatedProperty]).toBe(expectedValue);
});
+
+ describe(`mutation ${REQUEST_CREATE_ROLE}`, () => {
+ beforeEach(() => {
+ mutations[REQUEST_CREATE_ROLE](state);
+ });
+
+ it('sets isCreatingRole to true', () => {
+ expect(state.isCreatingRole).toBe(true);
+ });
+
+ it('sets createRoleError to null', () => {
+ expect(state.createRoleError).toBe(null);
+ });
+
+ it('sets hasCredentials to false', () => {
+ expect(state.hasCredentials).toBe(false);
+ });
+ });
+
+ describe(`mutation ${CREATE_ROLE_SUCCESS}`, () => {
+ beforeEach(() => {
+ mutations[CREATE_ROLE_SUCCESS](state);
+ });
+
+ it('sets isCreatingRole to false', () => {
+ expect(state.isCreatingRole).toBe(false);
+ });
+
+ it('sets createRoleError to null', () => {
+ expect(state.createRoleError).toBe(null);
+ });
+
+ it('sets hasCredentials to false', () => {
+ expect(state.hasCredentials).toBe(true);
+ });
+ });
+
+ describe(`mutation ${CREATE_ROLE_ERROR}`, () => {
+ const error = new Error();
+
+ beforeEach(() => {
+ mutations[CREATE_ROLE_ERROR](state, { error });
+ });
+
+ it('sets isCreatingRole to false', () => {
+ expect(state.isCreatingRole).toBe(false);
+ });
+
+ it('sets createRoleError to the error object', () => {
+ expect(state.createRoleError).toBe(error);
+ });
+
+ it('sets hasCredentials to false', () => {
+ expect(state.hasCredentials).toBe(false);
+ });
+ });
+
+ describe(`mutation ${REQUEST_CREATE_CLUSTER}`, () => {
+ beforeEach(() => {
+ mutations[REQUEST_CREATE_CLUSTER](state);
+ });
+
+ it('sets isCreatingCluster to true', () => {
+ expect(state.isCreatingCluster).toBe(true);
+ });
+
+ it('sets createClusterError to null', () => {
+ expect(state.createClusterError).toBe(null);
+ });
+ });
+
+ describe(`mutation ${CREATE_CLUSTER_ERROR}`, () => {
+ const error = new Error();
+
+ beforeEach(() => {
+ mutations[CREATE_CLUSTER_ERROR](state, { error });
+ });
+
+ it('sets isCreatingRole to false', () => {
+ expect(state.isCreatingCluster).toBe(false);
+ });
+
+ it('sets createRoleError to the error object', () => {
+ expect(state.createClusterError).toBe(error);
+ });
+ });
+
+ describe(`mutation ${SIGN_OUT}`, () => {
+ beforeEach(() => {
+ state.hasCredentials = true;
+ mutations[SIGN_OUT](state);
+ });
+
+ it('sets hasCredentials to false', () => {
+ expect(state.hasCredentials).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/projects/gke_cluster_namespace/gke_cluster_namespace_spec.js b/spec/frontend/create_cluster/gke_cluster_namespace/gke_cluster_namespace_spec.js
index 7b8df03d3c3..b1c25d8fff7 100644
--- a/spec/frontend/projects/gke_cluster_namespace/gke_cluster_namespace_spec.js
+++ b/spec/frontend/create_cluster/gke_cluster_namespace/gke_cluster_namespace_spec.js
@@ -1,4 +1,4 @@
-import initGkeNamespace from '~/projects/gke_cluster_namespace';
+import initGkeNamespace from '~/create_cluster/gke_cluster_namespace';
describe('GKE cluster namespace', () => {
const changeEvent = new Event('change');
@@ -14,7 +14,7 @@ describe('GKE cluster namespace', () => {
<input class="js-gl-managed" type="checkbox" value="1" checked />
<div class="js-namespace">
<input type="text" />
- </div>
+ </div>
<div class="js-namespace-prefixed">
<input type="text" />
</div>
diff --git a/spec/frontend/create_cluster/init_create_cluster_spec.js b/spec/frontend/create_cluster/init_create_cluster_spec.js
new file mode 100644
index 00000000000..e7b9a7adde4
--- /dev/null
+++ b/spec/frontend/create_cluster/init_create_cluster_spec.js
@@ -0,0 +1,73 @@
+import initCreateCluster from '~/create_cluster/init_create_cluster';
+import initGkeDropdowns from '~/create_cluster/gke_cluster';
+import initGkeNamespace from '~/create_cluster/gke_cluster_namespace';
+import PersistentUserCallout from '~/persistent_user_callout';
+
+jest.mock('~/create_cluster/gke_cluster', () => jest.fn());
+jest.mock('~/create_cluster/gke_cluster_namespace', () => jest.fn());
+jest.mock('~/persistent_user_callout', () => ({
+ factory: jest.fn(),
+}));
+
+describe('initCreateCluster', () => {
+ let document;
+ let gon;
+
+ beforeEach(() => {
+ document = {
+ body: { dataset: {} },
+ querySelector: jest.fn(),
+ };
+ gon = { features: {} };
+ });
+ afterEach(() => {
+ initGkeDropdowns.mockReset();
+ initGkeNamespace.mockReset();
+ PersistentUserCallout.factory.mockReset();
+ });
+
+ describe.each`
+ pageSuffix | page
+ ${':clusters:new'} | ${'project:clusters:new'}
+ ${':clusters:create_gcp'} | ${'groups:clusters:create_gcp'}
+ ${':clusters:create_user'} | ${'admin:clusters:create_user'}
+ `('when cluster page ends in $pageSuffix', ({ page }) => {
+ beforeEach(() => {
+ document.body.dataset = { page };
+
+ initCreateCluster(document, gon);
+ });
+
+ it('initializes create GKE cluster app', () => {
+ expect(initGkeDropdowns).toHaveBeenCalled();
+ });
+
+ it('initializes gcp signup offer banner', () => {
+ expect(PersistentUserCallout.factory).toHaveBeenCalled();
+ });
+ });
+
+ describe('when creating a project level cluster', () => {
+ it('initializes gke namespace app', () => {
+ document.body.dataset.page = 'project:clusters:new';
+
+ initCreateCluster(document, gon);
+
+ expect(initGkeNamespace).toHaveBeenCalled();
+ });
+ });
+
+ describe.each`
+ clusterLevel | page
+ ${'group level'} | ${'groups:clusters:new'}
+ ${'instance level'} | ${'admin:clusters:create_gcp'}
+ `('when creating a $clusterLevel cluster', ({ page }) => {
+ it('does not initialize gke namespace app', () => {
+ document.body.dataset = { page };
+
+ initCreateCluster(document, gon);
+
+ expect(initGkeNamespace).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/cycle_analytics/stage_nav_item_spec.js b/spec/frontend/cycle_analytics/stage_nav_item_spec.js
index ff079082ca7..a7a1d563e1e 100644
--- a/spec/frontend/cycle_analytics/stage_nav_item_spec.js
+++ b/spec/frontend/cycle_analytics/stage_nav_item_spec.js
@@ -133,45 +133,19 @@ describe('StageNavItem', () => {
hasStageName();
});
- it('renders options menu', () => {
- expect(wrapper.find('.more-actions-toggle').exists()).toBe(true);
+ it('does not render options menu', () => {
+ expect(wrapper.find('.more-actions-toggle').exists()).toBe(false);
});
- describe('Default stages', () => {
- beforeEach(() => {
- wrapper = createComponent(
- { canEdit: true, isUserAllowed: true, isDefaultStage: true },
- false,
- );
- });
- it('can hide the stage', () => {
- expect(wrapper.text()).toContain('Hide stage');
- });
- it('can not edit the stage', () => {
- expect(wrapper.text()).not.toContain('Edit stage');
- });
- it('can not remove the stage', () => {
- expect(wrapper.text()).not.toContain('Remove stage');
- });
+ it('can not edit the stage', () => {
+ expect(wrapper.text()).not.toContain('Edit stage');
+ });
+ it('can not remove the stage', () => {
+ expect(wrapper.text()).not.toContain('Remove stage');
});
- describe('Custom stages', () => {
- beforeEach(() => {
- wrapper = createComponent(
- { canEdit: true, isUserAllowed: true, isDefaultStage: false },
- false,
- );
- });
- it('can edit the stage', () => {
- expect(wrapper.text()).toContain('Edit stage');
- });
- it('can remove the stage', () => {
- expect(wrapper.text()).toContain('Remove stage');
- });
-
- it('can not hide the stage', () => {
- expect(wrapper.text()).not.toContain('Hide stage');
- });
+ it('can not hide the stage', () => {
+ expect(wrapper.text()).not.toContain('Hide stage');
});
});
});
diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js
index 290c0e797cb..3c6553f3547 100644
--- a/spec/frontend/environment.js
+++ b/spec/frontend/environment.js
@@ -41,6 +41,12 @@ class CustomEnvironment extends JSDOMEnvironment {
this.global.fixturesBasePath = `${ROOT_PATH}/tmp/tests/frontend/fixtures${IS_EE ? '-ee' : ''}`;
this.global.staticFixturesBasePath = `${ROOT_PATH}/spec/frontend/fixtures`;
+ /**
+ * window.fetch() is required by the apollo-upload-client library otherwise
+ * a ReferenceError is generated: https://github.com/jaydenseric/apollo-upload-client/issues/100
+ */
+ this.global.fetch = () => {};
+
// Not yet supported by JSDOM: https://github.com/jsdom/jsdom/issues/317
this.global.document.createRange = () => ({
setStart: () => {},
diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js
new file mode 100644
index 00000000000..54e8b0848a2
--- /dev/null
+++ b/spec/frontend/error_tracking/components/error_details_spec.js
@@ -0,0 +1,105 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { GlLoadingIcon, GlLink } from '@gitlab/ui';
+import Stacktrace from '~/error_tracking/components/stacktrace.vue';
+import ErrorDetails from '~/error_tracking/components/error_details.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('ErrorDetails', () => {
+ let store;
+ let wrapper;
+ let actions;
+ let getters;
+
+ function mountComponent() {
+ wrapper = shallowMount(ErrorDetails, {
+ localVue,
+ store,
+ propsData: {
+ issueDetailsPath: '/123/details',
+ issueStackTracePath: '/stacktrace',
+ },
+ });
+ }
+
+ beforeEach(() => {
+ actions = {
+ startPollingDetails: () => {},
+ startPollingStacktrace: () => {},
+ };
+
+ getters = {
+ sentryUrl: () => 'sentry.io',
+ stacktrace: () => [{ context: [1, 2], lineNo: 53, filename: 'index.js' }],
+ };
+
+ const state = {
+ error: {},
+ loading: true,
+ stacktraceData: {},
+ loadingStacktrace: true,
+ };
+
+ store = new Vuex.Store({
+ modules: {
+ details: {
+ namespaced: true,
+ actions,
+ state,
+ getters,
+ },
+ },
+ });
+ });
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ }
+ });
+
+ describe('loading', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('should show spinner while loading', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ expect(wrapper.find(GlLink).exists()).toBe(false);
+ expect(wrapper.find(Stacktrace).exists()).toBe(false);
+ });
+ });
+
+ describe('Error details', () => {
+ it('should show Sentry error details without stacktrace', () => {
+ store.state.details.loading = false;
+ store.state.details.error.id = 1;
+ mountComponent();
+ expect(wrapper.find(GlLink).exists()).toBe(true);
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ expect(wrapper.find(Stacktrace).exists()).toBe(false);
+ });
+
+ describe('Stacktrace', () => {
+ it('should show stacktrace', () => {
+ store.state.details.loading = false;
+ store.state.details.error.id = 1;
+ store.state.details.loadingStacktrace = false;
+ mountComponent();
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+ expect(wrapper.find(Stacktrace).exists()).toBe(true);
+ });
+
+ it('should NOT show stacktrace if no entries', () => {
+ store.state.details.loading = false;
+ store.state.details.loadingStacktrace = false;
+ store.getters = { 'details/sentryUrl': () => 'sentry.io', 'details/stacktrace': () => [] };
+ mountComponent();
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+ expect(wrapper.find(Stacktrace).exists()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/error_tracking/components/error_tracking_list_spec.js b/spec/frontend/error_tracking/components/error_tracking_list_spec.js
index ce8b8908026..1bbf23cc602 100644
--- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js
+++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js
@@ -34,7 +34,7 @@ describe('ErrorTrackingList', () => {
beforeEach(() => {
actions = {
- getErrorList: () => {},
+ getSentryData: () => {},
startPolling: () => {},
restartPolling: jest.fn().mockName('restartPolling'),
};
@@ -45,8 +45,13 @@ describe('ErrorTrackingList', () => {
};
store = new Vuex.Store({
- actions,
- state,
+ modules: {
+ list: {
+ namespaced: true,
+ actions,
+ state,
+ },
+ },
});
});
@@ -70,7 +75,7 @@ describe('ErrorTrackingList', () => {
describe('results', () => {
beforeEach(() => {
- store.state.loading = false;
+ store.state.list.loading = false;
mountComponent();
});
@@ -84,7 +89,7 @@ describe('ErrorTrackingList', () => {
describe('no results', () => {
beforeEach(() => {
- store.state.loading = false;
+ store.state.list.loading = false;
mountComponent();
});
diff --git a/spec/frontend/error_tracking/components/stacktrace_entry_spec.js b/spec/frontend/error_tracking/components/stacktrace_entry_spec.js
new file mode 100644
index 00000000000..95958408770
--- /dev/null
+++ b/spec/frontend/error_tracking/components/stacktrace_entry_spec.js
@@ -0,0 +1,49 @@
+import { shallowMount } from '@vue/test-utils';
+import StackTraceEntry from '~/error_tracking/components/stacktrace_entry.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import FileIcon from '~/vue_shared/components/file_icon.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+
+describe('Stacktrace Entry', () => {
+ let wrapper;
+
+ function mountComponent(props) {
+ wrapper = shallowMount(StackTraceEntry, {
+ propsData: {
+ filePath: 'sidekiq/util.rb',
+ lines: [
+ [22, ' def safe_thread(name, \u0026block)\n'],
+ [23, ' Thread.new do\n'],
+ [24, " Thread.current['sidekiq_label'] = name\n"],
+ [25, ' watchdog(name, \u0026block)\n'],
+ ],
+ errorLine: 24,
+ ...props,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ }
+ });
+
+ it('should render stacktrace entry collapsed', () => {
+ expect(wrapper.find(StackTraceEntry).exists()).toBe(true);
+ expect(wrapper.find(ClipboardButton).exists()).toBe(true);
+ expect(wrapper.find(Icon).exists()).toBe(true);
+ expect(wrapper.find(FileIcon).exists()).toBe(true);
+ expect(wrapper.element.querySelectorAll('table').length).toBe(0);
+ });
+
+ it('should render stacktrace entry table expanded', () => {
+ mountComponent({ expanded: true });
+ expect(wrapper.element.querySelectorAll('tr.line_holder').length).toBe(4);
+ expect(wrapper.element.querySelectorAll('.line_content.old').length).toBe(1);
+ });
+});
diff --git a/spec/frontend/error_tracking/components/stacktrace_spec.js b/spec/frontend/error_tracking/components/stacktrace_spec.js
new file mode 100644
index 00000000000..4f4a60acba4
--- /dev/null
+++ b/spec/frontend/error_tracking/components/stacktrace_spec.js
@@ -0,0 +1,45 @@
+import { shallowMount } from '@vue/test-utils';
+import Stacktrace from '~/error_tracking/components/stacktrace.vue';
+import StackTraceEntry from '~/error_tracking/components/stacktrace_entry.vue';
+
+describe('ErrorDetails', () => {
+ let wrapper;
+
+ const stackTraceEntry = {
+ filename: 'sidekiq/util.rb',
+ context: [
+ [22, ' def safe_thread(name, \u0026block)\n'],
+ [23, ' Thread.new do\n'],
+ [24, " Thread.current['sidekiq_label'] = name\n"],
+ [25, ' watchdog(name, \u0026block)\n'],
+ ],
+ lineNo: 24,
+ };
+
+ function mountComponent(entries) {
+ wrapper = shallowMount(Stacktrace, {
+ propsData: {
+ entries,
+ },
+ });
+ }
+
+ describe('Stacktrace', () => {
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ }
+ });
+
+ it('should render single Stacktrace entry', () => {
+ mountComponent([stackTraceEntry]);
+ expect(wrapper.findAll(StackTraceEntry).length).toBe(1);
+ });
+
+ it('should render multiple Stacktrace entry', () => {
+ const entriesNum = 3;
+ mountComponent(new Array(entriesNum).fill(stackTraceEntry));
+ expect(wrapper.findAll(StackTraceEntry).length).toBe(entriesNum);
+ });
+ });
+});
diff --git a/spec/frontend/error_tracking/store/details/actions_spec.js b/spec/frontend/error_tracking/store/details/actions_spec.js
new file mode 100644
index 00000000000..f72cd1e413b
--- /dev/null
+++ b/spec/frontend/error_tracking/store/details/actions_spec.js
@@ -0,0 +1,94 @@
+import axios from '~/lib/utils/axios_utils';
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import createFlash from '~/flash';
+import * as actions from '~/error_tracking/store/details/actions';
+import * as types from '~/error_tracking/store/details/mutation_types';
+
+jest.mock('~/flash.js');
+let mock;
+
+describe('Sentry error details store actions', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ createFlash.mockClear();
+ });
+
+ describe('startPollingDetails', () => {
+ const endpoint = '123/details';
+ it('should commit SET_ERROR with received response', done => {
+ const payload = { error: { id: 1 } };
+ mock.onGet().reply(200, payload);
+ testAction(
+ actions.startPollingDetails,
+ { endpoint },
+ {},
+ [
+ { type: types.SET_ERROR, payload: payload.error },
+ { type: types.SET_LOADING, payload: false },
+ ],
+ [],
+ () => {
+ done();
+ },
+ );
+ });
+
+ it('should show flash on API error', done => {
+ mock.onGet().reply(400);
+
+ testAction(
+ actions.startPollingDetails,
+ { endpoint },
+ {},
+ [{ type: types.SET_LOADING, payload: false }],
+ [],
+ () => {
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ done();
+ },
+ );
+ });
+ });
+
+ describe('startPollingStacktrace', () => {
+ const endpoint = '123/stacktrace';
+ it('should commit SET_ERROR with received response', done => {
+ const payload = { error: [1, 2, 3] };
+ mock.onGet().reply(200, payload);
+ testAction(
+ actions.startPollingStacktrace,
+ { endpoint },
+ {},
+ [
+ { type: types.SET_STACKTRACE_DATA, payload: payload.error },
+ { type: types.SET_LOADING_STACKTRACE, payload: false },
+ ],
+ [],
+ () => {
+ done();
+ },
+ );
+ });
+
+ it('should show flash on API error', done => {
+ mock.onGet().reply(400);
+
+ testAction(
+ actions.startPollingStacktrace,
+ { endpoint },
+ {},
+ [{ type: types.SET_LOADING_STACKTRACE, payload: false }],
+ [],
+ () => {
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ done();
+ },
+ );
+ });
+ });
+});
diff --git a/spec/frontend/error_tracking/store/details/getters_spec.js b/spec/frontend/error_tracking/store/details/getters_spec.js
new file mode 100644
index 00000000000..ea57de5872b
--- /dev/null
+++ b/spec/frontend/error_tracking/store/details/getters_spec.js
@@ -0,0 +1,13 @@
+import * as getters from '~/error_tracking/store/details/getters';
+
+describe('Sentry error details store getters', () => {
+ const state = {
+ stacktraceData: { stack_trace_entries: [1, 2] },
+ };
+
+ describe('stacktrace', () => {
+ it('should get stacktrace', () => {
+ expect(getters.stacktrace(state)).toEqual([2, 1]);
+ });
+ });
+});
diff --git a/spec/frontend/error_tracking/store/list/getters_spec.js b/spec/frontend/error_tracking/store/list/getters_spec.js
new file mode 100644
index 00000000000..3cd7fa37d44
--- /dev/null
+++ b/spec/frontend/error_tracking/store/list/getters_spec.js
@@ -0,0 +1,33 @@
+import * as getters from '~/error_tracking/store/list/getters';
+
+describe('Error Tracking getters', () => {
+ let state;
+
+ const mockErrors = [
+ { title: 'ActiveModel::MissingAttributeError: missing attribute: encrypted_password' },
+ { title: 'Grape::Exceptions::MethodNotAllowed: Grape::Exceptions::MethodNotAllowed' },
+ { title: 'NoMethodError: undefined method `sanitize_http_headers=' },
+ { title: 'NoMethodError: undefined method `pry' },
+ ];
+
+ beforeEach(() => {
+ state = {
+ errors: mockErrors,
+ };
+ });
+
+ describe('search results', () => {
+ it('should return errors filtered by words in title matching the query', () => {
+ const filteredErrors = getters.filterErrorsByTitle(state)('NoMethod');
+
+ expect(filteredErrors).not.toContainEqual(mockErrors[0]);
+ expect(filteredErrors.length).toBe(2);
+ });
+
+ it('should not return results if there is no matching query', () => {
+ const filteredErrors = getters.filterErrorsByTitle(state)('GitLab');
+
+ expect(filteredErrors.length).toBe(0);
+ });
+ });
+});
diff --git a/spec/frontend/error_tracking/store/mutation_spec.js b/spec/frontend/error_tracking/store/list/mutation_spec.js
index 8117104bdbc..6e021185b4d 100644
--- a/spec/frontend/error_tracking/store/mutation_spec.js
+++ b/spec/frontend/error_tracking/store/list/mutation_spec.js
@@ -1,5 +1,5 @@
-import mutations from '~/error_tracking/store/mutations';
-import * as types from '~/error_tracking/store/mutation_types';
+import mutations from '~/error_tracking/store/list/mutations';
+import * as types from '~/error_tracking/store/list/mutation_types';
describe('Error tracking mutations', () => {
describe('SET_ERRORS', () => {
diff --git a/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js b/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js
index 23e57c4bbf1..bff8ad0877a 100644
--- a/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js
+++ b/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js
@@ -1,7 +1,9 @@
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
-import { GlButton, GlFormInput } from '@gitlab/ui';
+import { GlFormInput } from '@gitlab/ui';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
import ErrorTrackingForm from '~/error_tracking_settings/components/error_tracking_form.vue';
+import createStore from '~/error_tracking_settings/store';
import { defaultProps } from '../mock';
const localVue = createLocalVue();
@@ -9,15 +11,18 @@ localVue.use(Vuex);
describe('error tracking settings form', () => {
let wrapper;
+ let store;
function mountComponent() {
wrapper = shallowMount(ErrorTrackingForm, {
localVue,
+ store,
propsData: defaultProps,
});
}
beforeEach(() => {
+ store = createStore();
mountComponent();
});
@@ -38,7 +43,7 @@ describe('error tracking settings form', () => {
.attributes('id'),
).toBe('error-tracking-token');
- expect(wrapper.findAll(GlButton).exists()).toBe(true);
+ expect(wrapper.findAll(LoadingButton).exists()).toBe(true);
});
it('is rendered with labels and placeholders', () => {
@@ -59,9 +64,21 @@ describe('error tracking settings form', () => {
});
});
+ describe('loading projects', () => {
+ beforeEach(() => {
+ store.state.isLoadingProjects = true;
+ });
+
+ it('shows loading spinner', () => {
+ const { label, loading } = wrapper.find(LoadingButton).props();
+ expect(loading).toBe(true);
+ expect(label).toBe('Connecting');
+ });
+ });
+
describe('after a successful connection', () => {
beforeEach(() => {
- wrapper.setProps({ connectSuccessful: true });
+ store.state.connectSuccessful = true;
});
it('shows the success checkmark', () => {
@@ -77,7 +94,7 @@ describe('error tracking settings form', () => {
describe('after an unsuccessful connection', () => {
beforeEach(() => {
- wrapper.setProps({ connectError: true });
+ store.state.connectError = true;
});
it('does not show the check mark', () => {
diff --git a/spec/frontend/error_tracking_settings/store/actions_spec.js b/spec/frontend/error_tracking_settings/store/actions_spec.js
index 1eab0f7470b..e12c4e20f58 100644
--- a/spec/frontend/error_tracking_settings/store/actions_spec.js
+++ b/spec/frontend/error_tracking_settings/store/actions_spec.js
@@ -69,7 +69,14 @@ describe('error tracking settings actions', () => {
});
it('should request projects correctly', done => {
- testAction(actions.requestProjects, null, state, [{ type: types.RESET_CONNECT }], [], done);
+ testAction(
+ actions.requestProjects,
+ null,
+ state,
+ [{ type: types.SET_PROJECTS_LOADING, payload: true }, { type: types.RESET_CONNECT }],
+ [],
+ done,
+ );
});
it('should receive projects correctly', done => {
@@ -81,6 +88,7 @@ describe('error tracking settings actions', () => {
[
{ type: types.UPDATE_CONNECT_SUCCESS },
{ type: types.RECEIVE_PROJECTS, payload: testPayload },
+ { type: types.SET_PROJECTS_LOADING, payload: false },
],
[],
done,
@@ -93,7 +101,11 @@ describe('error tracking settings actions', () => {
actions.receiveProjectsError,
testPayload,
state,
- [{ type: types.UPDATE_CONNECT_ERROR }, { type: types.CLEAR_PROJECTS }],
+ [
+ { type: types.UPDATE_CONNECT_ERROR },
+ { type: types.CLEAR_PROJECTS },
+ { type: types.SET_PROJECTS_LOADING, payload: false },
+ ],
[],
done,
);
diff --git a/spec/frontend/fixtures/merge_requests.rb b/spec/frontend/fixtures/merge_requests.rb
index 8fbdb534b3d..f20c0aa3540 100644
--- a/spec/frontend/fixtures/merge_requests.rb
+++ b/spec/frontend/fixtures/merge_requests.rb
@@ -8,7 +8,23 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont
let(:admin) { create(:admin) }
let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
let(:project) { create(:project, :repository, namespace: namespace, path: 'merge-requests-project') }
- let(:merge_request) { create(:merge_request, :with_diffs, source_project: project, target_project: project, description: '- [ ] Task List Item') }
+
+ # rubocop: disable Layout/TrailingWhitespace
+ let(:merge_request) do
+ create(
+ :merge_request,
+ :with_diffs,
+ source_project: project,
+ target_project: project,
+ description: <<~MARKDOWN.strip_heredoc
+ - [ ] Task List Item
+ - [ ]
+ - [ ] Task List Item 2
+ MARKDOWN
+ )
+ end
+ # rubocop: enable Layout/TrailingWhitespace
+
let(:merged_merge_request) { create(:merge_request, :merged, source_project: project, target_project: project) }
let(:pipeline) do
create(
diff --git a/spec/frontend/fixtures/static/environments_logs.html b/spec/frontend/fixtures/static/environments_logs.html
index ccf9c364154..88bb0a3ed41 100644
--- a/spec/frontend/fixtures/static/environments_logs.html
+++ b/spec/frontend/fixtures/static/environments_logs.html
@@ -2,8 +2,8 @@
class="js-kubernetes-logs"
data-current-environment-name="production"
data-environments-path="/root/my-project/environments.json"
- data-logs-page="/root/my-project/environments/1/logs"
- data-logs-path="/root/my-project/environments/1/logs.json"
+ data-project-full-path="root/my-project"
+ data-environment-id=1
>
<div class="build-page-pod-logs">
<div class="build-trace-container prepend-top-default">
diff --git a/spec/frontend/fixtures/static/signin_tabs.html b/spec/frontend/fixtures/static/signin_tabs.html
index 7e66ab9394b..247a6b03054 100644
--- a/spec/frontend/fixtures/static/signin_tabs.html
+++ b/spec/frontend/fixtures/static/signin_tabs.html
@@ -5,4 +5,7 @@
<li>
<a href="#login-pane">Standard</a>
</li>
+<li>
+<a href="#register-pane">Register</a>
+</li>
</ul>
diff --git a/spec/frontend/fixtures/u2f.rb b/spec/frontend/fixtures/u2f.rb
index dded6ce6380..9710fbbc181 100644
--- a/spec/frontend/fixtures/u2f.rb
+++ b/spec/frontend/fixtures/u2f.rb
@@ -34,7 +34,9 @@ context 'U2F' do
before do
sign_in(user)
- allow_any_instance_of(Profiles::TwoFactorAuthsController).to receive(:build_qr_code).and_return('qrcode:blackandwhitesquares')
+ allow_next_instance_of(Profiles::TwoFactorAuthsController) do |instance|
+ allow(instance).to receive(:build_qr_code).and_return('qrcode:blackandwhitesquares')
+ end
end
it 'u2f/register.html' do
diff --git a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap
new file mode 100644
index 00000000000..69ad71a1efb
--- /dev/null
+++ b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap
@@ -0,0 +1,101 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`grafana integration component default state to match the default snapshot 1`] = `
+<section
+ class="settings no-animate js-grafana-integration"
+ id="grafana"
+>
+ <div
+ class="settings-header"
+ >
+ <h4
+ class="js-section-header"
+ >
+
+ Grafana Authentication
+
+ </h4>
+
+ <glbutton-stub
+ class="js-settings-toggle"
+ >
+ Expand
+ </glbutton-stub>
+
+ <p
+ class="js-section-sub-header"
+ >
+
+ Embed Grafana charts in GitLab issues.
+
+ </p>
+ </div>
+
+ <div
+ class="settings-content"
+ >
+ <form>
+ <glformcheckbox-stub
+ class="mb-4"
+ id="grafana-integration-enabled"
+ >
+
+ Active
+
+ </glformcheckbox-stub>
+
+ <glformgroup-stub
+ description="Enter the base URL of the Grafana instance."
+ label="Grafana URL"
+ label-for="grafana-url"
+ >
+ <glforminput-stub
+ id="grafana-url"
+ placeholder="https://my-url.grafana.net/"
+ value="http://test.host"
+ />
+ </glformgroup-stub>
+
+ <glformgroup-stub
+ label="API Token"
+ label-for="grafana-token"
+ >
+ <glforminput-stub
+ id="grafana-token"
+ value="someToken"
+ />
+
+ <p
+ class="form-text text-muted"
+ >
+
+ Enter the Grafana API Token.
+
+ <a
+ href="https://grafana.com/docs/http_api/auth/#create-api-token"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+
+ More information
+
+ <icon-stub
+ class="vertical-align-middle"
+ name="external-link"
+ size="16"
+ />
+ </a>
+ </p>
+ </glformgroup-stub>
+
+ <glbutton-stub
+ variant="success"
+ >
+
+ Save Changes
+
+ </glbutton-stub>
+ </form>
+ </div>
+</section>
+`;
diff --git a/spec/frontend/grafana_integration/components/grafana_integration_spec.js b/spec/frontend/grafana_integration/components/grafana_integration_spec.js
new file mode 100644
index 00000000000..c098ada0519
--- /dev/null
+++ b/spec/frontend/grafana_integration/components/grafana_integration_spec.js
@@ -0,0 +1,125 @@
+import { mount, shallowMount } from '@vue/test-utils';
+import { GlButton } from '@gitlab/ui';
+import GrafanaIntegration from '~/grafana_integration/components/grafana_integration.vue';
+import { createStore } from '~/grafana_integration/store';
+import axios from '~/lib/utils/axios_utils';
+import { refreshCurrentPage } from '~/lib/utils/url_utility';
+import createFlash from '~/flash';
+import { TEST_HOST } from 'helpers/test_constants';
+
+jest.mock('~/lib/utils/url_utility');
+jest.mock('~/flash');
+
+describe('grafana integration component', () => {
+ let wrapper;
+ let store;
+ const operationsSettingsEndpoint = `${TEST_HOST}/mock/ops/settings/endpoint`;
+ const grafanaIntegrationUrl = `${TEST_HOST}`;
+ const grafanaIntegrationToken = 'someToken';
+
+ beforeEach(() => {
+ store = createStore({
+ operationsSettingsEndpoint,
+ grafanaIntegrationUrl,
+ grafanaIntegrationToken,
+ });
+ });
+
+ afterEach(() => {
+ if (wrapper.destroy) {
+ wrapper.destroy();
+ createFlash.mockReset();
+ refreshCurrentPage.mockReset();
+ }
+ });
+
+ describe('default state', () => {
+ it('to match the default snapshot', () => {
+ wrapper = shallowMount(GrafanaIntegration, { store });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('renders header text', () => {
+ wrapper = shallowMount(GrafanaIntegration, { store });
+
+ expect(wrapper.find('.js-section-header').text()).toBe('Grafana Authentication');
+ });
+
+ describe('expand/collapse button', () => {
+ it('renders as an expand button by default', () => {
+ wrapper = shallowMount(GrafanaIntegration, { store });
+
+ const button = wrapper.find(GlButton);
+
+ expect(button.text()).toBe('Expand');
+ });
+ });
+
+ describe('sub-header', () => {
+ it('renders descriptive text', () => {
+ wrapper = shallowMount(GrafanaIntegration, { store });
+
+ expect(wrapper.find('.js-section-sub-header').text()).toContain(
+ 'Embed Grafana charts in GitLab issues.',
+ );
+ });
+ });
+
+ describe('form', () => {
+ beforeEach(() => {
+ jest.spyOn(axios, 'patch').mockImplementation();
+ });
+
+ afterEach(() => {
+ axios.patch.mockReset();
+ });
+
+ describe('submit button', () => {
+ const findSubmitButton = () => wrapper.find('.settings-content form').find(GlButton);
+
+ const endpointRequest = [
+ operationsSettingsEndpoint,
+ {
+ project: {
+ grafana_integration_attributes: {
+ grafana_url: grafanaIntegrationUrl,
+ token: grafanaIntegrationToken,
+ enabled: false,
+ },
+ },
+ },
+ ];
+
+ it('submits form on click', () => {
+ wrapper = mount(GrafanaIntegration, { store });
+ axios.patch.mockResolvedValue();
+
+ findSubmitButton(wrapper).trigger('click');
+
+ expect(axios.patch).toHaveBeenCalledWith(...endpointRequest);
+ return wrapper.vm.$nextTick().then(() => expect(refreshCurrentPage).toHaveBeenCalled());
+ });
+
+ it('creates flash banner on error', () => {
+ const message = 'mockErrorMessage';
+ wrapper = mount(GrafanaIntegration, { store });
+ axios.patch.mockRejectedValue({ response: { data: { message } } });
+
+ findSubmitButton().trigger('click');
+
+ expect(axios.patch).toHaveBeenCalledWith(...endpointRequest);
+ return wrapper.vm
+ .$nextTick()
+ .then(jest.runAllTicks)
+ .then(() =>
+ expect(createFlash).toHaveBeenCalledWith(
+ `There was an error saving your changes. ${message}`,
+ 'alert',
+ ),
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/grafana_integration/store/mutations_spec.js b/spec/frontend/grafana_integration/store/mutations_spec.js
new file mode 100644
index 00000000000..18e87394189
--- /dev/null
+++ b/spec/frontend/grafana_integration/store/mutations_spec.js
@@ -0,0 +1,35 @@
+import mutations from '~/grafana_integration/store/mutations';
+import createState from '~/grafana_integration/store/state';
+
+describe('grafana integration mutations', () => {
+ let localState;
+
+ beforeEach(() => {
+ localState = createState();
+ });
+
+ describe('SET_GRAFANA_URL', () => {
+ it('sets grafanaUrl', () => {
+ const mockUrl = 'mockUrl';
+ mutations.SET_GRAFANA_URL(localState, mockUrl);
+
+ expect(localState.grafanaUrl).toBe(mockUrl);
+ });
+ });
+
+ describe('SET_GRAFANA_TOKEN', () => {
+ it('sets grafanaToken', () => {
+ const mockToken = 'mockToken';
+ mutations.SET_GRAFANA_TOKEN(localState, mockToken);
+
+ expect(localState.grafanaToken).toBe(mockToken);
+ });
+ });
+ describe('SET_GRAFANA_ENABLED', () => {
+ it('updates grafanaEnabled for integration', () => {
+ mutations.SET_GRAFANA_ENABLED(localState, true);
+
+ expect(localState.grafanaEnabled).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/helpers/monitor_helper_spec.js b/spec/frontend/helpers/monitor_helper_spec.js
index 2e8bff298c4..0798ca580e2 100644
--- a/spec/frontend/helpers/monitor_helper_spec.js
+++ b/spec/frontend/helpers/monitor_helper_spec.js
@@ -41,5 +41,87 @@ describe('monitor helper', () => {
),
).toEqual([{ ...expectedDataSeries[0], data: [[1, 1]] }]);
});
+
+ it('updates series name from templates', () => {
+ const config = {
+ ...defaultConfig,
+ name: '{{cmd}}',
+ };
+
+ const [result] = monitorHelper.makeDataSeries(
+ [{ metric: { cmd: 'brpop' }, values: series }],
+ config,
+ );
+
+ expect(result.name).toEqual('brpop');
+ });
+
+ it('supports space-padded template expressions', () => {
+ const config = {
+ ...defaultConfig,
+ name: 'backend: {{ backend }}',
+ };
+
+ const [result] = monitorHelper.makeDataSeries(
+ [{ metric: { backend: 'HA Server' }, values: series }],
+ config,
+ );
+
+ expect(result.name).toEqual('backend: HA Server');
+ });
+
+ it('supports repeated template variables', () => {
+ const config = { ...defaultConfig, name: '{{cmd}}, {{cmd}}' };
+
+ const [result] = monitorHelper.makeDataSeries(
+ [{ metric: { cmd: 'brpop' }, values: series }],
+ config,
+ );
+
+ expect(result.name).toEqual('brpop, brpop');
+ });
+
+ it('supports hyphenated template variables', () => {
+ const config = { ...defaultConfig, name: 'expired - {{ test-attribute }}' };
+
+ const [result] = monitorHelper.makeDataSeries(
+ [{ metric: { 'test-attribute': 'test-attribute-value' }, values: series }],
+ config,
+ );
+
+ expect(result.name).toEqual('expired - test-attribute-value');
+ });
+
+ it('updates multiple series names from templates', () => {
+ const config = {
+ ...defaultConfig,
+ name: '{{job}}: {{cmd}}',
+ };
+
+ const [result] = monitorHelper.makeDataSeries(
+ [{ metric: { cmd: 'brpop', job: 'redis' }, values: series }],
+ config,
+ );
+
+ expect(result.name).toEqual('redis: brpop');
+ });
+
+ it('updates name for each series', () => {
+ const config = {
+ ...defaultConfig,
+ name: '{{cmd}}',
+ };
+
+ const [firstSeries, secondSeries] = monitorHelper.makeDataSeries(
+ [
+ { metric: { cmd: 'brpop' }, values: series },
+ { metric: { cmd: 'zrangebyscore' }, values: series },
+ ],
+ config,
+ );
+
+ expect(firstSeries.name).toEqual('brpop');
+ expect(secondSeries.name).toEqual('zrangebyscore');
+ });
});
});
diff --git a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap
new file mode 100644
index 00000000000..5d6c31f01d9
--- /dev/null
+++ b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap
@@ -0,0 +1,61 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`IDE pipeline stage renders stage details & icon 1`] = `
+<div
+ class="ide-stage card prepend-top-default"
+>
+ <div
+ class="card-header"
+ >
+ <ciicon-stub
+ cssclasses=""
+ size="24"
+ status="[object Object]"
+ />
+
+ <strong
+ class="prepend-left-8 ide-stage-title"
+ data-container="body"
+ data-original-title=""
+ title=""
+ >
+
+ build
+
+ </strong>
+
+ <div
+ class="append-right-8 prepend-left-4"
+ >
+ <span
+ class="badge badge-pill"
+ >
+ 4
+ </span>
+ </div>
+
+ <icon-stub
+ class="ide-stage-collapse-icon"
+ name="angle-down"
+ size="16"
+ />
+ </div>
+
+ <div
+ class="card-body"
+ >
+ <item-stub
+ job="[object Object]"
+ />
+ <item-stub
+ job="[object Object]"
+ />
+ <item-stub
+ job="[object Object]"
+ />
+ <item-stub
+ job="[object Object]"
+ />
+ </div>
+</div>
+`;
diff --git a/spec/frontend/ide/components/jobs/stage_spec.js b/spec/frontend/ide/components/jobs/stage_spec.js
new file mode 100644
index 00000000000..2e42ab26d27
--- /dev/null
+++ b/spec/frontend/ide/components/jobs/stage_spec.js
@@ -0,0 +1,86 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLoadingIcon } from '@gitlab/ui';
+import Stage from '~/ide/components/jobs/stage.vue';
+import Item from '~/ide/components/jobs/item.vue';
+import { stages, jobs } from '../../mock_data';
+
+describe('IDE pipeline stage', () => {
+ let wrapper;
+ const defaultProps = {
+ stage: {
+ ...stages[0],
+ id: 0,
+ dropdownPath: stages[0].dropdown_path,
+ jobs: [...jobs],
+ isLoading: false,
+ isCollapsed: false,
+ },
+ };
+
+ const findHeader = () => wrapper.find({ ref: 'cardHeader' });
+ const findJobList = () => wrapper.find({ ref: 'jobList' });
+
+ const createComponent = props => {
+ wrapper = shallowMount(Stage, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ sync: false,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('emits fetch event when mounted', () => {
+ createComponent();
+ expect(wrapper.emitted().fetch).toBeDefined();
+ });
+
+ it('renders loading icon when no jobs and isLoading is true', () => {
+ createComponent({
+ stage: { ...defaultProps.stage, isLoading: true, jobs: [] },
+ });
+
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+
+ it('emits toggleCollaped event with stage id when clicking header', () => {
+ const id = 5;
+ createComponent({ stage: { ...defaultProps.stage, id } });
+ findHeader().trigger('click');
+ expect(wrapper.emitted().toggleCollapsed[0][0]).toBe(id);
+ });
+
+ it('emits clickViewLog entity with job', () => {
+ const [job] = defaultProps.stage.jobs;
+ createComponent();
+ wrapper
+ .findAll(Item)
+ .at(0)
+ .vm.$emit('clickViewLog', job);
+ expect(wrapper.emitted().clickViewLog[0][0]).toBe(job);
+ });
+
+ it('renders stage details & icon', () => {
+ createComponent();
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('when collapsed', () => {
+ beforeEach(() => {
+ createComponent({ stage: { ...defaultProps.stage, isCollapsed: true } });
+ });
+
+ it('does not render job list', () => {
+ expect(findJobList().isVisible()).toBe(false);
+ });
+
+ it('sets border bottom class', () => {
+ expect(findHeader().classes('border-bottom-0')).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/preview/clientside_spec.js b/spec/frontend/ide/components/preview/clientside_spec.js
index dfc76628d0c..6a33f4998c5 100644
--- a/spec/frontend/ide/components/preview/clientside_spec.js
+++ b/spec/frontend/ide/components/preview/clientside_spec.js
@@ -24,6 +24,9 @@ describe('IDE clientside preview', () => {
getFileData: jest.fn().mockReturnValue(Promise.resolve({})),
getRawFileData: jest.fn().mockReturnValue(Promise.resolve('')),
};
+ const storeClientsideActions = {
+ pingUsage: jest.fn().mockReturnValue(Promise.resolve({})),
+ };
const waitForCalls = () => new Promise(setImmediate);
@@ -42,6 +45,12 @@ describe('IDE clientside preview', () => {
...getters,
},
actions: storeActions,
+ modules: {
+ clientside: {
+ namespaced: true,
+ actions: storeClientsideActions,
+ },
+ },
});
wrapper = shallowMount(Clientside, {
@@ -76,7 +85,8 @@ describe('IDE clientside preview', () => {
describe('with main entry', () => {
beforeEach(() => {
createComponent({ getters: { packageJson: dummyPackageJson } });
- return wrapper.vm.initPreview();
+
+ return waitForCalls();
});
it('creates sandpack manager', () => {
@@ -95,6 +105,10 @@ describe('IDE clientside preview', () => {
},
);
});
+
+ it('pings usage', () => {
+ expect(storeClientsideActions.pingUsage).toHaveBeenCalledTimes(1);
+ });
});
describe('computed', () => {
@@ -178,13 +192,13 @@ describe('IDE clientside preview', () => {
});
describe('showOpenInCodeSandbox', () => {
- it('returns true when visiblity is public', () => {
+ it('returns true when visibility is public', () => {
createComponent({ getters: { currentProject: () => ({ visibility: 'public' }) } });
expect(wrapper.vm.showOpenInCodeSandbox).toBe(true);
});
- it('returns false when visiblity is private', () => {
+ it('returns false when visibility is private', () => {
createComponent({ getters: { currentProject: () => ({ visibility: 'private' }) } });
expect(wrapper.vm.showOpenInCodeSandbox).toBe(false);
diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js
index 3d5ed4b5c0c..bb0d20bed91 100644
--- a/spec/frontend/ide/services/index_spec.js
+++ b/spec/frontend/ide/services/index_spec.js
@@ -1,11 +1,18 @@
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
import services from '~/ide/services';
import Api from '~/api';
+import { escapeFileUrl } from '~/ide/stores/utils';
jest.mock('~/api');
const TEST_PROJECT_ID = 'alice/wonderland';
const TEST_BRANCH = 'master-patch-123';
const TEST_COMMIT_SHA = '123456789';
+const TEST_FILE_PATH = 'README2.md';
+const TEST_FILE_OLD_PATH = 'OLD_README2.md';
+const TEST_FILE_PATH_SPECIAL = 'READM?ME/abc';
+const TEST_FILE_CONTENTS = 'raw file content';
describe('IDE services', () => {
describe('commit', () => {
@@ -28,4 +35,80 @@ describe('IDE services', () => {
expect(Api.commitMultiple).toHaveBeenCalledWith(TEST_PROJECT_ID, payload);
});
});
+
+ describe('getBaseRawFileData', () => {
+ let file;
+ let mock;
+
+ beforeEach(() => {
+ file = {
+ mrChange: null,
+ projectId: TEST_PROJECT_ID,
+ path: TEST_FILE_PATH,
+ };
+
+ jest.spyOn(axios, 'get');
+
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('gives back file.baseRaw for files with that property present', () => {
+ file.baseRaw = TEST_FILE_CONTENTS;
+
+ return services.getBaseRawFileData(file, TEST_COMMIT_SHA).then(content => {
+ expect(content).toEqual(TEST_FILE_CONTENTS);
+ });
+ });
+
+ it('gives back file.baseRaw for files for temp files', () => {
+ file.tempFile = true;
+ file.baseRaw = TEST_FILE_CONTENTS;
+
+ return services.getBaseRawFileData(file, TEST_COMMIT_SHA).then(content => {
+ expect(content).toEqual(TEST_FILE_CONTENTS);
+ });
+ });
+
+ describe.each`
+ relativeUrlRoot | filePath | isRenamed
+ ${''} | ${TEST_FILE_PATH} | ${false}
+ ${''} | ${TEST_FILE_OLD_PATH} | ${true}
+ ${''} | ${TEST_FILE_PATH_SPECIAL} | ${false}
+ ${''} | ${TEST_FILE_PATH_SPECIAL} | ${true}
+ ${'gitlab'} | ${TEST_FILE_OLD_PATH} | ${true}
+ `(
+ 'with relativeUrlRoot ($relativeUrlRoot) and filePath ($filePath) and isRenamed ($isRenamed)',
+ ({ relativeUrlRoot, filePath, isRenamed }) => {
+ beforeEach(() => {
+ if (isRenamed) {
+ file.mrChange = {
+ renamed_file: true,
+ old_path: filePath,
+ };
+ } else {
+ file.path = filePath;
+ }
+
+ gon.relative_url_root = relativeUrlRoot;
+
+ mock
+ .onGet(
+ `${relativeUrlRoot}/${TEST_PROJECT_ID}/raw/${TEST_COMMIT_SHA}/${escapeFileUrl(
+ filePath,
+ )}`,
+ )
+ .reply(200, TEST_FILE_CONTENTS);
+ });
+
+ it('fetches file content', () =>
+ services.getBaseRawFileData(file, TEST_COMMIT_SHA).then(content => {
+ expect(content).toEqual(TEST_FILE_CONTENTS);
+ }));
+ },
+ );
+ });
});
diff --git a/spec/frontend/ide/stores/modules/clientside/actions_spec.js b/spec/frontend/ide/stores/modules/clientside/actions_spec.js
new file mode 100644
index 00000000000..a47bc0bd711
--- /dev/null
+++ b/spec/frontend/ide/stores/modules/clientside/actions_spec.js
@@ -0,0 +1,39 @@
+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 * as actions from '~/ide/stores/modules/clientside/actions';
+
+const TEST_PROJECT_URL = `${TEST_HOST}/lorem/ipsum`;
+const TEST_USAGE_URL = `${TEST_PROJECT_URL}/usage_ping/web_ide_clientside_preview`;
+
+describe('IDE store module clientside actions', () => {
+ let rootGetters;
+ let mock;
+
+ beforeEach(() => {
+ rootGetters = {
+ currentProject: {
+ web_url: TEST_PROJECT_URL,
+ },
+ };
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('pingUsage', () => {
+ it('posts to usage endpoint', done => {
+ const usageSpy = jest.fn(() => [200]);
+
+ mock.onPost(TEST_USAGE_URL).reply(() => usageSpy());
+
+ testAction(actions.pingUsage, null, rootGetters, [], [], () => {
+ expect(usageSpy).toHaveBeenCalled();
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap b/spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap
new file mode 100644
index 00000000000..f57391a6b0d
--- /dev/null
+++ b/spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap
@@ -0,0 +1,15 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Issuables list component with empty issues response with all state should display a catch-all if there are no issues to show 1`] = `
+<glemptystate-stub
+ description="The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
+ svgpath="/emptySvg"
+ title="There are no issues to show"
+/>
+`;
+
+exports[`Issuables list component with empty issues response with closed state should display a message "There are no closed issues" if there are no closed issues 1`] = `"There are no closed issues"`;
+
+exports[`Issuables list component with empty issues response with empty query should display the message "There are no open issues" 1`] = `"There are no open issues"`;
+
+exports[`Issuables list component with empty issues response with query in window location should display "Sorry, your filter produced no results" if filters are too specific 1`] = `"Sorry, your filter produced no results"`;
diff --git a/spec/frontend/issuables_list/components/issuable_spec.js b/spec/frontend/issuables_list/components/issuable_spec.js
new file mode 100644
index 00000000000..6148f3c68f2
--- /dev/null
+++ b/spec/frontend/issuables_list/components/issuable_spec.js
@@ -0,0 +1,345 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLink } from '@gitlab/ui';
+import { TEST_HOST } from 'helpers/test_constants';
+import { trimText } from 'helpers/text_helper';
+import initUserPopovers from '~/user_popovers';
+import { formatDate } from '~/lib/utils/datetime_utility';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
+import Issuable from '~/issuables_list/components/issuable.vue';
+import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
+import { simpleIssue, testAssignees, testLabels } from '../issuable_list_test_data';
+
+jest.mock('~/user_popovers');
+
+const TEST_NOW = '2019-08-28T20:03:04.713Z';
+const TEST_MONTH_AGO = '2019-07-28';
+const TEST_MONTH_LATER = '2019-09-30';
+const DATE_FORMAT = 'mmm d, yyyy';
+const TEST_USER_NAME = 'Tyler Durden';
+const TEST_BASE_URL = `${TEST_HOST}/issues`;
+const TEST_TASK_STATUS = '50 of 100 tasks completed';
+const TEST_MILESTONE = {
+ title: 'Milestone title',
+ web_url: `${TEST_HOST}/milestone/1`,
+};
+const TEXT_CLOSED = 'CLOSED';
+const TEST_META_COUNT = 100;
+
+// Use FixedDate so that time sensitive info in snapshots don't fail
+class FixedDate extends Date {
+ constructor(date = TEST_NOW) {
+ super(date);
+ }
+}
+
+describe('Issuable component', () => {
+ let issuable;
+ let DateOrig;
+ let wrapper;
+
+ const factory = (props = {}) => {
+ wrapper = shallowMount(Issuable, {
+ propsData: {
+ issuable: simpleIssue,
+ baseUrl: TEST_BASE_URL,
+ ...props,
+ },
+ sync: false,
+ });
+ };
+
+ beforeEach(() => {
+ issuable = { ...simpleIssue };
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ beforeAll(() => {
+ DateOrig = window.Date;
+ window.Date = FixedDate;
+ });
+
+ afterAll(() => {
+ window.Date = DateOrig;
+ });
+
+ const findConfidentialIcon = () => wrapper.find('.fa-eye-slash');
+ const findTaskStatus = () => wrapper.find('.task-status');
+ const findOpenedAgoContainer = () => wrapper.find({ ref: 'openedAgoByContainer' });
+ const findMilestone = () => wrapper.find('.js-milestone');
+ const findMilestoneTooltip = () => findMilestone().attributes('data-original-title');
+ const findDueDate = () => wrapper.find('.js-due-date');
+ const findLabelContainer = () => wrapper.find('.js-labels');
+ const findLabelLinks = () => findLabelContainer().findAll(GlLink);
+ const findWeight = () => wrapper.find('.js-weight');
+ const findAssignees = () => wrapper.find(IssueAssignees);
+ const findMergeRequestsCount = () => wrapper.find('.js-merge-requests');
+ const findUpvotes = () => wrapper.find('.js-upvotes');
+ const findDownvotes = () => wrapper.find('.js-downvotes');
+ const findNotes = () => wrapper.find('.js-notes');
+ const findBulkCheckbox = () => wrapper.find('input.selected-issuable');
+
+ describe('when mounted', () => {
+ it('initializes user popovers', () => {
+ expect(initUserPopovers).not.toHaveBeenCalled();
+
+ factory();
+
+ expect(initUserPopovers).toHaveBeenCalledWith([findOpenedAgoContainer().find('a').element]);
+ });
+ });
+
+ describe('with simple issuable', () => {
+ beforeEach(() => {
+ Object.assign(issuable, {
+ has_tasks: false,
+ task_status: TEST_TASK_STATUS,
+ created_at: TEST_MONTH_AGO,
+ author: {
+ ...issuable.author,
+ name: TEST_USER_NAME,
+ },
+ labels: [],
+ });
+
+ factory({ issuable });
+ });
+
+ it.each`
+ desc | finder
+ ${'bulk editing checkbox'} | ${findBulkCheckbox}
+ ${'confidential icon'} | ${findConfidentialIcon}
+ ${'task status'} | ${findTaskStatus}
+ ${'milestone'} | ${findMilestone}
+ ${'due date'} | ${findDueDate}
+ ${'labels'} | ${findLabelContainer}
+ ${'weight'} | ${findWeight}
+ ${'merge request count'} | ${findMergeRequestsCount}
+ ${'upvotes'} | ${findUpvotes}
+ ${'downvotes'} | ${findDownvotes}
+ `('does not render $desc', ({ finder }) => {
+ expect(finder().exists()).toBe(false);
+ });
+
+ it('does not have closed text', () => {
+ expect(wrapper.text()).not.toContain(TEXT_CLOSED);
+ });
+
+ it('does not have closed class', () => {
+ expect(wrapper.classes('closed')).toBe(false);
+ });
+
+ it('renders fuzzy opened date and author', () => {
+ expect(trimText(findOpenedAgoContainer().text())).toEqual(
+ `opened 1 month ago by ${TEST_USER_NAME}`,
+ );
+ });
+
+ it('renders no comments', () => {
+ expect(findNotes().classes('no-comments')).toBe(true);
+ });
+ });
+
+ describe('with confidential issuable', () => {
+ beforeEach(() => {
+ issuable.confidential = true;
+
+ factory({ issuable });
+ });
+
+ it('renders the confidential icon', () => {
+ expect(findConfidentialIcon().exists()).toBe(true);
+ });
+ });
+
+ describe('with task status', () => {
+ beforeEach(() => {
+ Object.assign(issuable, {
+ has_tasks: true,
+ task_status: TEST_TASK_STATUS,
+ });
+
+ factory({ issuable });
+ });
+
+ it('renders task status', () => {
+ expect(findTaskStatus().exists()).toBe(true);
+ expect(findTaskStatus().text()).toBe(TEST_TASK_STATUS);
+ });
+ });
+
+ describe.each`
+ desc | dueDate | expectedTooltipPart
+ ${'past due'} | ${TEST_MONTH_AGO} | ${'Past due'}
+ ${'future due'} | ${TEST_MONTH_LATER} | ${'1 month remaining'}
+ `('with milestone with $desc', ({ dueDate, expectedTooltipPart }) => {
+ beforeEach(() => {
+ issuable.milestone = { ...TEST_MILESTONE, due_date: dueDate };
+
+ factory({ issuable });
+ });
+
+ it('renders milestone', () => {
+ expect(findMilestone().exists()).toBe(true);
+ expect(
+ findMilestone()
+ .find('.fa-clock-o')
+ .exists(),
+ ).toBe(true);
+ expect(findMilestone().text()).toEqual(TEST_MILESTONE.title);
+ });
+
+ it('renders tooltip', () => {
+ expect(findMilestoneTooltip()).toBe(
+ `${formatDate(dueDate, DATE_FORMAT)} (${expectedTooltipPart})`,
+ );
+ });
+
+ it('renders milestone with the correct href', () => {
+ const { title } = issuable.milestone;
+ const expected = mergeUrlParams({ milestone_title: title }, TEST_BASE_URL);
+
+ expect(findMilestone().attributes('href')).toBe(expected);
+ });
+ });
+
+ describe.each`
+ dueDate | hasClass | desc
+ ${TEST_MONTH_LATER} | ${false} | ${'with future due date'}
+ ${TEST_MONTH_AGO} | ${true} | ${'with past due date'}
+ `('$desc', ({ dueDate, hasClass }) => {
+ beforeEach(() => {
+ issuable.due_date = dueDate;
+
+ factory({ issuable });
+ });
+
+ it('renders due date', () => {
+ expect(findDueDate().exists()).toBe(true);
+ expect(findDueDate().text()).toBe(formatDate(dueDate, DATE_FORMAT));
+ });
+
+ it(hasClass ? 'has cred class' : 'does not have cred class', () => {
+ expect(findDueDate().classes('cred')).toEqual(hasClass);
+ });
+ });
+
+ describe('with labels', () => {
+ beforeEach(() => {
+ issuable.labels = [...testLabels];
+
+ factory({ issuable });
+ });
+
+ it('renders labels', () => {
+ factory({ issuable });
+
+ const labels = findLabelLinks().wrappers.map(label => ({
+ href: label.attributes('href'),
+ text: label.text(),
+ tooltip: label.find('span').attributes('data-original-title'),
+ }));
+
+ const expected = testLabels.map(label => ({
+ href: mergeUrlParams({ 'label_name[]': label.name }, TEST_BASE_URL),
+ text: label.name,
+ tooltip: label.description,
+ }));
+
+ expect(labels).toEqual(expected);
+ });
+ });
+
+ describe.each`
+ weight
+ ${0}
+ ${10}
+ ${12345}
+ `('with weight $weight', ({ weight }) => {
+ beforeEach(() => {
+ issuable.weight = weight;
+
+ factory({ issuable });
+ });
+
+ it('renders weight', () => {
+ expect(findWeight().exists()).toBe(true);
+ expect(findWeight().text()).toEqual(weight.toString());
+ });
+ });
+
+ describe('with closed state', () => {
+ beforeEach(() => {
+ issuable.state = 'closed';
+
+ factory({ issuable });
+ });
+
+ it('renders closed text', () => {
+ expect(wrapper.text()).toContain(TEXT_CLOSED);
+ });
+
+ it('has closed class', () => {
+ expect(wrapper.classes('closed')).toBe(true);
+ });
+ });
+
+ describe('with assignees', () => {
+ beforeEach(() => {
+ issuable.assignees = testAssignees;
+
+ factory({ issuable });
+ });
+
+ it('renders assignees', () => {
+ expect(findAssignees().exists()).toBe(true);
+ expect(findAssignees().props('assignees')).toEqual(testAssignees);
+ });
+ });
+
+ describe.each`
+ desc | key | finder
+ ${'with merge requests count'} | ${'merge_requests_count'} | ${findMergeRequestsCount}
+ ${'with upvote count'} | ${'upvotes'} | ${findUpvotes}
+ ${'with downvote count'} | ${'downvotes'} | ${findDownvotes}
+ ${'with notes count'} | ${'user_notes_count'} | ${findNotes}
+ `('$desc', ({ key, finder }) => {
+ beforeEach(() => {
+ issuable[key] = TEST_META_COUNT;
+
+ factory({ issuable });
+ });
+
+ it('renders merge requests count', () => {
+ expect(finder().exists()).toBe(true);
+ expect(finder().text()).toBe(TEST_META_COUNT.toString());
+ expect(finder().classes('no-comments')).toBe(false);
+ });
+ });
+
+ describe('with bulk editing', () => {
+ describe.each`
+ selected | desc
+ ${true} | ${'when selected'}
+ ${false} | ${'when unselected'}
+ `('$desc', ({ selected }) => {
+ beforeEach(() => {
+ factory({ isBulkEditing: true, selected });
+ });
+
+ it(`renders checked is ${selected}`, () => {
+ expect(findBulkCheckbox().element.checked).toBe(selected);
+ });
+
+ it('emits select when clicked', () => {
+ expect(wrapper.emitted().select).toBeUndefined();
+
+ findBulkCheckbox().trigger('click');
+
+ expect(wrapper.emitted().select).toEqual([[{ issuable, selected: !selected }]]);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/issuables_list/components/issuables_list_app_spec.js b/spec/frontend/issuables_list/components/issuables_list_app_spec.js
new file mode 100644
index 00000000000..e598a9c5a5d
--- /dev/null
+++ b/spec/frontend/issuables_list/components/issuables_list_app_spec.js
@@ -0,0 +1,410 @@
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { GlEmptyState, GlPagination, GlSkeletonLoading } from '@gitlab/ui';
+import flash from '~/flash';
+import waitForPromises from 'helpers/wait_for_promises';
+import { TEST_HOST } from 'helpers/test_constants';
+import IssuablesListApp from '~/issuables_list/components/issuables_list_app.vue';
+import Issuable from '~/issuables_list/components/issuable.vue';
+import issueablesEventBus from '~/issuables_list/eventhub';
+import { PAGE_SIZE, PAGE_SIZE_MANUAL, RELATIVE_POSITION } from '~/issuables_list/constants';
+
+jest.mock('~/flash', () => jest.fn());
+jest.mock('~/issuables_list/eventhub');
+
+const TEST_LOCATION = `${TEST_HOST}/issues`;
+const TEST_ENDPOINT = '/issues';
+const TEST_CREATE_ISSUES_PATH = '/createIssue';
+const TEST_EMPTY_SVG_PATH = '/emptySvg';
+
+const localVue = createLocalVue();
+
+const MOCK_ISSUES = Array(PAGE_SIZE_MANUAL)
+ .fill(0)
+ .map((_, i) => ({
+ id: i,
+ web_url: `url${i}`,
+ }));
+
+describe('Issuables list component', () => {
+ let oldLocation;
+ let mockAxios;
+ let wrapper;
+ let apiSpy;
+
+ const setupApiMock = cb => {
+ apiSpy = jest.fn(cb);
+
+ mockAxios.onGet(TEST_ENDPOINT).reply(cfg => apiSpy(cfg));
+ };
+
+ const factory = (props = { sortKey: 'priority' }) => {
+ wrapper = shallowMount(localVue.extend(IssuablesListApp), {
+ propsData: {
+ endpoint: TEST_ENDPOINT,
+ createIssuePath: TEST_CREATE_ISSUES_PATH,
+ emptySvgPath: TEST_EMPTY_SVG_PATH,
+ ...props,
+ },
+ localVue,
+ sync: false,
+ });
+ };
+
+ const findLoading = () => wrapper.find(GlSkeletonLoading);
+ const findIssuables = () => wrapper.findAll(Issuable);
+ const findFirstIssuable = () => findIssuables().wrappers[0];
+ const findEmptyState = () => wrapper.find(GlEmptyState);
+
+ beforeEach(() => {
+ mockAxios = new MockAdapter(axios);
+
+ oldLocation = window.location;
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: { href: '', search: '' },
+ });
+ window.location.href = TEST_LOCATION;
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mockAxios.restore();
+ jest.clearAllMocks();
+ window.location = oldLocation;
+ });
+
+ describe('with failed issues response', () => {
+ beforeEach(() => {
+ setupApiMock(() => [500]);
+
+ factory();
+
+ return waitForPromises();
+ });
+
+ it('does not show loading', () => {
+ expect(wrapper.vm.loading).toBe(false);
+ });
+
+ it('flashes an error', () => {
+ expect(flash).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('with successful issues response', () => {
+ beforeEach(() => {
+ setupApiMock(() => [
+ 200,
+ MOCK_ISSUES.slice(0, PAGE_SIZE),
+ {
+ 'x-total': 100,
+ 'x-page': 2,
+ },
+ ]);
+ });
+
+ it('has default props and data', () => {
+ factory();
+ expect(wrapper.vm).toMatchObject({
+ // Props
+ canBulkEdit: false,
+ createIssuePath: TEST_CREATE_ISSUES_PATH,
+ emptySvgPath: TEST_EMPTY_SVG_PATH,
+
+ // Data
+ filters: {
+ state: 'opened',
+ },
+ isBulkEditing: false,
+ issuables: [],
+ loading: true,
+ page: 1,
+ selection: {},
+ totalItems: 0,
+ });
+ });
+
+ it('does not call API until mounted', () => {
+ expect(apiSpy).not.toHaveBeenCalled();
+ });
+
+ describe('when mounted', () => {
+ beforeEach(() => {
+ factory();
+ });
+
+ it('calls API', () => {
+ expect(apiSpy).toHaveBeenCalled();
+ });
+
+ it('shows loading', () => {
+ expect(findLoading().exists()).toBe(true);
+ expect(findIssuables().length).toBe(0);
+ expect(findEmptyState().exists()).toBe(false);
+ });
+ });
+
+ describe('when finished loading', () => {
+ beforeEach(() => {
+ factory();
+
+ return waitForPromises();
+ });
+
+ it('does not display empty state', () => {
+ expect(wrapper.vm.issuables.length).toBeGreaterThan(0);
+ expect(wrapper.vm.emptyState).toEqual({});
+ expect(wrapper.contains(GlEmptyState)).toBe(false);
+ });
+
+ it('sets the proper page and total items', () => {
+ expect(wrapper.vm.totalItems).toBe(100);
+ expect(wrapper.vm.page).toBe(2);
+ });
+
+ it('renders one page of issuables and pagination', () => {
+ expect(findIssuables().length).toBe(PAGE_SIZE);
+ expect(wrapper.find(GlPagination).exists()).toBe(true);
+ });
+ });
+ });
+
+ describe('with bulk editing enabled', () => {
+ beforeEach(() => {
+ issueablesEventBus.$on.mockReset();
+ issueablesEventBus.$emit.mockReset();
+
+ setupApiMock(() => [200, MOCK_ISSUES.slice(0)]);
+ factory({ canBulkEdit: true });
+
+ return waitForPromises();
+ });
+
+ it('is not enabled by default', () => {
+ expect(wrapper.vm.isBulkEditing).toBe(false);
+ });
+
+ it('does not select issues by default', () => {
+ expect(wrapper.vm.selection).toEqual({});
+ });
+
+ it('"Select All" checkbox toggles all visible issuables"', () => {
+ wrapper.vm.onSelectAll();
+ expect(wrapper.vm.selection).toEqual(
+ wrapper.vm.issuables.reduce((acc, i) => ({ ...acc, [i.id]: true }), {}),
+ );
+
+ wrapper.vm.onSelectAll();
+ expect(wrapper.vm.selection).toEqual({});
+ });
+
+ it('"Select All checkbox" selects all issuables if only some are selected"', () => {
+ wrapper.vm.selection = { [wrapper.vm.issuables[0].id]: true };
+ wrapper.vm.onSelectAll();
+ expect(wrapper.vm.selection).toEqual(
+ wrapper.vm.issuables.reduce((acc, i) => ({ ...acc, [i.id]: true }), {}),
+ );
+ });
+
+ it('selects and deselects issuables', () => {
+ const [i0, i1, i2] = wrapper.vm.issuables;
+
+ expect(wrapper.vm.selection).toEqual({});
+ wrapper.vm.onSelectIssuable({ issuable: i0, selected: false });
+ expect(wrapper.vm.selection).toEqual({});
+ wrapper.vm.onSelectIssuable({ issuable: i1, selected: true });
+ expect(wrapper.vm.selection).toEqual({ '1': true });
+ wrapper.vm.onSelectIssuable({ issuable: i0, selected: true });
+ expect(wrapper.vm.selection).toEqual({ '1': true, '0': true });
+ wrapper.vm.onSelectIssuable({ issuable: i2, selected: true });
+ expect(wrapper.vm.selection).toEqual({ '1': true, '0': true, '2': true });
+ wrapper.vm.onSelectIssuable({ issuable: i2, selected: true });
+ expect(wrapper.vm.selection).toEqual({ '1': true, '0': true, '2': true });
+ wrapper.vm.onSelectIssuable({ issuable: i0, selected: false });
+ expect(wrapper.vm.selection).toEqual({ '1': true, '2': true });
+ });
+
+ it('broadcasts a message to the bulk edit sidebar when a value is added to selection', () => {
+ issueablesEventBus.$emit.mockReset();
+ const i1 = wrapper.vm.issuables[1];
+
+ wrapper.vm.onSelectIssuable({ issuable: i1, selected: true });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(issueablesEventBus.$emit).toHaveBeenCalledTimes(1);
+ expect(issueablesEventBus.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit');
+ });
+ });
+
+ it('does not broadcast a message to the bulk edit sidebar when a value is not added to selection', () => {
+ issueablesEventBus.$emit.mockReset();
+
+ return wrapper.vm
+ .$nextTick()
+ .then(waitForPromises)
+ .then(() => {
+ const i1 = wrapper.vm.issuables[1];
+
+ wrapper.vm.onSelectIssuable({ issuable: i1, selected: false });
+ })
+ .then(wrapper.vm.$nextTick)
+ .then(() => {
+ expect(issueablesEventBus.$emit).toHaveBeenCalledTimes(0);
+ });
+ });
+
+ it('listens to a message to toggle bulk editing', () => {
+ expect(wrapper.vm.isBulkEditing).toBe(false);
+ expect(issueablesEventBus.$on.mock.calls[0][0]).toBe('issuables:toggleBulkEdit');
+ issueablesEventBus.$on.mock.calls[0][1](true); // Call the message handler
+
+ return waitForPromises()
+ .then(() => {
+ expect(wrapper.vm.isBulkEditing).toBe(true);
+ issueablesEventBus.$on.mock.calls[0][1](false);
+ })
+ .then(() => {
+ expect(wrapper.vm.isBulkEditing).toBe(false);
+ });
+ });
+ });
+
+ describe('with query params in window.location', () => {
+ const query =
+ '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&utf8=%E2%9C%93&weight=0';
+ const expectedFilters = {
+ assignee_username: 'root',
+ author_username: 'root',
+ confidential: 'yes',
+ my_reaction_emoji: 'airplane',
+ scope: 'all',
+ state: 'opened',
+ utf8: '✓',
+ weight: '0',
+ milestone: 'v3.0',
+ labels: 'Aquapod,Astro',
+ order_by: 'milestone_due',
+ sort: 'desc',
+ };
+
+ beforeEach(() => {
+ window.location.href = `${TEST_LOCATION}${query}`;
+ window.location.search = query;
+ setupApiMock(() => [200, MOCK_ISSUES.slice(0)]);
+ factory({ sortKey: 'milestone_due_desc' });
+ return waitForPromises();
+ });
+
+ it('applies filters and sorts', () => {
+ expect(wrapper.vm.hasFilters).toBe(true);
+ expect(wrapper.vm.filters).toEqual(expectedFilters);
+
+ expect(apiSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ params: {
+ ...expectedFilters,
+ with_labels_details: true,
+ page: 1,
+ per_page: PAGE_SIZE,
+ },
+ }),
+ );
+ });
+
+ it('passes the base url to issuable', () => {
+ expect(findFirstIssuable().props('baseUrl')).toEqual(TEST_LOCATION);
+ });
+ });
+
+ describe('with hash in window.location', () => {
+ beforeEach(() => {
+ window.location.href = `${TEST_LOCATION}#stuff`;
+ setupApiMock(() => [200, MOCK_ISSUES.slice(0)]);
+ factory();
+ return waitForPromises();
+ });
+
+ it('passes the base url to issuable', () => {
+ expect(findFirstIssuable().props('baseUrl')).toEqual(TEST_LOCATION);
+ });
+ });
+
+ describe('with manual sort', () => {
+ beforeEach(() => {
+ setupApiMock(() => [200, MOCK_ISSUES.slice(0)]);
+ factory({ sortKey: RELATIVE_POSITION });
+ });
+
+ it('uses manual page size', () => {
+ expect(apiSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ params: expect.objectContaining({
+ per_page: PAGE_SIZE_MANUAL,
+ }),
+ }),
+ );
+ });
+ });
+
+ describe('with empty issues response', () => {
+ beforeEach(() => {
+ setupApiMock(() => [200, []]);
+ });
+
+ describe('with query in window location', () => {
+ beforeEach(() => {
+ window.location.search = '?weight=Any';
+
+ factory();
+
+ return waitForPromises().then(() => wrapper.vm.$nextTick());
+ });
+
+ it('should display "Sorry, your filter produced no results" if filters are too specific', () => {
+ expect(findEmptyState().props('title')).toMatchSnapshot();
+ });
+ });
+
+ describe('with closed state', () => {
+ beforeEach(() => {
+ window.location.search = '?state=closed';
+
+ factory();
+
+ return waitForPromises().then(() => wrapper.vm.$nextTick());
+ });
+
+ it('should display a message "There are no closed issues" if there are no closed issues', () => {
+ expect(findEmptyState().props('title')).toMatchSnapshot();
+ });
+ });
+
+ describe('with all state', () => {
+ beforeEach(() => {
+ window.location.search = '?state=all';
+
+ factory();
+
+ return waitForPromises().then(() => wrapper.vm.$nextTick());
+ });
+
+ it('should display a catch-all if there are no issues to show', () => {
+ expect(findEmptyState().element).toMatchSnapshot();
+ });
+ });
+
+ describe('with empty query', () => {
+ beforeEach(() => {
+ factory();
+
+ return wrapper.vm.$nextTick().then(waitForPromises);
+ });
+
+ it('should display the message "There are no open issues"', () => {
+ expect(findEmptyState().props('title')).toMatchSnapshot();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/issuables_list/issuable_list_test_data.js b/spec/frontend/issuables_list/issuable_list_test_data.js
new file mode 100644
index 00000000000..617780fd736
--- /dev/null
+++ b/spec/frontend/issuables_list/issuable_list_test_data.js
@@ -0,0 +1,72 @@
+export const simpleIssue = {
+ id: 442,
+ iid: 31,
+ title: 'Dismiss Cipher with no integrity',
+ state: 'opened',
+ created_at: '2019-08-26T19:06:32.667Z',
+ updated_at: '2019-08-28T19:53:58.314Z',
+ labels: [],
+ milestone: null,
+ assignees: [],
+ author: {
+ id: 3,
+ name: 'Elnora Bernhard',
+ username: 'treva.lesch',
+ state: 'active',
+ avatar_url: 'https://www.gravatar.com/avatar/a8c0d9c2882406cf2a9b71494625a796?s=80&d=identicon',
+ web_url: 'http://localhost:3001/treva.lesch',
+ },
+ assignee: null,
+ user_notes_count: 0,
+ merge_requests_count: 0,
+ upvotes: 0,
+ downvotes: 0,
+ due_date: null,
+ confidential: false,
+ web_url: 'http://localhost:3001/h5bp/html5-boilerplate/issues/31',
+ has_tasks: false,
+ weight: null,
+};
+
+export const testLabels = [
+ {
+ id: 1,
+ name: 'Tanuki',
+ description: 'A cute animal',
+ color: '#ff0000',
+ text_color: '#ffffff',
+ },
+ {
+ id: 2,
+ name: 'Octocat',
+ description: 'A grotesque mish-mash of whiskers and tentacles',
+ color: '#333333',
+ text_color: '#000000',
+ },
+ {
+ id: 3,
+ name: 'scoped::label',
+ description: 'A scoped label',
+ color: '#00ff00',
+ text_color: '#ffffff',
+ },
+];
+
+export const testAssignees = [
+ {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ state: 'active',
+ avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ web_url: 'http://localhost:3001/root',
+ },
+ {
+ id: 22,
+ name: 'User 0',
+ username: 'user0',
+ state: 'active',
+ avatar_url: 'https://www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80&d=identicon',
+ web_url: 'http://localhost:3001/user0',
+ },
+];
diff --git a/spec/frontend/issue_show/helpers.js b/spec/frontend/issue_show/helpers.js
new file mode 100644
index 00000000000..5d2ced98ae4
--- /dev/null
+++ b/spec/frontend/issue_show/helpers.js
@@ -0,0 +1,10 @@
+// eslint-disable-next-line import/prefer-default-export
+export const keyboardDownEvent = (code, metaKey = false, ctrlKey = false) => {
+ const e = new CustomEvent('keydown');
+
+ e.keyCode = code;
+ e.metaKey = metaKey;
+ e.ctrlKey = ctrlKey;
+
+ return e;
+};
diff --git a/spec/frontend/jobs/components/log/log_spec.js b/spec/frontend/jobs/components/log/log_spec.js
index cc334009982..7c834542a9a 100644
--- a/spec/frontend/jobs/components/log/log_spec.js
+++ b/spec/frontend/jobs/components/log/log_spec.js
@@ -60,8 +60,8 @@ describe('Job Log', () => {
expect(wrapper.find('.collapsible-line').attributes('role')).toBe('button');
});
- it('renders an icon with the closed state', () => {
- expect(wrapper.find('.collapsible-line svg').classes()).toContain('ic-angle-right');
+ it('renders an icon with the open state', () => {
+ expect(wrapper.find('.collapsible-line svg').classes()).toContain('ic-angle-down');
});
describe('on click header section', () => {
diff --git a/spec/frontend/jobs/store/utils_spec.js b/spec/frontend/jobs/store/utils_spec.js
index 43dacfe622c..8819f39dee0 100644
--- a/spec/frontend/jobs/store/utils_spec.js
+++ b/spec/frontend/jobs/store/utils_spec.js
@@ -26,7 +26,7 @@ describe('Jobs Store Utils', () => {
const parsedHeaderLine = parseHeaderLine(headerLine, 2);
expect(parsedHeaderLine).toEqual({
- isClosed: true,
+ isClosed: false,
isHeader: true,
line: {
...headerLine,
@@ -57,7 +57,7 @@ describe('Jobs Store Utils', () => {
it('adds the section duration to the correct header', () => {
const parsed = [
{
- isClosed: true,
+ isClosed: false,
isHeader: true,
line: {
section: 'prepare-script',
@@ -66,7 +66,7 @@ describe('Jobs Store Utils', () => {
lines: [],
},
{
- isClosed: true,
+ isClosed: false,
isHeader: true,
line: {
section: 'foo-bar',
@@ -85,7 +85,7 @@ describe('Jobs Store Utils', () => {
it('does not add the section duration when the headers do not match', () => {
const parsed = [
{
- isClosed: true,
+ isClosed: false,
isHeader: true,
line: {
section: 'bar-foo',
@@ -94,7 +94,7 @@ describe('Jobs Store Utils', () => {
lines: [],
},
{
- isClosed: true,
+ isClosed: false,
isHeader: true,
line: {
section: 'foo-bar',
@@ -183,7 +183,7 @@ describe('Jobs Store Utils', () => {
describe('collpasible section', () => {
it('adds a `isClosed` property', () => {
- expect(result[1].isClosed).toEqual(true);
+ expect(result[1].isClosed).toEqual(false);
});
it('adds a `isHeader` property', () => {
@@ -213,7 +213,7 @@ describe('Jobs Store Utils', () => {
const existingLog = [
{
isHeader: true,
- isClosed: true,
+ isClosed: false,
line: { content: [{ text: 'bar' }], offset: 10, lineNumber: 1 },
},
];
@@ -263,7 +263,7 @@ describe('Jobs Store Utils', () => {
const existingLog = [
{
isHeader: true,
- isClosed: true,
+ isClosed: false,
lines: [{ offset: 101, content: [{ text: 'foobar' }], lineNumber: 2 }],
line: {
offset: 10,
@@ -435,7 +435,7 @@ describe('Jobs Store Utils', () => {
expect(result).toEqual([
{
- isClosed: true,
+ isClosed: false,
isHeader: true,
line: {
offset: 1,
@@ -461,7 +461,7 @@ describe('Jobs Store Utils', () => {
expect(result).toEqual([
{
- isClosed: true,
+ isClosed: false,
isHeader: true,
line: {
offset: 1,
diff --git a/spec/frontend/lib/utils/chart_utils_spec.js b/spec/frontend/lib/utils/chart_utils_spec.js
new file mode 100644
index 00000000000..e811b8405fb
--- /dev/null
+++ b/spec/frontend/lib/utils/chart_utils_spec.js
@@ -0,0 +1,11 @@
+import { firstAndLastY } from '~/lib/utils/chart_utils';
+
+describe('Chart utils', () => {
+ describe('firstAndLastY', () => {
+ it('returns the first and last y-values of a given data set as an array', () => {
+ const data = [['', 1], ['', 2], ['', 3]];
+
+ expect(firstAndLastY(data)).toEqual([1, 3]);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js
index e2e71229320..ee27789b6b9 100644
--- a/spec/frontend/lib/utils/datetime_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime_utility_spec.js
@@ -428,16 +428,57 @@ describe('newDate', () => {
});
describe('getDateInPast', () => {
- const date = new Date(1563235200000); // 2019-07-16T00:00:00.000Z;
+ const date = new Date('2019-07-16T00:00:00.000Z');
const daysInPast = 90;
it('returns the correct date in the past', () => {
const dateInPast = datetimeUtility.getDateInPast(date, daysInPast);
- expect(dateInPast).toBe('2019-04-17T00:00:00.000Z');
+ const expectedDateInPast = new Date('2019-04-17T00:00:00.000Z');
+
+ expect(dateInPast).toStrictEqual(expectedDateInPast);
});
it('does not modifiy the original date', () => {
datetimeUtility.getDateInPast(date, daysInPast);
- expect(date).toStrictEqual(new Date(1563235200000));
+ expect(date).toStrictEqual(new Date('2019-07-16T00:00:00.000Z'));
+ });
+});
+
+describe('getDatesInRange', () => {
+ it('returns an empty array if 1st or 2nd argument is not a Date object', () => {
+ const d1 = new Date('2019-01-01');
+ const d2 = 90;
+ const range = datetimeUtility.getDatesInRange(d1, d2);
+
+ expect(range).toEqual([]);
+ });
+
+ it('returns a range of dates between two given dates', () => {
+ const d1 = new Date('2019-01-01');
+ const d2 = new Date('2019-01-31');
+
+ const range = datetimeUtility.getDatesInRange(d1, d2);
+
+ expect(range.length).toEqual(31);
+ });
+
+ it('applies mapper function if provided fro each item in range', () => {
+ const d1 = new Date('2019-01-01');
+ const d2 = new Date('2019-01-31');
+ const formatter = date => date.getDate();
+
+ const range = datetimeUtility.getDatesInRange(d1, d2, formatter);
+
+ range.forEach((formattedItem, index) => {
+ expect(formattedItem).toEqual(index + 1);
+ });
+ });
+});
+
+describe('secondsToMilliseconds', () => {
+ it('converts seconds to milliseconds correctly', () => {
+ expect(datetimeUtility.secondsToMilliseconds(0)).toBe(0);
+ expect(datetimeUtility.secondsToMilliseconds(60)).toBe(60000);
+ expect(datetimeUtility.secondsToMilliseconds(123)).toBe(123000);
});
});
diff --git a/spec/frontend/lib/utils/number_utility_spec.js b/spec/frontend/lib/utils/number_utility_spec.js
index 381d7c6f8d9..2f8f1092612 100644
--- a/spec/frontend/lib/utils/number_utility_spec.js
+++ b/spec/frontend/lib/utils/number_utility_spec.js
@@ -7,6 +7,8 @@ import {
sum,
isOdd,
median,
+ changeInPercent,
+ formattedChangeInPercent,
} from '~/lib/utils/number_utils';
describe('Number Utils', () => {
@@ -122,4 +124,42 @@ describe('Number Utils', () => {
expect(median(items)).toBe(14.5);
});
});
+
+ describe('changeInPercent', () => {
+ it.each`
+ firstValue | secondValue | expectedOutput
+ ${99} | ${100} | ${1}
+ ${100} | ${99} | ${-1}
+ ${0} | ${99} | ${Infinity}
+ ${2} | ${2} | ${0}
+ ${-100} | ${-99} | ${1}
+ `(
+ 'computes the change between $firstValue and $secondValue in percent',
+ ({ firstValue, secondValue, expectedOutput }) => {
+ expect(changeInPercent(firstValue, secondValue)).toBe(expectedOutput);
+ },
+ );
+ });
+
+ describe('formattedChangeInPercent', () => {
+ it('prepends "%" to the output', () => {
+ expect(formattedChangeInPercent(1, 2)).toMatch(/%$/);
+ });
+
+ it('indicates if the change was a decrease', () => {
+ expect(formattedChangeInPercent(100, 99)).toContain('-1');
+ });
+
+ it('indicates if the change was an increase', () => {
+ expect(formattedChangeInPercent(99, 100)).toContain('+1');
+ });
+
+ it('shows "-" per default if the change can not be expressed in an integer', () => {
+ expect(formattedChangeInPercent(0, 1)).toBe('-');
+ });
+
+ it('shows the given fallback if the change can not be expressed in an integer', () => {
+ expect(formattedChangeInPercent(0, 1, { nonFiniteResult: '*' })).toBe('*');
+ });
+ });
});
diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js
index b6f1aef9ce4..deb6dab772e 100644
--- a/spec/frontend/lib/utils/text_utility_spec.js
+++ b/spec/frontend/lib/utils/text_utility_spec.js
@@ -90,6 +90,19 @@ describe('text_utility', () => {
});
});
+ describe('convertToSnakeCase', () => {
+ it.each`
+ txt | result
+ ${'snakeCase'} | ${'snake_case'}
+ ${'snake Case'} | ${'snake_case'}
+ ${'snake case'} | ${'snake_case'}
+ ${'snake_case'} | ${'snake_case'}
+ ${'snakeCasesnake Case'} | ${'snake_casesnake_case'}
+ `('converts string $txt to $result string', ({ txt, result }) => {
+ expect(textUtils.convertToSnakeCase(txt)).toEqual(result);
+ });
+ });
+
describe('convertToSentenceCase', () => {
it('converts Sentence Case to Sentence case', () => {
expect(textUtils.convertToSentenceCase('Hello World')).toBe('Hello world');
diff --git a/spec/frontend/monitoring/charts/time_series_spec.js b/spec/frontend/monitoring/charts/time_series_spec.js
new file mode 100644
index 00000000000..554535418fe
--- /dev/null
+++ b/spec/frontend/monitoring/charts/time_series_spec.js
@@ -0,0 +1,397 @@
+import { shallowMount } from '@vue/test-utils';
+import { setTestTimeout } from 'helpers/timeout';
+import { createStore } from '~/monitoring/stores';
+import { GlLink } from '@gitlab/ui';
+import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
+import { shallowWrapperContainsSlotText } from 'helpers/vue_test_utils_helper';
+import TimeSeries from '~/monitoring/components/charts/time_series.vue';
+import * as types from '~/monitoring/stores/mutation_types';
+import {
+ deploymentData,
+ metricsGroupsAPIResponse,
+ mockedQueryResultPayload,
+ mockProjectDir,
+ mockHost,
+} from '../mock_data';
+
+import * as iconUtils from '~/lib/utils/icon_utils';
+
+const mockSvgPathContent = 'mockSvgPathContent';
+const mockWidgets = 'mockWidgets';
+
+jest.mock('~/lib/utils/icon_utils', () => ({
+ getSvgIconPathContent: jest.fn().mockImplementation(
+ () =>
+ new Promise(resolve => {
+ resolve(mockSvgPathContent);
+ }),
+ ),
+}));
+
+describe('Time series component', () => {
+ let mockGraphData;
+ let makeTimeSeriesChart;
+ let store;
+
+ beforeEach(() => {
+ setTestTimeout(1000);
+
+ store = createStore();
+
+ store.commit(
+ `monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`,
+ metricsGroupsAPIResponse,
+ );
+
+ store.commit(`monitoringDashboard/${types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS}`, deploymentData);
+
+ // Mock data contains 2 panels, pick the first one
+ store.commit(`monitoringDashboard/${types.SET_QUERY_RESULT}`, mockedQueryResultPayload);
+
+ [mockGraphData] = store.state.monitoringDashboard.dashboard.panel_groups[0].metrics;
+
+ makeTimeSeriesChart = (graphData, type) =>
+ shallowMount(TimeSeries, {
+ propsData: {
+ graphData: { ...graphData, type },
+ deploymentData: store.state.monitoringDashboard.deploymentData,
+ projectPath: `${mockHost}${mockProjectDir}`,
+ },
+ slots: {
+ default: mockWidgets,
+ },
+ sync: false,
+ store,
+ attachToDocument: true,
+ });
+ });
+
+ describe('general functions', () => {
+ let timeSeriesChart;
+
+ beforeEach(done => {
+ timeSeriesChart = makeTimeSeriesChart(mockGraphData, 'area-chart');
+ timeSeriesChart.vm.$nextTick(done);
+ });
+
+ it('renders chart title', () => {
+ expect(timeSeriesChart.find('.js-graph-title').text()).toBe(mockGraphData.title);
+ });
+
+ it('contains graph widgets from slot', () => {
+ expect(timeSeriesChart.find('.js-graph-widgets').text()).toBe(mockWidgets);
+ });
+
+ it('allows user to override max value label text using prop', () => {
+ timeSeriesChart.setProps({ legendMaxText: 'legendMaxText' });
+
+ expect(timeSeriesChart.props().legendMaxText).toBe('legendMaxText');
+ });
+
+ it('allows user to override average value label text using prop', () => {
+ timeSeriesChart.setProps({ legendAverageText: 'averageText' });
+
+ expect(timeSeriesChart.props().legendAverageText).toBe('averageText');
+ });
+
+ describe('methods', () => {
+ describe('formatTooltipText', () => {
+ let mockDate;
+ let mockCommitUrl;
+ let generateSeriesData;
+
+ beforeEach(() => {
+ mockDate = deploymentData[0].created_at;
+ mockCommitUrl = deploymentData[0].commitUrl;
+ generateSeriesData = type => ({
+ seriesData: [
+ {
+ seriesName: timeSeriesChart.vm.chartData[0].name,
+ componentSubType: type,
+ value: [mockDate, 5.55555],
+ dataIndex: 0,
+ },
+ ],
+ value: mockDate,
+ });
+ });
+
+ describe('when series is of line type', () => {
+ beforeEach(done => {
+ timeSeriesChart.vm.formatTooltipText(generateSeriesData('line'));
+ timeSeriesChart.vm.$nextTick(done);
+ });
+
+ it('formats tooltip title', () => {
+ expect(timeSeriesChart.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM');
+ });
+
+ it('formats tooltip content', () => {
+ const name = 'Pod average';
+ const value = '5.556';
+ const dataIndex = 0;
+ const seriesLabel = timeSeriesChart.find(GlChartSeriesLabel);
+
+ expect(seriesLabel.vm.color).toBe('');
+ expect(shallowWrapperContainsSlotText(seriesLabel, 'default', name)).toBe(true);
+ expect(timeSeriesChart.vm.tooltip.content).toEqual([
+ { name, value, dataIndex, color: undefined },
+ ]);
+
+ expect(
+ shallowWrapperContainsSlotText(
+ timeSeriesChart.find(GlAreaChart),
+ 'tooltipContent',
+ value,
+ ),
+ ).toBe(true);
+ });
+ });
+
+ describe('when series is of scatter type, for deployments', () => {
+ beforeEach(() => {
+ timeSeriesChart.vm.formatTooltipText(generateSeriesData('scatter'));
+ });
+
+ it('formats tooltip title', () => {
+ expect(timeSeriesChart.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM');
+ });
+
+ it('formats tooltip sha', () => {
+ expect(timeSeriesChart.vm.tooltip.sha).toBe('f5bcd1d9');
+ });
+
+ it('formats tooltip commit url', () => {
+ expect(timeSeriesChart.vm.tooltip.commitUrl).toBe(mockCommitUrl);
+ });
+ });
+ });
+
+ describe('setSvg', () => {
+ const mockSvgName = 'mockSvgName';
+
+ beforeEach(done => {
+ timeSeriesChart.vm.setSvg(mockSvgName);
+ timeSeriesChart.vm.$nextTick(done);
+ });
+
+ it('gets svg path content', () => {
+ expect(iconUtils.getSvgIconPathContent).toHaveBeenCalledWith(mockSvgName);
+ });
+
+ it('sets svg path content', () => {
+ timeSeriesChart.vm.$nextTick(() => {
+ expect(timeSeriesChart.vm.svgs[mockSvgName]).toBe(`path://${mockSvgPathContent}`);
+ });
+ });
+
+ it('contains an svg object within an array to properly render icon', () => {
+ timeSeriesChart.vm.$nextTick(() => {
+ expect(timeSeriesChart.vm.chartOptions.dataZoom).toEqual([
+ {
+ handleIcon: `path://${mockSvgPathContent}`,
+ },
+ ]);
+ });
+ });
+ });
+
+ describe('onResize', () => {
+ const mockWidth = 233;
+
+ beforeEach(() => {
+ jest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => ({
+ width: mockWidth,
+ }));
+ timeSeriesChart.vm.onResize();
+ });
+
+ it('sets area chart width', () => {
+ expect(timeSeriesChart.vm.width).toBe(mockWidth);
+ });
+ });
+ });
+
+ describe('computed', () => {
+ describe('chartData', () => {
+ let chartData;
+ const seriesData = () => chartData[0];
+
+ beforeEach(() => {
+ ({ chartData } = timeSeriesChart.vm);
+ });
+
+ it('utilizes all data points', () => {
+ const { values } = mockGraphData.queries[0].result[0];
+
+ expect(chartData.length).toBe(1);
+ expect(seriesData().data.length).toBe(values.length);
+ });
+
+ it('creates valid data', () => {
+ const { data } = seriesData();
+
+ expect(
+ data.filter(
+ ([time, value]) => new Date(time).getTime() > 0 && typeof value === 'number',
+ ).length,
+ ).toBe(data.length);
+ });
+
+ it('formats line width correctly', () => {
+ expect(chartData[0].lineStyle.width).toBe(2);
+ });
+ });
+
+ describe('chartOptions', () => {
+ describe('are extended by `option`', () => {
+ const mockSeriesName = 'Extra series 1';
+ const mockOption = {
+ option1: 'option1',
+ option2: 'option2',
+ };
+
+ it('arbitrary options', () => {
+ timeSeriesChart.setProps({
+ option: mockOption,
+ });
+
+ expect(timeSeriesChart.vm.chartOptions).toEqual(expect.objectContaining(mockOption));
+ });
+
+ it('additional series', () => {
+ timeSeriesChart.setProps({
+ option: {
+ series: [
+ {
+ name: mockSeriesName,
+ },
+ ],
+ },
+ });
+
+ const optionSeries = timeSeriesChart.vm.chartOptions.series;
+
+ expect(optionSeries.length).toEqual(2);
+ expect(optionSeries[0].name).toEqual(mockSeriesName);
+ });
+ });
+
+ describe('yAxis formatter', () => {
+ let format;
+
+ beforeEach(() => {
+ format = timeSeriesChart.vm.chartOptions.yAxis.axisLabel.formatter;
+ });
+
+ it('rounds to 3 decimal places', () => {
+ expect(format(0.88888)).toBe('0.889');
+ });
+ });
+ });
+
+ describe('scatterSeries', () => {
+ it('utilizes deployment data', () => {
+ expect(timeSeriesChart.vm.scatterSeries.data).toEqual([
+ ['2019-07-16T10:14:25.589Z', 0],
+ ['2019-07-16T11:14:25.589Z', 0],
+ ['2019-07-16T12:14:25.589Z', 0],
+ ]);
+
+ expect(timeSeriesChart.vm.scatterSeries.symbolSize).toBe(14);
+ });
+ });
+
+ describe('yAxisLabel', () => {
+ it('constructs a label for the chart y-axis', () => {
+ expect(timeSeriesChart.vm.yAxisLabel).toBe('Memory Used per Pod');
+ });
+ });
+ });
+
+ afterEach(() => {
+ timeSeriesChart.destroy();
+ });
+ });
+
+ describe('wrapped components', () => {
+ const glChartComponents = [
+ {
+ chartType: 'area-chart',
+ component: GlAreaChart,
+ },
+ {
+ chartType: 'line-chart',
+ component: GlLineChart,
+ },
+ ];
+
+ glChartComponents.forEach(dynamicComponent => {
+ describe(`GitLab UI: ${dynamicComponent.chartType}`, () => {
+ let timeSeriesAreaChart;
+ let glChart;
+
+ beforeEach(done => {
+ timeSeriesAreaChart = makeTimeSeriesChart(mockGraphData, dynamicComponent.chartType);
+ glChart = timeSeriesAreaChart.find(dynamicComponent.component);
+ timeSeriesAreaChart.vm.$nextTick(done);
+ });
+
+ afterEach(() => {
+ timeSeriesAreaChart.destroy();
+ });
+
+ it('is a Vue instance', () => {
+ expect(glChart.exists()).toBe(true);
+ expect(glChart.isVueInstance()).toBe(true);
+ });
+
+ it('receives data properties needed for proper chart render', () => {
+ const props = glChart.props();
+
+ expect(props.data).toBe(timeSeriesAreaChart.vm.chartData);
+ expect(props.option).toBe(timeSeriesAreaChart.vm.chartOptions);
+ expect(props.formatTooltipText).toBe(timeSeriesAreaChart.vm.formatTooltipText);
+ expect(props.thresholds).toBe(timeSeriesAreaChart.vm.thresholds);
+ });
+
+ it('recieves a tooltip title', done => {
+ const mockTitle = 'mockTitle';
+ timeSeriesAreaChart.vm.tooltip.title = mockTitle;
+
+ timeSeriesAreaChart.vm.$nextTick(() => {
+ expect(shallowWrapperContainsSlotText(glChart, 'tooltipTitle', mockTitle)).toBe(true);
+ done();
+ });
+ });
+
+ describe('when tooltip is showing deployment data', () => {
+ const mockSha = 'mockSha';
+ const commitUrl = `${mockProjectDir}/commit/${mockSha}`;
+
+ beforeEach(done => {
+ timeSeriesAreaChart.vm.tooltip.isDeployment = true;
+ timeSeriesAreaChart.vm.$nextTick(done);
+ });
+
+ it('uses deployment title', () => {
+ expect(shallowWrapperContainsSlotText(glChart, 'tooltipTitle', 'Deployed')).toBe(true);
+ });
+
+ it('renders clickable commit sha in tooltip content', done => {
+ timeSeriesAreaChart.vm.tooltip.sha = mockSha;
+ timeSeriesAreaChart.vm.tooltip.commitUrl = commitUrl;
+
+ timeSeriesAreaChart.vm.$nextTick(() => {
+ const commitLink = timeSeriesAreaChart.find(GlLink);
+
+ expect(shallowWrapperContainsSlotText(commitLink, 'default', mockSha)).toBe(true);
+ expect(commitLink.attributes('href')).toEqual(commitUrl);
+ done();
+ });
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/monitoring/components/charts/anomaly_spec.js b/spec/frontend/monitoring/components/charts/anomaly_spec.js
new file mode 100644
index 00000000000..6707d0b1fe8
--- /dev/null
+++ b/spec/frontend/monitoring/components/charts/anomaly_spec.js
@@ -0,0 +1,303 @@
+import Anomaly from '~/monitoring/components/charts/anomaly.vue';
+
+import { shallowMount } from '@vue/test-utils';
+import { colorValues } from '~/monitoring/constants';
+import {
+ anomalyDeploymentData,
+ mockProjectDir,
+ anomalyMockGraphData,
+ anomalyMockResultValues,
+} from '../../mock_data';
+import { TEST_HOST } from 'helpers/test_constants';
+import MonitorTimeSeriesChart from '~/monitoring/components/charts/time_series.vue';
+
+const mockWidgets = 'mockWidgets';
+const mockProjectPath = `${TEST_HOST}${mockProjectDir}`;
+
+jest.mock('~/lib/utils/icon_utils'); // mock getSvgIconPathContent
+
+const makeAnomalyGraphData = (datasetName, template = anomalyMockGraphData) => {
+ const queries = anomalyMockResultValues[datasetName].map((values, index) => ({
+ ...template.queries[index],
+ result: [
+ {
+ metrics: {},
+ values,
+ },
+ ],
+ }));
+ return { ...template, queries };
+};
+
+describe('Anomaly chart component', () => {
+ let wrapper;
+
+ const setupAnomalyChart = props => {
+ wrapper = shallowMount(Anomaly, {
+ propsData: { ...props },
+ slots: {
+ default: mockWidgets,
+ },
+ sync: false,
+ });
+ };
+ const findTimeSeries = () => wrapper.find(MonitorTimeSeriesChart);
+ const getTimeSeriesProps = () => findTimeSeries().props();
+
+ describe('wrapped monitor-time-series-chart component', () => {
+ const dataSetName = 'noAnomaly';
+ const dataSet = anomalyMockResultValues[dataSetName];
+ const inputThresholds = ['some threshold'];
+
+ beforeEach(() => {
+ setupAnomalyChart({
+ graphData: makeAnomalyGraphData(dataSetName),
+ deploymentData: anomalyDeploymentData,
+ thresholds: inputThresholds,
+ projectPath: mockProjectPath,
+ });
+ });
+
+ it('is a Vue instance', () => {
+ expect(findTimeSeries().exists()).toBe(true);
+ expect(findTimeSeries().isVueInstance()).toBe(true);
+ });
+
+ describe('receives props correctly', () => {
+ describe('graph-data', () => {
+ it('receives a single "metric" series', () => {
+ const { graphData } = getTimeSeriesProps();
+ expect(graphData.queries.length).toBe(1);
+ });
+
+ it('receives "metric" with all data', () => {
+ const { graphData } = getTimeSeriesProps();
+ const query = graphData.queries[0];
+ const expectedQuery = makeAnomalyGraphData(dataSetName).queries[0];
+ expect(query).toEqual(expectedQuery);
+ });
+
+ it('receives the "metric" results', () => {
+ const { graphData } = getTimeSeriesProps();
+ const { result } = graphData.queries[0];
+ const { values } = result[0];
+ const [metricDataset] = dataSet;
+ expect(values).toEqual(expect.any(Array));
+
+ values.forEach(([, y], index) => {
+ expect(y).toBeCloseTo(metricDataset[index][1]);
+ });
+ });
+ });
+
+ describe('option', () => {
+ let option;
+ let series;
+
+ beforeEach(() => {
+ ({ option } = getTimeSeriesProps());
+ ({ series } = option);
+ });
+
+ it('contains a boundary band', () => {
+ expect(series).toEqual(expect.any(Array));
+ expect(series.length).toEqual(2); // 1 upper + 1 lower boundaries
+ expect(series[0].stack).toEqual(series[1].stack);
+
+ series.forEach(s => {
+ expect(s.type).toBe('line');
+ expect(s.lineStyle.width).toBe(0);
+ expect(s.lineStyle.color).toMatch(/rgba\(.+\)/);
+ expect(s.lineStyle.color).toMatch(s.color);
+ expect(s.symbol).toEqual('none');
+ });
+ });
+
+ it('upper boundary values are stacked on top of lower boundary', () => {
+ const [lowerSeries, upperSeries] = series;
+ const [, upperDataset, lowerDataset] = dataSet;
+
+ lowerSeries.data.forEach(([, y], i) => {
+ expect(y).toBeCloseTo(lowerDataset[i][1]);
+ });
+
+ upperSeries.data.forEach(([, y], i) => {
+ expect(y).toBeCloseTo(upperDataset[i][1] - lowerDataset[i][1]);
+ });
+ });
+ });
+
+ describe('series-config', () => {
+ let seriesConfig;
+
+ beforeEach(() => {
+ ({ seriesConfig } = getTimeSeriesProps());
+ });
+
+ it('display symbols is enabled', () => {
+ expect(seriesConfig).toEqual(
+ expect.objectContaining({
+ type: 'line',
+ symbol: 'circle',
+ showSymbol: true,
+ symbolSize: expect.any(Function),
+ itemStyle: {
+ color: expect.any(Function),
+ },
+ }),
+ );
+ });
+ it('does not display anomalies', () => {
+ const { symbolSize, itemStyle } = seriesConfig;
+ const [metricDataset] = dataSet;
+
+ metricDataset.forEach((v, dataIndex) => {
+ const size = symbolSize(null, { dataIndex });
+ const color = itemStyle.color({ dataIndex });
+
+ // normal color and small size
+ expect(size).toBeCloseTo(0);
+ expect(color).toBe(colorValues.primaryColor);
+ });
+ });
+
+ it('can format y values (to use in tooltips)', () => {
+ expect(parseFloat(wrapper.vm.yValueFormatted(0, 0))).toEqual(dataSet[0][0][1]);
+ expect(parseFloat(wrapper.vm.yValueFormatted(1, 0))).toEqual(dataSet[1][0][1]);
+ expect(parseFloat(wrapper.vm.yValueFormatted(2, 0))).toEqual(dataSet[2][0][1]);
+ });
+ });
+
+ describe('inherited properties', () => {
+ it('"deployment-data" keeps the same value', () => {
+ const { deploymentData } = getTimeSeriesProps();
+ expect(deploymentData).toEqual(anomalyDeploymentData);
+ });
+ it('"thresholds" keeps the same value', () => {
+ const { thresholds } = getTimeSeriesProps();
+ expect(thresholds).toEqual(inputThresholds);
+ });
+ it('"projectPath" keeps the same value', () => {
+ const { projectPath } = getTimeSeriesProps();
+ expect(projectPath).toEqual(mockProjectPath);
+ });
+ });
+ });
+ });
+
+ describe('with no boundary data', () => {
+ const dataSetName = 'noBoundary';
+ const dataSet = anomalyMockResultValues[dataSetName];
+
+ beforeEach(() => {
+ setupAnomalyChart({
+ graphData: makeAnomalyGraphData(dataSetName),
+ deploymentData: anomalyDeploymentData,
+ });
+ });
+
+ describe('option', () => {
+ let option;
+ let series;
+
+ beforeEach(() => {
+ ({ option } = getTimeSeriesProps());
+ ({ series } = option);
+ });
+
+ it('does not display a boundary band', () => {
+ expect(series).toEqual(expect.any(Array));
+ expect(series.length).toEqual(0); // no boundaries
+ });
+
+ it('can format y values (to use in tooltips)', () => {
+ expect(parseFloat(wrapper.vm.yValueFormatted(0, 0))).toEqual(dataSet[0][0][1]);
+ expect(wrapper.vm.yValueFormatted(1, 0)).toBe(''); // missing boundary
+ expect(wrapper.vm.yValueFormatted(2, 0)).toBe(''); // missing boundary
+ });
+ });
+ });
+
+ describe('with one anomaly', () => {
+ const dataSetName = 'oneAnomaly';
+ const dataSet = anomalyMockResultValues[dataSetName];
+
+ beforeEach(() => {
+ setupAnomalyChart({
+ graphData: makeAnomalyGraphData(dataSetName),
+ deploymentData: anomalyDeploymentData,
+ });
+ });
+
+ describe('series-config', () => {
+ it('displays one anomaly', () => {
+ const { seriesConfig } = getTimeSeriesProps();
+ const { symbolSize, itemStyle } = seriesConfig;
+ const [metricDataset] = dataSet;
+
+ const bigDots = metricDataset.filter((v, dataIndex) => {
+ const size = symbolSize(null, { dataIndex });
+ return size > 0.1;
+ });
+ const redDots = metricDataset.filter((v, dataIndex) => {
+ const color = itemStyle.color({ dataIndex });
+ return color === colorValues.anomalySymbol;
+ });
+
+ expect(bigDots.length).toBe(1);
+ expect(redDots.length).toBe(1);
+ });
+ });
+ });
+
+ describe('with offset', () => {
+ const dataSetName = 'negativeBoundary';
+ const dataSet = anomalyMockResultValues[dataSetName];
+ const expectedOffset = 4; // Lowst point in mock data is -3.70, it gets rounded
+
+ beforeEach(() => {
+ setupAnomalyChart({
+ graphData: makeAnomalyGraphData(dataSetName),
+ deploymentData: anomalyDeploymentData,
+ });
+ });
+
+ describe('receives props correctly', () => {
+ describe('graph-data', () => {
+ it('receives a single "metric" series', () => {
+ const { graphData } = getTimeSeriesProps();
+ expect(graphData.queries.length).toBe(1);
+ });
+
+ it('receives "metric" results and applies the offset to them', () => {
+ const { graphData } = getTimeSeriesProps();
+ const { result } = graphData.queries[0];
+ const { values } = result[0];
+ const [metricDataset] = dataSet;
+ expect(values).toEqual(expect.any(Array));
+
+ values.forEach(([, y], index) => {
+ expect(y).toBeCloseTo(metricDataset[index][1] + expectedOffset);
+ });
+ });
+ });
+ });
+
+ describe('option', () => {
+ it('upper boundary values are stacked on top of lower boundary, plus the offset', () => {
+ const { option } = getTimeSeriesProps();
+ const { series } = option;
+ const [lowerSeries, upperSeries] = series;
+ const [, upperDataset, lowerDataset] = dataSet;
+
+ lowerSeries.data.forEach(([, y], i) => {
+ expect(y).toBeCloseTo(lowerDataset[i][1] + expectedOffset);
+ });
+
+ upperSeries.data.forEach(([, y], i) => {
+ expect(y).toBeCloseTo(upperDataset[i][1] - lowerDataset[i][1]);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/monitoring/components/date_time_picker/date_time_picker_spec.js b/spec/frontend/monitoring/components/date_time_picker/date_time_picker_spec.js
index be544435671..ca05461c8cf 100644
--- a/spec/frontend/monitoring/components/date_time_picker/date_time_picker_spec.js
+++ b/spec/frontend/monitoring/components/date_time_picker/date_time_picker_spec.js
@@ -51,6 +51,16 @@ describe('DateTimePicker', () => {
});
});
+ it('renders dropdown without a selectedTimeWindow set', done => {
+ createComponent({
+ selectedTimeWindow: {},
+ });
+ dateTimePicker.vm.$nextTick(() => {
+ expect(dateTimePicker.findAll('input').length).toBe(2);
+ done();
+ });
+ });
+
it('renders inputs with h/m/s truncated if its all 0s', done => {
createComponent({
selectedTimeWindow: {
diff --git a/spec/frontend/monitoring/embed/embed_spec.js b/spec/frontend/monitoring/embed/embed_spec.js
index 5de1a7c4c3b..3e22b0858e6 100644
--- a/spec/frontend/monitoring/embed/embed_spec.js
+++ b/spec/frontend/monitoring/embed/embed_spec.js
@@ -61,8 +61,8 @@ describe('Embed', () => {
describe('metrics are available', () => {
beforeEach(() => {
- store.state.monitoringDashboard.groups = groups;
- store.state.monitoringDashboard.groups[0].metrics = metricsData;
+ store.state.monitoringDashboard.dashboard.panel_groups = groups;
+ store.state.monitoringDashboard.dashboard.panel_groups[0].metrics = metricsData;
store.state.monitoringDashboard.metricsWithData = metricsWithData;
mountComponent();
diff --git a/spec/frontend/monitoring/embed/mock_data.js b/spec/frontend/monitoring/embed/mock_data.js
index df4acb82e95..1685021fd4b 100644
--- a/spec/frontend/monitoring/embed/mock_data.js
+++ b/spec/frontend/monitoring/embed/mock_data.js
@@ -81,7 +81,9 @@ export const metricsData = [
export const initialState = {
monitoringDashboard: {},
- groups: [],
+ dashboard: {
+ panel_groups: [],
+ },
metricsWithData: [],
useDashboardEndpoint: true,
};
diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js
new file mode 100644
index 00000000000..c42366ab484
--- /dev/null
+++ b/spec/frontend/monitoring/mock_data.js
@@ -0,0 +1,465 @@
+export const mockHost = 'http://test.host';
+export const mockProjectDir = '/frontend-fixtures/environments-project';
+
+export const anomalyDeploymentData = [
+ {
+ id: 111,
+ iid: 3,
+ sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
+ ref: {
+ name: 'master',
+ },
+ created_at: '2019-08-19T22:00:00.000Z',
+ deployed_at: '2019-08-19T22:01:00.000Z',
+ tag: false,
+ 'last?': true,
+ },
+ {
+ id: 110,
+ iid: 2,
+ sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
+ ref: {
+ name: 'master',
+ },
+ created_at: '2019-08-19T23:00:00.000Z',
+ deployed_at: '2019-08-19T23:00:00.000Z',
+ tag: false,
+ 'last?': false,
+ },
+];
+
+export const anomalyMockResultValues = {
+ noAnomaly: [
+ [
+ ['2019-08-19T19:00:00.000Z', 1.25],
+ ['2019-08-19T20:00:00.000Z', 1.45],
+ ['2019-08-19T21:00:00.000Z', 1.55],
+ ['2019-08-19T22:00:00.000Z', 1.48],
+ ],
+ [
+ // upper boundary
+ ['2019-08-19T19:00:00.000Z', 2],
+ ['2019-08-19T20:00:00.000Z', 2.55],
+ ['2019-08-19T21:00:00.000Z', 2.65],
+ ['2019-08-19T22:00:00.000Z', 3.0],
+ ],
+ [
+ // lower boundary
+ ['2019-08-19T19:00:00.000Z', 0.45],
+ ['2019-08-19T20:00:00.000Z', 0.65],
+ ['2019-08-19T21:00:00.000Z', 0.7],
+ ['2019-08-19T22:00:00.000Z', 0.8],
+ ],
+ ],
+ noBoundary: [
+ [
+ ['2019-08-19T19:00:00.000Z', 1.25],
+ ['2019-08-19T20:00:00.000Z', 1.45],
+ ['2019-08-19T21:00:00.000Z', 1.55],
+ ['2019-08-19T22:00:00.000Z', 1.48],
+ ],
+ [
+ // empty upper boundary
+ ],
+ [
+ // empty lower boundary
+ ],
+ ],
+ oneAnomaly: [
+ [
+ ['2019-08-19T19:00:00.000Z', 1.25],
+ ['2019-08-19T20:00:00.000Z', 3.45], // anomaly
+ ['2019-08-19T21:00:00.000Z', 1.55],
+ ],
+ [
+ // upper boundary
+ ['2019-08-19T19:00:00.000Z', 2],
+ ['2019-08-19T20:00:00.000Z', 2.55],
+ ['2019-08-19T21:00:00.000Z', 2.65],
+ ],
+ [
+ // lower boundary
+ ['2019-08-19T19:00:00.000Z', 0.45],
+ ['2019-08-19T20:00:00.000Z', 0.65],
+ ['2019-08-19T21:00:00.000Z', 0.7],
+ ],
+ ],
+ negativeBoundary: [
+ [
+ ['2019-08-19T19:00:00.000Z', 1.25],
+ ['2019-08-19T20:00:00.000Z', 3.45], // anomaly
+ ['2019-08-19T21:00:00.000Z', 1.55],
+ ],
+ [
+ // upper boundary
+ ['2019-08-19T19:00:00.000Z', 2],
+ ['2019-08-19T20:00:00.000Z', 2.55],
+ ['2019-08-19T21:00:00.000Z', 2.65],
+ ],
+ [
+ // lower boundary
+ ['2019-08-19T19:00:00.000Z', -1.25],
+ ['2019-08-19T20:00:00.000Z', -2.65],
+ ['2019-08-19T21:00:00.000Z', -3.7], // lowest point
+ ],
+ ],
+};
+
+export const anomalyMockGraphData = {
+ title: 'Requests Per Second Mock Data',
+ type: 'anomaly-chart',
+ weight: 3,
+ metrics: [
+ // Not used
+ ],
+ queries: [
+ {
+ metricId: '90',
+ id: 'metric',
+ query_range: 'MOCK_PROMETHEUS_METRIC_QUERY_RANGE',
+ unit: 'RPS',
+ label: 'Metrics RPS',
+ metric_id: 90,
+ prometheus_endpoint_path: 'MOCK_METRIC_PEP',
+ result: [
+ {
+ metric: {},
+ values: [['2019-08-19T19:00:00.000Z', 0]],
+ },
+ ],
+ },
+ {
+ metricId: '91',
+ id: 'upper',
+ query_range: '...',
+ unit: 'RPS',
+ label: 'Upper Limit Metrics RPS',
+ metric_id: 91,
+ prometheus_endpoint_path: 'MOCK_UPPER_PEP',
+ result: [
+ {
+ metric: {},
+ values: [['2019-08-19T19:00:00.000Z', 0]],
+ },
+ ],
+ },
+ {
+ metricId: '92',
+ id: 'lower',
+ query_range: '...',
+ unit: 'RPS',
+ label: 'Lower Limit Metrics RPS',
+ metric_id: 92,
+ prometheus_endpoint_path: 'MOCK_LOWER_PEP',
+ result: [
+ {
+ metric: {},
+ values: [['2019-08-19T19:00:00.000Z', 0]],
+ },
+ ],
+ },
+ ],
+};
+
+export const deploymentData = [
+ {
+ id: 111,
+ iid: 3,
+ sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
+ commitUrl:
+ 'http://test.host/frontend-fixtures/environments-project/commit/f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
+ ref: {
+ name: 'master',
+ },
+ created_at: '2019-07-16T10:14:25.589Z',
+ tag: false,
+ tagUrl: 'http://test.host/frontend-fixtures/environments-project/tags/false',
+ 'last?': true,
+ },
+ {
+ id: 110,
+ iid: 2,
+ sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
+ commitUrl:
+ 'http://test.host/frontend-fixtures/environments-project/commit/f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
+ ref: {
+ name: 'master',
+ },
+ created_at: '2019-07-16T11:14:25.589Z',
+ tag: false,
+ tagUrl: 'http://test.host/frontend-fixtures/environments-project/tags/false',
+ 'last?': false,
+ },
+ {
+ id: 109,
+ iid: 1,
+ sha: '6511e58faafaa7ad2228990ec57f19d66f7db7c2',
+ commitUrl:
+ 'http://test.host/frontend-fixtures/environments-project/commit/6511e58faafaa7ad2228990ec57f19d66f7db7c2',
+ ref: {
+ name: 'update2-readme',
+ },
+ created_at: '2019-07-16T12:14:25.589Z',
+ tag: false,
+ tagUrl: 'http://test.host/frontend-fixtures/environments-project/tags/false',
+ 'last?': false,
+ },
+];
+
+export const metricsNewGroupsAPIResponse = [
+ {
+ group: 'System metrics (Kubernetes)',
+ priority: 5,
+ panels: [
+ {
+ title: 'Memory Usage (Pod average)',
+ type: 'area-chart',
+ y_label: 'Memory Used per Pod',
+ weight: 2,
+ metrics: [
+ {
+ id: 'system_metrics_kubernetes_container_memory_average',
+ query_range:
+ 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024',
+ label: 'Pod average',
+ unit: 'MB',
+ metric_id: 17,
+ prometheus_endpoint_path:
+ '/root/autodevops-deploy/environments/32/prometheus/api/v1/query_range?query=avg%28sum%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+by+%28job%29%29+without+%28job%29+%2F+count%28avg%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+without+%28job%29%29+%2F1024%2F1024',
+ appearance: {
+ line: {
+ width: 2,
+ },
+ },
+ },
+ ],
+ },
+ ],
+ },
+];
+
+export const mockedQueryResultPayload = {
+ metricId: '17_system_metrics_kubernetes_container_memory_average',
+ result: [
+ {
+ metric: {},
+ values: [
+ [1563272065.589, '10.396484375'],
+ [1563272125.589, '10.333984375'],
+ [1563272185.589, '10.333984375'],
+ [1563272245.589, '10.333984375'],
+ [1563272305.589, '10.333984375'],
+ [1563272365.589, '10.333984375'],
+ [1563272425.589, '10.38671875'],
+ [1563272485.589, '10.333984375'],
+ [1563272545.589, '10.333984375'],
+ [1563272605.589, '10.333984375'],
+ [1563272665.589, '10.333984375'],
+ [1563272725.589, '10.333984375'],
+ [1563272785.589, '10.396484375'],
+ [1563272845.589, '10.333984375'],
+ [1563272905.589, '10.333984375'],
+ [1563272965.589, '10.3984375'],
+ [1563273025.589, '10.337890625'],
+ [1563273085.589, '10.34765625'],
+ [1563273145.589, '10.337890625'],
+ [1563273205.589, '10.337890625'],
+ [1563273265.589, '10.337890625'],
+ [1563273325.589, '10.337890625'],
+ [1563273385.589, '10.337890625'],
+ [1563273445.589, '10.337890625'],
+ [1563273505.589, '10.337890625'],
+ [1563273565.589, '10.337890625'],
+ [1563273625.589, '10.337890625'],
+ [1563273685.589, '10.337890625'],
+ [1563273745.589, '10.337890625'],
+ [1563273805.589, '10.337890625'],
+ [1563273865.589, '10.390625'],
+ [1563273925.589, '10.390625'],
+ ],
+ },
+ ],
+};
+
+export const metricsGroupsAPIResponse = [
+ {
+ group: 'System metrics (Kubernetes)',
+ priority: 5,
+ panels: [
+ {
+ title: 'Memory Usage (Pod average)',
+ type: 'area-chart',
+ y_label: 'Memory Used per Pod',
+ weight: 2,
+ metrics: [
+ {
+ id: 'system_metrics_kubernetes_container_memory_average',
+ query_range:
+ 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024',
+ label: 'Pod average',
+ unit: 'MB',
+ metric_id: 17,
+ prometheus_endpoint_path:
+ '/root/autodevops-deploy/environments/32/prometheus/api/v1/query_range?query=avg%28sum%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+by+%28job%29%29+without+%28job%29+%2F+count%28avg%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+without+%28job%29%29+%2F1024%2F1024',
+ appearance: {
+ line: {
+ width: 2,
+ },
+ },
+ },
+ ],
+ },
+ {
+ title: 'Core Usage (Total)',
+ type: 'area-chart',
+ y_label: 'Total Cores',
+ weight: 3,
+ metrics: [
+ {
+ id: 'system_metrics_kubernetes_container_cores_total',
+ query_range:
+ 'avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}[15m])) by (job)) without (job)',
+ label: 'Total',
+ unit: 'cores',
+ metric_id: 13,
+ },
+ ],
+ },
+ ],
+ },
+];
+
+export const environmentData = [
+ {
+ id: 34,
+ name: 'production',
+ state: 'available',
+ external_url: 'http://root-autodevops-deploy.my-fake-domain.com',
+ environment_type: null,
+ stop_action: false,
+ metrics_path: '/root/hello-prometheus/environments/34/metrics',
+ environment_path: '/root/hello-prometheus/environments/34',
+ stop_path: '/root/hello-prometheus/environments/34/stop',
+ terminal_path: '/root/hello-prometheus/environments/34/terminal',
+ folder_path: '/root/hello-prometheus/environments/folders/production',
+ created_at: '2018-06-29T16:53:38.301Z',
+ updated_at: '2018-06-29T16:57:09.825Z',
+ last_deployment: {
+ id: 127,
+ },
+ },
+ {
+ id: 35,
+ name: 'review/noop-branch',
+ state: 'available',
+ external_url: 'http://root-autodevops-deploy-review-noop-branc-die93w.my-fake-domain.com',
+ environment_type: 'review',
+ stop_action: true,
+ metrics_path: '/root/hello-prometheus/environments/35/metrics',
+ environment_path: '/root/hello-prometheus/environments/35',
+ stop_path: '/root/hello-prometheus/environments/35/stop',
+ terminal_path: '/root/hello-prometheus/environments/35/terminal',
+ folder_path: '/root/hello-prometheus/environments/folders/review',
+ created_at: '2018-07-03T18:39:41.702Z',
+ updated_at: '2018-07-03T18:44:54.010Z',
+ last_deployment: {
+ id: 128,
+ },
+ },
+ {
+ id: 36,
+ name: 'no-deployment/noop-branch',
+ state: 'available',
+ created_at: '2018-07-04T18:39:41.702Z',
+ updated_at: '2018-07-04T18:44:54.010Z',
+ },
+];
+
+export const metricsDashboardResponse = {
+ dashboard: {
+ dashboard: 'Environment metrics',
+ priority: 1,
+ panel_groups: [
+ {
+ group: 'System metrics (Kubernetes)',
+ priority: 5,
+ panels: [
+ {
+ title: 'Memory Usage (Total)',
+ type: 'area-chart',
+ y_label: 'Total Memory Used',
+ weight: 4,
+ metrics: [
+ {
+ id: 'system_metrics_kubernetes_container_memory_total',
+ query_range:
+ 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) /1024/1024/1024',
+ label: 'Total',
+ unit: 'GB',
+ metric_id: 12,
+ prometheus_endpoint_path: 'http://test',
+ },
+ ],
+ },
+ {
+ title: 'Core Usage (Total)',
+ type: 'area-chart',
+ y_label: 'Total Cores',
+ weight: 3,
+ metrics: [
+ {
+ id: 'system_metrics_kubernetes_container_cores_total',
+ query_range:
+ 'avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}[15m])) by (job)) without (job)',
+ label: 'Total',
+ unit: 'cores',
+ metric_id: 13,
+ },
+ ],
+ },
+ {
+ title: 'Memory Usage (Pod average)',
+ type: 'line-chart',
+ y_label: 'Memory Used per Pod',
+ weight: 2,
+ metrics: [
+ {
+ id: 'system_metrics_kubernetes_container_memory_average',
+ query_range:
+ 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024',
+ label: 'Pod average',
+ unit: 'MB',
+ metric_id: 14,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ status: 'success',
+};
+
+export const dashboardGitResponse = [
+ {
+ default: true,
+ display_name: 'Default',
+ can_edit: false,
+ project_blob_path: null,
+ path: 'config/prometheus/common_metrics.yml',
+ },
+ {
+ default: false,
+ display_name: 'Custom Dashboard 1',
+ can_edit: true,
+ project_blob_path: `${mockProjectDir}/blob/master/dashboards/.gitlab/dashboards/dashboard_1.yml`,
+ path: '.gitlab/dashboards/dashboard_1.yml',
+ },
+ {
+ default: false,
+ display_name: 'Custom Dashboard 2',
+ can_edit: true,
+ project_blob_path: `${mockProjectDir}/blob/master/dashboards/.gitlab/dashboards/dashboard_2.yml`,
+ path: '.gitlab/dashboards/dashboard_2.yml',
+ },
+];
diff --git a/spec/frontend/monitoring/panel_type_spec.js b/spec/frontend/monitoring/panel_type_spec.js
new file mode 100644
index 00000000000..54a63e7f61f
--- /dev/null
+++ b/spec/frontend/monitoring/panel_type_spec.js
@@ -0,0 +1,166 @@
+import { shallowMount } from '@vue/test-utils';
+import AxiosMockAdapter from 'axios-mock-adapter';
+import { setTestTimeout } from 'helpers/timeout';
+import axios from '~/lib/utils/axios_utils';
+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 { graphDataPrometheusQueryRange } from '../../javascripts/monitoring/mock_data';
+import { anomalyMockGraphData } from '../../frontend/monitoring/mock_data';
+import { createStore } from '~/monitoring/stores';
+
+global.IS_EE = true;
+global.URL.createObjectURL = jest.fn();
+
+describe('Panel Type component', () => {
+ let axiosMock;
+ let store;
+ let panelType;
+ const dashboardWidth = 100;
+ const exampleText = 'example_text';
+
+ beforeEach(() => {
+ setTestTimeout(1000);
+ axiosMock = new AxiosMockAdapter(axios);
+ });
+
+ afterEach(() => {
+ axiosMock.reset();
+ });
+
+ describe('When no graphData is available', () => {
+ let glEmptyChart;
+ // Deep clone object before modifying
+ const graphDataNoResult = JSON.parse(JSON.stringify(graphDataPrometheusQueryRange));
+ graphDataNoResult.queries[0].result = [];
+
+ beforeEach(() => {
+ panelType = shallowMount(PanelType, {
+ propsData: {
+ clipboardText: 'dashboard_link',
+ dashboardWidth,
+ graphData: graphDataNoResult,
+ },
+ sync: false,
+ attachToDocument: true,
+ });
+ });
+
+ afterEach(() => {
+ panelType.destroy();
+ });
+
+ describe('Empty Chart component', () => {
+ beforeEach(() => {
+ glEmptyChart = panelType.find(EmptyChart);
+ });
+
+ it('is a Vue instance', () => {
+ expect(glEmptyChart.isVueInstance()).toBe(true);
+ });
+
+ it('it receives a graph title', () => {
+ const props = glEmptyChart.props();
+
+ expect(props.graphTitle).toBe(panelType.vm.graphData.title);
+ });
+ });
+ });
+
+ describe('when Graph data is available', () => {
+ const propsData = {
+ clipboardText: exampleText,
+ dashboardWidth,
+ graphData: graphDataPrometheusQueryRange,
+ };
+
+ beforeEach(done => {
+ store = createStore();
+ panelType = shallowMount(PanelType, {
+ propsData,
+ store,
+ sync: false,
+ attachToDocument: true,
+ });
+ panelType.vm.$nextTick(done);
+ });
+
+ afterEach(() => {
+ panelType.destroy();
+ });
+
+ describe('Time Series Chart panel type', () => {
+ it('is rendered', () => {
+ expect(panelType.find(TimeSeriesChart).isVueInstance()).toBe(true);
+ expect(panelType.find(TimeSeriesChart).exists()).toBe(true);
+ });
+
+ it('sets clipboard text on the dropdown', () => {
+ const link = () => panelType.find('.js-chart-link');
+ const clipboardText = () => link().element.dataset.clipboardText;
+
+ expect(clipboardText()).toBe(exampleText);
+ });
+ });
+
+ describe('Anomaly Chart panel type', () => {
+ beforeEach(done => {
+ panelType.setProps({
+ graphData: anomalyMockGraphData,
+ });
+ panelType.vm.$nextTick(done);
+ });
+
+ it('is rendered with an anomaly chart', () => {
+ expect(panelType.find(AnomalyChart).isVueInstance()).toBe(true);
+ expect(panelType.find(AnomalyChart).exists()).toBe(true);
+ });
+ });
+ });
+
+ describe('when downloading metrics data as CSV', () => {
+ beforeEach(done => {
+ graphDataPrometheusQueryRange.y_label = 'metric';
+ store = createStore();
+ panelType = shallowMount(PanelType, {
+ propsData: {
+ clipboardText: exampleText,
+ dashboardWidth,
+ graphData: graphDataPrometheusQueryRange,
+ },
+ store,
+ sync: false,
+ attachToDocument: true,
+ });
+ panelType.vm.$nextTick(done);
+ });
+
+ afterEach(() => {
+ panelType.destroy();
+ });
+
+ describe('csvText', () => {
+ it('converts metrics data from json to csv', () => {
+ const header = `timestamp,${graphDataPrometheusQueryRange.y_label}`;
+ const data = graphDataPrometheusQueryRange.queries[0].result[0].values;
+ const firstRow = `${data[0][0]},${data[0][1]}`;
+ const secondRow = `${data[1][0]},${data[1][1]}`;
+
+ expect(panelType.vm.csvText).toBe(`${header}\r\n${firstRow}\r\n${secondRow}\r\n`);
+ });
+ });
+
+ describe('downloadCsv', () => {
+ it('produces a link with a Blob', () => {
+ expect(global.URL.createObjectURL).toHaveBeenLastCalledWith(expect.any(Blob));
+ expect(global.URL.createObjectURL).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ size: panelType.vm.csvText.length,
+ type: 'text/plain',
+ }),
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js
new file mode 100644
index 00000000000..d4bc613ffea
--- /dev/null
+++ b/spec/frontend/monitoring/store/actions_spec.js
@@ -0,0 +1,416 @@
+import MockAdapter from 'axios-mock-adapter';
+import { TEST_HOST } from 'helpers/test_constants';
+import testAction from 'helpers/vuex_action_helper';
+import axios from '~/lib/utils/axios_utils';
+import statusCodes from '~/lib/utils/http_status';
+import { backOff } from '~/lib/utils/common_utils';
+
+import store from '~/monitoring/stores';
+import * as types from '~/monitoring/stores/mutation_types';
+import {
+ backOffRequest,
+ fetchDashboard,
+ receiveMetricsDashboardSuccess,
+ receiveMetricsDashboardFailure,
+ fetchDeploymentsData,
+ fetchEnvironmentsData,
+ fetchPrometheusMetrics,
+ fetchPrometheusMetric,
+ requestMetricsData,
+ setEndpoints,
+ setGettingStartedEmptyState,
+} from '~/monitoring/stores/actions';
+import storeState from '~/monitoring/stores/state';
+import {
+ deploymentData,
+ environmentData,
+ metricsDashboardResponse,
+ metricsGroupsAPIResponse,
+ dashboardGitResponse,
+} from '../mock_data';
+
+jest.mock('~/lib/utils/common_utils');
+
+const resetStore = str => {
+ str.replaceState({
+ showEmptyState: true,
+ emptyState: 'loading',
+ groups: [],
+ });
+};
+
+const MAX_REQUESTS = 3;
+
+describe('Monitoring store helpers', () => {
+ let mock;
+
+ // Mock underlying `backOff` function to remove in-built delay.
+ backOff.mockImplementation(
+ callback =>
+ new Promise((resolve, reject) => {
+ const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg));
+ const next = () => callback(next, stop);
+ callback(next, stop);
+ }),
+ );
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('backOffRequest', () => {
+ it('returns immediately when recieving a 200 status code', () => {
+ mock.onGet(TEST_HOST).reply(200);
+
+ return backOffRequest(() => axios.get(TEST_HOST)).then(() => {
+ expect(mock.history.get.length).toBe(1);
+ });
+ });
+
+ it(`repeats the network call ${MAX_REQUESTS} times when receiving a 204 response`, done => {
+ mock.onGet(TEST_HOST).reply(statusCodes.NO_CONTENT, {});
+
+ backOffRequest(() => axios.get(TEST_HOST))
+ .then(done.fail)
+ .catch(() => {
+ expect(mock.history.get.length).toBe(MAX_REQUESTS);
+ done();
+ });
+ });
+ });
+});
+
+describe('Monitoring store actions', () => {
+ let mock;
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+ afterEach(() => {
+ resetStore(store);
+ mock.restore();
+ });
+ describe('requestMetricsData', () => {
+ it('sets emptyState to loading', () => {
+ const commit = jest.fn();
+ const { state } = store;
+ requestMetricsData({
+ state,
+ commit,
+ });
+ expect(commit).toHaveBeenCalledWith(types.REQUEST_METRICS_DATA);
+ });
+ });
+ describe('fetchDeploymentsData', () => {
+ it('commits RECEIVE_DEPLOYMENTS_DATA_SUCCESS on error', done => {
+ const dispatch = jest.fn();
+ const { state } = store;
+ state.deploymentsEndpoint = '/success';
+ mock.onGet(state.deploymentsEndpoint).reply(200, {
+ deployments: deploymentData,
+ });
+ fetchDeploymentsData({
+ state,
+ dispatch,
+ })
+ .then(() => {
+ expect(dispatch).toHaveBeenCalledWith('receiveDeploymentsDataSuccess', deploymentData);
+ done();
+ })
+ .catch(done.fail);
+ });
+ it('commits RECEIVE_DEPLOYMENTS_DATA_FAILURE on error', done => {
+ const dispatch = jest.fn();
+ const { state } = store;
+ state.deploymentsEndpoint = '/error';
+ mock.onGet(state.deploymentsEndpoint).reply(500);
+ fetchDeploymentsData({
+ state,
+ dispatch,
+ })
+ .then(() => {
+ expect(dispatch).toHaveBeenCalledWith('receiveDeploymentsDataFailure');
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+ describe('fetchEnvironmentsData', () => {
+ it('commits RECEIVE_ENVIRONMENTS_DATA_SUCCESS on error', done => {
+ const dispatch = jest.fn();
+ const { state } = store;
+ state.environmentsEndpoint = '/success';
+ mock.onGet(state.environmentsEndpoint).reply(200, {
+ environments: environmentData,
+ });
+ fetchEnvironmentsData({
+ state,
+ dispatch,
+ })
+ .then(() => {
+ expect(dispatch).toHaveBeenCalledWith('receiveEnvironmentsDataSuccess', environmentData);
+ done();
+ })
+ .catch(done.fail);
+ });
+ it('commits RECEIVE_ENVIRONMENTS_DATA_FAILURE on error', done => {
+ const dispatch = jest.fn();
+ const { state } = store;
+ state.environmentsEndpoint = '/error';
+ mock.onGet(state.environmentsEndpoint).reply(500);
+ fetchEnvironmentsData({
+ state,
+ dispatch,
+ })
+ .then(() => {
+ expect(dispatch).toHaveBeenCalledWith('receiveEnvironmentsDataFailure');
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+ describe('Set endpoints', () => {
+ let mockedState;
+ beforeEach(() => {
+ mockedState = storeState();
+ });
+ it('should commit SET_ENDPOINTS mutation', done => {
+ testAction(
+ setEndpoints,
+ {
+ metricsEndpoint: 'additional_metrics.json',
+ deploymentsEndpoint: 'deployments.json',
+ environmentsEndpoint: 'deployments.json',
+ },
+ mockedState,
+ [
+ {
+ type: types.SET_ENDPOINTS,
+ payload: {
+ metricsEndpoint: 'additional_metrics.json',
+ deploymentsEndpoint: 'deployments.json',
+ environmentsEndpoint: 'deployments.json',
+ },
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+ describe('Set empty states', () => {
+ let mockedState;
+ beforeEach(() => {
+ mockedState = storeState();
+ });
+ it('should commit SET_METRICS_ENDPOINT mutation', done => {
+ testAction(
+ setGettingStartedEmptyState,
+ null,
+ mockedState,
+ [
+ {
+ type: types.SET_GETTING_STARTED_EMPTY_STATE,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+ describe('fetchDashboard', () => {
+ let dispatch;
+ let state;
+ const response = metricsDashboardResponse;
+ beforeEach(() => {
+ dispatch = jest.fn();
+ state = storeState();
+ state.dashboardEndpoint = '/dashboard';
+ });
+ it('dispatches receive and success actions', done => {
+ const params = {};
+ mock.onGet(state.dashboardEndpoint).reply(200, response);
+ fetchDashboard(
+ {
+ state,
+ dispatch,
+ },
+ params,
+ )
+ .then(() => {
+ expect(dispatch).toHaveBeenCalledWith('requestMetricsDashboard');
+ expect(dispatch).toHaveBeenCalledWith('receiveMetricsDashboardSuccess', {
+ response,
+ params,
+ });
+ done();
+ })
+ .catch(done.fail);
+ });
+ it('dispatches failure action', done => {
+ const params = {};
+ mock.onGet(state.dashboardEndpoint).reply(500);
+ fetchDashboard(
+ {
+ state,
+ dispatch,
+ },
+ params,
+ )
+ .then(() => {
+ expect(dispatch).toHaveBeenCalledWith(
+ 'receiveMetricsDashboardFailure',
+ new Error('Request failed with status code 500'),
+ );
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+ describe('receiveMetricsDashboardSuccess', () => {
+ let commit;
+ let dispatch;
+ let state;
+ beforeEach(() => {
+ commit = jest.fn();
+ dispatch = jest.fn();
+ state = storeState();
+ });
+ it('stores groups ', () => {
+ const params = {};
+ const response = metricsDashboardResponse;
+ receiveMetricsDashboardSuccess(
+ {
+ state,
+ commit,
+ dispatch,
+ },
+ {
+ response,
+ params,
+ },
+ );
+ expect(commit).toHaveBeenCalledWith(
+ types.RECEIVE_METRICS_DATA_SUCCESS,
+ metricsDashboardResponse.dashboard.panel_groups,
+ );
+ expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetrics', params);
+ });
+ it('sets the dashboards loaded from the repository', () => {
+ const params = {};
+ const response = metricsDashboardResponse;
+ response.all_dashboards = dashboardGitResponse;
+ receiveMetricsDashboardSuccess(
+ {
+ state,
+ commit,
+ dispatch,
+ },
+ {
+ response,
+ params,
+ },
+ );
+ expect(commit).toHaveBeenCalledWith(types.SET_ALL_DASHBOARDS, dashboardGitResponse);
+ });
+ });
+ describe('receiveMetricsDashboardFailure', () => {
+ let commit;
+ beforeEach(() => {
+ commit = jest.fn();
+ });
+ it('commits failure action', () => {
+ receiveMetricsDashboardFailure({
+ commit,
+ });
+ expect(commit).toHaveBeenCalledWith(types.RECEIVE_METRICS_DATA_FAILURE, undefined);
+ });
+ it('commits failure action with error', () => {
+ receiveMetricsDashboardFailure(
+ {
+ commit,
+ },
+ 'uh-oh',
+ );
+ expect(commit).toHaveBeenCalledWith(types.RECEIVE_METRICS_DATA_FAILURE, 'uh-oh');
+ });
+ });
+ describe('fetchPrometheusMetrics', () => {
+ let commit;
+ let dispatch;
+ beforeEach(() => {
+ commit = jest.fn();
+ dispatch = jest.fn();
+ });
+ it('commits empty state when state.groups is empty', done => {
+ const state = storeState();
+ const params = {};
+ fetchPrometheusMetrics(
+ {
+ state,
+ commit,
+ dispatch,
+ },
+ params,
+ )
+ .then(() => {
+ expect(commit).toHaveBeenCalledWith(types.SET_NO_DATA_EMPTY_STATE);
+ expect(dispatch).not.toHaveBeenCalled();
+ done();
+ })
+ .catch(done.fail);
+ });
+ it('dispatches fetchPrometheusMetric for each panel query', done => {
+ const params = {};
+ const state = storeState();
+ state.dashboard.panel_groups = metricsDashboardResponse.dashboard.panel_groups;
+ const metric = state.dashboard.panel_groups[0].panels[0].metrics[0];
+ fetchPrometheusMetrics(
+ {
+ state,
+ commit,
+ dispatch,
+ },
+ params,
+ )
+ .then(() => {
+ expect(dispatch).toHaveBeenCalledTimes(3);
+ expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', {
+ metric,
+ params,
+ });
+ done();
+ })
+ .catch(done.fail);
+ done();
+ });
+ });
+ describe('fetchPrometheusMetric', () => {
+ it('commits prometheus query result', done => {
+ const commit = jest.fn();
+ const params = {
+ start: '2019-08-06T12:40:02.184Z',
+ end: '2019-08-06T20:40:02.184Z',
+ };
+ const metric = metricsDashboardResponse.dashboard.panel_groups[0].panels[0].metrics[0];
+ const state = storeState();
+ const data = metricsGroupsAPIResponse[0].panels[0].metrics[0];
+ const response = {
+ data,
+ };
+ mock.onGet('http://test').reply(200, response);
+ fetchPrometheusMetric({ state, commit }, { metric, params })
+ .then(() => {
+ expect(commit).toHaveBeenCalledWith(types.SET_QUERY_RESULT, {
+ metricId: metric.metric_id,
+ result: data.result,
+ });
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js
new file mode 100644
index 00000000000..fdad290a8d6
--- /dev/null
+++ b/spec/frontend/monitoring/store/mutations_spec.js
@@ -0,0 +1,142 @@
+import mutations from '~/monitoring/stores/mutations';
+import * as types from '~/monitoring/stores/mutation_types';
+import state from '~/monitoring/stores/state';
+import {
+ metricsGroupsAPIResponse,
+ deploymentData,
+ metricsDashboardResponse,
+ dashboardGitResponse,
+} from '../mock_data';
+import { uniqMetricsId } from '~/monitoring/stores/utils';
+
+describe('Monitoring mutations', () => {
+ let stateCopy;
+ beforeEach(() => {
+ stateCopy = state();
+ });
+ describe('RECEIVE_METRICS_DATA_SUCCESS', () => {
+ let groups;
+ beforeEach(() => {
+ stateCopy.dashboard.panel_groups = [];
+ groups = metricsGroupsAPIResponse;
+ });
+ it('adds a key to the group', () => {
+ mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
+ expect(stateCopy.dashboard.panel_groups[0].key).toBe('system-metrics-kubernetes--0');
+ });
+ it('normalizes values', () => {
+ mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
+ const expectedLabel = 'Pod average';
+ const { label, query_range } = stateCopy.dashboard.panel_groups[0].metrics[0].metrics[0];
+ expect(label).toEqual(expectedLabel);
+ expect(query_range.length).toBeGreaterThan(0);
+ });
+ it('contains one group, which it has two panels and one metrics property', () => {
+ mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
+ expect(stateCopy.dashboard.panel_groups).toBeDefined();
+ expect(stateCopy.dashboard.panel_groups.length).toEqual(1);
+ expect(stateCopy.dashboard.panel_groups[0].panels.length).toEqual(2);
+ expect(stateCopy.dashboard.panel_groups[0].panels[0].metrics.length).toEqual(1);
+ expect(stateCopy.dashboard.panel_groups[0].panels[1].metrics.length).toEqual(1);
+ });
+ it('assigns queries a metric id', () => {
+ mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
+ expect(stateCopy.dashboard.panel_groups[0].metrics[0].queries[0].metricId).toEqual(
+ '17_system_metrics_kubernetes_container_memory_average',
+ );
+ });
+ describe('dashboard endpoint', () => {
+ const dashboardGroups = metricsDashboardResponse.dashboard.panel_groups;
+ it('aliases group panels to metrics for backwards compatibility', () => {
+ mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups);
+ expect(stateCopy.dashboard.panel_groups[0].metrics[0]).toBeDefined();
+ });
+ it('aliases panel metrics to queries for backwards compatibility', () => {
+ mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups);
+ expect(stateCopy.dashboard.panel_groups[0].metrics[0].queries).toBeDefined();
+ });
+ });
+ });
+
+ describe('RECEIVE_DEPLOYMENTS_DATA_SUCCESS', () => {
+ it('stores the deployment data', () => {
+ stateCopy.deploymentData = [];
+ mutations[types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS](stateCopy, deploymentData);
+ expect(stateCopy.deploymentData).toBeDefined();
+ expect(stateCopy.deploymentData.length).toEqual(3);
+ expect(typeof stateCopy.deploymentData[0]).toEqual('object');
+ });
+ });
+ describe('SET_ENDPOINTS', () => {
+ it('should set all the endpoints', () => {
+ mutations[types.SET_ENDPOINTS](stateCopy, {
+ metricsEndpoint: 'additional_metrics.json',
+ environmentsEndpoint: 'environments.json',
+ deploymentsEndpoint: 'deployments.json',
+ dashboardEndpoint: 'dashboard.json',
+ projectPath: '/gitlab-org/gitlab-foss',
+ });
+ expect(stateCopy.metricsEndpoint).toEqual('additional_metrics.json');
+ expect(stateCopy.environmentsEndpoint).toEqual('environments.json');
+ expect(stateCopy.deploymentsEndpoint).toEqual('deployments.json');
+ expect(stateCopy.dashboardEndpoint).toEqual('dashboard.json');
+ expect(stateCopy.projectPath).toEqual('/gitlab-org/gitlab-foss');
+ });
+ });
+ describe('SET_QUERY_RESULT', () => {
+ const metricId = 12;
+ const id = 'system_metrics_kubernetes_container_memory_total';
+ const result = [
+ {
+ values: [[0, 1], [1, 1], [1, 3]],
+ },
+ ];
+ beforeEach(() => {
+ const dashboardGroups = metricsDashboardResponse.dashboard.panel_groups;
+ mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups);
+ });
+ it('clears empty state', () => {
+ mutations[types.SET_QUERY_RESULT](stateCopy, {
+ metricId,
+ result,
+ });
+ expect(stateCopy.showEmptyState).toBe(false);
+ });
+ it('sets metricsWithData value', () => {
+ const uniqId = uniqMetricsId({
+ metric_id: metricId,
+ id,
+ });
+ mutations[types.SET_QUERY_RESULT](stateCopy, {
+ metricId: uniqId,
+ result,
+ });
+ expect(stateCopy.metricsWithData).toEqual([uniqId]);
+ });
+ it('does not store empty results', () => {
+ mutations[types.SET_QUERY_RESULT](stateCopy, {
+ metricId,
+ result: [],
+ });
+ expect(stateCopy.metricsWithData).toEqual([]);
+ });
+ });
+ describe('SET_ALL_DASHBOARDS', () => {
+ it('stores `undefined` dashboards as an empty array', () => {
+ mutations[types.SET_ALL_DASHBOARDS](stateCopy, undefined);
+
+ expect(stateCopy.allDashboards).toEqual([]);
+ });
+
+ it('stores `null` dashboards as an empty array', () => {
+ mutations[types.SET_ALL_DASHBOARDS](stateCopy, null);
+
+ expect(stateCopy.allDashboards).toEqual([]);
+ });
+
+ it('stores dashboards loaded from the git repository', () => {
+ mutations[types.SET_ALL_DASHBOARDS](stateCopy, dashboardGitResponse);
+ expect(stateCopy.allDashboards).toEqual(dashboardGitResponse);
+ });
+ });
+});
diff --git a/spec/frontend/monitoring/store/utils_spec.js b/spec/frontend/monitoring/store/utils_spec.js
new file mode 100644
index 00000000000..98388ac19f8
--- /dev/null
+++ b/spec/frontend/monitoring/store/utils_spec.js
@@ -0,0 +1,74 @@
+import { groupQueriesByChartInfo, normalizeMetric, uniqMetricsId } from '~/monitoring/stores/utils';
+
+describe('groupQueriesByChartInfo', () => {
+ let input;
+ let output;
+
+ it('groups metrics with the same chart title and y_axis label', () => {
+ input = [
+ { title: 'title', y_label: 'MB', queries: [{}] },
+ { title: 'title', y_label: 'MB', queries: [{}] },
+ { title: 'new title', y_label: 'MB', queries: [{}] },
+ ];
+
+ output = [
+ {
+ title: 'title',
+ y_label: 'MB',
+ queries: [{ metricId: null }, { metricId: null }],
+ },
+ { title: 'new title', y_label: 'MB', queries: [{ metricId: null }] },
+ ];
+
+ expect(groupQueriesByChartInfo(input)).toEqual(output);
+ });
+
+ // Functionality associated with the /additional_metrics endpoint
+ it("associates a chart's stringified metric_id with the metric", () => {
+ input = [{ id: 3, title: 'new title', y_label: 'MB', queries: [{}] }];
+ output = [{ id: 3, title: 'new title', y_label: 'MB', queries: [{ metricId: '3' }] }];
+
+ expect(groupQueriesByChartInfo(input)).toEqual(output);
+ });
+
+ // Functionality associated with the /metrics_dashboard endpoint
+ it('aliases a stringified metrics_id on the metric to the metricId key', () => {
+ input = [{ title: 'new title', y_label: 'MB', queries: [{ metric_id: 3 }] }];
+ output = [{ title: 'new title', y_label: 'MB', queries: [{ metricId: '3', metric_id: 3 }] }];
+
+ expect(groupQueriesByChartInfo(input)).toEqual(output);
+ });
+});
+
+describe('normalizeMetric', () => {
+ [
+ { args: [], expected: 'undefined_undefined' },
+ { args: [undefined], expected: 'undefined_undefined' },
+ { args: [{ id: 'something' }], expected: 'undefined_something' },
+ { args: [{ id: 45 }], expected: 'undefined_45' },
+ { args: [{ metric_id: 5 }], expected: '5_undefined' },
+ { args: [{ metric_id: 'something' }], expected: 'something_undefined' },
+ {
+ args: [{ metric_id: 5, id: 'system_metrics_kubernetes_container_memory_total' }],
+ expected: '5_system_metrics_kubernetes_container_memory_total',
+ },
+ ].forEach(({ args, expected }) => {
+ it(`normalizes metric to "${expected}" with args=${JSON.stringify(args)}`, () => {
+ expect(normalizeMetric(...args)).toEqual({ metric_id: expected });
+ });
+ });
+});
+
+describe('uniqMetricsId', () => {
+ [
+ { input: { id: 1 }, expected: 'undefined_1' },
+ { input: { metric_id: 2 }, expected: '2_undefined' },
+ { input: { metric_id: 2, id: 21 }, expected: '2_21' },
+ { input: { metric_id: 22, id: 1 }, expected: '22_1' },
+ { input: { metric_id: 'aaa', id: '_a' }, expected: 'aaa__a' },
+ ].forEach(({ input, expected }) => {
+ it(`creates unique metric ID with ${JSON.stringify(input)}`, () => {
+ expect(uniqMetricsId(input)).toEqual(expected);
+ });
+ });
+});
diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js
new file mode 100644
index 00000000000..45b99b71e06
--- /dev/null
+++ b/spec/frontend/notes/components/comment_form_spec.js
@@ -0,0 +1,331 @@
+import $ from 'jquery';
+import { mount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import Autosize from 'autosize';
+import axios from '~/lib/utils/axios_utils';
+import createStore from '~/notes/stores';
+import CommentForm from '~/notes/components/comment_form.vue';
+import * as constants from '~/notes/constants';
+import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
+import { trimText } from 'helpers/text_helper';
+import { keyboardDownEvent } from '../../issue_show/helpers';
+import {
+ loggedOutnoteableData,
+ notesDataMock,
+ userDataMock,
+ noteableDataMock,
+} from '../../notes/mock_data';
+
+jest.mock('autosize');
+jest.mock('~/commons/nav/user_merge_requests');
+jest.mock('~/gl_form');
+
+describe('issue_comment_form component', () => {
+ let store;
+ let wrapper;
+ let axiosMock;
+
+ const setupStore = (userData, noteableData) => {
+ store.dispatch('setUserData', userData);
+ store.dispatch('setNoteableData', noteableData);
+ store.dispatch('setNotesData', notesDataMock);
+ };
+
+ const mountComponent = (noteableType = 'issue') => {
+ wrapper = mount(CommentForm, {
+ propsData: {
+ noteableType,
+ },
+ store,
+ sync: false,
+ });
+ };
+
+ beforeEach(() => {
+ axiosMock = new MockAdapter(axios);
+ store = createStore();
+ });
+
+ afterEach(() => {
+ axiosMock.restore();
+ wrapper.destroy();
+ jest.clearAllMocks();
+ });
+
+ describe('user is logged in', () => {
+ beforeEach(() => {
+ setupStore(userDataMock, noteableDataMock);
+
+ mountComponent();
+ });
+
+ it('should render user avatar with link', () => {
+ expect(wrapper.find('.timeline-icon .user-avatar-link').attributes('href')).toEqual(
+ userDataMock.path,
+ );
+ });
+
+ describe('handleSave', () => {
+ it('should request to save note when note is entered', () => {
+ wrapper.vm.note = 'hello world';
+ jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(new Promise(() => {}));
+ jest.spyOn(wrapper.vm, 'resizeTextarea');
+ jest.spyOn(wrapper.vm, 'stopPolling');
+
+ wrapper.vm.handleSave();
+
+ expect(wrapper.vm.isSubmitting).toEqual(true);
+ expect(wrapper.vm.note).toEqual('');
+ expect(wrapper.vm.saveNote).toHaveBeenCalled();
+ expect(wrapper.vm.stopPolling).toHaveBeenCalled();
+ expect(wrapper.vm.resizeTextarea).toHaveBeenCalled();
+ });
+
+ it('should toggle issue state when no note', () => {
+ jest.spyOn(wrapper.vm, 'toggleIssueState');
+
+ wrapper.vm.handleSave();
+
+ expect(wrapper.vm.toggleIssueState).toHaveBeenCalled();
+ });
+
+ it('should disable action button whilst submitting', done => {
+ const saveNotePromise = Promise.resolve();
+ wrapper.vm.note = 'hello world';
+ jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(saveNotePromise);
+ jest.spyOn(wrapper.vm, 'stopPolling');
+
+ const actionButton = wrapper.find('.js-action-button');
+
+ wrapper.vm.handleSave();
+
+ wrapper.vm
+ .$nextTick()
+ .then(() => {
+ expect(actionButton.vm.disabled).toBeTruthy();
+ })
+ .then(saveNotePromise)
+ .then(wrapper.vm.$nextTick)
+ .then(() => {
+ expect(actionButton.vm.disabled).toBeFalsy();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('textarea', () => {
+ it('should render textarea with placeholder', () => {
+ expect(wrapper.find('.js-main-target-form textarea').attributes('placeholder')).toEqual(
+ 'Write a comment or drag your files here…',
+ );
+ });
+
+ it('should make textarea disabled while requesting', done => {
+ const $submitButton = $(wrapper.find('.js-comment-submit-button').element);
+ wrapper.vm.note = 'hello world';
+ jest.spyOn(wrapper.vm, 'stopPolling');
+ jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(new Promise(() => {}));
+
+ wrapper.vm.$nextTick(() => {
+ // Wait for wrapper.vm.note change triggered. It should enable $submitButton.
+ $submitButton.trigger('click');
+
+ wrapper.vm.$nextTick(() => {
+ // Wait for wrapper.isSubmitting triggered. It should disable textarea.
+ expect(wrapper.find('.js-main-target-form textarea').attributes('disabled')).toBe(
+ 'disabled',
+ );
+ done();
+ });
+ });
+ });
+
+ it('should support quick actions', () => {
+ expect(
+ wrapper.find('.js-main-target-form textarea').attributes('data-supports-quick-actions'),
+ ).toBe('true');
+ });
+
+ it('should link to markdown docs', () => {
+ const { markdownDocsPath } = notesDataMock;
+
+ expect(
+ wrapper
+ .find(`a[href="${markdownDocsPath}"]`)
+ .text()
+ .trim(),
+ ).toEqual('Markdown');
+ });
+
+ it('should link to quick actions docs', () => {
+ const { quickActionsDocsPath } = notesDataMock;
+
+ expect(
+ wrapper
+ .find(`a[href="${quickActionsDocsPath}"]`)
+ .text()
+ .trim(),
+ ).toEqual('quick actions');
+ });
+
+ it('should resize textarea after note discarded', done => {
+ jest.spyOn(wrapper.vm, 'discard');
+
+ wrapper.vm.note = 'foo';
+ wrapper.vm.discard();
+
+ wrapper.vm.$nextTick(() => {
+ expect(Autosize.update).toHaveBeenCalled();
+ done();
+ });
+ });
+
+ describe('edit mode', () => {
+ it('should enter edit mode when arrow up is pressed', () => {
+ jest.spyOn(wrapper.vm, 'editCurrentUserLastNote');
+ wrapper.find('.js-main-target-form textarea').value = 'Foo';
+ wrapper
+ .find('.js-main-target-form textarea')
+ .element.dispatchEvent(keyboardDownEvent(38, true));
+
+ expect(wrapper.vm.editCurrentUserLastNote).toHaveBeenCalled();
+ });
+
+ it('inits autosave', () => {
+ expect(wrapper.vm.autosave).toBeDefined();
+ expect(wrapper.vm.autosave.key).toEqual(`autosave/Note/Issue/${noteableDataMock.id}`);
+ });
+ });
+
+ describe('event enter', () => {
+ it('should save note when cmd+enter is pressed', () => {
+ jest.spyOn(wrapper.vm, 'handleSave');
+ wrapper.find('.js-main-target-form textarea').value = 'Foo';
+ wrapper
+ .find('.js-main-target-form textarea')
+ .element.dispatchEvent(keyboardDownEvent(13, true));
+
+ expect(wrapper.vm.handleSave).toHaveBeenCalled();
+ });
+
+ it('should save note when ctrl+enter is pressed', () => {
+ jest.spyOn(wrapper.vm, 'handleSave');
+ wrapper.find('.js-main-target-form textarea').value = 'Foo';
+ wrapper
+ .find('.js-main-target-form textarea')
+ .element.dispatchEvent(keyboardDownEvent(13, false, true));
+
+ expect(wrapper.vm.handleSave).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('actions', () => {
+ it('should be possible to close the issue', () => {
+ expect(
+ wrapper
+ .find('.btn-comment-and-close')
+ .text()
+ .trim(),
+ ).toEqual('Close issue');
+ });
+
+ it('should render comment button as disabled', () => {
+ expect(wrapper.find('.js-comment-submit-button').attributes('disabled')).toEqual(
+ 'disabled',
+ );
+ });
+
+ it('should enable comment button if it has note', done => {
+ wrapper.vm.note = 'Foo';
+ wrapper.vm.$nextTick(() => {
+ expect(wrapper.find('.js-comment-submit-button').attributes('disabled')).toBeFalsy();
+ done();
+ });
+ });
+
+ it('should update buttons texts when it has note', done => {
+ wrapper.vm.note = 'Foo';
+ wrapper.vm.$nextTick(() => {
+ expect(
+ wrapper
+ .find('.btn-comment-and-close')
+ .text()
+ .trim(),
+ ).toEqual('Comment & close issue');
+
+ done();
+ });
+ });
+
+ it('updates button text with noteable type', done => {
+ wrapper.setProps({ noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE });
+
+ wrapper.vm.$nextTick(() => {
+ expect(
+ wrapper
+ .find('.btn-comment-and-close')
+ .text()
+ .trim(),
+ ).toEqual('Close merge request');
+ done();
+ });
+ });
+
+ describe('when clicking close/reopen button', () => {
+ it('should disable button and show a loading spinner', done => {
+ const toggleStateButton = wrapper.find('.js-action-button');
+
+ toggleStateButton.trigger('click');
+ wrapper.vm.$nextTick(() => {
+ expect(toggleStateButton.element.disabled).toEqual(true);
+ expect(toggleStateButton.find('.js-loading-button-icon').exists()).toBe(true);
+
+ done();
+ });
+ });
+ });
+
+ describe('when toggling state', () => {
+ it('should update MR count', done => {
+ jest.spyOn(wrapper.vm, 'closeIssue').mockResolvedValue();
+
+ wrapper.vm.toggleIssueState();
+
+ wrapper.vm.$nextTick(() => {
+ expect(refreshUserMergeRequestCounts).toHaveBeenCalled();
+
+ done();
+ });
+ });
+ });
+ });
+
+ describe('issue is confidential', () => {
+ it('shows information warning', done => {
+ store.dispatch('setNoteableData', Object.assign(noteableDataMock, { confidential: true }));
+ wrapper.vm.$nextTick(() => {
+ expect(wrapper.find('.confidential-issue-warning')).toBeDefined();
+ done();
+ });
+ });
+ });
+ });
+
+ describe('user is not logged in', () => {
+ beforeEach(() => {
+ setupStore(null, loggedOutnoteableData);
+
+ mountComponent();
+ });
+
+ it('should render signed out widget', () => {
+ expect(trimText(wrapper.text())).toEqual('Please register or sign in to reply');
+ });
+
+ it('should not render submission form', () => {
+ expect(wrapper.find('textarea').exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/notes/components/diff_discussion_header_spec.js b/spec/frontend/notes/components/diff_discussion_header_spec.js
new file mode 100644
index 00000000000..f90147f9105
--- /dev/null
+++ b/spec/frontend/notes/components/diff_discussion_header_spec.js
@@ -0,0 +1,141 @@
+import { mount, createLocalVue } from '@vue/test-utils';
+
+import createStore from '~/notes/stores';
+import diffDiscussionHeader from '~/notes/components/diff_discussion_header.vue';
+
+import { discussionMock } from '../../../javascripts/notes/mock_data';
+import mockDiffFile from '../../diffs/mock_data/diff_discussions';
+
+const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json';
+
+describe('diff_discussion_header component', () => {
+ let store;
+ let wrapper;
+
+ preloadFixtures(discussionWithTwoUnresolvedNotes);
+
+ beforeEach(() => {
+ window.mrTabs = {};
+ store = createStore();
+
+ const localVue = createLocalVue();
+ wrapper = mount(diffDiscussionHeader, {
+ store,
+ propsData: { discussion: discussionMock },
+ localVue,
+ sync: false,
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should render user avatar', () => {
+ const discussion = { ...discussionMock };
+ discussion.diff_file = mockDiffFile;
+ discussion.diff_discussion = true;
+
+ wrapper.setProps({ discussion });
+
+ expect(wrapper.find('.user-avatar-link').exists()).toBe(true);
+ });
+
+ describe('action text', () => {
+ const commitId = 'razupaltuff';
+ const truncatedCommitId = commitId.substr(0, 8);
+ let commitElement;
+
+ beforeEach(done => {
+ store.state.diffs = {
+ projectPath: 'something',
+ };
+
+ wrapper.setProps({
+ discussion: {
+ ...discussionMock,
+ for_commit: true,
+ commit_id: commitId,
+ diff_discussion: true,
+ diff_file: {
+ ...mockDiffFile,
+ },
+ },
+ });
+
+ wrapper.vm
+ .$nextTick()
+ .then(() => {
+ commitElement = wrapper.find('.commit-sha');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ describe('for diff threads without a commit id', () => {
+ it('should show started a thread on the diff text', done => {
+ Object.assign(wrapper.vm.discussion, {
+ for_commit: false,
+ commit_id: null,
+ });
+
+ wrapper.vm.$nextTick(() => {
+ expect(wrapper.text()).toContain('started a thread on the diff');
+
+ done();
+ });
+ });
+
+ it('should show thread on older version text', done => {
+ Object.assign(wrapper.vm.discussion, {
+ for_commit: false,
+ commit_id: null,
+ active: false,
+ });
+
+ wrapper.vm.$nextTick(() => {
+ expect(wrapper.text()).toContain('started a thread on an old version of the diff');
+
+ done();
+ });
+ });
+ });
+
+ describe('for commit threads', () => {
+ it('should display a monospace started a thread on commit', () => {
+ expect(wrapper.text()).toContain(`started a thread on commit ${truncatedCommitId}`);
+ expect(commitElement.exists()).toBe(true);
+ expect(commitElement.text()).toContain(truncatedCommitId);
+ });
+ });
+
+ describe('for diff thread with a commit id', () => {
+ it('should display started thread on commit header', done => {
+ wrapper.vm.discussion.for_commit = false;
+
+ wrapper.vm.$nextTick(() => {
+ expect(wrapper.text()).toContain(`started a thread on commit ${truncatedCommitId}`);
+
+ expect(commitElement).not.toBe(null);
+
+ done();
+ });
+ });
+
+ it('should display outdated change on commit header', done => {
+ wrapper.vm.discussion.for_commit = false;
+ wrapper.vm.discussion.active = false;
+
+ wrapper.vm.$nextTick(() => {
+ expect(wrapper.text()).toContain(
+ `started a thread on an outdated change in commit ${truncatedCommitId}`,
+ );
+
+ expect(commitElement).not.toBe(null);
+
+ done();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js
index d3c8cf72376..91f9dab2530 100644
--- a/spec/frontend/notes/components/discussion_actions_spec.js
+++ b/spec/frontend/notes/components/discussion_actions_spec.js
@@ -1,6 +1,6 @@
import createStore from '~/notes/stores';
import { shallowMount, mount, createLocalVue } from '@vue/test-utils';
-import { discussionMock } from '../../../javascripts/notes/mock_data';
+import { discussionMock } from '../../notes/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_notes_spec.js b/spec/frontend/notes/components/discussion_notes_spec.js
index 58d367077e8..f77236b14bc 100644
--- a/spec/frontend/notes/components/discussion_notes_spec.js
+++ b/spec/frontend/notes/components/discussion_notes_spec.js
@@ -8,11 +8,7 @@ import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_sys
import SystemNote from '~/vue_shared/components/notes/system_note.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import createStore from '~/notes/stores';
-import {
- noteableDataMock,
- discussionMock,
- notesDataMock,
-} from '../../../javascripts/notes/mock_data';
+import { noteableDataMock, discussionMock, notesDataMock } from '../../notes/mock_data';
const localVue = createLocalVue();
diff --git a/spec/frontend/notes/components/note_app_spec.js b/spec/frontend/notes/components/note_app_spec.js
index a8ec47fd44f..3716b349210 100644
--- a/spec/frontend/notes/components/note_app_spec.js
+++ b/spec/frontend/notes/components/note_app_spec.js
@@ -9,7 +9,8 @@ import createStore from '~/notes/stores';
import '~/behaviors/markdown/render_gfm';
import { setTestTimeout } from 'helpers/timeout';
// TODO: use generated fixture (https://gitlab.com/gitlab-org/gitlab-foss/issues/62491)
-import * as mockData from '../../../javascripts/notes/mock_data';
+import * as mockData from '../../notes/mock_data';
+import * as urlUtility from '~/lib/utils/url_utility';
setTestTimeout(1000);
@@ -54,7 +55,9 @@ describe('note_app', () => {
components: {
NotesApp,
},
- template: '<div class="js-vue-notes-event"><notes-app v-bind="$attrs" /></div>',
+ template: `<div class="js-vue-notes-event">
+ <notes-app ref="notesApp" v-bind="$attrs" />
+ </div>`,
},
{
attachToDocument: true,
@@ -313,4 +316,23 @@ describe('note_app', () => {
});
});
});
+
+ describe('mounted', () => {
+ beforeEach(() => {
+ axiosMock.onAny().reply(mockData.getIndividualNoteResponse);
+ wrapper = mountComponent();
+ return waitForDiscussionsRequest();
+ });
+
+ it('should listen hashchange event', () => {
+ const notesApp = wrapper.find(NotesApp);
+ const hash = 'some dummy hash';
+ jest.spyOn(urlUtility, 'getLocationHash').mockReturnValueOnce(hash);
+ const setTargetNoteHash = jest.spyOn(notesApp.vm, 'setTargetNoteHash');
+
+ window.dispatchEvent(new Event('hashchange'), hash);
+
+ expect(setTargetNoteHash).toHaveBeenCalled();
+ });
+ });
});
diff --git a/spec/frontend/notes/mock_data.js b/spec/frontend/notes/mock_data.js
new file mode 100644
index 00000000000..01cb70d395c
--- /dev/null
+++ b/spec/frontend/notes/mock_data.js
@@ -0,0 +1,1255 @@
+// Copied to ee/spec/frontend/notes/mock_data.js
+
+export const notesDataMock = {
+ discussionsPath: '/gitlab-org/gitlab-foss/issues/26/discussions.json',
+ lastFetchedAt: 1501862675,
+ markdownDocsPath: '/help/user/markdown',
+ newSessionPath: '/users/sign_in?redirect_to_referer=yes',
+ notesPath: '/gitlab-org/gitlab-foss/noteable/issue/98/notes',
+ quickActionsDocsPath: '/help/user/project/quick_actions',
+ registerPath: '/users/sign_in?redirect_to_referer=yes#register-pane',
+ prerenderedNotesCount: 1,
+ closePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=close',
+ reopenPath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=reopen',
+ canAwardEmoji: true,
+};
+
+export const userDataMock = {
+ avatar_url: 'mock_path',
+ id: 1,
+ name: 'Root',
+ path: '/root',
+ state: 'active',
+ username: 'root',
+};
+
+export const noteableDataMock = {
+ assignees: [],
+ author_id: 1,
+ branch_name: null,
+ confidential: false,
+ create_note_path: '/gitlab-org/gitlab-foss/notes?target_id=98&target_type=issue',
+ created_at: '2017-02-07T10:11:18.395Z',
+ current_user: {
+ can_create_note: true,
+ can_update: true,
+ can_award_emoji: true,
+ },
+ description: '',
+ due_date: null,
+ human_time_estimate: null,
+ human_total_time_spent: null,
+ id: 98,
+ iid: 26,
+ labels: [],
+ lock_version: null,
+ milestone: null,
+ milestone_id: null,
+ moved_to_id: null,
+ preview_note_path: '/gitlab-org/gitlab-foss/preview_markdown?target_id=98&target_type=Issue',
+ project_id: 2,
+ state: 'opened',
+ time_estimate: 0,
+ title: '14',
+ total_time_spent: 0,
+ noteable_note_url: '/group/project/merge_requests/1#note_1',
+ updated_at: '2017-08-04T09:53:01.226Z',
+ updated_by_id: 1,
+ web_url: '/gitlab-org/gitlab-foss/issues/26',
+ noteableType: 'issue',
+};
+
+export const lastFetchedAt = '1501862675';
+
+export const individualNote = {
+ expanded: true,
+ id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
+ individual_note: true,
+ notes: [
+ {
+ id: '1390',
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: 'test',
+ path: '/root',
+ },
+ created_at: '2017-08-01T17: 09: 33.762Z',
+ updated_at: '2017-08-01T17: 09: 33.762Z',
+ system: false,
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: null,
+ human_access: 'Owner',
+ note: 'sdfdsaf',
+ note_html: "<p dir='auto'>sdfdsaf</p>",
+ current_user: {
+ can_edit: true,
+ can_award_emoji: true,
+ },
+ discussion_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
+ emoji_awardable: true,
+ award_emoji: [
+ { name: 'baseball', user: { id: 1, name: 'Root', username: 'root' } },
+ { name: 'art', user: { id: 1, name: 'Root', username: 'root' } },
+ ],
+ toggle_award_path: '/gitlab-org/gitlab-foss/notes/1390/toggle_award_emoji',
+ noteable_note_url: '/group/project/merge_requests/1#note_1',
+ note_url: '/group/project/merge_requests/1#note_1',
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390&user_id=1',
+ path: '/gitlab-org/gitlab-foss/notes/1390',
+ },
+ ],
+ reply_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
+};
+
+export const note = {
+ id: '546',
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ state: 'active',
+ avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ path: '/root',
+ },
+ created_at: '2017-08-10T15:24:03.087Z',
+ updated_at: '2017-08-10T15:24:03.087Z',
+ system: false,
+ noteable_id: 67,
+ noteable_type: 'Issue',
+ noteable_iid: 7,
+ type: null,
+ human_access: 'Owner',
+ note: 'Vel id placeat reprehenderit sit numquam.',
+ note_html: '<p dir="auto">Vel id placeat reprehenderit sit numquam.</p>',
+ current_user: {
+ can_edit: true,
+ can_award_emoji: true,
+ },
+ discussion_id: 'd3842a451b7f3d9a5dfce329515127b2d29a4cd0',
+ emoji_awardable: true,
+ award_emoji: [
+ {
+ name: 'baseball',
+ user: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ },
+ },
+ {
+ name: 'bath_tone3',
+ user: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ },
+ },
+ ],
+ toggle_award_path: '/gitlab-org/gitlab-foss/notes/546/toggle_award_emoji',
+ note_url: '/group/project/merge_requests/1#note_1',
+ noteable_note_url: '/group/project/merge_requests/1#note_1',
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_546&user_id=1',
+ path: '/gitlab-org/gitlab-foss/notes/546',
+};
+
+export const discussionMock = {
+ id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
+ reply_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
+ expanded: true,
+ notes: [
+ {
+ id: '1395',
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ created_at: '2017-08-02T10:51:58.559Z',
+ updated_at: '2017-08-02T10:51:58.559Z',
+ system: false,
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: 'DiscussionNote',
+ human_access: 'Owner',
+ note: 'THIS IS A DICUSSSION!',
+ note_html: "<p dir='auto'>THIS IS A DICUSSSION!</p>",
+ current_user: {
+ can_edit: true,
+ can_award_emoji: true,
+ can_resolve: true,
+ },
+ discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
+ emoji_awardable: true,
+ award_emoji: [],
+ noteable_note_url: '/group/project/merge_requests/1#note_1',
+ toggle_award_path: '/gitlab-org/gitlab-foss/notes/1395/toggle_award_emoji',
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1395&user_id=1',
+ path: '/gitlab-org/gitlab-foss/notes/1395',
+ },
+ {
+ id: '1396',
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ created_at: '2017-08-02T10:56:50.980Z',
+ updated_at: '2017-08-03T14:19:35.691Z',
+ system: false,
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: 'DiscussionNote',
+ human_access: 'Owner',
+ note: 'sadfasdsdgdsf',
+ note_html: "<p dir='auto'>sadfasdsdgdsf</p>",
+ last_edited_at: '2017-08-03T14:19:35.691Z',
+ last_edited_by: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ current_user: {
+ can_edit: true,
+ can_award_emoji: true,
+ can_resolve: true,
+ },
+ discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
+ emoji_awardable: true,
+ award_emoji: [],
+ toggle_award_path: '/gitlab-org/gitlab-foss/notes/1396/toggle_award_emoji',
+ noteable_note_url: '/group/project/merge_requests/1#note_1',
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1396&user_id=1',
+ path: '/gitlab-org/gitlab-foss/notes/1396',
+ },
+ {
+ id: '1437',
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ created_at: '2017-08-03T18:11:18.780Z',
+ updated_at: '2017-08-04T09:52:31.062Z',
+ system: false,
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: 'DiscussionNote',
+ human_access: 'Owner',
+ note: 'adsfasf Should disappear',
+ note_html: "<p dir='auto'>adsfasf Should disappear</p>",
+ last_edited_at: '2017-08-04T09:52:31.062Z',
+ last_edited_by: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ current_user: {
+ can_edit: true,
+ can_award_emoji: true,
+ can_resolve: true,
+ },
+ discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
+ emoji_awardable: true,
+ award_emoji: [],
+ noteable_note_url: '/group/project/merge_requests/1#note_1',
+ toggle_award_path: '/gitlab-org/gitlab-foss/notes/1437/toggle_award_emoji',
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1437&user_id=1',
+ path: '/gitlab-org/gitlab-foss/notes/1437',
+ },
+ ],
+ individual_note: false,
+ resolvable: true,
+ active: true,
+};
+
+export const loggedOutnoteableData = {
+ id: '98',
+ iid: 26,
+ author_id: 1,
+ description: '',
+ lock_version: 1,
+ milestone_id: null,
+ state: 'opened',
+ title: 'asdsa',
+ updated_by_id: 1,
+ created_at: '2017-02-07T10:11:18.395Z',
+ updated_at: '2017-08-08T10:22:51.564Z',
+ time_estimate: 0,
+ total_time_spent: 0,
+ human_time_estimate: null,
+ human_total_time_spent: null,
+ milestone: null,
+ labels: [],
+ branch_name: null,
+ confidential: false,
+ assignees: [
+ {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ web_url: 'http://localhost:3000/root',
+ },
+ ],
+ due_date: null,
+ moved_to_id: null,
+ project_id: 2,
+ web_url: '/gitlab-org/gitlab-foss/issues/26',
+ current_user: {
+ can_create_note: false,
+ can_update: false,
+ },
+ noteable_note_url: '/group/project/merge_requests/1#note_1',
+ create_note_path: '/gitlab-org/gitlab-foss/notes?target_id=98&target_type=issue',
+ preview_note_path: '/gitlab-org/gitlab-foss/preview_markdown?target_id=98&target_type=Issue',
+};
+
+export const collapseNotesMock = [
+ {
+ expanded: true,
+ id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
+ individual_note: true,
+ notes: [
+ {
+ id: '1390',
+ attachment: null,
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: 'test',
+ path: '/root',
+ },
+ created_at: '2018-02-26T18:07:41.071Z',
+ updated_at: '2018-02-26T18:07:41.071Z',
+ system: true,
+ system_note_icon_name: 'pencil',
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: null,
+ human_access: 'Owner',
+ note: 'changed the description',
+ note_html: '<p dir="auto">changed the description</p>',
+ current_user: { can_edit: false },
+ discussion_id: 'b97fb7bda470a65b3e009377a9032edec0a4dd05',
+ emoji_awardable: false,
+ path: '/h5bp/html5-boilerplate/notes/1057',
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fh5bp%2Fhtml5-boilerplate%2Fissues%2F10%23note_1057&user_id=1',
+ },
+ ],
+ },
+ {
+ expanded: true,
+ id: 'ffde43f25984ad7f2b4275135e0e2846875336c0',
+ individual_note: true,
+ notes: [
+ {
+ id: '1391',
+ attachment: null,
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: 'test',
+ path: '/root',
+ },
+ created_at: '2018-02-26T18:13:24.071Z',
+ updated_at: '2018-02-26T18:13:24.071Z',
+ system: true,
+ system_note_icon_name: 'pencil',
+ noteable_id: 99,
+ noteable_type: 'Issue',
+ type: null,
+ human_access: 'Owner',
+ note: 'changed the description',
+ note_html: '<p dir="auto">changed the description</p>',
+ current_user: { can_edit: false },
+ discussion_id: '3eb958b4d81dec207ec3537a2f3bd8b9f271bb34',
+ emoji_awardable: false,
+ path: '/h5bp/html5-boilerplate/notes/1057',
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fh5bp%2Fhtml5-boilerplate%2Fissues%2F10%23note_1057&user_id=1',
+ },
+ ],
+ },
+];
+
+export const INDIVIDUAL_NOTE_RESPONSE_MAP = {
+ GET: {
+ '/gitlab-org/gitlab-foss/issues/26/discussions.json': [
+ {
+ id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
+ reply_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
+ expanded: true,
+ notes: [
+ {
+ id: '1390',
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ created_at: '2017-08-01T17:09:33.762Z',
+ updated_at: '2017-08-01T17:09:33.762Z',
+ system: false,
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: null,
+ human_access: 'Owner',
+ note: 'sdfdsaf',
+ note_html: '\u003cp dir="auto"\u003esdfdsaf\u003c/p\u003e',
+ current_user: {
+ can_edit: true,
+ can_award_emoji: true,
+ },
+ discussion_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
+ emoji_awardable: true,
+ award_emoji: [
+ {
+ name: 'baseball',
+ user: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ },
+ },
+ {
+ name: 'art',
+ user: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ },
+ },
+ ],
+ noteable_note_url: '/group/project/merge_requests/1#note_1',
+ toggle_award_path: '/gitlab-org/gitlab-foss/notes/1390/toggle_award_emoji',
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390\u0026user_id=1',
+ path: '/gitlab-org/gitlab-foss/notes/1390',
+ },
+ ],
+ individual_note: true,
+ },
+ {
+ id: '70d5c92a4039a36c70100c6691c18c27e4b0a790',
+ reply_id: '70d5c92a4039a36c70100c6691c18c27e4b0a790',
+ expanded: true,
+ notes: [
+ {
+ id: '1391',
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ created_at: '2017-08-02T10:51:38.685Z',
+ updated_at: '2017-08-02T10:51:38.685Z',
+ system: false,
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: null,
+ human_access: 'Owner',
+ note: 'New note!',
+ note_html: '\u003cp dir="auto"\u003eNew note!\u003c/p\u003e',
+ current_user: {
+ can_edit: true,
+ can_award_emoji: true,
+ },
+ discussion_id: '70d5c92a4039a36c70100c6691c18c27e4b0a790',
+ emoji_awardable: true,
+ award_emoji: [],
+ noteable_note_url: '/group/project/merge_requests/1#note_1',
+ toggle_award_path: '/gitlab-org/gitlab-foss/notes/1391/toggle_award_emoji',
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1391\u0026user_id=1',
+ path: '/gitlab-org/gitlab-foss/notes/1391',
+ },
+ ],
+ individual_note: true,
+ },
+ ],
+ '/gitlab-org/gitlab-foss/noteable/issue/98/notes': {
+ last_fetched_at: 1512900838,
+ notes: [],
+ },
+ },
+ PUT: {
+ '/gitlab-org/gitlab-foss/notes/1471': {
+ commands_changes: null,
+ valid: true,
+ id: '1471',
+ attachment: null,
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ created_at: '2017-08-08T16:53:00.666Z',
+ updated_at: '2017-12-10T11:03:21.876Z',
+ system: false,
+ noteable_id: 124,
+ noteable_type: 'Issue',
+ noteable_iid: 29,
+ type: 'DiscussionNote',
+ human_access: 'Owner',
+ note: 'Adding a comment',
+ note_html: '\u003cp dir="auto"\u003eAdding a comment\u003c/p\u003e',
+ last_edited_at: '2017-12-10T11:03:21.876Z',
+ last_edited_by: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ current_user: {
+ can_edit: true,
+ can_award_emoji: true,
+ },
+ discussion_id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052',
+ emoji_awardable: true,
+ award_emoji: [],
+ noteable_note_url: '/group/project/merge_requests/1#note_1',
+ toggle_award_path: '/gitlab-org/gitlab-foss/notes/1471/toggle_award_emoji',
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F29%23note_1471\u0026user_id=1',
+ path: '/gitlab-org/gitlab-foss/notes/1471',
+ },
+ },
+};
+
+export const DISCUSSION_NOTE_RESPONSE_MAP = {
+ ...INDIVIDUAL_NOTE_RESPONSE_MAP,
+ GET: {
+ ...INDIVIDUAL_NOTE_RESPONSE_MAP.GET,
+ '/gitlab-org/gitlab-foss/issues/26/discussions.json': [
+ {
+ id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052',
+ reply_id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052',
+ expanded: true,
+ notes: [
+ {
+ id: '1471',
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ created_at: '2017-08-08T16:53:00.666Z',
+ updated_at: '2017-08-08T16:53:00.666Z',
+ system: false,
+ noteable_id: 124,
+ noteable_type: 'Issue',
+ noteable_iid: 29,
+ type: 'DiscussionNote',
+ human_access: 'Owner',
+ note: 'Adding a comment',
+ note_html: '\u003cp dir="auto"\u003eAdding a comment\u003c/p\u003e',
+ current_user: {
+ can_edit: true,
+ can_award_emoji: true,
+ },
+ discussion_id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052',
+ emoji_awardable: true,
+ award_emoji: [],
+ toggle_award_path: '/gitlab-org/gitlab-foss/notes/1471/toggle_award_emoji',
+ noteable_note_url: '/group/project/merge_requests/1#note_1',
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F29%23note_1471\u0026user_id=1',
+ path: '/gitlab-org/gitlab-foss/notes/1471',
+ },
+ ],
+ individual_note: false,
+ },
+ ],
+ },
+};
+
+export function getIndividualNoteResponse(config) {
+ return [200, INDIVIDUAL_NOTE_RESPONSE_MAP[config.method.toUpperCase()][config.url]];
+}
+
+export function getDiscussionNoteResponse(config) {
+ return [200, DISCUSSION_NOTE_RESPONSE_MAP[config.method.toUpperCase()][config.url]];
+}
+
+export const notesWithDescriptionChanges = [
+ {
+ id: '39b271c2033e9ed43d8edb393702f65f7a830459',
+ reply_id: '39b271c2033e9ed43d8edb393702f65f7a830459',
+ expanded: true,
+ notes: [
+ {
+ id: '901',
+ type: null,
+ attachment: null,
+ author: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ path: '/root',
+ },
+ created_at: '2018-05-29T12:05:36.117Z',
+ updated_at: '2018-05-29T12:05:36.117Z',
+ system: false,
+ noteable_id: 182,
+ noteable_type: 'Issue',
+ resolvable: false,
+ noteable_iid: 12,
+ note:
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
+ note_html:
+ '<p dir="auto">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>',
+ current_user: { can_edit: true, can_award_emoji: true },
+ resolved: false,
+ resolved_by: null,
+ discussion_id: '39b271c2033e9ed43d8edb393702f65f7a830459',
+ emoji_awardable: true,
+ award_emoji: [],
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_901&user_id=1',
+ human_access: 'Owner',
+ toggle_award_path: '/gitlab-org/gitlab-shell/notes/901/toggle_award_emoji',
+ path: '/gitlab-org/gitlab-shell/notes/901',
+ },
+ ],
+ individual_note: true,
+ resolvable: false,
+ resolved: false,
+ diff_discussion: false,
+ },
+ {
+ id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795',
+ reply_id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795',
+ expanded: true,
+ notes: [
+ {
+ id: '902',
+ type: null,
+ attachment: null,
+ author: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ path: '/root',
+ },
+ created_at: '2018-05-29T12:05:58.694Z',
+ updated_at: '2018-05-29T12:05:58.694Z',
+ system: false,
+ noteable_id: 182,
+ noteable_type: 'Issue',
+ resolvable: false,
+ noteable_iid: 12,
+ note:
+ 'Varius vel pharetra vel turpis nunc eget lorem. Ipsum dolor sit amet consectetur adipiscing.',
+ note_html:
+ '<p dir="auto">Varius vel pharetra vel turpis nunc eget lorem. Ipsum dolor sit amet consectetur adipiscing.</p>',
+ current_user: { can_edit: true, can_award_emoji: true },
+ resolved: false,
+ resolved_by: null,
+ discussion_id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795',
+ emoji_awardable: true,
+ award_emoji: [],
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_902&user_id=1',
+ human_access: 'Owner',
+ toggle_award_path: '/gitlab-org/gitlab-shell/notes/902/toggle_award_emoji',
+ path: '/gitlab-org/gitlab-shell/notes/902',
+ },
+ ],
+ individual_note: true,
+ resolvable: false,
+ resolved: false,
+ diff_discussion: false,
+ },
+ {
+ id: '7f1feda384083eb31763366e6392399fde6f3f31',
+ reply_id: '7f1feda384083eb31763366e6392399fde6f3f31',
+ expanded: true,
+ notes: [
+ {
+ id: '903',
+ type: null,
+ attachment: null,
+ author: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ path: '/root',
+ },
+ created_at: '2018-05-29T12:06:05.772Z',
+ updated_at: '2018-05-29T12:06:05.772Z',
+ system: true,
+ noteable_id: 182,
+ noteable_type: 'Issue',
+ resolvable: false,
+ noteable_iid: 12,
+ note: 'changed the description',
+ note_html: '<p dir="auto">changed the description</p>',
+ current_user: { can_edit: false, can_award_emoji: true },
+ resolved: false,
+ resolved_by: null,
+ system_note_icon_name: 'pencil-square',
+ discussion_id: '7f1feda384083eb31763366e6392399fde6f3f31',
+ emoji_awardable: false,
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_903&user_id=1',
+ human_access: 'Owner',
+ path: '/gitlab-org/gitlab-shell/notes/903',
+ },
+ ],
+ individual_note: true,
+ resolvable: false,
+ resolved: false,
+ diff_discussion: false,
+ },
+ {
+ id: '091865fe3ae20f0045234a3d103e3b15e73405b5',
+ reply_id: '091865fe3ae20f0045234a3d103e3b15e73405b5',
+ expanded: true,
+ notes: [
+ {
+ id: '904',
+ type: null,
+ attachment: null,
+ author: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ path: '/root',
+ },
+ created_at: '2018-05-29T12:06:16.112Z',
+ updated_at: '2018-05-29T12:06:16.112Z',
+ system: false,
+ noteable_id: 182,
+ noteable_type: 'Issue',
+ resolvable: false,
+ noteable_iid: 12,
+ note: 'Ullamcorper eget nulla facilisi etiam',
+ note_html: '<p dir="auto">Ullamcorper eget nulla facilisi etiam</p>',
+ current_user: { can_edit: true, can_award_emoji: true },
+ resolved: false,
+ resolved_by: null,
+ discussion_id: '091865fe3ae20f0045234a3d103e3b15e73405b5',
+ emoji_awardable: true,
+ award_emoji: [],
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_904&user_id=1',
+ human_access: 'Owner',
+ toggle_award_path: '/gitlab-org/gitlab-shell/notes/904/toggle_award_emoji',
+ path: '/gitlab-org/gitlab-shell/notes/904',
+ },
+ ],
+ individual_note: true,
+ resolvable: false,
+ resolved: false,
+ diff_discussion: false,
+ },
+ {
+ id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044',
+ reply_id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044',
+ expanded: true,
+ notes: [
+ {
+ id: '905',
+ type: null,
+ attachment: null,
+ author: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ path: '/root',
+ },
+ created_at: '2018-05-29T12:06:28.851Z',
+ updated_at: '2018-05-29T12:06:28.851Z',
+ system: true,
+ noteable_id: 182,
+ noteable_type: 'Issue',
+ resolvable: false,
+ noteable_iid: 12,
+ note: 'changed the description',
+ note_html: '<p dir="auto">changed the description</p>',
+ current_user: { can_edit: false, can_award_emoji: true },
+ resolved: false,
+ resolved_by: null,
+ system_note_icon_name: 'pencil-square',
+ discussion_id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044',
+ emoji_awardable: false,
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_905&user_id=1',
+ human_access: 'Owner',
+ path: '/gitlab-org/gitlab-shell/notes/905',
+ },
+ ],
+ individual_note: true,
+ resolvable: false,
+ resolved: false,
+ diff_discussion: false,
+ },
+ {
+ id: '70411b08cdfc01f24187a06d77daa33464cb2620',
+ reply_id: '70411b08cdfc01f24187a06d77daa33464cb2620',
+ expanded: true,
+ notes: [
+ {
+ id: '906',
+ type: null,
+ attachment: null,
+ author: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ path: '/root',
+ },
+ created_at: '2018-05-29T12:20:02.925Z',
+ updated_at: '2018-05-29T12:20:02.925Z',
+ system: true,
+ noteable_id: 182,
+ noteable_type: 'Issue',
+ resolvable: false,
+ noteable_iid: 12,
+ note: 'changed the description',
+ note_html: '<p dir="auto">changed the description</p>',
+ current_user: { can_edit: false, can_award_emoji: true },
+ resolved: false,
+ resolved_by: null,
+ system_note_icon_name: 'pencil-square',
+ discussion_id: '70411b08cdfc01f24187a06d77daa33464cb2620',
+ emoji_awardable: false,
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_906&user_id=1',
+ human_access: 'Owner',
+ path: '/gitlab-org/gitlab-shell/notes/906',
+ },
+ ],
+ individual_note: true,
+ resolvable: false,
+ resolved: false,
+ diff_discussion: false,
+ },
+];
+
+export const collapsedSystemNotes = [
+ {
+ id: '39b271c2033e9ed43d8edb393702f65f7a830459',
+ reply_id: '39b271c2033e9ed43d8edb393702f65f7a830459',
+ expanded: true,
+ notes: [
+ {
+ id: '901',
+ type: null,
+ attachment: null,
+ author: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ path: '/root',
+ },
+ created_at: '2018-05-29T12:05:36.117Z',
+ updated_at: '2018-05-29T12:05:36.117Z',
+ system: false,
+ noteable_id: 182,
+ noteable_type: 'Issue',
+ resolvable: false,
+ noteable_iid: 12,
+ note:
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
+ note_html:
+ '<p dir="auto">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>',
+ current_user: { can_edit: true, can_award_emoji: true },
+ resolved: false,
+ resolved_by: null,
+ discussion_id: '39b271c2033e9ed43d8edb393702f65f7a830459',
+ emoji_awardable: true,
+ award_emoji: [],
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_901&user_id=1',
+ human_access: 'Owner',
+ toggle_award_path: '/gitlab-org/gitlab-shell/notes/901/toggle_award_emoji',
+ path: '/gitlab-org/gitlab-shell/notes/901',
+ },
+ ],
+ individual_note: true,
+ resolvable: false,
+ resolved: false,
+ diff_discussion: false,
+ },
+ {
+ id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795',
+ reply_id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795',
+ expanded: true,
+ notes: [
+ {
+ id: '902',
+ type: null,
+ attachment: null,
+ author: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ path: '/root',
+ },
+ created_at: '2018-05-29T12:05:58.694Z',
+ updated_at: '2018-05-29T12:05:58.694Z',
+ system: false,
+ noteable_id: 182,
+ noteable_type: 'Issue',
+ resolvable: false,
+ noteable_iid: 12,
+ note:
+ 'Varius vel pharetra vel turpis nunc eget lorem. Ipsum dolor sit amet consectetur adipiscing.',
+ note_html:
+ '<p dir="auto">Varius vel pharetra vel turpis nunc eget lorem. Ipsum dolor sit amet consectetur adipiscing.</p>',
+ current_user: { can_edit: true, can_award_emoji: true },
+ resolved: false,
+ resolved_by: null,
+ discussion_id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795',
+ emoji_awardable: true,
+ award_emoji: [],
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_902&user_id=1',
+ human_access: 'Owner',
+ toggle_award_path: '/gitlab-org/gitlab-shell/notes/902/toggle_award_emoji',
+ path: '/gitlab-org/gitlab-shell/notes/902',
+ },
+ ],
+ individual_note: true,
+ resolvable: false,
+ resolved: false,
+ diff_discussion: false,
+ },
+ {
+ id: '091865fe3ae20f0045234a3d103e3b15e73405b5',
+ reply_id: '091865fe3ae20f0045234a3d103e3b15e73405b5',
+ expanded: true,
+ notes: [
+ {
+ id: '904',
+ type: null,
+ attachment: null,
+ author: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ path: '/root',
+ },
+ created_at: '2018-05-29T12:06:16.112Z',
+ updated_at: '2018-05-29T12:06:16.112Z',
+ system: false,
+ noteable_id: 182,
+ noteable_type: 'Issue',
+ resolvable: false,
+ noteable_iid: 12,
+ note: 'Ullamcorper eget nulla facilisi etiam',
+ note_html: '<p dir="auto">Ullamcorper eget nulla facilisi etiam</p>',
+ current_user: { can_edit: true, can_award_emoji: true },
+ resolved: false,
+ resolved_by: null,
+ discussion_id: '091865fe3ae20f0045234a3d103e3b15e73405b5',
+ emoji_awardable: true,
+ award_emoji: [],
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_904&user_id=1',
+ human_access: 'Owner',
+ toggle_award_path: '/gitlab-org/gitlab-shell/notes/904/toggle_award_emoji',
+ path: '/gitlab-org/gitlab-shell/notes/904',
+ },
+ ],
+ individual_note: true,
+ resolvable: false,
+ resolved: false,
+ diff_discussion: false,
+ },
+ {
+ id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044',
+ reply_id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044',
+ expanded: true,
+ notes: [
+ {
+ id: '905',
+ type: null,
+ attachment: null,
+ author: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ path: '/root',
+ },
+ created_at: '2018-05-29T12:06:28.851Z',
+ updated_at: '2018-05-29T12:06:28.851Z',
+ system: true,
+ noteable_id: 182,
+ noteable_type: 'Issue',
+ resolvable: false,
+ noteable_iid: 12,
+ start_description_version_id: undefined,
+ note: 'changed the description',
+ note_html: '<p dir="auto">changed the description</p>',
+ current_user: { can_edit: false, can_award_emoji: true },
+ resolved: false,
+ resolved_by: null,
+ system_note_icon_name: 'pencil-square',
+ discussion_id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044',
+ emoji_awardable: false,
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_905&user_id=1',
+ human_access: 'Owner',
+ path: '/gitlab-org/gitlab-shell/notes/905',
+ },
+ ],
+ individual_note: true,
+ resolvable: false,
+ resolved: false,
+ diff_discussion: false,
+ },
+ {
+ id: '70411b08cdfc01f24187a06d77daa33464cb2620',
+ reply_id: '70411b08cdfc01f24187a06d77daa33464cb2620',
+ expanded: true,
+ notes: [
+ {
+ id: '906',
+ type: null,
+ attachment: null,
+ author: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ path: '/root',
+ },
+ created_at: '2018-05-29T12:20:02.925Z',
+ updated_at: '2018-05-29T12:20:02.925Z',
+ system: true,
+ noteable_id: 182,
+ noteable_type: 'Issue',
+ resolvable: false,
+ noteable_iid: 12,
+ note: 'changed the description',
+ note_html: '<p dir="auto">changed the description</p>',
+ current_user: { can_edit: false, can_award_emoji: true },
+ resolved: false,
+ resolved_by: null,
+ system_note_icon_name: 'pencil-square',
+ discussion_id: '70411b08cdfc01f24187a06d77daa33464cb2620',
+ emoji_awardable: false,
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_906&user_id=1',
+ human_access: 'Owner',
+ path: '/gitlab-org/gitlab-shell/notes/906',
+ },
+ ],
+ individual_note: true,
+ resolvable: false,
+ resolved: false,
+ diff_discussion: false,
+ },
+];
+
+export const discussion1 = {
+ id: 'abc1',
+ resolvable: true,
+ resolved: false,
+ active: true,
+ diff_file: {
+ file_path: 'about.md',
+ },
+ position: {
+ new_line: 50,
+ old_line: null,
+ },
+ notes: [
+ {
+ created_at: '2018-07-04T16:25:41.749Z',
+ },
+ ],
+};
+
+export const resolvedDiscussion1 = {
+ id: 'abc1',
+ resolvable: true,
+ resolved: true,
+ diff_file: {
+ file_path: 'about.md',
+ },
+ position: {
+ new_line: 50,
+ old_line: null,
+ },
+ notes: [
+ {
+ created_at: '2018-07-04T16:25:41.749Z',
+ },
+ ],
+};
+
+export const discussion2 = {
+ id: 'abc2',
+ resolvable: true,
+ resolved: false,
+ active: true,
+ diff_file: {
+ file_path: 'README.md',
+ },
+ position: {
+ new_line: null,
+ old_line: 20,
+ },
+ notes: [
+ {
+ created_at: '2018-07-04T12:05:41.749Z',
+ },
+ ],
+};
+
+export const discussion3 = {
+ id: 'abc3',
+ resolvable: true,
+ active: true,
+ resolved: false,
+ diff_file: {
+ file_path: 'README.md',
+ },
+ position: {
+ new_line: 21,
+ old_line: null,
+ },
+ notes: [
+ {
+ created_at: '2018-07-05T17:25:41.749Z',
+ },
+ ],
+};
+
+export const unresolvableDiscussion = {
+ resolvable: false,
+};
+
+export const discussionFiltersMock = [
+ {
+ title: 'Show all activity',
+ value: 0,
+ },
+ {
+ title: 'Show comments only',
+ value: 1,
+ },
+ {
+ title: 'Show system notes only',
+ value: 2,
+ },
+];
diff --git a/spec/frontend/performance_bar/components/add_request_spec.js b/spec/frontend/performance_bar/components/add_request_spec.js
new file mode 100644
index 00000000000..cef264f3915
--- /dev/null
+++ b/spec/frontend/performance_bar/components/add_request_spec.js
@@ -0,0 +1,62 @@
+import AddRequest from '~/performance_bar/components/add_request.vue';
+import { shallowMount } from '@vue/test-utils';
+
+describe('add request form', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = shallowMount(AddRequest);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('hides the input on load', () => {
+ expect(wrapper.find('input').exists()).toBe(false);
+ });
+
+ describe('when clicking the button', () => {
+ beforeEach(() => {
+ wrapper.find('button').trigger('click');
+ });
+
+ it('shows the form', () => {
+ expect(wrapper.find('input').exists()).toBe(true);
+ });
+
+ describe('when pressing escape', () => {
+ beforeEach(() => {
+ wrapper.find('input').trigger('keyup.esc');
+ });
+
+ it('hides the input', () => {
+ expect(wrapper.find('input').exists()).toBe(false);
+ });
+ });
+
+ describe('when submitting the form', () => {
+ beforeEach(() => {
+ wrapper.find('input').setValue('http://gitlab.example.com/users/root/calendar.json');
+ wrapper.find('input').trigger('keyup.enter');
+ });
+
+ it('emits an event to add the request', () => {
+ expect(wrapper.emitted()['add-request']).toBeTruthy();
+ expect(wrapper.emitted()['add-request'][0]).toEqual([
+ 'http://gitlab.example.com/users/root/calendar.json',
+ ]);
+ });
+
+ it('hides the input', () => {
+ expect(wrapper.find('input').exists()).toBe(false);
+ });
+
+ it('clears the value for next time', () => {
+ wrapper.find('button').trigger('click');
+
+ expect(wrapper.find('input').text()).toEqual('');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/graph/action_component_spec.js b/spec/frontend/pipelines/graph/action_component_spec.js
new file mode 100644
index 00000000000..38ffe98c79b
--- /dev/null
+++ b/spec/frontend/pipelines/graph/action_component_spec.js
@@ -0,0 +1,75 @@
+import { mount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
+import axios from '~/lib/utils/axios_utils';
+import ActionComponent from '~/pipelines/components/graph/action_component.vue';
+
+describe('pipeline graph action component', () => {
+ let wrapper;
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+
+ mock.onPost('foo.json').reply(200);
+
+ wrapper = mount(ActionComponent, {
+ propsData: {
+ tooltipText: 'bar',
+ link: 'foo',
+ actionIcon: 'cancel',
+ },
+ sync: false,
+ });
+ });
+
+ afterEach(() => {
+ mock.restore();
+ wrapper.destroy();
+ });
+
+ it('should render the provided title as a bootstrap tooltip', () => {
+ expect(wrapper.attributes('data-original-title')).toBe('bar');
+ });
+
+ it('should update bootstrap tooltip when title changes', done => {
+ wrapper.setProps({ tooltipText: 'changed' });
+
+ wrapper.vm
+ .$nextTick()
+ .then(() => {
+ expect(wrapper.attributes('data-original-title')).toBe('changed');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('should render an svg', () => {
+ expect(wrapper.find('.ci-action-icon-wrapper')).toBeDefined();
+ expect(wrapper.find('svg')).toBeDefined();
+ });
+
+ describe('on click', () => {
+ it('emits `pipelineActionRequestComplete` after a successful request', done => {
+ jest.spyOn(wrapper.vm, '$emit');
+
+ wrapper.find('button').trigger('click');
+
+ waitForPromises()
+ .then(() => {
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('pipelineActionRequestComplete');
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('renders a loading icon while waiting for request', done => {
+ wrapper.find('button').trigger('click');
+
+ wrapper.vm.$nextTick(() => {
+ expect(wrapper.find('.js-action-icon-loading').exists()).toBe(true);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/pipeline_triggerer_spec.js b/spec/frontend/pipelines/pipeline_triggerer_spec.js
new file mode 100644
index 00000000000..45ac278dd38
--- /dev/null
+++ b/spec/frontend/pipelines/pipeline_triggerer_spec.js
@@ -0,0 +1,57 @@
+import { mount } from '@vue/test-utils';
+import pipelineTriggerer from '~/pipelines/components/pipeline_triggerer.vue';
+
+describe('Pipelines Triggerer', () => {
+ let wrapper;
+
+ const mockData = {
+ pipeline: {
+ user: {
+ name: 'foo',
+ avatar_url: '/avatar',
+ path: '/path',
+ },
+ },
+ };
+
+ const createComponent = () => {
+ wrapper = mount(pipelineTriggerer, {
+ propsData: mockData,
+ sync: false,
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should render a table cell', () => {
+ expect(wrapper.contains('.table-section')).toBe(true);
+ });
+
+ it('should render triggerer information when triggerer is provided', () => {
+ const link = wrapper.find('.js-pipeline-url-user');
+
+ expect(link.attributes('href')).toEqual(mockData.pipeline.user.path);
+ expect(link.find('.js-user-avatar-image-toolip').text()).toEqual(mockData.pipeline.user.name);
+ expect(link.find('img.avatar').attributes('src')).toEqual(
+ `${mockData.pipeline.user.avatar_url}?width=26`,
+ );
+ });
+
+ it('should render "API" when no triggerer is provided', () => {
+ wrapper.setProps({
+ pipeline: {
+ user: null,
+ },
+ });
+
+ wrapper.vm.$nextTick(() => {
+ expect(wrapper.find('.js-pipeline-url-api').text()).toEqual('API');
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/pipelines_table_row_spec.js b/spec/frontend/pipelines/pipelines_table_row_spec.js
new file mode 100644
index 00000000000..1c785ec6ffe
--- /dev/null
+++ b/spec/frontend/pipelines/pipelines_table_row_spec.js
@@ -0,0 +1,228 @@
+import { mount } from '@vue/test-utils';
+import PipelinesTableRowComponent from '~/pipelines/components/pipelines_table_row.vue';
+import eventHub from '~/pipelines/event_hub';
+
+describe('Pipelines Table Row', () => {
+ const jsonFixtureName = 'pipelines/pipelines.json';
+
+ const createWrapper = pipeline =>
+ mount(PipelinesTableRowComponent, {
+ propsData: {
+ pipeline,
+ autoDevopsHelpPath: 'foo',
+ viewType: 'root',
+ },
+ sync: false,
+ });
+
+ let wrapper;
+ let pipeline;
+ let pipelineWithoutAuthor;
+ let pipelineWithoutCommit;
+
+ preloadFixtures(jsonFixtureName);
+
+ beforeEach(() => {
+ const { pipelines } = getJSONFixture(jsonFixtureName);
+
+ pipeline = pipelines.find(p => p.user !== null && p.commit !== null);
+ pipelineWithoutAuthor = pipelines.find(p => p.user === null && p.commit !== null);
+ pipelineWithoutCommit = pipelines.find(p => p.user === null && p.commit === null);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('should render a table row', () => {
+ wrapper = createWrapper(pipeline);
+
+ expect(wrapper.attributes('class')).toContain('gl-responsive-table-row');
+ });
+
+ describe('status column', () => {
+ beforeEach(() => {
+ wrapper = createWrapper(pipeline);
+ });
+
+ it('should render a pipeline link', () => {
+ expect(wrapper.find('.table-section.commit-link a').attributes('href')).toEqual(
+ pipeline.path,
+ );
+ });
+
+ it('should render status text', () => {
+ expect(wrapper.find('.table-section.commit-link a').text()).toContain(
+ pipeline.details.status.text,
+ );
+ });
+ });
+
+ describe('information column', () => {
+ beforeEach(() => {
+ wrapper = createWrapper(pipeline);
+ });
+
+ it('should render a pipeline link', () => {
+ expect(wrapper.find('.table-section:nth-child(2) a').attributes('href')).toEqual(
+ pipeline.path,
+ );
+ });
+
+ it('should render pipeline ID', () => {
+ expect(wrapper.find('.table-section:nth-child(2) a > span').text()).toEqual(
+ `#${pipeline.id}`,
+ );
+ });
+
+ describe('when a user is provided', () => {
+ it('should render user information', () => {
+ expect(
+ wrapper.find('.table-section:nth-child(3) .js-pipeline-url-user').attributes('href'),
+ ).toEqual(pipeline.user.path);
+
+ expect(
+ wrapper
+ .find('.table-section:nth-child(3) .js-user-avatar-image-toolip')
+ .text()
+ .trim(),
+ ).toEqual(pipeline.user.name);
+ });
+ });
+ });
+
+ describe('commit column', () => {
+ it('should render link to commit', () => {
+ wrapper = createWrapper(pipeline);
+
+ const commitLink = wrapper.find('.branch-commit .commit-sha');
+
+ expect(commitLink.attributes('href')).toEqual(pipeline.commit.commit_path);
+ });
+
+ const findElements = () => {
+ const commitTitleElement = wrapper.find('.branch-commit .commit-title');
+ const commitAuthorElement = commitTitleElement.find('a.avatar-image-container');
+
+ if (!commitAuthorElement.exists()) {
+ return {
+ commitAuthorElement,
+ };
+ }
+
+ const commitAuthorLink = commitAuthorElement.attributes('href');
+ const commitAuthorName = commitAuthorElement
+ .find('.js-user-avatar-image-toolip')
+ .text()
+ .trim();
+
+ return {
+ commitAuthorElement,
+ commitAuthorLink,
+ commitAuthorName,
+ };
+ };
+
+ it('renders nothing without commit', () => {
+ expect(pipelineWithoutCommit.commit).toBe(null);
+
+ wrapper = createWrapper(pipelineWithoutCommit);
+ const { commitAuthorElement } = findElements();
+
+ expect(commitAuthorElement.exists()).toBe(false);
+ });
+
+ it('renders commit author', () => {
+ wrapper = createWrapper(pipeline);
+ const { commitAuthorLink, commitAuthorName } = findElements();
+
+ expect(commitAuthorLink).toEqual(pipeline.commit.author.path);
+ expect(commitAuthorName).toEqual(pipeline.commit.author.username);
+ });
+
+ it('renders commit with unregistered author', () => {
+ expect(pipelineWithoutAuthor.commit.author).toBe(null);
+
+ wrapper = createWrapper(pipelineWithoutAuthor);
+ const { commitAuthorLink, commitAuthorName } = findElements();
+
+ expect(commitAuthorLink).toEqual(`mailto:${pipelineWithoutAuthor.commit.author_email}`);
+ expect(commitAuthorName).toEqual(pipelineWithoutAuthor.commit.author_name);
+ });
+ });
+
+ describe('stages column', () => {
+ beforeEach(() => {
+ wrapper = createWrapper(pipeline);
+ });
+
+ it('should render an icon for each stage', () => {
+ expect(
+ wrapper.findAll('.table-section:nth-child(4) .js-builds-dropdown-button').length,
+ ).toEqual(pipeline.details.stages.length);
+ });
+ });
+
+ describe('actions column', () => {
+ const scheduledJobAction = {
+ name: 'some scheduled job',
+ };
+
+ beforeEach(() => {
+ const withActions = Object.assign({}, pipeline);
+ withActions.details.scheduled_actions = [scheduledJobAction];
+ withActions.flags.cancelable = true;
+ withActions.flags.retryable = true;
+ withActions.cancel_path = '/cancel';
+ withActions.retry_path = '/retry';
+
+ wrapper = createWrapper(withActions);
+ });
+
+ it('should render the provided actions', () => {
+ expect(wrapper.find('.js-pipelines-retry-button').exists()).toBe(true);
+ expect(wrapper.find('.js-pipelines-cancel-button').exists()).toBe(true);
+ const dropdownMenu = wrapper.find('.dropdown-menu');
+
+ expect(dropdownMenu.text()).toContain(scheduledJobAction.name);
+ });
+
+ it('emits `retryPipeline` event when retry button is clicked and toggles loading', () => {
+ eventHub.$on('retryPipeline', endpoint => {
+ expect(endpoint).toBe('/retry');
+ });
+
+ wrapper.find('.js-pipelines-retry-button').trigger('click');
+ expect(wrapper.vm.isRetrying).toBe(true);
+ });
+
+ it('emits `openConfirmationModal` event when cancel button is clicked and toggles loading', () => {
+ eventHub.$once('openConfirmationModal', data => {
+ const { id, ref, commit } = pipeline;
+
+ expect(data.endpoint).toBe('/cancel');
+ expect(data.pipeline).toEqual(
+ expect.objectContaining({
+ id,
+ ref,
+ commit,
+ }),
+ );
+ });
+
+ wrapper.find('.js-pipelines-cancel-button').trigger('click');
+ });
+
+ it('renders a loading icon when `cancelingPipeline` matches pipeline id', done => {
+ wrapper.setProps({ cancelingPipeline: pipeline.id });
+ wrapper.vm
+ .$nextTick()
+ .then(() => {
+ expect(wrapper.vm.isCancelling).toBe(true);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/test_reports/mock_data.js b/spec/frontend/pipelines/test_reports/mock_data.js
new file mode 100644
index 00000000000..b0f22bc63fb
--- /dev/null
+++ b/spec/frontend/pipelines/test_reports/mock_data.js
@@ -0,0 +1,123 @@
+import { formatTime } from '~/lib/utils/datetime_utility';
+import { TestStatus } from '~/pipelines/constants';
+
+export const testCases = [
+ {
+ classname: 'spec.test_spec',
+ execution_time: 0.000748,
+ name: 'Test#subtract when a is 1 and b is 2 raises an error',
+ stack_trace: null,
+ status: TestStatus.SUCCESS,
+ system_output: null,
+ },
+ {
+ classname: 'spec.test_spec',
+ execution_time: 0.000064,
+ name: 'Test#subtract when a is 2 and b is 1 returns correct result',
+ stack_trace: null,
+ status: TestStatus.SUCCESS,
+ system_output: null,
+ },
+ {
+ classname: 'spec.test_spec',
+ execution_time: 0.009292,
+ name: 'Test#sum when a is 1 and b is 2 returns summary',
+ stack_trace: null,
+ status: TestStatus.FAILED,
+ 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)>'",
+ },
+ {
+ classname: 'spec.test_spec',
+ execution_time: 0.00018,
+ name: 'Test#sum when a is 100 and b is 200 returns summary',
+ stack_trace: null,
+ status: TestStatus.FAILED,
+ 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)>'",
+ },
+ {
+ classname: 'spec.test_spec',
+ execution_time: 0,
+ name: 'Test#skipped text',
+ stack_trace: null,
+ status: TestStatus.SKIPPED,
+ system_output: null,
+ },
+];
+
+export const testCasesFormatted = [
+ {
+ ...testCases[2],
+ icon: 'status_failed_borderless',
+ formattedTime: formatTime(testCases[0].execution_time * 1000),
+ },
+ {
+ ...testCases[3],
+ icon: 'status_failed_borderless',
+ formattedTime: formatTime(testCases[1].execution_time * 1000),
+ },
+ {
+ ...testCases[4],
+ icon: 'status_skipped_borderless',
+ formattedTime: formatTime(testCases[2].execution_time * 1000),
+ },
+ {
+ ...testCases[0],
+ icon: 'status_success_borderless',
+ formattedTime: formatTime(testCases[3].execution_time * 1000),
+ },
+ {
+ ...testCases[1],
+ icon: 'status_success_borderless',
+ formattedTime: formatTime(testCases[4].execution_time * 1000),
+ },
+];
+
+export const testSuites = [
+ {
+ error_count: 0,
+ failed_count: 2,
+ name: 'rspec:osx',
+ skipped_count: 0,
+ success_count: 2,
+ test_cases: testCases,
+ total_count: 4,
+ total_time: 60,
+ },
+ {
+ error_count: 0,
+ failed_count: 10,
+ name: 'rspec:osx',
+ skipped_count: 0,
+ success_count: 50,
+ test_cases: [],
+ total_count: 60,
+ total_time: 0.010284,
+ },
+];
+
+export const testSuitesFormatted = testSuites.map(x => ({
+ ...x,
+ formattedTime: formatTime(x.total_time * 1000),
+}));
+
+export const testReports = {
+ error_count: 0,
+ failed_count: 2,
+ skipped_count: 0,
+ success_count: 2,
+ test_suites: testSuites,
+ total_count: 4,
+ total_time: 0.010284,
+};
+
+export const testReportsWithNoSuites = {
+ error_count: 0,
+ failed_count: 2,
+ skipped_count: 0,
+ success_count: 2,
+ test_suites: [],
+ total_count: 4,
+ total_time: 0.010284,
+};
diff --git a/spec/frontend/pipelines/test_reports/stores/actions_spec.js b/spec/frontend/pipelines/test_reports/stores/actions_spec.js
new file mode 100644
index 00000000000..c1721e12234
--- /dev/null
+++ b/spec/frontend/pipelines/test_reports/stores/actions_spec.js
@@ -0,0 +1,109 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import * as actions from '~/pipelines/stores/test_reports/actions';
+import * as types from '~/pipelines/stores/test_reports/mutation_types';
+import { TEST_HOST } from '../../../helpers/test_constants';
+import testAction from '../../../helpers/vuex_action_helper';
+import createFlash from '~/flash';
+import { testReports } from '../mock_data';
+
+jest.mock('~/flash.js');
+
+describe('Actions TestReports Store', () => {
+ let mock;
+ let state;
+
+ const endpoint = `${TEST_HOST}/test_reports.json`;
+ const defaultState = {
+ endpoint,
+ testReports: {},
+ selectedSuite: {},
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ state = defaultState;
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('fetch reports', () => {
+ beforeEach(() => {
+ mock.onGet(`${TEST_HOST}/test_reports.json`).replyOnce(200, testReports, {});
+ });
+
+ it('sets testReports and shows tests', done => {
+ testAction(
+ actions.fetchReports,
+ null,
+ state,
+ [{ type: types.SET_REPORTS, payload: testReports }],
+ [{ type: 'toggleLoading' }, { type: 'toggleLoading' }],
+ done,
+ );
+ });
+
+ it('should create flash on API error', done => {
+ testAction(
+ actions.fetchReports,
+ null,
+ {
+ endpoint: null,
+ },
+ [],
+ [{ type: 'toggleLoading' }, { type: 'toggleLoading' }],
+ () => {
+ expect(createFlash).toHaveBeenCalled();
+ done();
+ },
+ );
+ });
+ });
+
+ describe('set selected suite', () => {
+ const selectedSuite = testReports.test_suites[0];
+
+ it('sets selectedSuite', done => {
+ testAction(
+ actions.setSelectedSuite,
+ selectedSuite,
+ state,
+ [{ type: types.SET_SELECTED_SUITE, payload: selectedSuite }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('remove selected suite', () => {
+ it('sets selectedSuite to {}', done => {
+ testAction(
+ actions.removeSelectedSuite,
+ {},
+ state,
+ [{ type: types.SET_SELECTED_SUITE, payload: {} }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('toggles loading', () => {
+ it('sets isLoading to true', done => {
+ testAction(actions.toggleLoading, {}, state, [{ type: types.TOGGLE_LOADING }], [], done);
+ });
+
+ it('toggles isLoading to false', done => {
+ testAction(
+ actions.toggleLoading,
+ {},
+ { ...state, isLoading: true },
+ [{ type: types.TOGGLE_LOADING }],
+ [],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/test_reports/stores/getters_spec.js b/spec/frontend/pipelines/test_reports/stores/getters_spec.js
new file mode 100644
index 00000000000..e630a005409
--- /dev/null
+++ b/spec/frontend/pipelines/test_reports/stores/getters_spec.js
@@ -0,0 +1,54 @@
+import * as getters from '~/pipelines/stores/test_reports/getters';
+import { testReports, testSuitesFormatted, testCasesFormatted } from '../mock_data';
+
+describe('Getters TestReports Store', () => {
+ let state;
+
+ const defaultState = {
+ testReports,
+ selectedSuite: testReports.test_suites[0],
+ };
+
+ const emptyState = {
+ testReports: {},
+ selectedSuite: {},
+ };
+
+ beforeEach(() => {
+ state = {
+ testReports,
+ };
+ });
+
+ const setupState = (testState = defaultState) => {
+ state = testState;
+ };
+
+ describe('getTestSuites', () => {
+ it('should return the test suites', () => {
+ setupState();
+
+ expect(getters.getTestSuites(state)).toEqual(testSuitesFormatted);
+ });
+
+ it('should return an empty array when testReports is empty', () => {
+ setupState(emptyState);
+
+ expect(getters.getTestSuites(state)).toEqual([]);
+ });
+ });
+
+ describe('getSuiteTests', () => {
+ it('should return the test cases inside the suite', () => {
+ setupState();
+
+ expect(getters.getSuiteTests(state)).toEqual(testCasesFormatted);
+ });
+
+ it('should return an empty array when testReports is empty', () => {
+ setupState(emptyState);
+
+ expect(getters.getSuiteTests(state)).toEqual([]);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js
new file mode 100644
index 00000000000..ad5b7f91163
--- /dev/null
+++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js
@@ -0,0 +1,63 @@
+import * as types from '~/pipelines/stores/test_reports/mutation_types';
+import mutations from '~/pipelines/stores/test_reports/mutations';
+import { testReports, testSuites } from '../mock_data';
+
+describe('Mutations TestReports Store', () => {
+ let mockState;
+
+ const defaultState = {
+ endpoint: '',
+ testReports: {},
+ selectedSuite: {},
+ isLoading: false,
+ };
+
+ beforeEach(() => {
+ mockState = defaultState;
+ });
+
+ describe('set endpoint', () => {
+ it('should set endpoint', () => {
+ const expectedState = Object.assign({}, mockState, { endpoint: 'foo' });
+ mutations[types.SET_ENDPOINT](mockState, 'foo');
+
+ expect(mockState.endpoint).toEqual(expectedState.endpoint);
+ });
+ });
+
+ describe('set reports', () => {
+ it('should set testReports', () => {
+ const expectedState = Object.assign({}, mockState, { testReports });
+ mutations[types.SET_REPORTS](mockState, testReports);
+
+ expect(mockState.testReports).toEqual(expectedState.testReports);
+ });
+ });
+
+ describe('set selected suite', () => {
+ it('should set selectedSuite', () => {
+ const expectedState = Object.assign({}, mockState, { selectedSuite: testSuites[0] });
+ mutations[types.SET_SELECTED_SUITE](mockState, testSuites[0]);
+
+ expect(mockState.selectedSuite).toEqual(expectedState.selectedSuite);
+ });
+ });
+
+ describe('toggle loading', () => {
+ it('should set to true', () => {
+ const expectedState = Object.assign({}, 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 });
+ mockState.isLoading = true;
+
+ mutations[types.TOGGLE_LOADING](mockState);
+
+ expect(mockState.isLoading).toEqual(expectedState.isLoading);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/test_reports/test_reports_spec.js b/spec/frontend/pipelines/test_reports/test_reports_spec.js
new file mode 100644
index 00000000000..4d6422745a9
--- /dev/null
+++ b/spec/frontend/pipelines/test_reports/test_reports_spec.js
@@ -0,0 +1,64 @@
+import Vuex from 'vuex';
+import TestReports from '~/pipelines/components/test_reports/test_reports.vue';
+import { shallowMount } from '@vue/test-utils';
+import { testReports } from './mock_data';
+import * as actions from '~/pipelines/stores/test_reports/actions';
+
+describe('Test reports app', () => {
+ let wrapper;
+ let store;
+
+ const loadingSpinner = () => wrapper.find('.js-loading-spinner');
+ const testsDetail = () => wrapper.find('.js-tests-detail');
+ const noTestsToShow = () => wrapper.find('.js-no-tests-to-show');
+
+ const createComponent = (state = {}) => {
+ store = new Vuex.Store({
+ state: {
+ isLoading: false,
+ selectedSuite: {},
+ testReports,
+ ...state,
+ },
+ actions,
+ });
+
+ wrapper = shallowMount(TestReports, {
+ store,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when loading', () => {
+ beforeEach(() => createComponent({ isLoading: true }));
+
+ it('shows the loading spinner', () => {
+ expect(noTestsToShow().exists()).toBe(false);
+ expect(testsDetail().exists()).toBe(false);
+ expect(loadingSpinner().exists()).toBe(true);
+ });
+ });
+
+ describe('when the api returns no data', () => {
+ beforeEach(() => createComponent({ testReports: {} }));
+
+ it('displays that there are no tests to show', () => {
+ const noTests = noTestsToShow();
+
+ expect(noTests.exists()).toBe(true);
+ expect(noTests.text()).toBe('There are no tests to show.');
+ });
+ });
+
+ describe('when the api returns data', () => {
+ beforeEach(() => createComponent());
+
+ it('sets testReports and shows tests', () => {
+ expect(wrapper.vm.testReports).toBeTruthy();
+ expect(wrapper.vm.showTests).toBeTruthy();
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
new file mode 100644
index 00000000000..b4305719ea8
--- /dev/null
+++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
@@ -0,0 +1,77 @@
+import Vuex from 'vuex';
+import SuiteTable from '~/pipelines/components/test_reports/test_suite_table.vue';
+import * as getters from '~/pipelines/stores/test_reports/getters';
+import { TestStatus } from '~/pipelines/constants';
+import { shallowMount } from '@vue/test-utils';
+import { testSuites, testCases } from './mock_data';
+
+describe('Test reports suite table', () => {
+ let wrapper;
+ let store;
+
+ const noCasesMessage = () => wrapper.find('.js-no-test-cases');
+ const allCaseRows = () => wrapper.findAll('.js-case-row');
+ const findCaseRowAtIndex = index => wrapper.findAll('.js-case-row').at(index);
+ const findIconForRow = (row, status) => row.find(`.ci-status-icon-${status}`);
+
+ const createComponent = (suite = testSuites[0]) => {
+ store = new Vuex.Store({
+ state: {
+ selectedSuite: suite,
+ },
+ getters,
+ });
+
+ wrapper = shallowMount(SuiteTable, {
+ store,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('should not render', () => {
+ beforeEach(() => createComponent([]));
+
+ it('a table when there are no test cases', () => {
+ expect(noCasesMessage().exists()).toBe(true);
+ });
+ });
+
+ describe('when a test suite is supplied', () => {
+ beforeEach(() => createComponent());
+
+ it('renders the correct number of rows', () => {
+ expect(allCaseRows().length).toBe(testCases.length);
+ });
+
+ it('renders the failed tests first', () => {
+ const failedCaseNames = testCases
+ .filter(x => x.status === TestStatus.FAILED)
+ .map(x => x.name);
+
+ const skippedCaseNames = testCases
+ .filter(x => x.status === TestStatus.SKIPPED)
+ .map(x => x.name);
+
+ expect(findCaseRowAtIndex(0).text()).toContain(failedCaseNames[0]);
+ expect(findCaseRowAtIndex(1).text()).toContain(failedCaseNames[1]);
+ expect(findCaseRowAtIndex(2).text()).toContain(skippedCaseNames[0]);
+ });
+
+ it('renders the correct icon for each status', () => {
+ const failedTest = testCases.findIndex(x => x.status === TestStatus.FAILED);
+ const skippedTest = testCases.findIndex(x => x.status === TestStatus.SKIPPED);
+ const successTest = testCases.findIndex(x => x.status === TestStatus.SUCCESS);
+
+ const failedRow = findCaseRowAtIndex(failedTest);
+ const skippedRow = findCaseRowAtIndex(skippedTest);
+ const successRow = findCaseRowAtIndex(successTest);
+
+ expect(findIconForRow(failedRow, TestStatus.FAILED).exists()).toBe(true);
+ expect(findIconForRow(skippedRow, TestStatus.SKIPPED).exists()).toBe(true);
+ expect(findIconForRow(successRow, TestStatus.SUCCESS).exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/test_reports/test_summary_spec.js b/spec/frontend/pipelines/test_reports/test_summary_spec.js
new file mode 100644
index 00000000000..19a7755dbdc
--- /dev/null
+++ b/spec/frontend/pipelines/test_reports/test_summary_spec.js
@@ -0,0 +1,78 @@
+import Summary from '~/pipelines/components/test_reports/test_summary.vue';
+import { mount } from '@vue/test-utils';
+import { testSuites } from './mock_data';
+
+describe('Test reports summary', () => {
+ let wrapper;
+
+ const backButton = () => wrapper.find('.js-back-button');
+ const totalTests = () => wrapper.find('.js-total-tests');
+ const failedTests = () => wrapper.find('.js-failed-tests');
+ const erroredTests = () => wrapper.find('.js-errored-tests');
+ const successRate = () => wrapper.find('.js-success-rate');
+ const duration = () => wrapper.find('.js-duration');
+
+ const defaultProps = {
+ report: testSuites[0],
+ showBack: false,
+ };
+
+ const createComponent = props => {
+ wrapper = mount(Summary, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ };
+
+ describe('should not render', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('a back button by default', () => {
+ expect(backButton().exists()).toBe(false);
+ });
+ });
+
+ describe('should render', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('a back button and emit on-back-click event', () => {
+ createComponent({
+ showBack: true,
+ });
+
+ expect(backButton().exists()).toBe(true);
+ });
+ });
+
+ describe('when a report is supplied', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('displays the correct total', () => {
+ expect(totalTests().text()).toBe('4 jobs');
+ });
+
+ it('displays the correct failure count', () => {
+ expect(failedTests().text()).toBe('2 failures');
+ });
+
+ it('displays the correct error count', () => {
+ expect(erroredTests().text()).toBe('0 errors');
+ });
+
+ it('calculates and displays percentages correctly', () => {
+ expect(successRate().text()).toBe('50% success rate');
+ });
+
+ it('displays the correctly formatted duration', () => {
+ expect(duration().text()).toBe('00:01:00');
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js
new file mode 100644
index 00000000000..e7599d5cdbc
--- /dev/null
+++ b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js
@@ -0,0 +1,54 @@
+import Vuex from 'vuex';
+import SummaryTable from '~/pipelines/components/test_reports/test_summary_table.vue';
+import * as getters from '~/pipelines/stores/test_reports/getters';
+import { mount, createLocalVue } from '@vue/test-utils';
+import { testReports, testReportsWithNoSuites } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('Test reports summary table', () => {
+ let wrapper;
+ let store;
+
+ const allSuitesRows = () => wrapper.findAll('.js-suite-row');
+ const noSuitesToShow = () => wrapper.find('.js-no-tests-suites');
+
+ const defaultProps = {
+ testReports,
+ };
+
+ const createComponent = (reports = null) => {
+ store = new Vuex.Store({
+ state: {
+ testReports: reports || testReports,
+ },
+ getters,
+ });
+
+ wrapper = mount(SummaryTable, {
+ propsData: defaultProps,
+ store,
+ localVue,
+ });
+ };
+
+ describe('when test reports are supplied', () => {
+ beforeEach(() => createComponent());
+
+ it('renders the correct number of rows', () => {
+ expect(noSuitesToShow().exists()).toBe(false);
+ expect(allSuitesRows().length).toBe(testReports.test_suites.length);
+ });
+ });
+
+ describe('when there are no test suites', () => {
+ beforeEach(() => {
+ createComponent({ testReportsWithNoSuites });
+ });
+
+ it('displays the no suites to show message', () => {
+ expect(noSuitesToShow().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/project_find_file_spec.js b/spec/frontend/project_find_file_spec.js
index 8102033139f..e60f9f62747 100644
--- a/spec/frontend/project_find_file_spec.js
+++ b/spec/frontend/project_find_file_spec.js
@@ -3,6 +3,9 @@ import $ from 'jquery';
import ProjectFindFile from '~/project_find_file';
import axios from '~/lib/utils/axios_utils';
import { TEST_HOST } from 'helpers/test_constants';
+import sanitize from 'sanitize-html';
+
+jest.mock('sanitize-html', () => jest.fn(val => val));
const BLOB_URL_TEMPLATE = `${TEST_HOST}/namespace/project/blob/master`;
const FILE_FIND_URL = `${TEST_HOST}/namespace/project/files/master?format=json`;
@@ -38,31 +41,31 @@ describe('ProjectFindFile', () => {
href: el.querySelector('a').href,
}));
+ const files = [
+ 'fileA.txt',
+ 'fileB.txt',
+ 'fi#leC.txt',
+ 'folderA/fileD.txt',
+ 'folder#B/fileE.txt',
+ 'folde?rC/fil#F.txt',
+ ];
+
beforeEach(() => {
// Create a mock adapter for stubbing axios API requests
mock = new MockAdapter(axios);
element = $(TEMPLATE);
+ mock.onGet(FILE_FIND_URL).replyOnce(200, files);
+ getProjectFindFileInstance(); // This triggers a load / axios call + subsequent render in the constructor
});
afterEach(() => {
// Reset the mock adapter
mock.restore();
+ sanitize.mockClear();
});
it('loads and renders elements from remote server', done => {
- const files = [
- 'fileA.txt',
- 'fileB.txt',
- 'fi#leC.txt',
- 'folderA/fileD.txt',
- 'folder#B/fileE.txt',
- 'folde?rC/fil#F.txt',
- ];
- mock.onGet(FILE_FIND_URL).replyOnce(200, files);
-
- getProjectFindFileInstance(); // This triggers a load / axios call + subsequent render in the constructor
-
setImmediate(() => {
expect(findFiles()).toEqual(
files.map(text => ({
@@ -74,4 +77,14 @@ describe('ProjectFindFile', () => {
done();
});
});
+
+ it('sanitizes search text', done => {
+ const searchText = element.find('.file-finder-input').val();
+
+ setImmediate(() => {
+ expect(sanitize).toHaveBeenCalledTimes(1);
+ expect(sanitize).toHaveBeenCalledWith(searchText);
+ done();
+ });
+ });
});
diff --git a/spec/frontend/registry/components/collapsible_container_spec.js b/spec/frontend/registry/components/collapsible_container_spec.js
index f93ebab1a4d..d035055afd3 100644
--- a/spec/frontend/registry/components/collapsible_container_spec.js
+++ b/spec/frontend/registry/components/collapsible_container_spec.js
@@ -1,10 +1,11 @@
import Vue from 'vue';
import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
-import collapsibleComponent from '~/registry/components/collapsible_container.vue';
-import { repoPropsData } from '../mock_data';
import createFlash from '~/flash';
+import Tracking from '~/tracking';
+import collapsibleComponent from '~/registry/components/collapsible_container.vue';
import * as getters from '~/registry/stores/getters';
+import { repoPropsData } from '../mock_data';
jest.mock('~/flash.js');
@@ -16,9 +17,10 @@ describe('collapsible registry container', () => {
let wrapper;
let store;
- const findDeleteBtn = w => w.find('.js-remove-repo');
- const findContainerImageTags = w => w.find('.container-image-tags');
- const findToggleRepos = w => w.findAll('.js-toggle-repo');
+ const findDeleteBtn = (w = wrapper) => w.find('.js-remove-repo');
+ const findContainerImageTags = (w = wrapper) => w.find('.container-image-tags');
+ const findToggleRepos = (w = wrapper) => w.findAll('.js-toggle-repo');
+ const findDeleteModal = (w = wrapper) => w.find({ ref: 'deleteModal' });
const mountWithStore = config => mount(collapsibleComponent, { ...config, store, localVue });
@@ -124,4 +126,45 @@ describe('collapsible registry container', () => {
expect(deleteBtn.exists()).toBe(false);
});
});
+
+ describe('tracking', () => {
+ const category = 'mock_page';
+ beforeEach(() => {
+ jest.spyOn(Tracking, 'event');
+ wrapper.vm.deleteItem = jest.fn().mockResolvedValue();
+ wrapper.vm.fetchRepos = jest.fn();
+ wrapper.setData({
+ tracking: {
+ ...wrapper.vm.tracking,
+ category,
+ },
+ });
+ });
+
+ it('send an event when delete button is clicked', () => {
+ const deleteBtn = findDeleteBtn();
+ deleteBtn.trigger('click');
+ expect(Tracking.event).toHaveBeenCalledWith(category, 'click_button', {
+ label: 'registry_repository_delete',
+ category,
+ });
+ });
+ it('send an event when cancel is pressed on modal', () => {
+ const deleteModal = findDeleteModal();
+ deleteModal.vm.$emit('cancel');
+ expect(Tracking.event).toHaveBeenCalledWith(category, 'cancel_delete', {
+ label: 'registry_repository_delete',
+ category,
+ });
+ });
+ it('send an event when confirm is clicked on modal', () => {
+ const deleteModal = findDeleteModal();
+ deleteModal.vm.$emit('ok');
+
+ expect(Tracking.event).toHaveBeenCalledWith(category, 'confirm_delete', {
+ label: 'registry_repository_delete',
+ category,
+ });
+ });
+ });
});
diff --git a/spec/frontend/registry/components/table_registry_spec.js b/spec/frontend/registry/components/table_registry_spec.js
index 7cb7c012d9d..ab88caf44e1 100644
--- a/spec/frontend/registry/components/table_registry_spec.js
+++ b/spec/frontend/registry/components/table_registry_spec.js
@@ -1,10 +1,14 @@
import Vue from 'vue';
import Vuex from 'vuex';
-import tableRegistry from '~/registry/components/table_registry.vue';
import { mount, createLocalVue } from '@vue/test-utils';
+import createFlash from '~/flash';
+import Tracking from '~/tracking';
+import tableRegistry from '~/registry/components/table_registry.vue';
import { repoPropsData } from '../mock_data';
import * as getters from '~/registry/stores/getters';
+jest.mock('~/flash');
+
const [firstImage, secondImage] = repoPropsData.list;
const localVue = createLocalVue();
@@ -15,11 +19,12 @@ describe('table registry', () => {
let wrapper;
let store;
- const findSelectAllCheckbox = w => w.find('.js-select-all-checkbox > input');
- const findSelectCheckboxes = w => w.findAll('.js-select-checkbox > input');
- const findDeleteButton = w => w.find('.js-delete-registry');
- const findDeleteButtonsRow = w => w.findAll('.js-delete-registry-row');
- const findPagination = w => w.find('.js-registry-pagination');
+ const findSelectAllCheckbox = (w = wrapper) => w.find('.js-select-all-checkbox > input');
+ const findSelectCheckboxes = (w = wrapper) => w.findAll('.js-select-checkbox > input');
+ const findDeleteButton = (w = wrapper) => w.find({ ref: 'bulkDeleteButton' });
+ const findDeleteButtonsRow = (w = wrapper) => w.findAll('.js-delete-registry-row');
+ const findPagination = (w = wrapper) => w.find('.js-registry-pagination');
+ const findDeleteModal = (w = wrapper) => w.find({ ref: 'deleteModal' });
const bulkDeletePath = 'path';
const mountWithStore = config => mount(tableRegistry, { ...config, store, localVue });
@@ -139,7 +144,7 @@ describe('table registry', () => {
},
});
wrapper.vm.handleMultipleDelete();
- expect(wrapper.vm.showError).toHaveBeenCalled();
+ expect(createFlash).toHaveBeenCalled();
});
});
@@ -169,6 +174,27 @@ describe('table registry', () => {
});
});
+ describe('modal event handlers', () => {
+ beforeEach(() => {
+ wrapper.vm.handleSingleDelete = jest.fn();
+ wrapper.vm.handleMultipleDelete = jest.fn();
+ });
+ it('on ok when one item is selected should call singleDelete', () => {
+ wrapper.setData({ itemsToBeDeleted: [0] });
+ wrapper.vm.onDeletionConfirmed();
+
+ expect(wrapper.vm.handleSingleDelete).toHaveBeenCalledWith(repoPropsData.list[0]);
+ expect(wrapper.vm.handleMultipleDelete).not.toHaveBeenCalled();
+ });
+ it('on ok when multiple items are selected should call muultiDelete', () => {
+ wrapper.setData({ itemsToBeDeleted: [0, 1, 2] });
+ wrapper.vm.onDeletionConfirmed();
+
+ expect(wrapper.vm.handleMultipleDelete).toHaveBeenCalled();
+ expect(wrapper.vm.handleSingleDelete).not.toHaveBeenCalled();
+ });
+ });
+
describe('pagination', () => {
const repo = {
repoPropsData,
@@ -265,4 +291,83 @@ describe('table registry', () => {
expect(deleteBtns.length).toBe(0);
});
});
+
+ describe('event tracking', () => {
+ const mockPageName = 'mock_page';
+
+ beforeEach(() => {
+ jest.spyOn(Tracking, 'event');
+ wrapper.vm.handleSingleDelete = jest.fn();
+ wrapper.vm.handleMultipleDelete = jest.fn();
+ document.body.dataset.page = mockPageName;
+ });
+
+ afterEach(() => {
+ document.body.dataset.page = null;
+ });
+
+ describe('single tag delete', () => {
+ beforeEach(() => {
+ wrapper.setData({ itemsToBeDeleted: [0] });
+ });
+
+ it('send an event when delete button is clicked', () => {
+ const deleteBtn = findDeleteButtonsRow();
+ deleteBtn.at(0).trigger('click');
+ expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'click_button', {
+ label: 'registry_tag_delete',
+ property: 'foo',
+ });
+ });
+ it('send an event when cancel is pressed on modal', () => {
+ const deleteModal = findDeleteModal();
+ deleteModal.vm.$emit('cancel');
+ expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'cancel_delete', {
+ label: 'registry_tag_delete',
+ property: 'foo',
+ });
+ });
+ it('send an event when confirm is clicked on modal', () => {
+ const deleteModal = findDeleteModal();
+ deleteModal.vm.$emit('ok');
+
+ expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'confirm_delete', {
+ label: 'registry_tag_delete',
+ property: 'foo',
+ });
+ });
+ });
+ describe('bulk tag delete', () => {
+ beforeEach(() => {
+ const items = [0, 1, 2];
+ wrapper.setData({ itemsToBeDeleted: items, selectedItems: items });
+ });
+
+ it('send an event when delete button is clicked', () => {
+ const deleteBtn = findDeleteButton();
+ deleteBtn.vm.$emit('click');
+ expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'click_button', {
+ label: 'bulk_registry_tag_delete',
+ property: 'foo',
+ });
+ });
+ it('send an event when cancel is pressed on modal', () => {
+ const deleteModal = findDeleteModal();
+ deleteModal.vm.$emit('cancel');
+ expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'cancel_delete', {
+ label: 'bulk_registry_tag_delete',
+ property: 'foo',
+ });
+ });
+ it('send an event when confirm is clicked on modal', () => {
+ const deleteModal = findDeleteModal();
+ deleteModal.vm.$emit('ok');
+
+ expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'confirm_delete', {
+ label: 'bulk_registry_tag_delete',
+ property: 'foo',
+ });
+ });
+ });
+ });
});
diff --git a/spec/frontend/releases/detail/components/app_spec.js b/spec/frontend/releases/detail/components/app_spec.js
index f8eb33a69a8..4726f18c8fa 100644
--- a/spec/frontend/releases/detail/components/app_spec.js
+++ b/spec/frontend/releases/detail/components/app_spec.js
@@ -8,15 +8,17 @@ describe('Release detail component', () => {
let wrapper;
let releaseClone;
let actions;
+ let state;
beforeEach(() => {
gon.api_version = 'v4';
releaseClone = JSON.parse(JSON.stringify(convertObjectPropsToCamelCase(release)));
- const state = {
+ state = {
release: releaseClone,
markdownDocsPath: 'path/to/markdown/docs',
+ updateReleaseApiDocsPath: 'path/to/update/release/api/docs',
};
actions = {
@@ -46,6 +48,21 @@ describe('Release detail component', () => {
expect(wrapper.find('#git-ref').element.value).toBe(releaseClone.tagName);
});
+ it('renders the correct help text under the "Tag name" field', () => {
+ const helperText = wrapper.find('#tag-name-help');
+ const helperTextLink = helperText.find('a');
+ const helperTextLinkAttrs = helperTextLink.attributes();
+
+ expect(helperText.text()).toBe(
+ 'Changing a Release tag is only supported via Releases API. More information',
+ );
+ expect(helperTextLink.text()).toBe('More information');
+ expect(helperTextLinkAttrs.href).toBe(state.updateReleaseApiDocsPath);
+ expect(helperTextLinkAttrs.rel).toContain('noopener');
+ expect(helperTextLinkAttrs.rel).toContain('noreferrer');
+ expect(helperTextLinkAttrs.target).toBe('_blank');
+ });
+
it('renders the correct release title in the "Release title" field', () => {
expect(wrapper.find('#release-title').element.value).toBe(releaseClone.name);
});
diff --git a/spec/frontend/releases/list/components/__snapshots__/release_block_spec.js.snap b/spec/frontend/releases/list/components/__snapshots__/release_block_spec.js.snap
deleted file mode 100644
index 8f2c0427c83..00000000000
--- a/spec/frontend/releases/list/components/__snapshots__/release_block_spec.js.snap
+++ /dev/null
@@ -1,332 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Release block with default props matches the snapshot 1`] = `
-<div
- class="card release-block"
- id="v0.3"
->
- <div
- class="card-body"
- >
- <div
- class="d-flex align-items-start"
- >
- <h2
- class="card-title mt-0 mr-auto"
- >
-
- New release
-
- <!---->
- </h2>
-
- <a
- class="btn btn-default js-edit-button ml-2"
- data-original-title="Edit this release"
- href="http://0.0.0.0:3001/root/release-test/-/releases/v0.3/edit"
- title=""
- >
- <svg
- aria-hidden="true"
- class="s16 ic-pencil"
- >
- <use
- xlink:href="#pencil"
- />
- </svg>
- </a>
- </div>
-
- <div
- class="card-subtitle d-flex flex-wrap text-secondary"
- >
- <div
- class="append-right-8"
- >
- <svg
- aria-hidden="true"
- class="align-middle s16 ic-commit"
- >
- <use
- xlink:href="#commit"
- />
- </svg>
-
- <span
- data-original-title="Initial commit"
- title=""
- >
- c22b0728
- </span>
- </div>
-
- <div
- class="append-right-8"
- >
- <svg
- aria-hidden="true"
- class="align-middle s16 ic-tag"
- >
- <use
- xlink:href="#tag"
- />
- </svg>
-
- <span
- data-original-title="Tag"
- title=""
- >
- v0.3
- </span>
- </div>
-
- <div
- class="js-milestone-list-label"
- >
- <svg
- aria-hidden="true"
- class="align-middle s16 ic-flag"
- >
- <use
- xlink:href="#flag"
- />
- </svg>
-
- <span
- class="js-label-text"
- >
- Milestones
- </span>
- </div>
-
- <a
- class="append-right-4 prepend-left-4 js-milestone-link"
- data-original-title="The 13.6 milestone!"
- href="http://0.0.0.0:3001/root/release-test/-/milestones/2"
- title=""
- >
-
- 13.6
-
- </a>
-
- •
-
- <a
- class="append-right-4 prepend-left-4 js-milestone-link"
- data-original-title="The 13.5 milestone!"
- href="http://0.0.0.0:3001/root/release-test/-/milestones/1"
- title=""
- >
-
- 13.5
-
- </a>
-
- <!---->
-
- <div
- class="append-right-4"
- >
-
- •
-
- <span
- data-original-title="Aug 26, 2019 5:54pm GMT+0000"
- title=""
- >
-
- released 1 month ago
-
- </span>
- </div>
-
- <div
- class="d-flex"
- >
-
- by
-
- <a
- class="user-avatar-link prepend-left-4"
- href=""
- >
- <span>
- <img
- alt="root's avatar"
- class="avatar s20 "
- data-original-title=""
- data-src="https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon"
- height="20"
- src="https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon"
- title=""
- width="20"
- />
-
- <div
- aria-hidden="true"
- class="js-user-avatar-image-toolip d-none"
- style="display: none;"
- >
- <div>
- root
- </div>
- </div>
- </span>
- <!---->
- </a>
- </div>
- </div>
-
- <div
- class="card-text prepend-top-default"
- >
- <b>
-
- Assets
-
- <span
- class="js-assets-count badge badge-pill"
- >
- 5
- </span>
- </b>
-
- <ul
- class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list"
- >
- <li
- class="append-bottom-8"
- >
- <a
- class=""
- data-original-title="Download asset"
- href="https://google.com"
- title=""
- >
- <svg
- aria-hidden="true"
- class="align-middle append-right-4 align-text-bottom s16 ic-package"
- >
- <use
- xlink:href="#package"
- />
- </svg>
-
- my link
-
- <span>
- (external source)
- </span>
- </a>
- </li>
- <li
- class="append-bottom-8"
- >
- <a
- class=""
- data-original-title="Download asset"
- href="https://gitlab.com/gitlab-org/gitlab-foss/-/jobs/artifacts/v11.6.0-rc4/download?job=rspec-mysql+41%2F50"
- title=""
- >
- <svg
- aria-hidden="true"
- class="align-middle append-right-4 align-text-bottom s16 ic-package"
- >
- <use
- xlink:href="#package"
- />
- </svg>
-
- my second link
-
- <!---->
- </a>
- </li>
- </ul>
-
- <div
- class="dropdown"
- >
- <button
- aria-expanded="false"
- aria-haspopup="true"
- class="btn btn-link"
- data-toggle="dropdown"
- type="button"
- >
- <svg
- aria-hidden="true"
- class="align-top append-right-4 s16 ic-doc-code"
- >
- <use
- xlink:href="#doc-code"
- />
- </svg>
-
- Source code
-
- <svg
- aria-hidden="true"
- class="s16 ic-arrow-down"
- >
- <use
- xlink:href="#arrow-down"
- />
- </svg>
- </button>
-
- <div
- class="js-sources-dropdown dropdown-menu"
- >
- <li>
- <a
- class=""
- href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.zip"
- >
- Download zip
- </a>
- </li>
- <li>
- <a
- class=""
- href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar.gz"
- >
- Download tar.gz
- </a>
- </li>
- <li>
- <a
- class=""
- href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar.bz2"
- >
- Download tar.bz2
- </a>
- </li>
- <li>
- <a
- class=""
- href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar"
- >
- Download tar
- </a>
- </li>
- </div>
- </div>
- </div>
-
- <div
- class="card-text prepend-top-default"
- >
- <div>
- <p
- data-sourcepos="1:1-1:21"
- dir="auto"
- >
- A super nice release!
- </p>
- </div>
- </div>
- </div>
-</div>
-`;
diff --git a/spec/frontend/releases/list/components/release_block_footer_spec.js b/spec/frontend/releases/list/components/release_block_footer_spec.js
new file mode 100644
index 00000000000..172147f1cc8
--- /dev/null
+++ b/spec/frontend/releases/list/components/release_block_footer_spec.js
@@ -0,0 +1,163 @@
+import { mount } from '@vue/test-utils';
+import ReleaseBlockFooter from '~/releases/list/components/release_block_footer.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+import { GlLink } from '@gitlab/ui';
+import { trimText } from 'helpers/text_helper';
+import { release } from '../../mock_data';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+jest.mock('~/vue_shared/mixins/timeago', () => ({
+ methods: {
+ timeFormated() {
+ return '7 fortnightes ago';
+ },
+ tooltipTitle() {
+ return 'February 30, 2401';
+ },
+ },
+}));
+
+describe('Release block footer', () => {
+ let wrapper;
+ let releaseClone;
+
+ const factory = (props = {}) => {
+ wrapper = mount(ReleaseBlockFooter, {
+ propsData: {
+ ...convertObjectPropsToCamelCase(releaseClone),
+ ...props,
+ },
+ sync: false,
+ });
+
+ return wrapper.vm.$nextTick();
+ };
+
+ beforeEach(() => {
+ releaseClone = JSON.parse(JSON.stringify(release));
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const commitInfoSection = () => wrapper.find('.js-commit-info');
+ const commitInfoSectionLink = () => commitInfoSection().find(GlLink);
+ const tagInfoSection = () => wrapper.find('.js-tag-info');
+ const tagInfoSectionLink = () => tagInfoSection().find(GlLink);
+ const authorDateInfoSection = () => wrapper.find('.js-author-date-info');
+
+ describe('with all props provided', () => {
+ beforeEach(() => factory());
+
+ it('renders the commit icon', () => {
+ const commitIcon = commitInfoSection().find(Icon);
+
+ expect(commitIcon.exists()).toBe(true);
+ expect(commitIcon.props('name')).toBe('commit');
+ });
+
+ it('renders the commit SHA with a link', () => {
+ const commitLink = commitInfoSectionLink();
+
+ expect(commitLink.exists()).toBe(true);
+ expect(commitLink.text()).toBe(releaseClone.commit.short_id);
+ expect(commitLink.attributes('href')).toBe(releaseClone.commit_path);
+ });
+
+ it('renders the tag icon', () => {
+ const commitIcon = tagInfoSection().find(Icon);
+
+ expect(commitIcon.exists()).toBe(true);
+ expect(commitIcon.props('name')).toBe('tag');
+ });
+
+ it('renders the tag name with a link', () => {
+ 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);
+ });
+
+ it('renders the author and creation time info', () => {
+ expect(trimText(authorDateInfoSection().text())).toBe(
+ `Created 7 fortnightes ago by ${releaseClone.author.username}`,
+ );
+ });
+
+ 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);
+ });
+
+ 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);
+ });
+ });
+
+ describe('without any commit info', () => {
+ beforeEach(() => factory({ commit: undefined }));
+
+ it('does not render any commit info', () => {
+ expect(commitInfoSection().exists()).toBe(false);
+ });
+ });
+
+ describe('without a commit URL', () => {
+ beforeEach(() => factory({ commitPath: undefined }));
+
+ 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);
+ });
+ });
+
+ describe('without a tag name', () => {
+ beforeEach(() => factory({ tagName: undefined }));
+
+ it('does not render any tag info', () => {
+ expect(tagInfoSection().exists()).toBe(false);
+ });
+ });
+
+ describe('without a tag URL', () => {
+ beforeEach(() => factory({ tagPath: undefined }));
+
+ it('renders the tag name as plain text (instead of a link)', () => {
+ expect(tagInfoSectionLink().exists()).toBe(false);
+ expect(tagInfoSection().text()).toBe(releaseClone.tag_name);
+ });
+ });
+
+ describe('without any author info', () => {
+ beforeEach(() => factory({ author: undefined }));
+
+ it('renders the release date without the author name', () => {
+ expect(trimText(authorDateInfoSection().text())).toBe('Created 7 fortnightes ago');
+ });
+ });
+
+ describe('without a released at date', () => {
+ beforeEach(() => factory({ releasedAt: undefined }));
+
+ it('renders the author name without the release date', () => {
+ expect(trimText(authorDateInfoSection().text())).toBe(
+ `Created by ${releaseClone.author.username}`,
+ );
+ });
+ });
+
+ describe('without a release date or author info', () => {
+ beforeEach(() => factory({ author: undefined, releasedAt: undefined }));
+
+ it('does not render any author or release date info', () => {
+ expect(authorDateInfoSection().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/releases/list/components/release_block_spec.js b/spec/frontend/releases/list/components/release_block_spec.js
index ac51c3af11a..b63ef068d8e 100644
--- a/spec/frontend/releases/list/components/release_block_spec.js
+++ b/spec/frontend/releases/list/components/release_block_spec.js
@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils';
import ReleaseBlock from '~/releases/list/components/release_block.vue';
+import ReleaseBlockFooter from '~/releases/list/components/release_block_footer.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { first } from 'underscore';
import { release } from '../../mock_data';
@@ -21,14 +22,16 @@ describe('Release block', () => {
let wrapper;
let releaseClone;
- const factory = (releaseProp, releaseEditPageFeatureFlag = true) => {
+ const factory = (releaseProp, featureFlags = {}) => {
wrapper = mount(ReleaseBlock, {
propsData: {
release: releaseProp,
},
provide: {
glFeatures: {
- releaseEditPage: releaseEditPageFeatureFlag,
+ releaseEditPage: true,
+ releaseIssueSummary: true,
+ ...featureFlags,
},
},
sync: false,
@@ -39,41 +42,25 @@ describe('Release block', () => {
const milestoneListLabel = () => wrapper.find('.js-milestone-list-label');
const editButton = () => wrapper.find('.js-edit-button');
- const RealDate = Date;
beforeEach(() => {
- // timeago.js calls Date(), so let's mock that case to avoid time-dependent test failures.
- const constantDate = new Date('2019-10-25T00:12:00');
-
- /* eslint no-global-assign:off */
- global.Date = jest.fn((...props) =>
- props.length ? new RealDate(...props) : new RealDate(constantDate),
- );
-
- Object.assign(Date, RealDate);
-
releaseClone = JSON.parse(JSON.stringify(release));
});
afterEach(() => {
wrapper.destroy();
- global.Date = RealDate;
});
describe('with default props', () => {
beforeEach(() => factory(release));
- it('matches the snapshot', () => {
- expect(wrapper.element).toMatchSnapshot();
- });
-
it("renders the block with an id equal to the release's tag name", () => {
expect(wrapper.attributes().id).toBe('v0.3');
});
it('renders an edit button that links to the "Edit release" page', () => {
expect(editButton().exists()).toBe(true);
- expect(editButton().attributes('href')).toBe(release._links.edit);
+ expect(editButton().attributes('href')).toBe(release._links.edit_url);
});
it('renders release name', () => {
@@ -158,6 +145,10 @@ describe('Release block', () => {
expect(milestoneLink.attributes('data-original-title')).toBe(milestone.description);
});
+
+ it('renders the footer', () => {
+ expect(wrapper.find(ReleaseBlockFooter).exists()).toBe(true);
+ });
});
it('renders commit sha', () => {
@@ -180,7 +171,7 @@ describe('Release block', () => {
});
});
- it("does not render an edit button if release._links.edit isn't a string", () => {
+ it("does not render an edit button if release._links.edit_url isn't a string", () => {
delete releaseClone._links;
return factory(releaseClone).then(() => {
@@ -189,7 +180,7 @@ describe('Release block', () => {
});
it('does not render an edit button if the releaseEditPage feature flag is disabled', () =>
- factory(releaseClone, false).then(() => {
+ factory(releaseClone, { releaseEditPage: false }).then(() => {
expect(editButton().exists()).toBe(false);
}));
diff --git a/spec/frontend/releases/mock_data.js b/spec/frontend/releases/mock_data.js
index b2ebf1174d4..61d95b86b1c 100644
--- a/spec/frontend/releases/mock_data.js
+++ b/spec/frontend/releases/mock_data.js
@@ -30,6 +30,7 @@ export const milestones = [
export const release = {
name: 'New release',
tag_name: 'v0.3',
+ tag_path: '/root/release-test/-/tags/v0.3',
description: 'A super nice release!',
description_html: '<p data-sourcepos="1:1-1:21" dir="auto">A super nice release!</p>',
created_at: '2019-08-26T17:54:04.952Z',
@@ -56,6 +57,7 @@ export const release = {
committer_email: 'admin@example.com',
committed_date: '2019-08-26T17:47:07.000Z',
},
+ commit_path: '/root/release-test/commit/c22b0728d1b465f82898c884d32b01aa642f96c1',
upcoming_release: false,
milestones,
assets: {
@@ -95,6 +97,6 @@ export const release = {
],
},
_links: {
- edit: 'http://0.0.0.0:3001/root/release-test/-/releases/v0.3/edit',
+ edit_url: 'http://0.0.0.0:3001/root/release-test/-/releases/v0.3/edit',
},
};
diff --git a/spec/frontend/repository/components/__snapshots__/directory_download_links_spec.js.snap b/spec/frontend/repository/components/__snapshots__/directory_download_links_spec.js.snap
new file mode 100644
index 00000000000..31a1cd23060
--- /dev/null
+++ b/spec/frontend/repository/components/__snapshots__/directory_download_links_spec.js.snap
@@ -0,0 +1,75 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Repository directory download links component renders downloads links for path app 1`] = `
+<section
+ class="border-top pt-1 mt-1"
+>
+ <h5
+ class="m-0 dropdown-bold-header"
+ >
+ Download this directory
+ </h5>
+
+ <div
+ class="dropdown-menu-content"
+ >
+ <div
+ class="btn-group ml-0 w-100"
+ >
+ <gllink-stub
+ class="btn btn-xs btn-primary"
+ href="http://test.com/?path=app"
+ >
+
+ zip
+
+ </gllink-stub>
+ <gllink-stub
+ class="btn btn-xs"
+ href="http://test.com/?path=app"
+ >
+
+ tar
+
+ </gllink-stub>
+ </div>
+ </div>
+</section>
+`;
+
+exports[`Repository directory download links component renders downloads links for path app/assets 1`] = `
+<section
+ class="border-top pt-1 mt-1"
+>
+ <h5
+ class="m-0 dropdown-bold-header"
+ >
+ Download this directory
+ </h5>
+
+ <div
+ class="dropdown-menu-content"
+ >
+ <div
+ class="btn-group ml-0 w-100"
+ >
+ <gllink-stub
+ class="btn btn-xs btn-primary"
+ href="http://test.com/?path=app/assets"
+ >
+
+ zip
+
+ </gllink-stub>
+ <gllink-stub
+ class="btn btn-xs"
+ href="http://test.com/?path=app/assets"
+ >
+
+ tar
+
+ </gllink-stub>
+ </div>
+ </div>
+</section>
+`;
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 08173f4f0c4..706c26403c0 100644
--- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
+++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
@@ -62,19 +62,23 @@ exports[`Repository last commit component renders commit widget 1`] = `
>
<!---->
- <gllink-stub
- class="js-commit-pipeline"
- data-original-title="Commit: failed"
- href="https://test.com/pipeline"
- title=""
+ <div
+ class="ci-status-link"
>
- <ciicon-stub
- aria-label="Commit: failed"
- cssclasses=""
- size="24"
- status="[object Object]"
- />
- </gllink-stub>
+ <gllink-stub
+ class="js-commit-pipeline"
+ data-original-title="Commit: failed"
+ href="https://test.com/pipeline"
+ title=""
+ >
+ <ciicon-stub
+ aria-label="Commit: failed"
+ cssclasses=""
+ size="24"
+ status="[object Object]"
+ />
+ </gllink-stub>
+ </div>
<div
class="commit-sha-group d-flex"
@@ -165,19 +169,23 @@ exports[`Repository last commit component renders the signature HTML as returned
</button>
</div>
- <gllink-stub
- class="js-commit-pipeline"
- data-original-title="Commit: failed"
- href="https://test.com/pipeline"
- title=""
+ <div
+ class="ci-status-link"
>
- <ciicon-stub
- aria-label="Commit: failed"
- cssclasses=""
- size="24"
- status="[object Object]"
- />
- </gllink-stub>
+ <gllink-stub
+ class="js-commit-pipeline"
+ data-original-title="Commit: failed"
+ href="https://test.com/pipeline"
+ title=""
+ >
+ <ciicon-stub
+ aria-label="Commit: failed"
+ cssclasses=""
+ size="24"
+ status="[object Object]"
+ />
+ </gllink-stub>
+ </div>
<div
class="commit-sha-group d-flex"
diff --git a/spec/frontend/repository/components/directory_download_links_spec.js b/spec/frontend/repository/components/directory_download_links_spec.js
new file mode 100644
index 00000000000..4d70b44de08
--- /dev/null
+++ b/spec/frontend/repository/components/directory_download_links_spec.js
@@ -0,0 +1,29 @@
+import { shallowMount } from '@vue/test-utils';
+import DirectoryDownloadLinks from '~/repository/components/directory_download_links.vue';
+
+let vm;
+
+function factory(currentPath) {
+ vm = shallowMount(DirectoryDownloadLinks, {
+ propsData: {
+ currentPath,
+ links: [{ text: 'zip', path: 'http://test.com/' }, { text: 'tar', path: 'http://test.com/' }],
+ },
+ });
+}
+
+describe('Repository directory download links component', () => {
+ afterEach(() => {
+ vm.destroy();
+ });
+
+ it.each`
+ path
+ ${'app'}
+ ${'app/assets'}
+ `('renders downloads links for path $path', ({ path }) => {
+ factory(path);
+
+ expect(vm.element).toMatchSnapshot();
+ });
+});
diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js
index 01b56d453e6..e07ad4cf46b 100644
--- a/spec/frontend/repository/components/last_commit_spec.js
+++ b/spec/frontend/repository/components/last_commit_spec.js
@@ -17,7 +17,7 @@ function createCommitData(data = {}) {
avatarUrl: 'https://test.com',
webUrl: 'https://test.com/test',
},
- latestPipeline: {
+ pipeline: {
detailedStatus: {
detailsPath: 'https://test.com/pipeline',
icon: 'failed',
@@ -74,7 +74,7 @@ describe('Repository last commit component', () => {
});
it('hides pipeline components when pipeline does not exist', () => {
- factory(createCommitData({ latestPipeline: null }));
+ factory(createCommitData({ pipeline: null }));
expect(vm.find('.js-commit-pipeline').exists()).toBe(false);
});
diff --git a/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap b/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap
new file mode 100644
index 00000000000..a5e3eb4bce1
--- /dev/null
+++ b/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap
@@ -0,0 +1,36 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Repository file preview component renders file HTML 1`] = `
+<article
+ class="file-holder limited-width-container readme-holder"
+>
+ <div
+ class="file-title"
+ >
+ <i
+ aria-hidden="true"
+ class="fa fa-file-text-o fa-fw"
+ />
+
+ <gllink-stub
+ href="http://test.com"
+ >
+ <strong>
+ README.md
+ </strong>
+ </gllink-stub>
+ </div>
+
+ <div
+ class="blob-viewer"
+ >
+ <div>
+ <div
+ class="blob"
+ >
+ test
+ </div>
+ </div>
+ </div>
+</article>
+`;
diff --git a/spec/frontend/repository/components/preview/index_spec.js b/spec/frontend/repository/components/preview/index_spec.js
new file mode 100644
index 00000000000..0112e6310f4
--- /dev/null
+++ b/spec/frontend/repository/components/preview/index_spec.js
@@ -0,0 +1,49 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLoadingIcon } from '@gitlab/ui';
+import Preview from '~/repository/components/preview/index.vue';
+
+let vm;
+let $apollo;
+
+function factory(blob) {
+ $apollo = {
+ query: jest.fn().mockReturnValue(Promise.resolve({})),
+ };
+
+ vm = shallowMount(Preview, {
+ propsData: {
+ blob,
+ },
+ mocks: {
+ $apollo,
+ },
+ });
+}
+
+describe('Repository file preview component', () => {
+ afterEach(() => {
+ vm.destroy();
+ });
+
+ it('renders file HTML', () => {
+ factory({
+ webUrl: 'http://test.com',
+ name: 'README.md',
+ });
+
+ vm.setData({ readme: { html: '<div class="blob">test</div>' } });
+
+ expect(vm.element).toMatchSnapshot();
+ });
+
+ it('renders loading icon', () => {
+ factory({
+ webUrl: 'http://test.com',
+ name: 'README.md',
+ });
+
+ vm.setData({ loading: 1 });
+
+ expect(vm.find(GlLoadingIcon).exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap
index d55dc553031..f8e65a51297 100644
--- a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap
+++ b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap
@@ -25,6 +25,8 @@ exports[`Repository table row component renders table row 1`] = `
<!---->
<!---->
+
+ <!---->
</td>
<td
diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js
index 827927e6d9a..41450becabb 100644
--- a/spec/frontend/repository/components/table/index_spec.js
+++ b/spec/frontend/repository/components/table/index_spec.js
@@ -1,18 +1,36 @@
import { shallowMount } from '@vue/test-utils';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlSkeletonLoading } from '@gitlab/ui';
import Table from '~/repository/components/table/index.vue';
+import TableRow from '~/repository/components/table/row.vue';
let vm;
let $apollo;
-function factory(path, data = () => ({})) {
- $apollo = {
- query: jest.fn().mockReturnValue(Promise.resolve({ data: data() })),
- };
-
+const MOCK_BLOBS = [
+ {
+ id: '123abc',
+ sha: '123abc',
+ flatPath: 'blob',
+ name: 'blob.md',
+ type: 'blob',
+ webUrl: 'http://test.com',
+ },
+ {
+ id: '124abc',
+ sha: '124abc',
+ flatPath: 'blob2',
+ name: 'blob2.md',
+ type: 'blob',
+ webUrl: 'http://test.com',
+ },
+];
+
+function factory({ path, isLoading = false, entries = {} }) {
vm = shallowMount(Table, {
propsData: {
path,
+ isLoading,
+ entries,
},
mocks: {
$apollo,
@@ -31,50 +49,30 @@ describe('Repository table component', () => {
${'app/assets'} | ${'master'}
${'/'} | ${'test'}
`('renders table caption for $ref in $path', ({ path, ref }) => {
- factory(path);
+ factory({ path });
vm.setData({ ref });
- expect(vm.find('caption').text()).toEqual(
+ expect(vm.find('.table').attributes('aria-label')).toEqual(
`Files, directories, and submodules in the path ${path} for commit reference ${ref}`,
);
});
it('shows loading icon', () => {
- factory('/');
-
- vm.setData({ isLoadingFiles: true });
+ factory({ path: '/', isLoading: true });
- expect(vm.find(GlLoadingIcon).isVisible()).toBe(true);
+ expect(vm.find(GlSkeletonLoading).exists()).toBe(true);
});
- describe('normalizeData', () => {
- it('normalizes edge nodes', () => {
- const output = vm.vm.normalizeData('blobs', [{ node: '1' }, { node: '2' }]);
-
- expect(output).toEqual(['1', '2']);
+ it('renders table rows', () => {
+ factory({
+ path: '/',
+ entries: {
+ blobs: MOCK_BLOBS,
+ },
});
- });
-
- describe('hasNextPage', () => {
- it('returns undefined when hasNextPage is false', () => {
- const output = vm.vm.hasNextPage({
- trees: { pageInfo: { hasNextPage: false } },
- submodules: { pageInfo: { hasNextPage: false } },
- blobs: { pageInfo: { hasNextPage: false } },
- });
- expect(output).toBe(undefined);
- });
-
- it('returns pageInfo object when hasNextPage is true', () => {
- const output = vm.vm.hasNextPage({
- trees: { pageInfo: { hasNextPage: false } },
- submodules: { pageInfo: { hasNextPage: false } },
- blobs: { pageInfo: { hasNextPage: true, nextCursor: 'test' } },
- });
-
- expect(output).toEqual({ hasNextPage: true, nextCursor: 'test' });
- });
+ expect(vm.find(TableRow).exists()).toBe(true);
+ expect(vm.findAll(TableRow).length).toBe(2);
});
});
diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js
index e539c560975..aa0b9385f1a 100644
--- a/spec/frontend/repository/components/table/row_spec.js
+++ b/spec/frontend/repository/components/table/row_spec.js
@@ -2,6 +2,7 @@ import { shallowMount, RouterLinkStub } from '@vue/test-utils';
import { GlBadge, GlLink } from '@gitlab/ui';
import { visitUrl } from '~/lib/utils/url_utility';
import TableRow from '~/repository/components/table/row.vue';
+import Icon from '~/vue_shared/components/icon.vue';
jest.mock('~/lib/utils/url_utility');
@@ -40,6 +41,7 @@ describe('Repository table row component', () => {
it('renders table row', () => {
factory({
id: '1',
+ sha: '123',
path: 'test',
type: 'file',
currentPath: '/',
@@ -56,6 +58,7 @@ describe('Repository table row component', () => {
`('renders a $componentName for type $type', ({ type, component }) => {
factory({
id: '1',
+ sha: '123',
path: 'test',
type,
currentPath: '/',
@@ -72,6 +75,7 @@ describe('Repository table row component', () => {
`('pushes new router if type $type is tree', ({ type, pushes }) => {
factory({
id: '1',
+ sha: '123',
path: 'test',
type,
currentPath: '/',
@@ -94,6 +98,7 @@ describe('Repository table row component', () => {
`('calls visitUrl if $type is not tree', ({ type, pushes }) => {
factory({
id: '1',
+ sha: '123',
path: 'test',
type,
currentPath: '/',
@@ -104,13 +109,14 @@ describe('Repository table row component', () => {
if (pushes) {
expect(visitUrl).not.toHaveBeenCalled();
} else {
- expect(visitUrl).toHaveBeenCalledWith('https://test.com');
+ expect(visitUrl).toHaveBeenCalledWith('https://test.com', undefined);
}
});
it('renders commit ID for submodule', () => {
factory({
id: '1',
+ sha: '123',
path: 'test',
type: 'commit',
currentPath: '/',
@@ -122,6 +128,7 @@ describe('Repository table row component', () => {
it('renders link with href', () => {
factory({
id: '1',
+ sha: '123',
path: 'test',
type: 'blob',
url: 'https://test.com',
@@ -134,6 +141,7 @@ describe('Repository table row component', () => {
it('renders LFS badge', () => {
factory({
id: '1',
+ sha: '123',
path: 'test',
type: 'commit',
currentPath: '/',
@@ -146,6 +154,7 @@ describe('Repository table row component', () => {
it('renders commit and web links with href for submodule', () => {
factory({
id: '1',
+ sha: '123',
path: 'test',
type: 'commit',
url: 'https://test.com',
@@ -156,4 +165,18 @@ describe('Repository table row component', () => {
expect(vm.find('a').attributes('href')).toEqual('https://test.com');
expect(vm.find(GlLink).attributes('href')).toEqual('https://test.com/commit');
});
+
+ it('renders lock icon', () => {
+ factory({
+ id: '1',
+ sha: '123',
+ path: 'test',
+ type: 'tree',
+ currentPath: '/',
+ });
+
+ vm.setData({ commit: { lockLabel: 'Locked by Root', committedDate: '2019-01-01' } });
+
+ expect(vm.find(Icon).exists()).toBe(true);
+ });
});
diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js
new file mode 100644
index 00000000000..148e307a5d4
--- /dev/null
+++ b/spec/frontend/repository/components/tree_content_spec.js
@@ -0,0 +1,71 @@
+import { shallowMount } from '@vue/test-utils';
+import TreeContent from '~/repository/components/tree_content.vue';
+import FilePreview from '~/repository/components/preview/index.vue';
+
+let vm;
+let $apollo;
+
+function factory(path, data = () => ({})) {
+ $apollo = {
+ query: jest.fn().mockReturnValue(Promise.resolve({ data: data() })),
+ };
+
+ vm = shallowMount(TreeContent, {
+ propsData: {
+ path,
+ },
+ mocks: {
+ $apollo,
+ },
+ });
+}
+
+describe('Repository table component', () => {
+ afterEach(() => {
+ vm.destroy();
+ });
+
+ it('renders file preview', () => {
+ factory('/');
+
+ vm.setData({ entries: { blobs: [{ name: 'README.md' }] } });
+
+ expect(vm.find(FilePreview).exists()).toBe(true);
+ });
+
+ describe('normalizeData', () => {
+ it('normalizes edge nodes', () => {
+ factory('/');
+
+ const output = vm.vm.normalizeData('blobs', [{ node: '1' }, { node: '2' }]);
+
+ expect(output).toEqual(['1', '2']);
+ });
+ });
+
+ describe('hasNextPage', () => {
+ it('returns undefined when hasNextPage is false', () => {
+ factory('/');
+
+ const output = vm.vm.hasNextPage({
+ trees: { pageInfo: { hasNextPage: false } },
+ submodules: { pageInfo: { hasNextPage: false } },
+ blobs: { pageInfo: { hasNextPage: false } },
+ });
+
+ expect(output).toBe(undefined);
+ });
+
+ it('returns pageInfo object when hasNextPage is true', () => {
+ factory('/');
+
+ const output = vm.vm.hasNextPage({
+ trees: { pageInfo: { hasNextPage: false } },
+ submodules: { pageInfo: { hasNextPage: false } },
+ blobs: { pageInfo: { hasNextPage: true, nextCursor: 'test' } },
+ });
+
+ expect(output).toEqual({ hasNextPage: true, nextCursor: 'test' });
+ });
+ });
+});
diff --git a/spec/frontend/repository/log_tree_spec.js b/spec/frontend/repository/log_tree_spec.js
index a3a766eca41..9199c726680 100644
--- a/spec/frontend/repository/log_tree_spec.js
+++ b/spec/frontend/repository/log_tree_spec.js
@@ -1,6 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
-import { normalizeData, resolveCommit, fetchLogsTree } from '~/repository/log_tree';
+import { resolveCommit, fetchLogsTree } from '~/repository/log_tree';
const mockData = [
{
@@ -15,22 +15,6 @@ const mockData = [
},
];
-describe('normalizeData', () => {
- it('normalizes data into LogTreeCommit object', () => {
- expect(normalizeData(mockData)).toEqual([
- {
- sha: '123',
- message: 'testing message',
- committedDate: '2019-01-01',
- commitPath: 'https://test.com',
- fileName: 'index.js',
- type: 'blob',
- __typename: 'LogTreeCommit',
- },
- ]);
- });
-});
-
describe('resolveCommit', () => {
it('calls resolve when commit found', () => {
const resolver = {
@@ -57,7 +41,7 @@ describe('fetchLogsTree', () => {
jest.spyOn(axios, 'get');
- global.gon = { gitlab_url: 'https://test.com' };
+ global.gon = { relative_url_root: '' };
client = {
readQuery: () => ({
@@ -80,10 +64,9 @@ describe('fetchLogsTree', () => {
it('calls axios get', () =>
fetchLogsTree(client, '', '0', resolver).then(() => {
- expect(axios.get).toHaveBeenCalledWith(
- 'https://test.com/gitlab-org/gitlab-foss/refs/master/logs_tree',
- { params: { format: 'json', offset: '0' } },
- );
+ expect(axios.get).toHaveBeenCalledWith('/gitlab-org/gitlab-foss/refs/master/logs_tree/', {
+ params: { format: 'json', offset: '0' },
+ });
}));
it('calls axios get once', () =>
diff --git a/spec/frontend/repository/pages/index_spec.js b/spec/frontend/repository/pages/index_spec.js
new file mode 100644
index 00000000000..c0afb7931b1
--- /dev/null
+++ b/spec/frontend/repository/pages/index_spec.js
@@ -0,0 +1,42 @@
+import { shallowMount } from '@vue/test-utils';
+import IndexPage from '~/repository/pages/index.vue';
+import TreePage from '~/repository/pages/tree.vue';
+import { updateElementsVisibility } from '~/repository/utils/dom';
+
+jest.mock('~/repository/utils/dom');
+
+describe('Repository index page component', () => {
+ let wrapper;
+
+ function factory() {
+ wrapper = shallowMount(IndexPage);
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+
+ updateElementsVisibility.mockClear();
+ });
+
+ it('calls updateElementsVisibility on mounted', () => {
+ factory();
+
+ expect(updateElementsVisibility).toHaveBeenCalledWith('.js-show-on-project-root', true);
+ });
+
+ it('calls updateElementsVisibility after destroy', () => {
+ factory();
+ wrapper.destroy();
+
+ expect(updateElementsVisibility.mock.calls.pop()).toEqual(['.js-show-on-project-root', false]);
+ });
+
+ it('renders TreePage', () => {
+ factory();
+
+ const child = wrapper.find(TreePage);
+
+ expect(child.exists()).toBe(true);
+ expect(child.props()).toEqual({ path: '/' });
+ });
+});
diff --git a/spec/frontend/repository/pages/tree_spec.js b/spec/frontend/repository/pages/tree_spec.js
new file mode 100644
index 00000000000..36662696c91
--- /dev/null
+++ b/spec/frontend/repository/pages/tree_spec.js
@@ -0,0 +1,60 @@
+import { shallowMount } from '@vue/test-utils';
+import TreePage from '~/repository/pages/tree.vue';
+import { updateElementsVisibility } from '~/repository/utils/dom';
+
+jest.mock('~/repository/utils/dom');
+
+describe('Repository tree page component', () => {
+ let wrapper;
+
+ function factory(path) {
+ wrapper = shallowMount(TreePage, { propsData: { path } });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+
+ updateElementsVisibility.mockClear();
+ });
+
+ describe('when root path', () => {
+ beforeEach(() => {
+ factory('/');
+ });
+
+ it('shows root elements', () => {
+ expect(updateElementsVisibility.mock.calls).toEqual([
+ ['.js-show-on-root', true],
+ ['.js-hide-on-root', false],
+ ]);
+ });
+
+ describe('when changed', () => {
+ beforeEach(() => {
+ updateElementsVisibility.mockClear();
+
+ wrapper.setProps({ path: '/test' });
+ });
+
+ it('hides root elements', () => {
+ expect(updateElementsVisibility.mock.calls).toEqual([
+ ['.js-show-on-root', false],
+ ['.js-hide-on-root', true],
+ ]);
+ });
+ });
+ });
+
+ describe('when non-root path', () => {
+ beforeEach(() => {
+ factory('/test');
+ });
+
+ it('hides root elements', () => {
+ expect(updateElementsVisibility.mock.calls).toEqual([
+ ['.js-show-on-root', false],
+ ['.js-hide-on-root', true],
+ ]);
+ });
+ });
+});
diff --git a/spec/frontend/repository/utils/commit_spec.js b/spec/frontend/repository/utils/commit_spec.js
new file mode 100644
index 00000000000..2d75358106c
--- /dev/null
+++ b/spec/frontend/repository/utils/commit_spec.js
@@ -0,0 +1,30 @@
+import { normalizeData } from '~/repository/utils/commit';
+
+const mockData = [
+ {
+ commit: {
+ id: '123',
+ message: 'testing message',
+ committed_date: '2019-01-01',
+ },
+ commit_path: `https://test.com`,
+ file_name: 'index.js',
+ type: 'blob',
+ },
+];
+
+describe('normalizeData', () => {
+ it('normalizes data into LogTreeCommit object', () => {
+ expect(normalizeData(mockData)).toEqual([
+ {
+ sha: '123',
+ message: 'testing message',
+ committedDate: '2019-01-01',
+ commitPath: 'https://test.com',
+ fileName: 'index.js',
+ type: 'blob',
+ __typename: 'LogTreeCommit',
+ },
+ ]);
+ });
+});
diff --git a/spec/frontend/repository/utils/dom_spec.js b/spec/frontend/repository/utils/dom_spec.js
new file mode 100644
index 00000000000..678d444904d
--- /dev/null
+++ b/spec/frontend/repository/utils/dom_spec.js
@@ -0,0 +1,20 @@
+import { setHTMLFixture } from '../../helpers/fixtures';
+import { updateElementsVisibility } from '~/repository/utils/dom';
+
+describe('updateElementsVisibility', () => {
+ it('adds hidden class', () => {
+ setHTMLFixture('<div class="js-test"></div>');
+
+ updateElementsVisibility('.js-test', false);
+
+ expect(document.querySelector('.js-test').classList).toContain('hidden');
+ });
+
+ it('removes hidden class', () => {
+ setHTMLFixture('<div class="hidden js-test"></div>');
+
+ updateElementsVisibility('.js-test', true);
+
+ expect(document.querySelector('.js-test').classList).not.toContain('hidden');
+ });
+});
diff --git a/spec/frontend/repository/utils/readme_spec.js b/spec/frontend/repository/utils/readme_spec.js
new file mode 100644
index 00000000000..6b7876c8947
--- /dev/null
+++ b/spec/frontend/repository/utils/readme_spec.js
@@ -0,0 +1,33 @@
+import { readmeFile } from '~/repository/utils/readme';
+
+describe('readmeFile', () => {
+ describe('markdown files', () => {
+ it('returns markdown file', () => {
+ expect(readmeFile([{ name: 'README' }, { name: 'README.md' }])).toEqual({
+ name: 'README.md',
+ });
+
+ expect(readmeFile([{ name: 'README' }, { name: 'index.md' }])).toEqual({
+ name: 'index.md',
+ });
+ });
+ });
+
+ describe('plain files', () => {
+ it('returns plain file', () => {
+ expect(readmeFile([{ name: 'README' }, { name: 'TEST.md' }])).toEqual({
+ name: 'README',
+ });
+
+ expect(readmeFile([{ name: 'readme' }, { name: 'TEST.md' }])).toEqual({
+ name: 'readme',
+ });
+ });
+ });
+
+ describe('non-previewable file', () => {
+ it('returns undefined', () => {
+ expect(readmeFile([{ name: 'index.js' }, { name: 'TEST.md' }])).toBe(undefined);
+ });
+ });
+});
diff --git a/spec/frontend/repository/utils/title_spec.js b/spec/frontend/repository/utils/title_spec.js
index c4879716fd7..63035933424 100644
--- a/spec/frontend/repository/utils/title_spec.js
+++ b/spec/frontend/repository/utils/title_spec.js
@@ -8,8 +8,8 @@ describe('setTitle', () => {
${'app/assets'} | ${'app/assets'}
${'app/assets/javascripts'} | ${'app/assets/javascripts'}
`('sets document title as $title for $path', ({ path, title }) => {
- setTitle(path, 'master', 'GitLab');
+ setTitle(path, 'master', 'GitLab Org / GitLab');
- expect(document.title).toEqual(`${title} · master · GitLab`);
+ expect(document.title).toEqual(`${title} · master · GitLab Org / GitLab · GitLab`);
});
});
diff --git a/spec/frontend/sentry/index_spec.js b/spec/frontend/sentry/index_spec.js
new file mode 100644
index 00000000000..82b6c445d96
--- /dev/null
+++ b/spec/frontend/sentry/index_spec.js
@@ -0,0 +1,44 @@
+import SentryConfig from '~/sentry/sentry_config';
+import index from '~/sentry/index';
+
+describe('SentryConfig options', () => {
+ const dsn = 'https://123@sentry.gitlab.test/123';
+ const currentUserId = 'currentUserId';
+ const gitlabUrl = 'gitlabUrl';
+ const environment = 'test';
+ const revision = 'revision';
+ let indexReturnValue;
+
+ beforeEach(() => {
+ window.gon = {
+ sentry_dsn: dsn,
+ sentry_environment: environment,
+ current_user_id: currentUserId,
+ gitlab_url: gitlabUrl,
+ revision,
+ };
+
+ process.env.HEAD_COMMIT_SHA = revision;
+
+ jest.spyOn(SentryConfig, 'init').mockImplementation();
+
+ indexReturnValue = index();
+ });
+
+ it('should init with .sentryDsn, .currentUserId, .whitelistUrls and environment', () => {
+ expect(SentryConfig.init).toHaveBeenCalledWith({
+ dsn,
+ currentUserId,
+ whitelistUrls: [gitlabUrl, 'webpack-internal://'],
+ environment,
+ release: revision,
+ tags: {
+ revision,
+ },
+ });
+ });
+
+ it('should return SentryConfig', () => {
+ expect(indexReturnValue).toBe(SentryConfig);
+ });
+});
diff --git a/spec/frontend/sentry/sentry_config_spec.js b/spec/frontend/sentry/sentry_config_spec.js
new file mode 100644
index 00000000000..62b8bbd50a2
--- /dev/null
+++ b/spec/frontend/sentry/sentry_config_spec.js
@@ -0,0 +1,214 @@
+import * as Sentry from '@sentry/browser';
+import SentryConfig from '~/sentry/sentry_config';
+
+describe('SentryConfig', () => {
+ describe('IGNORE_ERRORS', () => {
+ it('should be an array of strings', () => {
+ const areStrings = SentryConfig.IGNORE_ERRORS.every(error => typeof error === 'string');
+
+ expect(areStrings).toBe(true);
+ });
+ });
+
+ describe('BLACKLIST_URLS', () => {
+ it('should be an array of regexps', () => {
+ const areRegExps = SentryConfig.BLACKLIST_URLS.every(url => url instanceof RegExp);
+
+ expect(areRegExps).toBe(true);
+ });
+ });
+
+ describe('SAMPLE_RATE', () => {
+ it('should be a finite number', () => {
+ expect(typeof SentryConfig.SAMPLE_RATE).toEqual('number');
+ });
+ });
+
+ describe('init', () => {
+ const options = {
+ currentUserId: 1,
+ };
+
+ beforeEach(() => {
+ jest.spyOn(SentryConfig, 'configure');
+ jest.spyOn(SentryConfig, 'bindSentryErrors');
+ jest.spyOn(SentryConfig, 'setUser');
+
+ SentryConfig.init(options);
+ });
+
+ it('should set the options property', () => {
+ expect(SentryConfig.options).toEqual(options);
+ });
+
+ it('should call the configure method', () => {
+ expect(SentryConfig.configure).toHaveBeenCalled();
+ });
+
+ it('should call the error bindings method', () => {
+ expect(SentryConfig.bindSentryErrors).toHaveBeenCalled();
+ });
+
+ it('should call setUser', () => {
+ expect(SentryConfig.setUser).toHaveBeenCalled();
+ });
+
+ it('should not call setUser if there is no current user ID', () => {
+ jest.clearAllMocks();
+
+ options.currentUserId = undefined;
+
+ SentryConfig.init(options);
+
+ expect(SentryConfig.setUser).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('configure', () => {
+ const sentryConfig = {};
+ const options = {
+ dsn: 'https://123@sentry.gitlab.test/123',
+ whitelistUrls: ['//gitlabUrl', 'webpack-internal://'],
+ environment: 'test',
+ release: 'revision',
+ tags: {
+ revision: 'revision',
+ },
+ };
+
+ beforeEach(() => {
+ jest.spyOn(Sentry, 'init').mockImplementation();
+
+ sentryConfig.options = options;
+ sentryConfig.IGNORE_ERRORS = 'ignore_errors';
+ sentryConfig.BLACKLIST_URLS = 'blacklist_urls';
+
+ SentryConfig.configure.call(sentryConfig);
+ });
+
+ it('should call Sentry.init', () => {
+ expect(Sentry.init).toHaveBeenCalledWith({
+ dsn: options.dsn,
+ release: options.release,
+ tags: options.tags,
+ sampleRate: 0.95,
+ whitelistUrls: options.whitelistUrls,
+ environment: 'test',
+ ignoreErrors: sentryConfig.IGNORE_ERRORS,
+ blacklistUrls: sentryConfig.BLACKLIST_URLS,
+ });
+ });
+
+ it('should set environment from options', () => {
+ sentryConfig.options.environment = 'development';
+
+ SentryConfig.configure.call(sentryConfig);
+
+ expect(Sentry.init).toHaveBeenCalledWith({
+ dsn: options.dsn,
+ release: options.release,
+ tags: options.tags,
+ sampleRate: 0.95,
+ whitelistUrls: options.whitelistUrls,
+ environment: 'development',
+ ignoreErrors: sentryConfig.IGNORE_ERRORS,
+ blacklistUrls: sentryConfig.BLACKLIST_URLS,
+ });
+ });
+ });
+
+ describe('setUser', () => {
+ let sentryConfig;
+
+ beforeEach(() => {
+ sentryConfig = { options: { currentUserId: 1 } };
+ jest.spyOn(Sentry, 'setUser');
+
+ SentryConfig.setUser.call(sentryConfig);
+ });
+
+ it('should call .setUser', () => {
+ expect(Sentry.setUser).toHaveBeenCalledWith({
+ id: sentryConfig.options.currentUserId,
+ });
+ });
+ });
+
+ describe('handleSentryErrors', () => {
+ let event;
+ let req;
+ let config;
+ let err;
+
+ beforeEach(() => {
+ event = {};
+ req = { status: 'status', responseText: 'Unknown response text', statusText: 'statusText' };
+ config = { type: 'type', url: 'url', data: 'data' };
+ err = {};
+
+ jest.spyOn(Sentry, 'captureMessage');
+
+ SentryConfig.handleSentryErrors(event, req, config, err);
+ });
+
+ it('should call Sentry.captureMessage', () => {
+ expect(Sentry.captureMessage).toHaveBeenCalledWith(err, {
+ extra: {
+ type: config.type,
+ url: config.url,
+ data: config.data,
+ status: req.status,
+ response: req.responseText,
+ error: err,
+ event,
+ },
+ });
+ });
+
+ describe('if no err is provided', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ SentryConfig.handleSentryErrors(event, req, config);
+ });
+
+ it('should use req.statusText as the error value', () => {
+ expect(Sentry.captureMessage).toHaveBeenCalledWith(req.statusText, {
+ extra: {
+ type: config.type,
+ url: config.url,
+ data: config.data,
+ status: req.status,
+ response: req.responseText,
+ error: req.statusText,
+ event,
+ },
+ });
+ });
+ });
+
+ describe('if no req.responseText is provided', () => {
+ beforeEach(() => {
+ req.responseText = undefined;
+
+ jest.clearAllMocks();
+
+ SentryConfig.handleSentryErrors(event, req, config, err);
+ });
+
+ it('should use `Unknown response text` as the response', () => {
+ expect(Sentry.captureMessage).toHaveBeenCalledWith(err, {
+ extra: {
+ type: config.type,
+ url: config.url,
+ data: config.data,
+ status: req.status,
+ response: 'Unknown response text',
+ error: err,
+ event,
+ },
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js
index 452d4cd07cc..d0d1af56872 100644
--- a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js
+++ b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js
@@ -24,6 +24,7 @@ describe('AssigneeAvatarLink component', () => {
};
wrapper = shallowMount(AssigneeAvatarLink, {
+ attachToDocument: true,
propsData,
sync: false,
});
diff --git a/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js b/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js
index ff0c8d181b5..c88ae196875 100644
--- a/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js
+++ b/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js
@@ -16,6 +16,7 @@ describe('CollapsedAssigneeList component', () => {
};
wrapper = shallowMount(CollapsedAssigneeList, {
+ attachToDocument: true,
propsData,
sync: false,
});
diff --git a/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js b/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js
index 6398351834c..1de21f30d21 100644
--- a/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js
+++ b/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js
@@ -18,6 +18,7 @@ describe('UncollapsedAssigneeList component', () => {
};
wrapper = mount(UncollapsedAssigneeList, {
+ attachToDocument: true,
sync: false,
propsData,
});
diff --git a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap
new file mode 100644
index 00000000000..95296de5a5d
--- /dev/null
+++ b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap
@@ -0,0 +1,37 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SplitButton renders actionItems 1`] = `
+<gldropdown-stub
+ menu-class="dropdown-menu-selectable "
+ split="true"
+ text="professor"
+>
+ <gldropdownitem-stub
+ active="true"
+ active-class="is-active"
+ >
+ <strong>
+ professor
+ </strong>
+
+ <div>
+ very symphonic
+ </div>
+ </gldropdownitem-stub>
+
+ <gldropdowndivider-stub />
+ <gldropdownitem-stub
+ active-class="is-active"
+ >
+ <strong>
+ captain
+ </strong>
+
+ <div>
+ warp drive
+ </div>
+ </gldropdownitem-stub>
+
+ <!---->
+</gldropdown-stub>
+`;
diff --git a/spec/frontend/vue_shared/components/commit_spec.js b/spec/frontend/vue_shared/components/commit_spec.js
new file mode 100644
index 00000000000..77d8e00cf00
--- /dev/null
+++ b/spec/frontend/vue_shared/components/commit_spec.js
@@ -0,0 +1,227 @@
+import { shallowMount } from '@vue/test-utils';
+import CommitComponent from '~/vue_shared/components/commit.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+
+describe('Commit component', () => {
+ let props;
+ let wrapper;
+
+ const findUserAvatar = () => wrapper.find(UserAvatarLink);
+
+ const createComponent = propsData => {
+ wrapper = shallowMount(CommitComponent, {
+ propsData,
+ sync: false,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should render a fork icon if it does not represent a tag', () => {
+ createComponent({
+ tag: false,
+ commitRef: {
+ name: 'master',
+ ref_url: 'http://localhost/namespace2/gitlabhq/tree/master',
+ },
+ commitUrl:
+ 'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067',
+ shortSha: 'b7836edd',
+ title: 'Commit message',
+ author: {
+ avatar_url: 'https://gitlab.com/uploads/-/system/user/avatar/300478/avatar.png',
+ web_url: 'https://gitlab.com/jschatz1',
+ path: '/jschatz1',
+ username: 'jschatz1',
+ },
+ });
+
+ expect(
+ wrapper
+ .find('.icon-container')
+ .find(Icon)
+ .exists(),
+ ).toBe(true);
+ });
+
+ describe('Given all the props', () => {
+ beforeEach(() => {
+ props = {
+ tag: true,
+ commitRef: {
+ name: 'master',
+ ref_url: 'http://localhost/namespace2/gitlabhq/tree/master',
+ },
+ commitUrl:
+ 'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067',
+ shortSha: 'b7836edd',
+ title: 'Commit message',
+ author: {
+ avatar_url: 'https://gitlab.com/uploads/-/system/user/avatar/300478/avatar.png',
+ web_url: 'https://gitlab.com/jschatz1',
+ path: '/jschatz1',
+ username: 'jschatz1',
+ },
+ };
+ createComponent(props);
+ });
+
+ it('should render a tag icon if it represents a tag', () => {
+ expect(wrapper.find('icon-stub[name="tag"]').exists()).toBe(true);
+ });
+
+ it('should render a link to the ref url', () => {
+ expect(wrapper.find('.ref-name').attributes('href')).toBe(props.commitRef.ref_url);
+ });
+
+ it('should render the ref name', () => {
+ expect(wrapper.find('.ref-name').text()).toContain(props.commitRef.name);
+ });
+
+ it('should render the commit short sha with a link to the commit url', () => {
+ expect(wrapper.find('.commit-sha').attributes('href')).toEqual(props.commitUrl);
+
+ expect(wrapper.find('.commit-sha').text()).toContain(props.shortSha);
+ });
+
+ it('should render icon for commit', () => {
+ expect(wrapper.find('icon-stub[name="commit"]').exists()).toBe(true);
+ });
+
+ describe('Given commit title and author props', () => {
+ it('should render a link to the author profile', () => {
+ const userAvatar = findUserAvatar();
+
+ expect(userAvatar.props('linkHref')).toBe(props.author.path);
+ });
+
+ it('Should render the author avatar with title and alt attributes', () => {
+ const userAvatar = findUserAvatar();
+
+ expect(userAvatar.exists()).toBe(true);
+
+ expect(userAvatar.props('imgAlt')).toBe(`${props.author.username}'s avatar`);
+ });
+ });
+
+ it('should render the commit title', () => {
+ expect(wrapper.find('.commit-row-message').attributes('href')).toEqual(props.commitUrl);
+
+ expect(wrapper.find('.commit-row-message').text()).toContain(props.title);
+ });
+ });
+
+ describe('When commit title is not provided', () => {
+ it('should render default message', () => {
+ props = {
+ tag: false,
+ commitRef: {
+ name: 'master',
+ ref_url: 'http://localhost/namespace2/gitlabhq/tree/master',
+ },
+ commitUrl:
+ 'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067',
+ shortSha: 'b7836edd',
+ title: null,
+ author: {},
+ };
+
+ createComponent(props);
+
+ expect(wrapper.find('.commit-title span').text()).toContain(
+ "Can't find HEAD commit for this branch",
+ );
+ });
+ });
+
+ describe('When commit ref is provided, but merge ref is not', () => {
+ it('should render the commit ref', () => {
+ props = {
+ tag: false,
+ commitRef: {
+ name: 'master',
+ ref_url: 'http://localhost/namespace2/gitlabhq/tree/master',
+ },
+ commitUrl:
+ 'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067',
+ shortSha: 'b7836edd',
+ title: null,
+ author: {},
+ };
+
+ createComponent(props);
+ const refEl = wrapper.find('.ref-name');
+
+ expect(refEl.text()).toContain('master');
+
+ expect(refEl.attributes('href')).toBe(props.commitRef.ref_url);
+
+ expect(refEl.attributes('data-original-title')).toBe(props.commitRef.name);
+
+ expect(wrapper.find('icon-stub[name="branch"]').exists()).toBe(true);
+ });
+ });
+
+ describe('When both commit and merge ref are provided', () => {
+ it('should render the merge ref', () => {
+ props = {
+ tag: false,
+ commitRef: {
+ name: 'master',
+ ref_url: 'http://localhost/namespace2/gitlabhq/tree/master',
+ },
+ commitUrl:
+ 'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067',
+ mergeRequestRef: {
+ iid: 1234,
+ path: 'https://example.com/path/to/mr',
+ title: 'Test MR',
+ },
+ shortSha: 'b7836edd',
+ title: null,
+ author: {},
+ };
+
+ createComponent(props);
+ const refEl = wrapper.find('.ref-name');
+
+ expect(refEl.text()).toContain('1234');
+
+ expect(refEl.attributes('href')).toBe(props.mergeRequestRef.path);
+
+ expect(refEl.attributes('data-original-title')).toBe(props.mergeRequestRef.title);
+
+ expect(wrapper.find('icon-stub[name="git-merge"]').exists()).toBe(true);
+ });
+ });
+
+ describe('When showRefInfo === false', () => {
+ it('should not render any ref info', () => {
+ props = {
+ tag: false,
+ commitRef: {
+ name: 'master',
+ ref_url: 'http://localhost/namespace2/gitlabhq/tree/master',
+ },
+ commitUrl:
+ 'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067',
+ mergeRequestRef: {
+ iid: 1234,
+ path: '/path/to/mr',
+ title: 'Test MR',
+ },
+ shortSha: 'b7836edd',
+ title: null,
+ author: {},
+ showRefInfo: false,
+ };
+
+ createComponent(props);
+
+ expect(wrapper.find('.ref-name').exists()).toBe(false);
+ });
+ });
+});
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
new file mode 100644
index 00000000000..3ad8f3aec7c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js
@@ -0,0 +1,45 @@
+import { shallowMount } from '@vue/test-utils';
+
+import ImageViewer from '~/vue_shared/components/content_viewer/viewers/image_viewer.vue';
+import { GREEN_BOX_IMAGE_URL } from 'spec/test_constants';
+
+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,
+ });
+ 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(imageInfo.exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js
index d1de98f4a15..9e6b5286899 100644
--- a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js
+++ b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js
@@ -1,114 +1,129 @@
-import Vue from 'vue';
-
+import { shallowMount } from '@vue/test-utils';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
-
-import mountComponent from 'helpers/vue_mount_component_helper';
import { mockAssigneesList } from '../../../../javascripts/boards/mock_data';
-const createComponent = (assignees = mockAssigneesList, cssClass = '') => {
- const Component = Vue.extend(IssueAssignees);
-
- return mountComponent(Component, {
- assignees,
- cssClass,
- });
-};
+const TEST_CSS_CLASSES = 'test-classes';
+const TEST_MAX_VISIBLE = 4;
+const TEST_ICON_SIZE = 16;
describe('IssueAssigneesComponent', () => {
+ let wrapper;
let vm;
- beforeEach(() => {
- vm = createComponent();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('data', () => {
- it('returns default data props', () => {
- expect(vm.maxVisibleAssignees).toBe(2);
- expect(vm.maxAssigneeAvatars).toBe(3);
- expect(vm.maxAssignees).toBe(99);
+ const factory = props => {
+ wrapper = shallowMount(IssueAssignees, {
+ propsData: {
+ assignees: mockAssigneesList,
+ ...props,
+ },
+ sync: false,
});
+ vm = wrapper.vm; // eslint-disable-line
+ };
+
+ const findTooltipText = () => wrapper.find('.js-assignee-tooltip').text();
+ const findAvatars = () => wrapper.findAll(UserAvatarLink);
+ const findOverflowCounter = () => wrapper.find('.avatar-counter');
+
+ it('returns default data props', () => {
+ factory({ assignees: mockAssigneesList });
+ expect(vm.iconSize).toBe(24);
+ expect(vm.maxVisible).toBe(3);
+ expect(vm.maxAssignees).toBe(99);
});
- describe('computed', () => {
- describe('countOverLimit', () => {
- it('should return difference between assignees count and maxVisibleAssignees', () => {
- expect(vm.countOverLimit).toBe(mockAssigneesList.length - vm.maxVisibleAssignees);
- });
- });
-
- describe('assigneesToShow', () => {
- it('should return assignees containing only 2 items when count more than maxAssigneeAvatars', () => {
- expect(vm.assigneesToShow.length).toBe(2);
- });
-
- it('should return all assignees as it is when count less than maxAssigneeAvatars', () => {
- vm.assignees = mockAssigneesList.slice(0, 3); // Set 3 Assignees
-
- expect(vm.assigneesToShow.length).toBe(3);
- });
- });
-
- describe('assigneesCounterTooltip', () => {
- it('should return string containing count of remaining assignees when count more than maxAssigneeAvatars', () => {
- expect(vm.assigneesCounterTooltip).toBe('3 more assignees');
- });
- });
-
- describe('shouldRenderAssigneesCounter', () => {
- it('should return `false` when assignees count less than maxAssigneeAvatars', () => {
- vm.assignees = mockAssigneesList.slice(0, 3); // Set 3 Assignees
-
- expect(vm.shouldRenderAssigneesCounter).toBe(false);
- });
-
- it('should return `true` when assignees count more than maxAssigneeAvatars', () => {
- expect(vm.shouldRenderAssigneesCounter).toBe(true);
+ describe.each`
+ numAssignees | maxVisible | expectedShown | expectedHidden
+ ${0} | ${3} | ${0} | ${''}
+ ${1} | ${3} | ${1} | ${''}
+ ${2} | ${3} | ${2} | ${''}
+ ${3} | ${3} | ${3} | ${''}
+ ${4} | ${3} | ${2} | ${'+2'}
+ ${5} | ${2} | ${1} | ${'+4'}
+ ${1000} | ${5} | ${4} | ${'99+'}
+ `(
+ 'with assignees ($numAssignees) and maxVisible ($maxVisible)',
+ ({ numAssignees, maxVisible, expectedShown, expectedHidden }) => {
+ beforeEach(() => {
+ factory({ assignees: Array(numAssignees).fill({}), maxVisible });
});
- });
- describe('assigneeCounterLabel', () => {
- it('should return count of additional assignees total assignees count more than maxAssigneeAvatars', () => {
- expect(vm.assigneeCounterLabel).toBe('+3');
+ if (expectedShown) {
+ it('shows assignee avatars', () => {
+ expect(findAvatars().length).toEqual(expectedShown);
+ });
+ } else {
+ it('does not show assignee avatars', () => {
+ expect(findAvatars().length).toEqual(0);
+ });
+ }
+
+ if (expectedHidden) {
+ it('shows overflow counter', () => {
+ const hiddenCount = numAssignees - expectedShown;
+
+ expect(findOverflowCounter().exists()).toBe(true);
+ expect(findOverflowCounter().text()).toEqual(expectedHidden.toString());
+ expect(findOverflowCounter().attributes('data-original-title')).toEqual(
+ `${hiddenCount} more assignees`,
+ );
+ });
+ } else {
+ it('does not show overflow counter', () => {
+ expect(findOverflowCounter().exists()).toBe(false);
+ });
+ }
+ },
+ );
+
+ describe('when mounted', () => {
+ beforeEach(() => {
+ factory({
+ imgCssClasses: TEST_CSS_CLASSES,
+ maxVisible: TEST_MAX_VISIBLE,
+ iconSize: TEST_ICON_SIZE,
});
});
- });
- describe('methods', () => {
- describe('avatarUrlTitle', () => {
- it('returns string containing alt text for assignee avatar', () => {
- expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Avatar for Terrell Graham');
- });
+ it('computes alt text for assignee avatar', () => {
+ expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Avatar for Terrell Graham');
});
- });
- describe('template', () => {
it('renders component root element with class `issue-assignees`', () => {
- expect(vm.$el.classList.contains('issue-assignees')).toBe(true);
+ expect(wrapper.element.classList.contains('issue-assignees')).toBe(true);
});
- it('renders assignee avatars', () => {
- expect(vm.$el.querySelectorAll('.user-avatar-link').length).toBe(2);
+ it('renders assignee', () => {
+ const data = findAvatars().wrappers.map(x => ({
+ ...x.props(),
+ }));
+
+ const expected = mockAssigneesList.slice(0, TEST_MAX_VISIBLE - 1).map(x =>
+ expect.objectContaining({
+ linkHref: x.web_url,
+ imgAlt: `Avatar for ${x.name}`,
+ imgCssClasses: TEST_CSS_CLASSES,
+ imgSrc: x.avatar_url,
+ imgSize: TEST_ICON_SIZE,
+ }),
+ );
+
+ expect(data).toEqual(expected);
});
- it('renders assignee tooltips', () => {
- const tooltipText = vm.$el
- .querySelectorAll('.user-avatar-link')[0]
- .querySelector('.js-assignee-tooltip').innerText;
-
- expect(tooltipText).toContain('Assignee');
- expect(tooltipText).toContain('Terrell Graham');
- expect(tooltipText).toContain('@monserrate.gleichner');
- });
+ describe('assignee tooltips', () => {
+ it('renders "Assignee" header', () => {
+ expect(findTooltipText()).toContain('Assignee');
+ });
- it('renders additional assignees count', () => {
- const avatarCounterEl = vm.$el.querySelector('.avatar-counter');
+ it('renders assignee name', () => {
+ expect(findTooltipText()).toContain('Terrell Graham');
+ });
- expect(avatarCounterEl.innerText.trim()).toBe('+3');
- expect(avatarCounterEl.getAttribute('data-original-title')).toBe('3 more assignees');
+ it('renders assignee @username', () => {
+ expect(findTooltipText()).toContain('@monserrate.gleichner');
+ });
});
});
});
diff --git a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js
index eafff7f681e..45f131194ca 100644
--- a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js
+++ b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import issuePlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
import createStore from '~/notes/stores';
-import { userDataMock } from '../../../../javascripts/notes/mock_data';
+import { userDataMock } from '../../../notes/mock_data';
describe('issue placeholder system note component', () => {
let store;
diff --git a/spec/frontend/vue_shared/components/notes/system_note_spec.js b/spec/frontend/vue_shared/components/notes/system_note_spec.js
index a65e3eb294a..c2e8359f78d 100644
--- a/spec/frontend/vue_shared/components/notes/system_note_spec.js
+++ b/spec/frontend/vue_shared/components/notes/system_note_spec.js
@@ -57,7 +57,7 @@ describe('system note component', () => {
// we need to strip them because they break layout of commit lists in system notes:
// https://gitlab.com/gitlab-org/gitlab-foss/uploads/b07a10670919254f0220d3ff5c1aa110/jqzI.png
it('removes wrapping paragraph from note HTML', () => {
- expect(vm.$el.querySelector('.system-note-message').innerHTML).toEqual('<span>closed</span>');
+ expect(vm.$el.querySelector('.system-note-message').innerHTML).toContain('<span>closed</span>');
});
it('should initMRPopovers onMount', () => {
diff --git a/spec/frontend/vue_shared/components/slot_switch_spec.js b/spec/frontend/vue_shared/components/slot_switch_spec.js
new file mode 100644
index 00000000000..cff955c05b2
--- /dev/null
+++ b/spec/frontend/vue_shared/components/slot_switch_spec.js
@@ -0,0 +1,56 @@
+import { shallowMount } from '@vue/test-utils';
+
+import SlotSwitch from '~/vue_shared/components/slot_switch';
+
+describe('SlotSwitch', () => {
+ const slots = {
+ first: '<a>AGP</a>',
+ second: '<p>PCI</p>',
+ };
+
+ let wrapper;
+
+ const createComponent = propsData => {
+ wrapper = shallowMount(SlotSwitch, {
+ propsData,
+ slots,
+ sync: false,
+ });
+ };
+
+ const getChildrenHtml = () => wrapper.findAll('* *').wrappers.map(c => c.html());
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ }
+ });
+
+ it('throws an error if activeSlotNames is missing', () => {
+ expect(createComponent).toThrow('[Vue warn]: Missing required prop: "activeSlotNames"');
+ });
+
+ it('renders no slots if activeSlotNames is empty', () => {
+ createComponent({
+ activeSlotNames: [],
+ });
+
+ expect(getChildrenHtml().length).toBe(0);
+ });
+
+ it('renders one slot if activeSlotNames contains single slot name', () => {
+ createComponent({
+ activeSlotNames: ['first'],
+ });
+
+ expect(getChildrenHtml()).toEqual([slots.first]);
+ });
+
+ it('renders multiple slots if activeSlotNames contains multiple slot names', () => {
+ createComponent({
+ activeSlotNames: Object.keys(slots),
+ });
+
+ expect(getChildrenHtml()).toEqual(Object.values(slots));
+ });
+});
diff --git a/spec/frontend/vue_shared/components/split_button_spec.js b/spec/frontend/vue_shared/components/split_button_spec.js
new file mode 100644
index 00000000000..520abb02cf7
--- /dev/null
+++ b/spec/frontend/vue_shared/components/split_button_spec.js
@@ -0,0 +1,104 @@
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+
+import SplitButton from '~/vue_shared/components/split_button.vue';
+
+const mockActionItems = [
+ {
+ eventName: 'concert',
+ title: 'professor',
+ description: 'very symphonic',
+ },
+ {
+ eventName: 'apocalypse',
+ title: 'captain',
+ description: 'warp drive',
+ },
+];
+
+describe('SplitButton', () => {
+ let wrapper;
+
+ const createComponent = propsData => {
+ wrapper = shallowMount(SplitButton, {
+ propsData,
+ sync: false,
+ });
+ };
+
+ const findDropdown = () => wrapper.find(GlDropdown);
+ const findDropdownItem = (index = 0) =>
+ findDropdown()
+ .findAll(GlDropdownItem)
+ .at(index);
+ const selectItem = index => {
+ findDropdownItem(index).vm.$emit('click');
+
+ return wrapper.vm.$nextTick();
+ };
+ const clickToggleButton = () => {
+ findDropdown().vm.$emit('click');
+
+ return wrapper.vm.$nextTick();
+ };
+
+ it('fails for empty actionItems', () => {
+ const actionItems = [];
+ expect(() => createComponent({ actionItems })).toThrow();
+ });
+
+ it('fails for single actionItems', () => {
+ const actionItems = [mockActionItems[0]];
+ expect(() => createComponent({ actionItems })).toThrow();
+ });
+
+ it('renders actionItems', () => {
+ createComponent({ actionItems: mockActionItems });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('toggle button text', () => {
+ beforeEach(() => {
+ createComponent({ actionItems: mockActionItems });
+ });
+
+ it('defaults to first actionItems title', () => {
+ expect(findDropdown().props().text).toBe(mockActionItems[0].title);
+ });
+
+ it('changes to selected actionItems title', () =>
+ selectItem(1).then(() => {
+ expect(findDropdown().props().text).toBe(mockActionItems[1].title);
+ }));
+ });
+
+ describe('emitted event', () => {
+ let eventHandler;
+
+ beforeEach(() => {
+ createComponent({ actionItems: mockActionItems });
+ });
+
+ const addEventHandler = ({ eventName }) => {
+ eventHandler = jest.fn();
+ wrapper.vm.$once(eventName, () => eventHandler());
+ };
+
+ it('defaults to first actionItems event', () => {
+ addEventHandler(mockActionItems[0]);
+
+ return clickToggleButton().then(() => {
+ expect(eventHandler).toHaveBeenCalled();
+ });
+ });
+
+ it('changes to selected actionItems event', () =>
+ selectItem(1)
+ .then(() => addEventHandler(mockActionItems[1]))
+ .then(clickToggleButton)
+ .then(() => {
+ expect(eventHandler).toHaveBeenCalled();
+ }));
+ });
+});
diff --git a/spec/frontend/vue_shared/components/table_pagination_spec.js b/spec/frontend/vue_shared/components/table_pagination_spec.js
new file mode 100644
index 00000000000..0a9ff36b2fb
--- /dev/null
+++ b/spec/frontend/vue_shared/components/table_pagination_spec.js
@@ -0,0 +1,335 @@
+import { shallowMount } from '@vue/test-utils';
+import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
+
+describe('Pagination component', () => {
+ let wrapper;
+ let spy;
+
+ const mountComponent = props => {
+ wrapper = shallowMount(TablePagination, {
+ sync: false,
+ propsData: props,
+ });
+ };
+
+ const findFirstButtonLink = () => wrapper.find('.js-first-button .page-link');
+ const findPreviousButton = () => wrapper.find('.js-previous-button');
+ const findPreviousButtonLink = () => wrapper.find('.js-previous-button .page-link');
+ const findNextButton = () => wrapper.find('.js-next-button');
+ const findNextButtonLink = () => wrapper.find('.js-next-button .page-link');
+ const findLastButtonLink = () => wrapper.find('.js-last-button .page-link');
+ const findPages = () => wrapper.findAll('.page');
+ const findSeparator = () => wrapper.find('.separator');
+
+ beforeEach(() => {
+ spy = jest.fn();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('render', () => {
+ it('should not render anything', () => {
+ mountComponent({
+ pageInfo: {
+ nextPage: NaN,
+ page: 1,
+ perPage: 20,
+ previousPage: NaN,
+ total: 15,
+ totalPages: 1,
+ },
+ change: spy,
+ });
+
+ expect(wrapper.isEmpty()).toBe(true);
+ });
+
+ describe('prev button', () => {
+ it('should be disabled and non clickable', () => {
+ mountComponent({
+ pageInfo: {
+ nextPage: 2,
+ page: 1,
+ perPage: 20,
+ previousPage: NaN,
+ total: 84,
+ totalPages: 5,
+ },
+ change: spy,
+ });
+
+ expect(findPreviousButton().classes()).toContain('disabled');
+ findPreviousButtonLink().trigger('click');
+ expect(spy).not.toHaveBeenCalled();
+ });
+
+ it('should be disabled and non clickable when total and totalPages are NaN', () => {
+ mountComponent({
+ pageInfo: {
+ nextPage: 2,
+ page: 1,
+ perPage: 20,
+ previousPage: NaN,
+ total: NaN,
+ totalPages: NaN,
+ },
+ change: spy,
+ });
+ expect(findPreviousButton().classes()).toContain('disabled');
+ findPreviousButtonLink().trigger('click');
+ expect(spy).not.toHaveBeenCalled();
+ });
+
+ it('should be enabled and clickable', () => {
+ mountComponent({
+ pageInfo: {
+ nextPage: 3,
+ page: 2,
+ perPage: 20,
+ previousPage: 1,
+ total: 84,
+ totalPages: 5,
+ },
+ change: spy,
+ });
+ findPreviousButtonLink().trigger('click');
+ expect(spy).toHaveBeenCalledWith(1);
+ });
+
+ it('should be enabled and clickable when total and totalPages are NaN', () => {
+ mountComponent({
+ pageInfo: {
+ nextPage: 3,
+ page: 2,
+ perPage: 20,
+ previousPage: 1,
+ total: NaN,
+ totalPages: NaN,
+ },
+ change: spy,
+ });
+ findPreviousButtonLink().trigger('click');
+ expect(spy).toHaveBeenCalledWith(1);
+ });
+ });
+
+ describe('first button', () => {
+ it('should call the change callback with the first page', () => {
+ mountComponent({
+ pageInfo: {
+ nextPage: 3,
+ page: 2,
+ perPage: 20,
+ previousPage: 1,
+ total: 84,
+ totalPages: 5,
+ },
+ change: spy,
+ });
+ const button = findFirstButtonLink();
+ expect(button.text().trim()).toEqual('« First');
+ button.trigger('click');
+ expect(spy).toHaveBeenCalledWith(1);
+ });
+
+ it('should call the change callback with the first page when total and totalPages are NaN', () => {
+ mountComponent({
+ pageInfo: {
+ nextPage: 3,
+ page: 2,
+ perPage: 20,
+ previousPage: 1,
+ total: NaN,
+ totalPages: NaN,
+ },
+ change: spy,
+ });
+ const button = findFirstButtonLink();
+ expect(button.text().trim()).toEqual('« First');
+ button.trigger('click');
+ expect(spy).toHaveBeenCalledWith(1);
+ });
+ });
+
+ describe('last button', () => {
+ it('should call the change callback with the last page', () => {
+ mountComponent({
+ pageInfo: {
+ nextPage: 3,
+ page: 2,
+ perPage: 20,
+ previousPage: 1,
+ total: 84,
+ totalPages: 5,
+ },
+ change: spy,
+ });
+ const button = findLastButtonLink();
+ expect(button.text().trim()).toEqual('Last »');
+ button.trigger('click');
+ expect(spy).toHaveBeenCalledWith(5);
+ });
+
+ it('should not render', () => {
+ mountComponent({
+ pageInfo: {
+ nextPage: 3,
+ page: 2,
+ perPage: 20,
+ previousPage: 1,
+ total: NaN,
+ totalPages: NaN,
+ },
+ change: spy,
+ });
+ expect(findLastButtonLink().exists()).toBe(false);
+ });
+ });
+
+ describe('next button', () => {
+ it('should be disabled and non clickable', () => {
+ mountComponent({
+ pageInfo: {
+ nextPage: NaN,
+ page: 5,
+ perPage: 20,
+ previousPage: 4,
+ total: 84,
+ totalPages: 5,
+ },
+ change: spy,
+ });
+ expect(
+ findNextButton()
+ .text()
+ .trim(),
+ ).toEqual('Next ›');
+ findNextButtonLink().trigger('click');
+ expect(spy).not.toHaveBeenCalled();
+ });
+
+ it('should be disabled and non clickable when total and totalPages are NaN', () => {
+ mountComponent({
+ pageInfo: {
+ nextPage: NaN,
+ page: 5,
+ perPage: 20,
+ previousPage: 4,
+ total: NaN,
+ totalPages: NaN,
+ },
+ change: spy,
+ });
+ expect(
+ findNextButton()
+ .text()
+ .trim(),
+ ).toEqual('Next ›');
+ findNextButtonLink().trigger('click');
+ expect(spy).not.toHaveBeenCalled();
+ });
+
+ it('should be enabled and clickable', () => {
+ mountComponent({
+ pageInfo: {
+ nextPage: 4,
+ page: 3,
+ perPage: 20,
+ previousPage: 2,
+ total: 84,
+ totalPages: 5,
+ },
+ change: spy,
+ });
+ findNextButtonLink().trigger('click');
+ expect(spy).toHaveBeenCalledWith(4);
+ });
+
+ it('should be enabled and clickable when total and totalPages are NaN', () => {
+ mountComponent({
+ pageInfo: {
+ nextPage: 4,
+ page: 3,
+ perPage: 20,
+ previousPage: 2,
+ total: NaN,
+ totalPages: NaN,
+ },
+ change: spy,
+ });
+ findNextButtonLink().trigger('click');
+ expect(spy).toHaveBeenCalledWith(4);
+ });
+ });
+
+ describe('numbered buttons', () => {
+ it('should render 5 pages', () => {
+ mountComponent({
+ pageInfo: {
+ nextPage: 4,
+ page: 3,
+ perPage: 20,
+ previousPage: 2,
+ total: 84,
+ totalPages: 5,
+ },
+ change: spy,
+ });
+ expect(findPages().length).toEqual(5);
+ });
+
+ it('should not render any page', () => {
+ mountComponent({
+ pageInfo: {
+ nextPage: 4,
+ page: 3,
+ perPage: 20,
+ previousPage: 2,
+ total: NaN,
+ totalPages: NaN,
+ },
+ change: spy,
+ });
+ expect(findPages().length).toEqual(0);
+ });
+ });
+
+ describe('spread operator', () => {
+ it('should render', () => {
+ mountComponent({
+ pageInfo: {
+ nextPage: 4,
+ page: 3,
+ perPage: 20,
+ previousPage: 2,
+ total: 84,
+ totalPages: 10,
+ },
+ change: spy,
+ });
+ expect(
+ findSeparator()
+ .text()
+ .trim(),
+ ).toEqual('...');
+ });
+
+ it('should not render', () => {
+ mountComponent({
+ pageInfo: {
+ nextPage: 4,
+ page: 3,
+ perPage: 20,
+ previousPage: 2,
+ total: NaN,
+ totalPages: NaN,
+ },
+ change: spy,
+ });
+ expect(findSeparator().exists()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js
new file mode 100644
index 00000000000..2f87359a4a6
--- /dev/null
+++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js
@@ -0,0 +1,108 @@
+import { shallowMount } from '@vue/test-utils';
+import { placeholderImage } from '~/lazy_loader';
+import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
+import defaultAvatarUrl from 'images/no_avatar.png';
+
+jest.mock('images/no_avatar.png', () => 'default-avatar-url');
+
+const DEFAULT_PROPS = {
+ size: 99,
+ imgSrc: 'myavatarurl.com',
+ imgAlt: 'mydisplayname',
+ cssClasses: 'myextraavatarclass',
+ tooltipText: 'tooltip text',
+ tooltipPlacement: 'bottom',
+};
+
+describe('User Avatar Image Component', () => {
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('Initialization', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(UserAvatarImage, {
+ propsData: {
+ ...DEFAULT_PROPS,
+ },
+ sync: false,
+ });
+ });
+
+ it('should have <img> as a child element', () => {
+ const imageElement = wrapper.find('img');
+
+ expect(imageElement.exists()).toBe(true);
+ expect(imageElement.attributes('src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
+ expect(imageElement.attributes('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
+ expect(imageElement.attributes('alt')).toBe(DEFAULT_PROPS.imgAlt);
+ });
+
+ it('should properly render img css', () => {
+ const classes = wrapper.find('img').classes();
+ expect(classes).toEqual(expect.arrayContaining(['avatar', 's99', DEFAULT_PROPS.cssClasses]));
+ expect(classes).not.toContain('lazy');
+ });
+ });
+
+ describe('Initialization when lazy', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(UserAvatarImage, {
+ propsData: {
+ ...DEFAULT_PROPS,
+ lazy: true,
+ },
+ sync: false,
+ });
+ });
+
+ it('should add lazy attributes', () => {
+ const imageElement = wrapper.find('img');
+
+ expect(imageElement.classes()).toContain('lazy');
+ expect(imageElement.attributes('src')).toBe(placeholderImage);
+ expect(imageElement.attributes('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
+ });
+ });
+
+ describe('Initialization without src', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(UserAvatarImage, { sync: false });
+ });
+
+ it('should have default avatar image', () => {
+ const imageElement = wrapper.find('img');
+
+ expect(imageElement.attributes('src')).toBe(`${defaultAvatarUrl}?width=20`);
+ });
+ });
+
+ describe('dynamic tooltip content', () => {
+ const props = DEFAULT_PROPS;
+ const slots = {
+ default: ['Action!'],
+ };
+
+ beforeEach(() => {
+ wrapper = shallowMount(UserAvatarImage, { propsData: { props }, slots, sync: false });
+ });
+
+ it('renders the tooltip slot', () => {
+ expect(wrapper.find('.js-user-avatar-image-toolip').exists()).toBe(true);
+ });
+
+ it('renders the tooltip content', () => {
+ expect(wrapper.find('.js-user-avatar-image-toolip').text()).toContain(slots.default[0]);
+ });
+
+ it('does not render tooltip data attributes for on avatar image', () => {
+ const avatarImg = wrapper.find('img');
+
+ expect(avatarImg.attributes('data-original-title')).toBeFalsy();
+ expect(avatarImg.attributes('data-placement')).not.toBeDefined();
+ expect(avatarImg.attributes('data-container')).not.toBeDefined();
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
new file mode 100644
index 00000000000..fc2eb6329b0
--- /dev/null
+++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
@@ -0,0 +1,186 @@
+import UserPopover from '~/vue_shared/components/user_popover/user_popover.vue';
+import { mount } from '@vue/test-utils';
+
+const DEFAULT_PROPS = {
+ loaded: true,
+ user: {
+ username: 'root',
+ name: 'Administrator',
+ location: 'Vienna',
+ bio: null,
+ organization: null,
+ status: null,
+ },
+};
+
+describe('User Popover Component', () => {
+ const fixtureTemplate = 'merge_requests/diff_comment.html';
+ preloadFixtures(fixtureTemplate);
+
+ let wrapper;
+
+ beforeEach(() => {
+ loadFixtures(fixtureTemplate);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('Empty', () => {
+ beforeEach(() => {
+ wrapper = mount(UserPopover, {
+ propsData: {
+ target: document.querySelector('.js-user-link'),
+ user: {
+ name: null,
+ username: null,
+ location: null,
+ bio: null,
+ organization: null,
+ status: null,
+ },
+ },
+ sync: false,
+ });
+ });
+
+ it('should return skeleton loaders', () => {
+ expect(wrapper.findAll('.animation-container').length).toBe(4);
+ });
+ });
+
+ describe('basic data', () => {
+ it('should show basic fields', () => {
+ wrapper = mount(UserPopover, {
+ propsData: {
+ ...DEFAULT_PROPS,
+ target: document.querySelector('.js-user-link'),
+ },
+ sync: false,
+ });
+
+ expect(wrapper.text()).toContain(DEFAULT_PROPS.user.name);
+ expect(wrapper.text()).toContain(DEFAULT_PROPS.user.username);
+ expect(wrapper.text()).toContain(DEFAULT_PROPS.user.location);
+ });
+
+ it('shows icon for location', () => {
+ const iconEl = wrapper.find('.js-location svg');
+
+ expect(iconEl.find('use').element.getAttribute('xlink:href')).toContain('location');
+ });
+ });
+
+ describe('job data', () => {
+ it('should show only bio if no organization is available', () => {
+ const testProps = Object.assign({}, DEFAULT_PROPS);
+ testProps.user.bio = 'Engineer';
+
+ wrapper = mount(UserPopover, {
+ propsData: {
+ ...testProps,
+ target: document.querySelector('.js-user-link'),
+ },
+ sync: false,
+ });
+
+ expect(wrapper.text()).toContain('Engineer');
+ });
+
+ it('should show only organization if no bio is available', () => {
+ const testProps = Object.assign({}, DEFAULT_PROPS);
+ testProps.user.organization = 'GitLab';
+
+ wrapper = mount(UserPopover, {
+ propsData: {
+ ...testProps,
+ target: document.querySelector('.js-user-link'),
+ },
+ sync: false,
+ });
+
+ expect(wrapper.text()).toContain('GitLab');
+ });
+
+ it('should display bio and organization in separate lines', () => {
+ const testProps = Object.assign({}, DEFAULT_PROPS);
+ testProps.user.bio = 'Engineer';
+ testProps.user.organization = 'GitLab';
+
+ wrapper = mount(UserPopover, {
+ propsData: {
+ ...DEFAULT_PROPS,
+ target: document.querySelector('.js-user-link'),
+ },
+ sync: false,
+ });
+
+ expect(wrapper.find('.js-bio').text()).toContain('Engineer');
+ expect(wrapper.find('.js-organization').text()).toContain('GitLab');
+ });
+
+ it('should not encode special characters in bio and organization', () => {
+ const testProps = Object.assign({}, DEFAULT_PROPS);
+ testProps.user.bio = 'Manager & Team Lead';
+ testProps.user.organization = 'Me & my <funky> Company';
+
+ wrapper = mount(UserPopover, {
+ propsData: {
+ ...DEFAULT_PROPS,
+ target: document.querySelector('.js-user-link'),
+ },
+ sync: false,
+ });
+
+ expect(wrapper.find('.js-bio').text()).toContain('Manager & Team Lead');
+ expect(wrapper.find('.js-organization').text()).toContain('Me & my <funky> Company');
+ });
+
+ it('shows icon for bio', () => {
+ const iconEl = wrapper.find('.js-bio svg');
+
+ expect(iconEl.find('use').element.getAttribute('xlink:href')).toContain('profile');
+ });
+
+ it('shows icon for organization', () => {
+ const iconEl = wrapper.find('.js-organization svg');
+
+ expect(iconEl.find('use').element.getAttribute('xlink:href')).toContain('work');
+ });
+ });
+
+ describe('status data', () => {
+ it('should show only message', () => {
+ const testProps = Object.assign({}, DEFAULT_PROPS);
+ testProps.user.status = { message_html: 'Hello World' };
+
+ wrapper = mount(UserPopover, {
+ propsData: {
+ ...DEFAULT_PROPS,
+ target: document.querySelector('.js-user-link'),
+ },
+ sync: false,
+ });
+
+ expect(wrapper.text()).toContain('Hello World');
+ });
+
+ it('should show message and emoji', () => {
+ const testProps = Object.assign({}, DEFAULT_PROPS);
+ testProps.user.status = { emoji: 'basketball_player', message_html: 'Hello World' };
+
+ wrapper = mount(UserPopover, {
+ propsData: {
+ ...DEFAULT_PROPS,
+ target: document.querySelector('.js-user-link'),
+ status: { emoji: 'basketball_player', message_html: 'Hello World' },
+ },
+ sync: false,
+ });
+
+ expect(wrapper.text()).toContain('Hello World');
+ expect(wrapper.html()).toContain('<gl-emoji data-name="basketball_player"');
+ });
+ });
+});