diff options
author | Filipa Lacerda <filipa@gitlab.com> | 2019-07-03 22:39:10 +0100 |
---|---|---|
committer | Filipa Lacerda <filipa@gitlab.com> | 2019-07-03 22:39:10 +0100 |
commit | 50be7237f41b0ac44b9aaf8b73c57993548d4c35 (patch) | |
tree | ecfeeae58829dadbd90de4f834c730d1d8c55e74 /spec/frontend | |
parent | 35331c435196ea1155eb15161f3f9a481a01501d (diff) | |
parent | 2ad75a4f96c4d377e18788966e7eefee4d78b6d2 (diff) | |
download | gitlab-ce-update-todo-in-ui.tar.gz |
Merge branch 'master' into update-todo-in-uiupdate-todo-in-ui
* master: (435 commits)
Change occurrence of Sidekiq::Testing.inline!
Fix order-dependent spec failure in appearance_spec.rb
Put a failed example from appearance_spec in quarantine
Cache PerformanceBar.allowed_user_ids list locally and in Redis
Add Grafana to Admin > Monitoring menu when enabled
Add changelog entry
Add salesforce logo
Move error_tracking_frontend specs to Jest
Only save Peek session in Redis when Peek is enabled
Migrate markdown header_spec.js to Jest
Fix golint command in Go guide doc to be recursive
Move images to their own dirs
Gitlab -> GitLab
Re-align CE and EE API docs
Rename Release groups in issue_workflow.md
Update api docs to finish aligning EE and CE docs
Update locale.pot
Update TODO: allow_collaboration column renaming
Show upcoming status for releases
Rebased and squashed commits
...
Diffstat (limited to 'spec/frontend')
30 files changed, 2494 insertions, 41 deletions
diff --git a/spec/frontend/boards/modal_store_spec.js b/spec/frontend/boards/modal_store_spec.js index 4dd27e94d97..5b5ae4b6556 100644 --- a/spec/frontend/boards/modal_store_spec.js +++ b/spec/frontend/boards/modal_store_spec.js @@ -25,7 +25,7 @@ describe('Modal store', () => { }); issue2 = new ListIssue({ title: 'Testing', - id: 1, + id: 2, iid: 2, confidential: false, labels: [], diff --git a/spec/frontend/boards/services/board_service_spec.js b/spec/frontend/boards/services/board_service_spec.js new file mode 100644 index 00000000000..de9fc998360 --- /dev/null +++ b/spec/frontend/boards/services/board_service_spec.js @@ -0,0 +1,390 @@ +import BoardService from '~/boards/services/board_service'; +import { TEST_HOST } from 'helpers/test_constants'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; + +describe('BoardService', () => { + const dummyResponse = "without type checking this doesn't matter"; + const boardId = 'dummy-board-id'; + const endpoints = { + boardsEndpoint: `${TEST_HOST}/boards`, + listsEndpoint: `${TEST_HOST}/lists`, + bulkUpdatePath: `${TEST_HOST}/bulk/update`, + recentBoardsEndpoint: `${TEST_HOST}/recent/boards`, + }; + + let service; + let axiosMock; + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + service = new BoardService({ + ...endpoints, + boardId, + }); + }); + + describe('all', () => { + it('makes a request to fetch lists', () => { + axiosMock.onGet(endpoints.listsEndpoint).replyOnce(200, dummyResponse); + const expectedResponse = expect.objectContaining({ data: dummyResponse }); + + return expect(service.all()).resolves.toEqual(expectedResponse); + }); + + it('fails for error response', () => { + axiosMock.onGet(endpoints.listsEndpoint).replyOnce(500); + + return expect(service.all()).rejects.toThrow(); + }); + }); + + describe('generateDefaultLists', () => { + const listsEndpointGenerate = `${endpoints.listsEndpoint}/generate.json`; + + it('makes a request to generate default lists', () => { + axiosMock.onPost(listsEndpointGenerate).replyOnce(200, dummyResponse); + const expectedResponse = expect.objectContaining({ data: dummyResponse }); + + return expect(service.generateDefaultLists()).resolves.toEqual(expectedResponse); + }); + + it('fails for error response', () => { + axiosMock.onPost(listsEndpointGenerate).replyOnce(500); + + return expect(service.generateDefaultLists()).rejects.toThrow(); + }); + }); + + describe('createList', () => { + const entityType = 'moorhen'; + const entityId = 'quack'; + const expectedRequest = expect.objectContaining({ + data: JSON.stringify({ list: { [entityType]: entityId } }), + }); + + let requestSpy; + + beforeEach(() => { + requestSpy = jest.fn(); + axiosMock.onPost(endpoints.listsEndpoint).replyOnce(config => requestSpy(config)); + }); + + it('makes a request to create a list', () => { + requestSpy.mockReturnValue([200, dummyResponse]); + const expectedResponse = expect.objectContaining({ data: dummyResponse }); + + return expect(service.createList(entityId, entityType)) + .resolves.toEqual(expectedResponse) + .then(() => { + expect(requestSpy).toHaveBeenCalledWith(expectedRequest); + }); + }); + + it('fails for error response', () => { + requestSpy.mockReturnValue([500]); + + return expect(service.createList(entityId, entityType)) + .rejects.toThrow() + .then(() => { + expect(requestSpy).toHaveBeenCalledWith(expectedRequest); + }); + }); + }); + + describe('updateList', () => { + const id = 'David Webb'; + const position = 'unknown'; + const expectedRequest = expect.objectContaining({ + data: JSON.stringify({ list: { position } }), + }); + + let requestSpy; + + beforeEach(() => { + requestSpy = jest.fn(); + axiosMock.onPut(`${endpoints.listsEndpoint}/${id}`).replyOnce(config => requestSpy(config)); + }); + + it('makes a request to update a list position', () => { + requestSpy.mockReturnValue([200, dummyResponse]); + const expectedResponse = expect.objectContaining({ data: dummyResponse }); + + return expect(service.updateList(id, position)) + .resolves.toEqual(expectedResponse) + .then(() => { + expect(requestSpy).toHaveBeenCalledWith(expectedRequest); + }); + }); + + it('fails for error response', () => { + requestSpy.mockReturnValue([500]); + + return expect(service.updateList(id, position)) + .rejects.toThrow() + .then(() => { + expect(requestSpy).toHaveBeenCalledWith(expectedRequest); + }); + }); + }); + + describe('destroyList', () => { + const id = '-42'; + + let requestSpy; + + beforeEach(() => { + requestSpy = jest.fn(); + axiosMock + .onDelete(`${endpoints.listsEndpoint}/${id}`) + .replyOnce(config => requestSpy(config)); + }); + + it('makes a request to delete a list', () => { + requestSpy.mockReturnValue([200, dummyResponse]); + const expectedResponse = expect.objectContaining({ data: dummyResponse }); + + return expect(service.destroyList(id)) + .resolves.toEqual(expectedResponse) + .then(() => { + expect(requestSpy).toHaveBeenCalled(); + }); + }); + + it('fails for error response', () => { + requestSpy.mockReturnValue([500]); + + return expect(service.destroyList(id)) + .rejects.toThrow() + .then(() => { + expect(requestSpy).toHaveBeenCalled(); + }); + }); + }); + + describe('getIssuesForList', () => { + const id = 'TOO-MUCH'; + const url = `${endpoints.listsEndpoint}/${id}/issues?id=${id}`; + + it('makes a request to fetch list issues', () => { + axiosMock.onGet(url).replyOnce(200, dummyResponse); + const expectedResponse = expect.objectContaining({ data: dummyResponse }); + + return expect(service.getIssuesForList(id)).resolves.toEqual(expectedResponse); + }); + + it('makes a request to fetch list issues with filter', () => { + const filter = { algal: 'scrubber' }; + axiosMock.onGet(`${url}&algal=scrubber`).replyOnce(200, dummyResponse); + const expectedResponse = expect.objectContaining({ data: dummyResponse }); + + return expect(service.getIssuesForList(id, filter)).resolves.toEqual(expectedResponse); + }); + + it('fails for error response', () => { + axiosMock.onGet(url).replyOnce(500); + + return expect(service.getIssuesForList(id)).rejects.toThrow(); + }); + }); + + describe('moveIssue', () => { + const urlRoot = 'potato'; + const id = 'over 9000'; + const fromListId = 'left'; + const toListId = 'right'; + const moveBeforeId = 'up'; + const moveAfterId = 'down'; + const expectedRequest = expect.objectContaining({ + data: JSON.stringify({ + from_list_id: fromListId, + to_list_id: toListId, + move_before_id: moveBeforeId, + move_after_id: moveAfterId, + }), + }); + + let requestSpy; + + beforeAll(() => { + global.gon.relative_url_root = urlRoot; + }); + + afterAll(() => { + delete global.gon.relative_url_root; + }); + + beforeEach(() => { + requestSpy = jest.fn(); + axiosMock + .onPut(`${urlRoot}/-/boards/${boardId}/issues/${id}`) + .replyOnce(config => requestSpy(config)); + }); + + it('makes a request to move an issue between lists', () => { + requestSpy.mockReturnValue([200, dummyResponse]); + const expectedResponse = expect.objectContaining({ data: dummyResponse }); + + return expect(service.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId)) + .resolves.toEqual(expectedResponse) + .then(() => { + expect(requestSpy).toHaveBeenCalledWith(expectedRequest); + }); + }); + + it('fails for error response', () => { + requestSpy.mockReturnValue([500]); + + return expect(service.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId)) + .rejects.toThrow() + .then(() => { + expect(requestSpy).toHaveBeenCalledWith(expectedRequest); + }); + }); + }); + + describe('newIssue', () => { + const id = 'not-creative'; + const issue = { some: 'issue data' }; + const url = `${endpoints.listsEndpoint}/${id}/issues`; + const expectedRequest = expect.objectContaining({ + data: JSON.stringify({ + issue, + }), + }); + + let requestSpy; + + beforeEach(() => { + requestSpy = jest.fn(); + axiosMock.onPost(url).replyOnce(config => requestSpy(config)); + }); + + it('makes a request to create a new issue', () => { + requestSpy.mockReturnValue([200, dummyResponse]); + const expectedResponse = expect.objectContaining({ data: dummyResponse }); + + return expect(service.newIssue(id, issue)) + .resolves.toEqual(expectedResponse) + .then(() => { + expect(requestSpy).toHaveBeenCalledWith(expectedRequest); + }); + }); + + it('fails for error response', () => { + requestSpy.mockReturnValue([500]); + + return expect(service.newIssue(id, issue)) + .rejects.toThrow() + .then(() => { + expect(requestSpy).toHaveBeenCalledWith(expectedRequest); + }); + }); + }); + + describe('getBacklog', () => { + const urlRoot = 'deep'; + const url = `${urlRoot}/-/boards/${boardId}/issues.json?not=relevant`; + const requestParams = { + not: 'relevant', + }; + + beforeAll(() => { + global.gon.relative_url_root = urlRoot; + }); + + afterAll(() => { + delete global.gon.relative_url_root; + }); + + it('makes a request to fetch backlog', () => { + axiosMock.onGet(url).replyOnce(200, dummyResponse); + const expectedResponse = expect.objectContaining({ data: dummyResponse }); + + return expect(service.getBacklog(requestParams)).resolves.toEqual(expectedResponse); + }); + + it('fails for error response', () => { + axiosMock.onGet(url).replyOnce(500); + + return expect(service.getBacklog(requestParams)).rejects.toThrow(); + }); + }); + + describe('bulkUpdate', () => { + const issueIds = [1, 2, 3]; + const extraData = { moar: 'data' }; + const expectedRequest = expect.objectContaining({ + data: JSON.stringify({ + update: { + ...extraData, + issuable_ids: '1,2,3', + }, + }), + }); + + let requestSpy; + + beforeEach(() => { + requestSpy = jest.fn(); + axiosMock.onPost(endpoints.bulkUpdatePath).replyOnce(config => requestSpy(config)); + }); + + it('makes a request to create a list', () => { + requestSpy.mockReturnValue([200, dummyResponse]); + const expectedResponse = expect.objectContaining({ data: dummyResponse }); + + return expect(service.bulkUpdate(issueIds, extraData)) + .resolves.toEqual(expectedResponse) + .then(() => { + expect(requestSpy).toHaveBeenCalledWith(expectedRequest); + }); + }); + + it('fails for error response', () => { + requestSpy.mockReturnValue([500]); + + return expect(service.bulkUpdate(issueIds, extraData)) + .rejects.toThrow() + .then(() => { + expect(requestSpy).toHaveBeenCalledWith(expectedRequest); + }); + }); + }); + + describe('getIssueInfo', () => { + const dummyEndpoint = `${TEST_HOST}/some/where`; + + it('makes a request to the given endpoint', () => { + axiosMock.onGet(dummyEndpoint).replyOnce(200, dummyResponse); + const expectedResponse = expect.objectContaining({ data: dummyResponse }); + + return expect(BoardService.getIssueInfo(dummyEndpoint)).resolves.toEqual(expectedResponse); + }); + + it('fails for error response', () => { + axiosMock.onGet(dummyEndpoint).replyOnce(500); + + return expect(BoardService.getIssueInfo(dummyEndpoint)).rejects.toThrow(); + }); + }); + + describe('toggleIssueSubscription', () => { + const dummyEndpoint = `${TEST_HOST}/some/where`; + + it('makes a request to the given endpoint', () => { + axiosMock.onPost(dummyEndpoint).replyOnce(200, dummyResponse); + const expectedResponse = expect.objectContaining({ data: dummyResponse }); + + return expect(BoardService.toggleIssueSubscription(dummyEndpoint)).resolves.toEqual( + expectedResponse, + ); + }); + + it('fails for error response', () => { + axiosMock.onPost(dummyEndpoint).replyOnce(500); + + return expect(BoardService.toggleIssueSubscription(dummyEndpoint)).rejects.toThrow(); + }); + }); +}); diff --git a/spec/frontend/branches/divergence_graph_spec.js b/spec/frontend/branches/divergence_graph_spec.js new file mode 100644 index 00000000000..4ed77c3a036 --- /dev/null +++ b/spec/frontend/branches/divergence_graph_spec.js @@ -0,0 +1,32 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import init from '~/branches/divergence_graph'; + +describe('Divergence graph', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + + mock.onGet('/-/diverging_counts').reply(200, { + master: { ahead: 1, behind: 1 }, + }); + + jest.spyOn(axios, 'get'); + + document.body.innerHTML = ` + <div class="js-branch-item" data-name="master"></div> + `; + }); + + afterEach(() => { + mock.restore(); + }); + + it('calls axos get with list of branch names', () => + init('/-/diverging_counts').then(() => { + expect(axios.get).toHaveBeenCalledWith('/-/diverging_counts', { + params: { names: ['master'] }, + }); + })); +}); diff --git a/spec/frontend/clusters/services/application_state_machine_spec.js b/spec/frontend/clusters/services/application_state_machine_spec.js index c146ef79be7..8632c5c4e26 100644 --- a/spec/frontend/clusters/services/application_state_machine_spec.js +++ b/spec/frontend/clusters/services/application_state_machine_spec.js @@ -72,9 +72,10 @@ describe('applicationStateMachine', () => { describe(`current state is ${INSTALLABLE}`, () => { it.each` - expectedState | event | effects - ${INSTALLING} | ${INSTALL_EVENT} | ${{ installFailed: false }} - ${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS} + expectedState | event | effects + ${INSTALLING} | ${INSTALL_EVENT} | ${{ installFailed: false }} + ${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS} + ${NOT_INSTALLABLE} | ${NOT_INSTALLABLE} | ${NO_EFFECTS} `(`transitions to $expectedState on $event event and applies $effects`, data => { const { expectedState, event, effects } = data; const currentAppState = { @@ -108,9 +109,10 @@ describe('applicationStateMachine', () => { describe(`current state is ${INSTALLED}`, () => { it.each` - expectedState | event | effects - ${UPDATING} | ${UPDATE_EVENT} | ${{ updateFailed: false, updateSuccessful: false }} - ${UNINSTALLING} | ${UNINSTALL_EVENT} | ${{ uninstallFailed: false, uninstallSuccessful: false }} + expectedState | event | effects + ${UPDATING} | ${UPDATE_EVENT} | ${{ updateFailed: false, updateSuccessful: false }} + ${UNINSTALLING} | ${UNINSTALL_EVENT} | ${{ uninstallFailed: false, uninstallSuccessful: false }} + ${NOT_INSTALLABLE} | ${NOT_INSTALLABLE} | ${NO_EFFECTS} `(`transitions to $expectedState on $event event and applies $effects`, data => { const { expectedState, event, effects } = data; const currentAppState = { @@ -119,7 +121,7 @@ describe('applicationStateMachine', () => { expect(transitionApplicationState(currentAppState, event)).toEqual({ status: expectedState, - ...effects, + ...noEffectsToEmptyObject(effects), }); }); }); diff --git a/spec/frontend/diffs/components/diff_discussion_reply_spec.js b/spec/frontend/diffs/components/diff_discussion_reply_spec.js new file mode 100644 index 00000000000..28689ab07de --- /dev/null +++ b/spec/frontend/diffs/components/diff_discussion_reply_spec.js @@ -0,0 +1,90 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import DiffDiscussionReply from '~/diffs/components/diff_discussion_reply.vue'; +import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; +import NoteSignedOutWidget from '~/notes/components/note_signed_out_widget.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('DiffDiscussionReply', () => { + let wrapper; + let getters; + let store; + + const createComponent = (props = {}, slots = {}) => { + wrapper = shallowMount(DiffDiscussionReply, { + store, + localVue, + sync: false, + propsData: { + ...props, + }, + slots: { + ...slots, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('if user can reply', () => { + beforeEach(() => { + getters = { + userCanReply: () => true, + getUserData: () => ({ + path: 'test-path', + avatar_url: 'avatar_url', + name: 'John Doe', + }), + }; + + store = new Vuex.Store({ + getters, + }); + }); + + it('should render a form if component has form', () => { + createComponent( + { + renderReplyPlaceholder: false, + hasForm: true, + }, + { + form: `<div id="test-form"></div>`, + }, + ); + + expect(wrapper.find('#test-form').exists()).toBe(true); + }); + + it('should render a reply placeholder if there is no form', () => { + createComponent({ + renderReplyPlaceholder: true, + hasForm: false, + }); + + expect(wrapper.find(ReplyPlaceholder).exists()).toBe(true); + }); + }); + + it('renders a signed out widget when user is not logged in', () => { + getters = { + userCanReply: () => false, + getUserData: () => null, + }; + + store = new Vuex.Store({ + getters, + }); + + createComponent({ + renderReplyPlaceholder: false, + hasForm: false, + }); + + expect(wrapper.find(NoteSignedOutWidget).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/diffs/components/diff_gutter_avatars_spec.js b/spec/frontend/diffs/components/diff_gutter_avatars_spec.js new file mode 100644 index 00000000000..48ee5c63f35 --- /dev/null +++ b/spec/frontend/diffs/components/diff_gutter_avatars_spec.js @@ -0,0 +1,113 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue'; +import discussionsMockData from '../mock_data/diff_discussions'; + +const localVue = createLocalVue(); +const getDiscussionsMockData = () => [Object.assign({}, discussionsMockData)]; + +describe('DiffGutterAvatars', () => { + let wrapper; + + const findCollapseButton = () => wrapper.find('.diff-notes-collapse'); + const findMoreCount = () => wrapper.find('.diff-comments-more-count'); + const findUserAvatars = () => wrapper.findAll('.diff-comment-avatar'); + + const createComponent = (props = {}) => { + wrapper = shallowMount(DiffGutterAvatars, { + localVue, + sync: false, + propsData: { + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when expanded', () => { + beforeEach(() => { + createComponent({ + discussions: getDiscussionsMockData(), + discussionsExpanded: true, + }); + }); + + it('renders a collapse button when discussions are expanded', () => { + expect(findCollapseButton().exists()).toBe(true); + }); + + it('should emit toggleDiscussions event on button click', () => { + findCollapseButton().trigger('click'); + + expect(wrapper.emitted().toggleLineDiscussions).toBeTruthy(); + }); + }); + + describe('when collapsed', () => { + beforeEach(() => { + createComponent({ + discussions: getDiscussionsMockData(), + discussionsExpanded: false, + }); + }); + + it('renders user avatars and moreCount text', () => { + expect(findUserAvatars().exists()).toBe(true); + expect(findMoreCount().exists()).toBe(true); + }); + + it('renders correct amount of user avatars', () => { + expect(findUserAvatars().length).toBe(3); + }); + + it('renders correct moreCount number', () => { + expect(findMoreCount().text()).toBe('+2'); + }); + + it('should emit toggleDiscussions event on avatars click', () => { + findUserAvatars() + .at(0) + .trigger('click'); + + expect(wrapper.emitted().toggleLineDiscussions).toBeTruthy(); + }); + + it('should emit toggleDiscussions event on more count text click', () => { + findMoreCount().trigger('click'); + + expect(wrapper.emitted().toggleLineDiscussions).toBeTruthy(); + }); + }); + + it('renders an empty more count string if there are no discussions', () => { + createComponent({ + discussions: [], + discussionsExpanded: false, + }); + + expect(findMoreCount().exists()).toBe(false); + }); + + describe('tooltip text', () => { + beforeEach(() => { + createComponent({ + discussions: getDiscussionsMockData(), + discussionsExpanded: false, + }); + }); + + it('returns original comment if it is shorter than max length', () => { + const note = wrapper.vm.discussions[0].notes[0]; + + expect(wrapper.vm.getTooltipText(note)).toEqual('Administrator: comment 1'); + }); + + it('returns truncated version of comment if it is longer than max length', () => { + const note = wrapper.vm.discussions[0].notes[1]; + + expect(wrapper.vm.getTooltipText(note)).toEqual('Fatih Acet: comment 2 is r...'); + }); + }); +}); diff --git a/spec/frontend/diffs/mock_data/diff_discussions.js b/spec/frontend/diffs/mock_data/diff_discussions.js new file mode 100644 index 00000000000..711ab543411 --- /dev/null +++ b/spec/frontend/diffs/mock_data/diff_discussions.js @@ -0,0 +1,529 @@ +export default { + id: '6b232e05bea388c6b043ccc243ba505faac04ea8', + reply_id: '6b232e05bea388c6b043ccc243ba505faac04ea8', + position: { + old_line: null, + new_line: 2, + old_path: 'CHANGELOG', + new_path: 'CHANGELOG', + base_sha: 'e63f41fe459e62e1228fcef60d7189127aeba95a', + start_sha: 'd9eaefe5a676b820c57ff18cf5b68316025f7962', + head_sha: 'c48ee0d1bf3b30453f5b32250ce03134beaa6d13', + }, + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_2', + expanded: true, + notes: [ + { + id: '1749', + type: 'DiffNote', + 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-04-03T21:06:21.521Z', + updated_at: '2018-04-08T08:50:41.762Z', + system: false, + noteable_id: 51, + noteable_type: 'MergeRequest', + noteable_iid: 20, + human_access: 'Owner', + note: 'comment 1', + note_html: '<p dir="auto">comment 1</p>', + last_edited_at: '2018-04-08T08:50:41.762Z', + last_edited_by: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + current_user: { + can_edit: true, + can_award_emoji: true, + }, + resolved: false, + resolvable: true, + resolved_by: null, + discussion_id: '6b232e05bea388c6b043ccc243ba505faac04ea8', + emoji_awardable: true, + award_emoji: [], + toggle_award_path: '/gitlab-org/gitlab-test/notes/1749/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-test%2Fmerge_requests%2F20%23note_1749&user_id=1', + path: '/gitlab-org/gitlab-test/notes/1749', + noteable_note_url: 'http://localhost:3000/gitlab-org/gitlab-test/merge_requests/20#note_1749', + resolve_path: + '/gitlab-org/gitlab-test/merge_requests/20/discussions/6b232e05bea388c6b043ccc243ba505faac04ea8/resolve', + resolve_with_issue_path: + '/gitlab-org/gitlab-test/issues/new?discussion_to_resolve=6b232e05bea388c6b043ccc243ba505faac04ea8&merge_request_to_resolve_discussions_of=20', + }, + { + id: '1753', + type: 'DiffNote', + attachment: null, + author: { + id: 1, + name: 'Fatih Acet', + username: 'fatihacet', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/fatihacevt', + }, + created_at: '2018-04-08T08:49:35.804Z', + updated_at: '2018-04-08T08:50:45.915Z', + system: false, + noteable_id: 51, + noteable_type: 'MergeRequest', + noteable_iid: 20, + human_access: 'Owner', + note: 'comment 2 is really long one', + note_html: '<p dir="auto">comment 2 is really long one</p>', + last_edited_at: '2018-04-08T08:50:45.915Z', + last_edited_by: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + current_user: { + can_edit: true, + can_award_emoji: true, + }, + resolved: false, + resolvable: true, + resolved_by: null, + discussion_id: '6b232e05bea388c6b043ccc243ba505faac04ea8', + emoji_awardable: true, + award_emoji: [], + toggle_award_path: '/gitlab-org/gitlab-test/notes/1753/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-test%2Fmerge_requests%2F20%23note_1753&user_id=1', + path: '/gitlab-org/gitlab-test/notes/1753', + noteable_note_url: 'http://localhost:3000/gitlab-org/gitlab-test/merge_requests/20#note_1753', + resolve_path: + '/gitlab-org/gitlab-test/merge_requests/20/discussions/6b232e05bea388c6b043ccc243ba505faac04ea8/resolve', + resolve_with_issue_path: + '/gitlab-org/gitlab-test/issues/new?discussion_to_resolve=6b232e05bea388c6b043ccc243ba505faac04ea8&merge_request_to_resolve_discussions_of=20', + }, + { + id: '1754', + type: 'DiffNote', + 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-04-08T08:50:48.294Z', + updated_at: '2018-04-08T08:50:48.294Z', + system: false, + noteable_id: 51, + noteable_type: 'MergeRequest', + noteable_iid: 20, + human_access: 'Owner', + note: 'comment 3', + note_html: '<p dir="auto">comment 3</p>', + current_user: { + can_edit: true, + can_award_emoji: true, + }, + resolved: false, + resolvable: true, + resolved_by: null, + discussion_id: '6b232e05bea388c6b043ccc243ba505faac04ea8', + emoji_awardable: true, + award_emoji: [], + toggle_award_path: '/gitlab-org/gitlab-test/notes/1754/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-test%2Fmerge_requests%2F20%23note_1754&user_id=1', + path: '/gitlab-org/gitlab-test/notes/1754', + noteable_note_url: 'http://localhost:3000/gitlab-org/gitlab-test/merge_requests/20#note_1754', + resolve_path: + '/gitlab-org/gitlab-test/merge_requests/20/discussions/6b232e05bea388c6b043ccc243ba505faac04ea8/resolve', + resolve_with_issue_path: + '/gitlab-org/gitlab-test/issues/new?discussion_to_resolve=6b232e05bea388c6b043ccc243ba505faac04ea8&merge_request_to_resolve_discussions_of=20', + }, + { + id: '1755', + type: 'DiffNote', + 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-04-08T08:50:50.911Z', + updated_at: '2018-04-08T08:50:50.911Z', + system: false, + noteable_id: 51, + noteable_type: 'MergeRequest', + noteable_iid: 20, + human_access: 'Owner', + note: 'comment 4', + note_html: '<p dir="auto">comment 4</p>', + current_user: { + can_edit: true, + can_award_emoji: true, + }, + resolved: false, + resolvable: true, + resolved_by: null, + discussion_id: '6b232e05bea388c6b043ccc243ba505faac04ea8', + emoji_awardable: true, + award_emoji: [], + toggle_award_path: '/gitlab-org/gitlab-test/notes/1755/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-test%2Fmerge_requests%2F20%23note_1755&user_id=1', + path: '/gitlab-org/gitlab-test/notes/1755', + noteable_note_url: 'http://localhost:3000/gitlab-org/gitlab-test/merge_requests/20#note_1755', + resolve_path: + '/gitlab-org/gitlab-test/merge_requests/20/discussions/6b232e05bea388c6b043ccc243ba505faac04ea8/resolve', + resolve_with_issue_path: + '/gitlab-org/gitlab-test/issues/new?discussion_to_resolve=6b232e05bea388c6b043ccc243ba505faac04ea8&merge_request_to_resolve_discussions_of=20', + }, + { + id: '1756', + type: 'DiffNote', + 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-04-08T08:50:53.895Z', + updated_at: '2018-04-08T08:50:53.895Z', + system: false, + noteable_id: 51, + noteable_type: 'MergeRequest', + noteable_iid: 20, + human_access: 'Owner', + note: 'comment 5', + note_html: '<p dir="auto">comment 5</p>', + current_user: { + can_edit: true, + can_award_emoji: true, + }, + resolved: false, + resolvable: true, + resolved_by: null, + discussion_id: '6b232e05bea388c6b043ccc243ba505faac04ea8', + emoji_awardable: true, + award_emoji: [], + toggle_award_path: '/gitlab-org/gitlab-test/notes/1756/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-test%2Fmerge_requests%2F20%23note_1756&user_id=1', + path: '/gitlab-org/gitlab-test/notes/1756', + noteable_note_url: 'http://localhost:3000/gitlab-org/gitlab-test/merge_requests/20#note_1756', + resolve_path: + '/gitlab-org/gitlab-test/merge_requests/20/discussions/6b232e05bea388c6b043ccc243ba505faac04ea8/resolve', + resolve_with_issue_path: + '/gitlab-org/gitlab-test/issues/new?discussion_to_resolve=6b232e05bea388c6b043ccc243ba505faac04ea8&merge_request_to_resolve_discussions_of=20', + }, + ], + individual_note: false, + resolvable: true, + resolved: false, + resolve_path: + '/gitlab-org/gitlab-test/merge_requests/20/discussions/6b232e05bea388c6b043ccc243ba505faac04ea8/resolve', + resolve_with_issue_path: + '/gitlab-org/gitlab-test/issues/new?discussion_to_resolve=6b232e05bea388c6b043ccc243ba505faac04ea8&merge_request_to_resolve_discussions_of=20', + diff_file: { + submodule: false, + submodule_link: null, + blob: { + id: '9e10516ca50788acf18c518a231914a21e5f16f7', + path: 'CHANGELOG', + name: 'CHANGELOG', + mode: '100644', + readable_text: true, + icon: 'file-text-o', + }, + blob_path: 'CHANGELOG', + blob_name: 'CHANGELOG', + blob_icon: '<i aria-hidden="true" data-hidden="true" class="fa fa-file-text-o fa-fw"></i>', + file_hash: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a', + file_path: 'CHANGELOG.rb', + new_file: false, + deleted_file: false, + renamed_file: false, + old_path: 'CHANGELOG', + new_path: 'CHANGELOG', + mode_changed: false, + a_mode: '100644', + b_mode: '100644', + text: true, + added_lines: 2, + removed_lines: 0, + diff_refs: { + base_sha: 'e63f41fe459e62e1228fcef60d7189127aeba95a', + start_sha: 'd9eaefe5a676b820c57ff18cf5b68316025f7962', + head_sha: 'c48ee0d1bf3b30453f5b32250ce03134beaa6d13', + }, + content_sha: 'c48ee0d1bf3b30453f5b32250ce03134beaa6d13', + stored_externally: null, + external_storage: null, + old_path_html: 'CHANGELOG_OLD', + new_path_html: 'CHANGELOG', + is_fully_expanded: true, + context_lines_path: + '/gitlab-org/gitlab-test/blob/c48ee0d1bf3b30453f5b32250ce03134beaa6d13/CHANGELOG/diff', + highlighted_diff_lines: [ + { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_1', + type: 'new', + old_line: null, + new_line: 1, + text: '<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', + rich_text: '<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', + meta_data: null, + }, + { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_2', + type: 'new', + old_line: null, + new_line: 2, + text: '<span id="LC2" class="line" lang="plaintext"></span>\n', + rich_text: '<span id="LC2" class="line" lang="plaintext"></span>\n', + meta_data: null, + }, + { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_3', + type: null, + old_line: 1, + new_line: 3, + text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', + rich_text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', + meta_data: null, + }, + { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_2_4', + type: null, + old_line: 2, + new_line: 4, + text: '<span id="LC4" class="line" lang="plaintext"></span>\n', + rich_text: '<span id="LC4" class="line" lang="plaintext"></span>\n', + meta_data: null, + }, + { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_3_5', + type: null, + old_line: 3, + new_line: 5, + text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', + rich_text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', + meta_data: null, + }, + { + line_code: null, + type: 'match', + old_line: null, + new_line: null, + text: '', + rich_text: '', + meta_data: { + old_pos: 3, + new_pos: 5, + }, + }, + { + line_code: null, + type: 'match', + old_line: null, + new_line: null, + text: '', + rich_text: '', + meta_data: { + old_pos: 3, + new_pos: 5, + }, + }, + { + line_code: null, + type: 'match', + old_line: null, + new_line: null, + text: '', + rich_text: '', + meta_data: { + old_pos: 3, + new_pos: 5, + }, + }, + ], + parallel_diff_lines: [ + { + left: null, + right: { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_1', + type: 'new', + old_line: null, + new_line: 1, + text: '<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', + rich_text: '<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', + meta_data: null, + }, + }, + { + left: null, + right: { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_2', + type: 'new', + old_line: null, + new_line: 2, + text: '<span id="LC2" class="line" lang="plaintext"></span>\n', + rich_text: '<span id="LC2" class="line" lang="plaintext"></span>\n', + meta_data: null, + }, + }, + { + left: { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_3', + type: null, + old_line: 1, + new_line: 3, + text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', + rich_text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', + meta_data: null, + }, + right: { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_3', + type: null, + old_line: 1, + new_line: 3, + text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', + rich_text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', + meta_data: null, + }, + }, + { + left: { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_2_4', + type: null, + old_line: 2, + new_line: 4, + text: '<span id="LC4" class="line" lang="plaintext"></span>\n', + rich_text: '<span id="LC4" class="line" lang="plaintext"></span>\n', + meta_data: null, + }, + right: { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_2_4', + type: null, + old_line: 2, + new_line: 4, + text: '<span id="LC4" class="line" lang="plaintext"></span>\n', + rich_text: '<span id="LC4" class="line" lang="plaintext"></span>\n', + meta_data: null, + }, + }, + { + left: { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_3_5', + type: null, + old_line: 3, + new_line: 5, + text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', + rich_text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', + meta_data: null, + }, + right: { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_3_5', + type: null, + old_line: 3, + new_line: 5, + text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', + rich_text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', + meta_data: null, + }, + }, + { + left: { + line_code: null, + type: 'match', + old_line: null, + new_line: null, + text: '', + rich_text: '', + meta_data: { + old_pos: 3, + new_pos: 5, + }, + }, + right: { + line_code: null, + type: 'match', + old_line: null, + new_line: null, + text: '', + rich_text: '', + meta_data: { + old_pos: 3, + new_pos: 5, + }, + }, + }, + ], + viewer: { + name: 'text', + error: null, + }, + }, + diff_discussion: true, + truncated_diff_lines: [ + { + text: 'line', + rich_text: + '<tr class="line_holder new" id="">\n<td class="diff-line-num new old_line" data-linenumber="1">\n \n</td>\n<td class="diff-line-num new new_line" data-linenumber="1">\n1\n</td>\n<td class="line_content new"><span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n</td>\n</tr>\n<tr class="line_holder new" id="">\n<td class="diff-line-num new old_line" data-linenumber="1">\n \n</td>\n<td class="diff-line-num new new_line" data-linenumber="2">\n2\n</td>\n<td class="line_content new"><span id="LC2" class="line" lang="plaintext"></span>\n</td>\n</tr>\n', + can_receive_suggestion: true, + line_code: '6f209374f7e565f771b95720abf46024c41d1885_1_1', + type: 'new', + old_line: null, + new_line: 1, + meta_data: null, + }, + ], +}; + +export const imageDiffDiscussions = [ + { + id: '1', + position: { + x: 10, + y: 10, + width: 100, + height: 200, + }, + }, + { + id: '2', + position: { + x: 5, + y: 5, + width: 100, + height: 200, + }, + }, +]; diff --git a/spec/frontend/error_tracking_settings/components/app_spec.js b/spec/frontend/error_tracking_settings/components/app_spec.js new file mode 100644 index 00000000000..022f12ef191 --- /dev/null +++ b/spec/frontend/error_tracking_settings/components/app_spec.js @@ -0,0 +1,63 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import ErrorTrackingSettings from '~/error_tracking_settings/components/app.vue'; +import ErrorTrackingForm from '~/error_tracking_settings/components/error_tracking_form.vue'; +import ProjectDropdown from '~/error_tracking_settings/components/project_dropdown.vue'; +import createStore from '~/error_tracking_settings/store'; +import { TEST_HOST } from 'helpers/test_constants'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('error tracking settings app', () => { + let store; + let wrapper; + + function mountComponent() { + wrapper = shallowMount(ErrorTrackingSettings, { + localVue, + store, // Override the imported store + propsData: { + initialEnabled: 'true', + initialApiHost: TEST_HOST, + initialToken: 'someToken', + initialProject: null, + listProjectsEndpoint: TEST_HOST, + operationsSettingsEndpoint: TEST_HOST, + }, + }); + } + + beforeEach(() => { + store = createStore(); + + mountComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('section', () => { + it('renders the form and dropdown', () => { + expect(wrapper.find(ErrorTrackingForm).exists()).toBeTruthy(); + expect(wrapper.find(ProjectDropdown).exists()).toBeTruthy(); + }); + + it('renders the Save Changes button', () => { + expect(wrapper.find('.js-error-tracking-button').exists()).toBeTruthy(); + }); + + it('enables the button by default', () => { + expect(wrapper.find('.js-error-tracking-button').attributes('disabled')).toBeFalsy(); + }); + + it('disables the button when saving', () => { + store.state.settingsLoading = true; + + expect(wrapper.find('.js-error-tracking-button').attributes('disabled')).toBeTruthy(); + }); + }); +}); 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 new file mode 100644 index 00000000000..23e57c4bbf1 --- /dev/null +++ b/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js @@ -0,0 +1,91 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlButton, GlFormInput } from '@gitlab/ui'; +import ErrorTrackingForm from '~/error_tracking_settings/components/error_tracking_form.vue'; +import { defaultProps } from '../mock'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('error tracking settings form', () => { + let wrapper; + + function mountComponent() { + wrapper = shallowMount(ErrorTrackingForm, { + localVue, + propsData: defaultProps, + }); + } + + beforeEach(() => { + mountComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('an empty form', () => { + it('is rendered', () => { + expect(wrapper.findAll(GlFormInput).length).toBe(2); + expect(wrapper.find(GlFormInput).attributes('id')).toBe('error-tracking-api-host'); + expect( + wrapper + .findAll(GlFormInput) + .at(1) + .attributes('id'), + ).toBe('error-tracking-token'); + + expect(wrapper.findAll(GlButton).exists()).toBe(true); + }); + + it('is rendered with labels and placeholders', () => { + const pageText = wrapper.text(); + + expect(pageText).toContain('Find your hostname in your Sentry account settings page'); + expect(pageText).toContain( + "After adding your Auth Token, use the 'Connect' button to load projects", + ); + + expect(pageText).not.toContain('Connection has failed. Re-check Auth Token and try again'); + expect( + wrapper + .findAll(GlFormInput) + .at(0) + .attributes('placeholder'), + ).toContain('https://mysentryserver.com'); + }); + }); + + describe('after a successful connection', () => { + beforeEach(() => { + wrapper.setProps({ connectSuccessful: true }); + }); + + it('shows the success checkmark', () => { + expect(wrapper.find('.js-error-tracking-connect-success').isVisible()).toBe(true); + }); + + it('does not show an error', () => { + expect(wrapper.text()).not.toContain( + 'Connection has failed. Re-check Auth Token and try again', + ); + }); + }); + + describe('after an unsuccessful connection', () => { + beforeEach(() => { + wrapper.setProps({ connectError: true }); + }); + + it('does not show the check mark', () => { + expect(wrapper.find('.js-error-tracking-connect-success').isVisible()).toBe(false); + }); + + it('shows an error', () => { + expect(wrapper.text()).toContain('Connection has failed. Re-check Auth Token and try again'); + }); + }); +}); diff --git a/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js b/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js new file mode 100644 index 00000000000..8e5dbe28452 --- /dev/null +++ b/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js @@ -0,0 +1,109 @@ +import _ from 'underscore'; +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import ProjectDropdown from '~/error_tracking_settings/components/project_dropdown.vue'; +import { defaultProps, projectList, staleProject } from '../mock'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('error tracking settings project dropdown', () => { + let wrapper; + + function mountComponent() { + wrapper = shallowMount(ProjectDropdown, { + localVue, + propsData: { + ..._.pick( + defaultProps, + 'dropdownLabel', + 'invalidProjectLabel', + 'projects', + 'projectSelectionLabel', + 'selectedProject', + 'token', + ), + hasProjects: false, + isProjectInvalid: false, + }, + }); + } + + beforeEach(() => { + mountComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('empty project list', () => { + it('renders the dropdown', () => { + expect(wrapper.find('#project-dropdown').exists()).toBeTruthy(); + expect(wrapper.find(GlDropdown).exists()).toBeTruthy(); + }); + + it('shows helper text', () => { + expect(wrapper.find('.js-project-dropdown-label').exists()).toBeTruthy(); + expect(wrapper.find('.js-project-dropdown-label').text()).toContain( + 'To enable project selection', + ); + }); + + it('does not show an error', () => { + expect(wrapper.find('.js-project-dropdown-error').exists()).toBeFalsy(); + }); + + it('does not contain any dropdown items', () => { + expect(wrapper.find(GlDropdownItem).exists()).toBeFalsy(); + expect(wrapper.find(GlDropdown).props('text')).toBe('No projects available'); + }); + }); + + describe('populated project list', () => { + beforeEach(() => { + wrapper.setProps({ projects: _.clone(projectList), hasProjects: true }); + }); + + it('renders the dropdown', () => { + expect(wrapper.find('#project-dropdown').exists()).toBeTruthy(); + expect(wrapper.find(GlDropdown).exists()).toBeTruthy(); + }); + + it('contains a number of dropdown items', () => { + expect(wrapper.find(GlDropdownItem).exists()).toBeTruthy(); + expect(wrapper.findAll(GlDropdownItem).length).toBe(2); + }); + }); + + describe('selected project', () => { + const selectedProject = _.clone(projectList[0]); + + beforeEach(() => { + wrapper.setProps({ projects: _.clone(projectList), selectedProject, hasProjects: true }); + }); + + it('does not show helper text', () => { + expect(wrapper.find('.js-project-dropdown-label').exists()).toBeFalsy(); + expect(wrapper.find('.js-project-dropdown-error').exists()).toBeFalsy(); + }); + }); + + describe('invalid project selected', () => { + beforeEach(() => { + wrapper.setProps({ + projects: _.clone(projectList), + selectedProject: staleProject, + isProjectInvalid: true, + }); + }); + + it('displays a error', () => { + expect(wrapper.find('.js-project-dropdown-label').exists()).toBeFalsy(); + expect(wrapper.find('.js-project-dropdown-error').exists()).toBeTruthy(); + }); + }); +}); diff --git a/spec/frontend/error_tracking_settings/mock.js b/spec/frontend/error_tracking_settings/mock.js new file mode 100644 index 00000000000..42233f82d54 --- /dev/null +++ b/spec/frontend/error_tracking_settings/mock.js @@ -0,0 +1,92 @@ +import createStore from '~/error_tracking_settings/store'; +import { TEST_HOST } from '../helpers/test_constants'; + +const defaultStore = createStore(); + +export const projectList = [ + { + name: 'name', + slug: 'slug', + organizationName: 'organizationName', + organizationSlug: 'organizationSlug', + }, + { + name: 'name2', + slug: 'slug2', + organizationName: 'organizationName2', + organizationSlug: 'organizationSlug2', + }, +]; + +export const staleProject = { + name: 'staleName', + slug: 'staleSlug', + organizationName: 'staleOrganizationName', + organizationSlug: 'staleOrganizationSlug', +}; + +export const normalizedProject = { + name: 'name', + slug: 'slug', + organizationName: 'organization_name', + organizationSlug: 'organization_slug', +}; + +export const sampleBackendProject = { + name: normalizedProject.name, + slug: normalizedProject.slug, + organization_name: normalizedProject.organizationName, + organization_slug: normalizedProject.organizationSlug, +}; + +export const sampleFrontendSettings = { + apiHost: 'apiHost', + enabled: false, + token: 'token', + selectedProject: { + slug: normalizedProject.slug, + name: normalizedProject.name, + organizationName: normalizedProject.organizationName, + organizationSlug: normalizedProject.organizationSlug, + }, +}; + +export const transformedSettings = { + api_host: 'apiHost', + enabled: false, + token: 'token', + project: { + slug: normalizedProject.slug, + name: normalizedProject.name, + organization_name: normalizedProject.organizationName, + organization_slug: normalizedProject.organizationSlug, + }, +}; + +export const defaultProps = { + ...defaultStore.state, + ...defaultStore.getters, +}; + +export const initialEmptyState = { + apiHost: '', + enabled: false, + project: null, + token: '', + listProjectsEndpoint: TEST_HOST, + operationsSettingsEndpoint: TEST_HOST, +}; + +export const initialPopulatedState = { + apiHost: 'apiHost', + enabled: true, + project: JSON.stringify(projectList[0]), + token: 'token', + listProjectsEndpoint: TEST_HOST, + operationsSettingsEndpoint: TEST_HOST, +}; + +export const projectWithHtmlTemplate = { + ...projectList[0], + name: '<strong>bold</strong>', +}; diff --git a/spec/frontend/error_tracking_settings/store/actions_spec.js b/spec/frontend/error_tracking_settings/store/actions_spec.js new file mode 100644 index 00000000000..1eab0f7470b --- /dev/null +++ b/spec/frontend/error_tracking_settings/store/actions_spec.js @@ -0,0 +1,194 @@ +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 { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import * as actions from '~/error_tracking_settings/store/actions'; +import * as types from '~/error_tracking_settings/store/mutation_types'; +import defaultState from '~/error_tracking_settings/store/state'; +import { projectList } from '../mock'; + +jest.mock('~/lib/utils/url_utility'); + +describe('error tracking settings actions', () => { + let state; + + describe('project list actions', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + state = { ...defaultState(), listProjectsEndpoint: TEST_HOST }; + }); + + afterEach(() => { + mock.restore(); + refreshCurrentPage.mockClear(); + }); + + it('should request and transform the project list', done => { + mock.onPost(TEST_HOST).reply(() => [200, { projects: projectList }]); + testAction( + actions.fetchProjects, + null, + state, + [], + [ + { type: 'requestProjects' }, + { + type: 'receiveProjectsSuccess', + payload: projectList.map(convertObjectPropsToCamelCase), + }, + ], + () => { + expect(mock.history.post.length).toBe(1); + done(); + }, + ); + }); + + it('should handle a server error', done => { + mock.onPost(`${TEST_HOST}.json`).reply(() => [400]); + testAction( + actions.fetchProjects, + null, + state, + [], + [ + { type: 'requestProjects' }, + { + type: 'receiveProjectsError', + }, + ], + () => { + expect(mock.history.post.length).toBe(1); + done(); + }, + ); + }); + + it('should request projects correctly', done => { + testAction(actions.requestProjects, null, state, [{ type: types.RESET_CONNECT }], [], done); + }); + + it('should receive projects correctly', done => { + const testPayload = []; + testAction( + actions.receiveProjectsSuccess, + testPayload, + state, + [ + { type: types.UPDATE_CONNECT_SUCCESS }, + { type: types.RECEIVE_PROJECTS, payload: testPayload }, + ], + [], + done, + ); + }); + + it('should handle errors when receiving projects', done => { + const testPayload = []; + testAction( + actions.receiveProjectsError, + testPayload, + state, + [{ type: types.UPDATE_CONNECT_ERROR }, { type: types.CLEAR_PROJECTS }], + [], + done, + ); + }); + }); + + describe('save changes actions', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + state = { + operationsSettingsEndpoint: TEST_HOST, + }; + }); + + afterEach(() => { + mock.restore(); + }); + + it('should save the page', done => { + mock.onPatch(TEST_HOST).reply(200); + testAction(actions.updateSettings, null, state, [], [{ type: 'requestSettings' }], () => { + expect(mock.history.patch.length).toBe(1); + expect(refreshCurrentPage).toHaveBeenCalled(); + done(); + }); + }); + + it('should handle a server error', done => { + mock.onPatch(TEST_HOST).reply(400); + testAction( + actions.updateSettings, + null, + state, + [], + [ + { type: 'requestSettings' }, + { + type: 'receiveSettingsError', + payload: new Error('Request failed with status code 400'), + }, + ], + () => { + expect(mock.history.patch.length).toBe(1); + done(); + }, + ); + }); + + it('should request to save the page', done => { + testAction( + actions.requestSettings, + null, + state, + [{ type: types.UPDATE_SETTINGS_LOADING, payload: true }], + [], + done, + ); + }); + + it('should handle errors when requesting to save the page', done => { + testAction( + actions.receiveSettingsError, + {}, + state, + [{ type: types.UPDATE_SETTINGS_LOADING, payload: false }], + [], + done, + ); + }); + }); + + describe('generic actions to update the store', () => { + const testData = 'test'; + it('should reset the `connect success` flag when updating the api host', done => { + testAction( + actions.updateApiHost, + testData, + state, + [{ type: types.UPDATE_API_HOST, payload: testData }, { type: types.RESET_CONNECT }], + [], + done, + ); + }); + + it('should reset the `connect success` flag when updating the token', done => { + testAction( + actions.updateToken, + testData, + state, + [{ type: types.UPDATE_TOKEN, payload: testData }, { type: types.RESET_CONNECT }], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/error_tracking_settings/store/getters_spec.js b/spec/frontend/error_tracking_settings/store/getters_spec.js new file mode 100644 index 00000000000..2c5ff084b8a --- /dev/null +++ b/spec/frontend/error_tracking_settings/store/getters_spec.js @@ -0,0 +1,93 @@ +import * as getters from '~/error_tracking_settings/store/getters'; +import defaultState from '~/error_tracking_settings/store/state'; +import { projectList, projectWithHtmlTemplate, staleProject } from '../mock'; + +describe('Error Tracking Settings - Getters', () => { + let state; + + beforeEach(() => { + state = defaultState(); + }); + + describe('hasProjects', () => { + it('should reflect when no projects exist', () => { + expect(getters.hasProjects(state)).toEqual(false); + }); + + it('should reflect when projects exist', () => { + state.projects = projectList; + + expect(getters.hasProjects(state)).toEqual(true); + }); + }); + + describe('isProjectInvalid', () => { + const mockGetters = { hasProjects: true }; + it('should show when a project is valid', () => { + state.projects = projectList; + [state.selectedProject] = projectList; + + expect(getters.isProjectInvalid(state, mockGetters)).toEqual(false); + }); + + it('should show when a project is invalid', () => { + state.projects = projectList; + state.selectedProject = staleProject; + + expect(getters.isProjectInvalid(state, mockGetters)).toEqual(true); + }); + }); + + describe('dropdownLabel', () => { + const mockGetters = { hasProjects: false }; + it('should display correctly when there are no projects available', () => { + expect(getters.dropdownLabel(state, mockGetters)).toEqual('No projects available'); + }); + + it('should display correctly when a project is selected', () => { + [state.selectedProject] = projectList; + + expect(getters.dropdownLabel(state, mockGetters)).toEqual('organizationName | name'); + }); + + it('should display correctly when no project is selected', () => { + state.projects = projectList; + + expect(getters.dropdownLabel(state, { hasProjects: true })).toEqual('Select project'); + }); + }); + + describe('invalidProjectLabel', () => { + it('should display an error containing the project name', () => { + [state.selectedProject] = projectList; + + expect(getters.invalidProjectLabel(state)).toEqual( + 'Project "name" is no longer available. Select another project to continue.', + ); + }); + + it('should properly escape the label text', () => { + state.selectedProject = projectWithHtmlTemplate; + + expect(getters.invalidProjectLabel(state)).toEqual( + 'Project "<strong>bold</strong>" is no longer available. Select another project to continue.', + ); + }); + }); + + describe('projectSelectionLabel', () => { + it('should show the correct message when the token is empty', () => { + expect(getters.projectSelectionLabel(state)).toEqual( + 'To enable project selection, enter a valid Auth Token', + ); + }); + + it('should show the correct message when token exists', () => { + state.token = 'test-token'; + + expect(getters.projectSelectionLabel(state)).toEqual( + "Click 'Connect' to re-establish the connection to Sentry and activate the dropdown.", + ); + }); + }); +}); diff --git a/spec/frontend/error_tracking_settings/store/mutation_spec.js b/spec/frontend/error_tracking_settings/store/mutation_spec.js new file mode 100644 index 00000000000..fa188462c3f --- /dev/null +++ b/spec/frontend/error_tracking_settings/store/mutation_spec.js @@ -0,0 +1,82 @@ +import { TEST_HOST } from 'helpers/test_constants'; +import mutations from '~/error_tracking_settings/store/mutations'; +import defaultState from '~/error_tracking_settings/store/state'; +import * as types from '~/error_tracking_settings/store/mutation_types'; +import { + initialEmptyState, + initialPopulatedState, + projectList, + sampleBackendProject, + normalizedProject, +} from '../mock'; + +describe('error tracking settings mutations', () => { + describe('mutations', () => { + let state; + + beforeEach(() => { + state = defaultState(); + }); + + it('should create an empty initial state correctly', () => { + mutations[types.SET_INITIAL_STATE](state, { + ...initialEmptyState, + }); + + expect(state.apiHost).toEqual(''); + expect(state.enabled).toEqual(false); + expect(state.selectedProject).toEqual(null); + expect(state.token).toEqual(''); + expect(state.listProjectsEndpoint).toEqual(TEST_HOST); + expect(state.operationsSettingsEndpoint).toEqual(TEST_HOST); + }); + + it('should populate the initial state correctly', () => { + mutations[types.SET_INITIAL_STATE](state, { + ...initialPopulatedState, + }); + + expect(state.apiHost).toEqual('apiHost'); + expect(state.enabled).toEqual(true); + expect(state.selectedProject).toEqual(projectList[0]); + expect(state.token).toEqual('token'); + expect(state.listProjectsEndpoint).toEqual(TEST_HOST); + expect(state.operationsSettingsEndpoint).toEqual(TEST_HOST); + }); + + it('should receive projects successfully', () => { + mutations[types.RECEIVE_PROJECTS](state, [sampleBackendProject]); + + expect(state.projects).toEqual([normalizedProject]); + }); + + it('should strip out unnecessary project properties', () => { + mutations[types.RECEIVE_PROJECTS](state, [ + { ...sampleBackendProject, extra_property: 'extra_property' }, + ]); + + expect(state.projects).toEqual([normalizedProject]); + }); + + it('should update state when connect is successful', () => { + mutations[types.UPDATE_CONNECT_SUCCESS](state); + + expect(state.connectSuccessful).toBe(true); + expect(state.connectError).toBe(false); + }); + + it('should update state when connect fails', () => { + mutations[types.UPDATE_CONNECT_ERROR](state); + + expect(state.connectSuccessful).toBe(false); + expect(state.connectError).toBe(true); + }); + + it('should update state when connect is reset', () => { + mutations[types.RESET_CONNECT](state); + + expect(state.connectSuccessful).toBe(false); + expect(state.connectError).toBe(false); + }); + }); +}); diff --git a/spec/frontend/error_tracking_settings/utils_spec.js b/spec/frontend/error_tracking_settings/utils_spec.js new file mode 100644 index 00000000000..4b144f7daf1 --- /dev/null +++ b/spec/frontend/error_tracking_settings/utils_spec.js @@ -0,0 +1,29 @@ +import { transformFrontendSettings } from '~/error_tracking_settings/utils'; +import { sampleFrontendSettings, transformedSettings } from './mock'; + +describe('error tracking settings utils', () => { + describe('data transform functions', () => { + it('should transform settings successfully for the backend', () => { + expect(transformFrontendSettings(sampleFrontendSettings)).toEqual(transformedSettings); + }); + + it('should transform empty values in the settings object to null', () => { + const emptyFrontendSettingsObject = { + apiHost: '', + enabled: false, + token: '', + selectedProject: null, + }; + const transformedEmptySettingsObject = { + api_host: null, + enabled: false, + token: null, + project: null, + }; + + expect(transformFrontendSettings(emptyFrontendSettingsObject)).toEqual( + transformedEmptySettingsObject, + ); + }); + }); +}); diff --git a/spec/frontend/helpers/vuex_action_helper.js b/spec/frontend/helpers/vuex_action_helper.js index 88652202a8e..6c3569a2247 100644 --- a/spec/frontend/helpers/vuex_action_helper.js +++ b/spec/frontend/helpers/vuex_action_helper.js @@ -20,7 +20,7 @@ const noop = () => {}; * // expected mutations * [ * { type: types.MUTATION} - * { type: types.MUTATION_1, payload: jasmine.any(Number)} + * { type: types.MUTATION_1, payload: expect.any(Number)} * ], * // expected actions * [ @@ -89,10 +89,7 @@ export default ( payload, ); - return new Promise(resolve => { - setImmediate(resolve); - }) - .then(() => result) + return (result || new Promise(resolve => setImmediate(resolve))) .catch(error => { validateResults(); throw error; diff --git a/spec/frontend/helpers/vuex_action_helper_spec.js b/spec/frontend/helpers/vuex_action_helper_spec.js new file mode 100644 index 00000000000..61d05762a04 --- /dev/null +++ b/spec/frontend/helpers/vuex_action_helper_spec.js @@ -0,0 +1,166 @@ +import MockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'helpers/test_constants'; +import axios from '~/lib/utils/axios_utils'; +import testAction from './vuex_action_helper'; + +describe('VueX test helper (testAction)', () => { + let originalExpect; + let assertion; + let mock; + const noop = () => {}; + + beforeEach(() => { + mock = new MockAdapter(axios); + /** + * In order to test the helper properly, we need to overwrite the Jest + * `expect` helper. We test that the testAction helper properly passes the + * dispatched actions/committed mutations to the Jest helper. + */ + originalExpect = expect; + assertion = null; + global.expect = actual => ({ + toEqual: () => { + originalExpect(actual).toEqual(assertion); + }, + }); + }); + + afterEach(() => { + mock.restore(); + global.expect = originalExpect; + }); + + it('properly passes state and payload to action', () => { + const exampleState = { FOO: 12, BAR: 3 }; + const examplePayload = { BAZ: 73, BIZ: 55 }; + + const action = ({ state }, payload) => { + originalExpect(state).toEqual(exampleState); + originalExpect(payload).toEqual(examplePayload); + }; + + assertion = { mutations: [], actions: [] }; + + testAction(action, examplePayload, exampleState); + }); + + describe('given a sync action', () => { + it('mocks committing mutations', () => { + const action = ({ commit }) => { + commit('MUTATION'); + }; + + assertion = { mutations: [{ type: 'MUTATION' }], actions: [] }; + + testAction(action, null, {}, assertion.mutations, assertion.actions, noop); + }); + + it('mocks dispatching actions', () => { + const action = ({ dispatch }) => { + dispatch('ACTION'); + }; + + assertion = { actions: [{ type: 'ACTION' }], mutations: [] }; + + testAction(action, null, {}, assertion.mutations, assertion.actions, noop); + }); + + it('works with done callback once finished', done => { + assertion = { mutations: [], actions: [] }; + + testAction(noop, null, {}, assertion.mutations, assertion.actions, done); + }); + + it('returns a promise', done => { + assertion = { mutations: [], actions: [] }; + + testAction(noop, null, {}, assertion.mutations, assertion.actions) + .then(done) + .catch(done.fail); + }); + }); + + describe('given an async action (returning a promise)', () => { + let lastError; + const data = { FOO: 'BAR' }; + + const asyncAction = ({ commit, dispatch }) => { + dispatch('ACTION'); + + return axios + .get(TEST_HOST) + .catch(error => { + commit('ERROR'); + lastError = error; + throw error; + }) + .then(() => { + commit('SUCCESS'); + return data; + }); + }; + + beforeEach(() => { + lastError = null; + }); + + it('works with done callback once finished', done => { + mock.onGet(TEST_HOST).replyOnce(200, 42); + + assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] }; + + testAction(asyncAction, null, {}, assertion.mutations, assertion.actions, done); + }); + + it('returns original data of successful promise while checking actions/mutations', done => { + mock.onGet(TEST_HOST).replyOnce(200, 42); + + assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] }; + + testAction(asyncAction, null, {}, assertion.mutations, assertion.actions) + .then(res => { + originalExpect(res).toEqual(data); + done(); + }) + .catch(done.fail); + }); + + it('returns original error of rejected promise while checking actions/mutations', done => { + mock.onGet(TEST_HOST).replyOnce(500, ''); + + assertion = { mutations: [{ type: 'ERROR' }], actions: [{ type: 'ACTION' }] }; + + testAction(asyncAction, null, {}, assertion.mutations, assertion.actions) + .then(done.fail) + .catch(error => { + originalExpect(error).toBe(lastError); + done(); + }); + }); + }); + + it('works with async actions not returning promises', done => { + const data = { FOO: 'BAR' }; + + const asyncAction = ({ commit, dispatch }) => { + dispatch('ACTION'); + + axios + .get(TEST_HOST) + .then(() => { + commit('SUCCESS'); + return data; + }) + .catch(error => { + commit('ERROR'); + throw error; + }); + }; + + mock.onGet(TEST_HOST).replyOnce(200, 42); + + assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] }; + + testAction(asyncAction, null, {}, assertion.mutations, assertion.actions, done); + }); +}); diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js new file mode 100644 index 00000000000..2b7dffdcd88 --- /dev/null +++ b/spec/frontend/ide/utils_spec.js @@ -0,0 +1,44 @@ +import { commitItemIconMap } from '~/ide/constants'; +import { getCommitIconMap } from '~/ide/utils'; +import { decorateData } from '~/ide/stores/utils'; + +describe('WebIDE utils', () => { + const createFile = (name = 'name', id = name, type = '', parent = null) => + decorateData({ + id, + type, + icon: 'icon', + url: 'url', + name, + path: parent ? `${parent.path}/${name}` : name, + parentPath: parent ? parent.path : '', + lastCommit: {}, + }); + + describe('getCommitIconMap', () => { + let entry; + + beforeEach(() => { + entry = createFile('Entry item'); + }); + + it('renders "deleted" icon for deleted entries', () => { + entry.deleted = true; + expect(getCommitIconMap(entry)).toEqual(commitItemIconMap.deleted); + }); + it('renders "addition" icon for temp entries', () => { + entry.tempFile = true; + expect(getCommitIconMap(entry)).toEqual(commitItemIconMap.addition); + }); + it('renders "modified" icon for newly-renamed entries', () => { + entry.prevPath = 'foo/bar'; + entry.tempFile = false; + expect(getCommitIconMap(entry)).toEqual(commitItemIconMap.modified); + }); + it('renders "modified" icon even for temp entries if they are newly-renamed', () => { + entry.prevPath = 'foo/bar'; + entry.tempFile = true; + expect(getCommitIconMap(entry)).toEqual(commitItemIconMap.modified); + }); + }); +}); diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js index 9f49e68cfe8..751fb5e1b94 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -334,6 +334,12 @@ describe('prettyTime methods', () => { assertTimeUnits(aboveOneDay, 33, 2, 2, 0); assertTimeUnits(aboveOneWeek, 26, 0, 1, 9); }); + + it('should correctly parse values when limitedToHours is true', () => { + const twoDays = datetimeUtility.parseSeconds(173000, { limitToHours: true }); + + assertTimeUnits(twoDays, 3, 48, 0, 0); + }); }); describe('stringifyTime', () => { diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js index 9e920d59093..dc886d0db3b 100644 --- a/spec/frontend/lib/utils/text_utility_spec.js +++ b/spec/frontend/lib/utils/text_utility_spec.js @@ -55,9 +55,24 @@ describe('text_utility', () => { }); }); - describe('slugifyWithHyphens', () => { + describe('slugify', () => { + it('should remove accents and convert to lower case', () => { + expect(textUtils.slugify('João')).toEqual('jo-o'); + }); it('should replaces whitespaces with hyphens and convert to lower case', () => { - expect(textUtils.slugifyWithHyphens('My Input String')).toEqual('my-input-string'); + expect(textUtils.slugify('My Input String')).toEqual('my-input-string'); + }); + it('should remove trailing whitespace and replace whitespaces within string with a hyphen', () => { + expect(textUtils.slugify(' a new project ')).toEqual('a-new-project'); + }); + it('should only remove non-allowed special characters', () => { + expect(textUtils.slugify('test!_pro-ject~')).toEqual('test-_pro-ject-'); + }); + it('should squash multiple hypens', () => { + expect(textUtils.slugify('test!!!!_pro-ject~')).toEqual('test-_pro-ject-'); + }); + it('should return empty string if only non-allowed characters', () => { + expect(textUtils.slugify('здрасти')).toEqual(''); }); }); diff --git a/spec/frontend/notes/components/discussion_notes_spec.js b/spec/frontend/notes/components/discussion_notes_spec.js index c3204b3aaa0..58d367077e8 100644 --- a/spec/frontend/notes/components/discussion_notes_spec.js +++ b/spec/frontend/notes/components/discussion_notes_spec.js @@ -87,7 +87,7 @@ describe('DiscussionNotes', () => { discussion.notes[0], ]; discussion.notes = notesData; - createComponent({ discussion }); + createComponent({ discussion, shouldRenderDiffs: true }); const notes = wrapper.findAll('.notes > li'); expect(notes.at(0).is(PlaceholderSystemNote)).toBe(true); @@ -112,6 +112,44 @@ describe('DiscussionNotes', () => { }); }); + describe('events', () => { + describe('with groupped notes and replies expanded', () => { + const findNoteAtIndex = index => wrapper.find(`.note:nth-of-type(${index + 1}`); + + beforeEach(() => { + createComponent({ shouldGroupReplies: true, isExpanded: true }); + }); + + it('emits deleteNote when first note emits handleDeleteNote', () => { + findNoteAtIndex(0).vm.$emit('handleDeleteNote'); + expect(wrapper.emitted().deleteNote).toBeTruthy(); + }); + + it('emits startReplying when first note emits startReplying', () => { + findNoteAtIndex(0).vm.$emit('startReplying'); + expect(wrapper.emitted().startReplying).toBeTruthy(); + }); + + it('emits deleteNote when second note emits handleDeleteNote', () => { + findNoteAtIndex(1).vm.$emit('handleDeleteNote'); + expect(wrapper.emitted().deleteNote).toBeTruthy(); + }); + }); + + describe('with ungroupped notes', () => { + let note; + beforeEach(() => { + createComponent(); + note = wrapper.find('.note'); + }); + + it('emits deleteNote when first note emits handleDeleteNote', () => { + note.vm.$emit('handleDeleteNote'); + expect(wrapper.emitted().deleteNote).toBeTruthy(); + }); + }); + }); + describe('componentData', () => { beforeEach(() => { createComponent(); diff --git a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js index 07a366cf339..e008f4ed093 100644 --- a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js +++ b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js @@ -2,13 +2,19 @@ import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vu import { shallowMount, createLocalVue } from '@vue/test-utils'; const localVue = createLocalVue(); +const buttonText = 'Test Button Text'; describe('ReplyPlaceholder', () => { let wrapper; + const findButton = () => wrapper.find({ ref: 'button' }); + beforeEach(() => { wrapper = shallowMount(ReplyPlaceholder, { localVue, + propsData: { + buttonText, + }, }); }); @@ -17,9 +23,7 @@ describe('ReplyPlaceholder', () => { }); it('emits onClick even on button click', () => { - const button = wrapper.find({ ref: 'button' }); - - button.trigger('click'); + findButton().trigger('click'); expect(wrapper.emitted()).toEqual({ onClick: [[]], @@ -27,8 +31,6 @@ describe('ReplyPlaceholder', () => { }); it('should render reply button', () => { - const button = wrapper.find({ ref: 'button' }); - - expect(button.text()).toEqual('Reply...'); + expect(findButton().text()).toEqual(buttonText); }); }); 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 3ad6bfa9e5f..cd8372a8800 100644 --- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap @@ -27,8 +27,8 @@ exports[`Repository last commit component renders commit widget 1`] = ` href="https://test.com/commit/123" > - Commit title - + Commit title + </gllink-stub> <!----> @@ -41,12 +41,12 @@ exports[`Repository last commit component renders commit widget 1`] = ` href="https://test.com/test" > - Test - + Test + </gllink-stub> - authored - + authored + <timeagotooltip-stub cssclass="" time="2019-01-01" @@ -81,8 +81,8 @@ exports[`Repository last commit component renders commit widget 1`] = ` class="label label-monospace monospace" > - 12345678 - + 12345678 + </div> <clipboardbutton-stub diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js index 972690a60f6..14479f3c3a4 100644 --- a/spec/frontend/repository/components/last_commit_spec.js +++ b/spec/frontend/repository/components/last_commit_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; import LastCommit from '~/repository/components/last_commit.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -6,7 +7,7 @@ let vm; function createCommitData(data = {}) { return { - id: '123456789', + sha: '123456789', title: 'Commit title', message: 'Commit message', webUrl: 'https://test.com/commit/123', @@ -16,7 +17,7 @@ function createCommitData(data = {}) { avatarUrl: 'https://test.com', webUrl: 'https://test.com/test', }, - pipeline: { + latestPipeline: { detailedStatus: { detailsPath: 'https://test.com/pipeline', icon: 'failed', @@ -52,12 +53,12 @@ describe('Repository last commit component', () => { it.each` loading | label - ${true} | ${'hides'} - ${false} | ${'shows'} - `('$label when $loading is true', ({ loading }) => { + ${true} | ${'shows'} + ${false} | ${'hides'} + `('$label when loading icon $loading is true', ({ loading }) => { factory(createCommitData(), loading); - expect(vm.isEmpty()).toBe(loading); + expect(vm.find(GlLoadingIcon).exists()).toBe(loading); }); it('renders commit widget', () => { @@ -73,11 +74,17 @@ describe('Repository last commit component', () => { }); it('hides pipeline components when pipeline does not exist', () => { - factory(createCommitData({ pipeline: null })); + factory(createCommitData({ latestPipeline: null })); expect(vm.find('.js-commit-pipeline').exists()).toBe(false); }); + it('renders pipeline components', () => { + factory(); + + expect(vm.find('.js-commit-pipeline').exists()).toBe(true); + }); + it('hides author component when author does not exist', () => { factory(createCommitData({ author: null })); 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 1f06d693411..d55dc553031 100644 --- a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap +++ b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap @@ -29,10 +29,20 @@ exports[`Repository table row component renders table row 1`] = ` <td class="d-none d-sm-table-cell tree-commit" - /> + > + <glskeletonloading-stub + class="h-auto" + lines="1" + /> + </td> <td class="tree-time-ago text-right" - /> + > + <glskeletonloading-stub + class="ml-auto h-auto w-50" + lines="1" + /> + </td> </tr> `; diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js index 5a345ddeacd..c566057ad3f 100644 --- a/spec/frontend/repository/components/table/row_spec.js +++ b/spec/frontend/repository/components/table/row_spec.js @@ -16,6 +16,8 @@ function factory(propsData = {}) { vm = shallowMount(TableRow, { propsData: { ...propsData, + name: propsData.path, + projectPath: 'gitlab-org/gitlab-ce', url: `https://test.com`, }, mocks: { diff --git a/spec/frontend/repository/log_tree_spec.js b/spec/frontend/repository/log_tree_spec.js new file mode 100644 index 00000000000..a9499f7c61b --- /dev/null +++ b/spec/frontend/repository/log_tree_spec.js @@ -0,0 +1,129 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import { normalizeData, resolveCommit, fetchLogsTree } from '~/repository/log_tree'; + +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', + }, + ]); + }); +}); + +describe('resolveCommit', () => { + it('calls resolve when commit found', () => { + const resolver = { + entry: { name: 'index.js', type: 'blob' }, + resolve: jest.fn(), + }; + const commits = [{ fileName: 'index.js', type: 'blob' }]; + + resolveCommit(commits, resolver); + + expect(resolver.resolve).toHaveBeenCalledWith({ fileName: 'index.js', type: 'blob' }); + }); +}); + +describe('fetchLogsTree', () => { + let mock; + let client; + let resolver; + + beforeEach(() => { + mock = new MockAdapter(axios); + + mock.onGet(/(.*)/).reply(200, mockData, {}); + + jest.spyOn(axios, 'get'); + + global.gon = { gitlab_url: 'https://test.com' }; + + client = { + readQuery: () => ({ + projectPath: 'gitlab-org/gitlab-ce', + ref: 'master', + commits: [], + }), + writeQuery: jest.fn(), + }; + + resolver = { + entry: { name: 'index.js', type: 'blob' }, + resolve: jest.fn(), + }; + }); + + afterEach(() => { + mock.restore(); + }); + + it('calls axios get', () => + fetchLogsTree(client, '', '0', resolver).then(() => { + expect(axios.get).toHaveBeenCalledWith( + 'https://test.com/gitlab-org/gitlab-ce/refs/master/logs_tree', + { params: { format: 'json', offset: '0' } }, + ); + })); + + it('calls axios get once', () => + Promise.all([ + fetchLogsTree(client, '', '0', resolver), + fetchLogsTree(client, '', '0', resolver), + ]).then(() => { + expect(axios.get.mock.calls.length).toEqual(1); + })); + + it('calls entry resolver', () => + fetchLogsTree(client, '', '0', resolver).then(() => { + expect(resolver.resolve).toHaveBeenCalledWith({ + __typename: 'LogTreeCommit', + commitPath: 'https://test.com', + committedDate: '2019-01-01', + fileName: 'index.js', + message: 'testing message', + sha: '123', + type: 'blob', + }); + })); + + it('writes query to client', () => + fetchLogsTree(client, '', '0', resolver).then(() => { + expect(client.writeQuery).toHaveBeenCalledWith({ + query: expect.anything(), + data: { + commits: [ + { + __typename: 'LogTreeCommit', + commitPath: 'https://test.com', + committedDate: '2019-01-01', + fileName: 'index.js', + message: 'testing message', + sha: '123', + type: 'blob', + }, + ], + }, + }); + })); +}); diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js index 7e7cc1488b8..15cf18700ed 100644 --- a/spec/frontend/test_setup.js +++ b/spec/frontend/test_setup.js @@ -1,10 +1,17 @@ import Vue from 'vue'; import * as jqueryMatchers from 'custom-jquery-matchers'; +import $ from 'jquery'; import Translate from '~/vue_shared/translate'; import axios from '~/lib/utils/axios_utils'; +import { config as testUtilsConfig } from '@vue/test-utils'; import { initializeTestTimeout } from './helpers/timeout'; import { loadHTMLFixture, setHTMLFixture } from './helpers/fixtures'; +// Expose jQuery so specs using jQuery plugins can be imported nicely. +// Here is an issue to explore better alternatives: +// https://gitlab.com/gitlab-org/gitlab-ee/issues/12448 +window.jQuery = $; + process.on('unhandledRejection', global.promiseRejectionHandler); afterEach(() => @@ -54,9 +61,21 @@ Object.assign(global, { preloadFixtures() {}, }); +Object.assign(global, { + MutationObserver() { + return { + disconnect() {}, + observe() {}, + }; + }, +}); + // custom-jquery-matchers was written for an old Jest version, we need to make it compatible Object.entries(jqueryMatchers).forEach(([matcherName, matcherFactory]) => { expect.extend({ [matcherName]: matcherFactory().compare, }); }); + +// Tech debt issue TBD +testUtilsConfig.logModifiedComponents = false; diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js index e43d5301a50..b85e2673624 100644 --- a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js +++ b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js @@ -88,7 +88,7 @@ describe('RelatedIssuableItem', () => { }); it('renders state title', () => { - const stateTitle = tokenState.attributes('data-original-title'); + const stateTitle = tokenState.attributes('title'); const formatedCreateDate = formatDate(props.createdAt); expect(stateTitle).toContain('<span class="bold">Opened</span>'); @@ -155,7 +155,9 @@ describe('RelatedIssuableItem', () => { describe('token assignees', () => { it('renders assignees avatars', () => { - expect(wrapper.findAll('.item-assignees .user-avatar-link').length).toBe(2); + // Expect 2 times 2 because assignees are rendered twice, due to layout issues + expect(wrapper.findAll('.item-assignees .user-avatar-link').length).toBeDefined(); + expect(wrapper.find('.item-assignees .avatar-counter').text()).toContain('+2'); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js new file mode 100644 index 00000000000..aa0b544f948 --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/header_spec.js @@ -0,0 +1,107 @@ +import Vue from 'vue'; +import $ from 'jquery'; +import headerComponent from '~/vue_shared/components/markdown/header.vue'; + +describe('Markdown field header component', () => { + let vm; + + beforeEach(done => { + const Component = Vue.extend(headerComponent); + + vm = new Component({ + propsData: { + previewMarkdown: false, + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('renders markdown header buttons', () => { + const buttons = [ + 'Add bold text', + 'Add italic text', + 'Insert a quote', + 'Insert suggestion', + 'Insert code', + 'Add a link', + 'Add a bullet list', + 'Add a numbered list', + 'Add a task list', + 'Add a table', + 'Go full screen', + ]; + const elements = vm.$el.querySelectorAll('.toolbar-btn'); + + elements.forEach((buttonEl, index) => { + expect(buttonEl.getAttribute('data-original-title')).toBe(buttons[index]); + }); + }); + + it('renders `write` link as active when previewMarkdown is false', () => { + expect(vm.$el.querySelector('li:nth-child(1)').classList.contains('active')).toBeTruthy(); + }); + + it('renders `preview` link as active when previewMarkdown is true', done => { + vm.previewMarkdown = true; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('li:nth-child(2)').classList.contains('active')).toBeTruthy(); + + done(); + }); + }); + + it('emits toggle markdown event when clicking preview', () => { + jest.spyOn(vm, '$emit').mockImplementation(); + + vm.$el.querySelector('.js-preview-link').click(); + + expect(vm.$emit).toHaveBeenCalledWith('preview-markdown'); + + vm.$el.querySelector('.js-write-link').click(); + + expect(vm.$emit).toHaveBeenCalledWith('write-markdown'); + }); + + it('does not emit toggle markdown event when triggered from another form', () => { + jest.spyOn(vm, '$emit').mockImplementation(); + + $(document).triggerHandler('markdown-preview:show', [ + $( + '<form><div class="js-vue-markdown-field"><textarea class="markdown-area"></textarea></div></form>', + ), + ]); + + expect(vm.$emit).not.toHaveBeenCalled(); + }); + + it('blurs preview link after click', () => { + const link = vm.$el.querySelector('li:nth-child(2) button'); + jest.spyOn(HTMLElement.prototype, 'blur').mockImplementation(); + + link.click(); + + expect(link.blur).toHaveBeenCalled(); + }); + + it('renders markdown table template', () => { + expect(vm.mdTable).toEqual( + '| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |', + ); + }); + + it('renders suggestion template', () => { + vm.lineContent = 'Some content'; + + expect(vm.mdSuggestion).toEqual('```suggestion:-0+0\n{text}\n```'); + }); + + it('does not render suggestion button if `canSuggest` is set to false', () => { + vm.canSuggest = false; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.qa-suggestion-btn')).toBe(null); + }); + }); +}); |